Repository navigation
Feature: Notifications
Out-of-band user notification (desktop banner + spoken voice) backed by a hand-editable user config file. Both are plain child processes and file I/O, so the feature works identically on every host -- opencode, Claude Code, and Cursor -- with no host-specific plumbing and no new npm dependencies.
| File | Role |
|---|---|
src/config.ts |
Zod section schemas, config file load/save/merge, platform defaults. |
src/notify.ts |
Per-platform dispatch for banner + voice, with an injectable spawner. |
src/tool-defs.ts |
config_get, config_set, notify_user tool definitions (shared registry, non-opencodeOnly). |
The config is one JSON file at ~/.config/thatch/config.json, placed next to
thatch.db by deriving the path from THATCH_DB_PATH (or the XDG default the
same way runtime.ts and mcp.ts derive the DB path). Tests pass an explicit
dbPath to keep writes inside a tempdir.
Key decisions:
-
Sections are
z.strictObject. Unknown keys fail validation instead of being silently stripped, so a newer-version config round-tripping through an older process cannot lose data. An unparseable file is ignored (empty config + warning), never fatal -- a broken hand edit must not take the agent down. -
Field-level merge in
config_set.mergeNotificationPrefsis a shallow spread of current + patch (withundefinedvalues stripped first). The tool's description and the system prompt both state the merge semantics, and the setter echoes the resulting section so the model can verify the write in the same turn. This is the guardrail against the classic read-modify-write failure where a model reconstructs a document and drops sibling fields. -
Atomic writes.
saveConfigwrites a temp file then renames, so a crash mid-write cannot leave a truncated config and concurrent readers never see a partial document. -
No env-var overrides for notification prefs. The config file is the
single source of truth;
THATCH_*env vars remain reserved for infrastructure (DB path, model, thresholds). Preferences the LLM manages belong in the file the LLM manages.
notificationDefaults() supplies the platform fallbacks shown by config_get
and applied by notify_user: darwin pins Zarvox + Submarine (the author's
preference); other platforms defer to their tools' defaults.
sendNotification(request, spawner) composes one or two command steps and
returns a result string for the tool response:
-
darwin: banner via
/usr/bin/osascript -e 'display notification ...'(title defaults tosource, thenthatch; sound defaults toSubmarine), voice via/usr/bin/say -v <voice>(absolute path --~/bin/sayshadows the system binary on the author's machine). -
linux: banner via
notify-send; voice viaspd-say -w, falling back toespeakwhen speech-dispatcher is absent. A voice override goes straight toespeak -vbecausespd-sayhas no voice flag. -
other platforms (win32 included):
[unsupported]result, nothing runs. Windows support is deliberately not a goal.
Details that matter:
-
The spawner is a
CoreContextextension field (ctx.spawner), the same pattern aswatchersandextractionPayloadProvider. Tests inject a mock; production falls back todefaultSpawner(Bun.spawn). No test ever fires a real banner or speaks. -
Failures never throw. Every step's exit code and stderr are captured;
the result string is
[notified]when anything succeeded,[failed]when all requested channels failed,[skipped]when configuredmodeisnone,[unsupported]on other platforms. - No delivery claims. osascript exits 0 whether or not the banner displayed (focus modes silently drop it), so result text reports command success only.
-
The spoken line prefixes the
sourcelabel ("PLAT-280: CI is green") so a user with several concurrent agent sessions knows which one spoke. The tool description enforces this on the model side. - Speech is awaited (the tool returns when the sentence finishes) so error reporting stays honest. There is no debounce; polling agents notify once at the end.
-
systemPrompt()andmcpInstructions()list the three new tools and carry a short "Notifications" section: when to notify, the source-label rule, and the config_get-before-config_set instruction. -
notify_useris notopencodeOnly: it needs no host capabilities, so MCP hosts get it too. Nothing was added tosrc/tools.tsor the plugin runtime.
-
tests/config.test.ts-- path derivation, missing/invalid/strict-schema handling, round-trip, atomic save leaves no temp files, merge keeps siblings, darwin defaults. -
tests/notify.test.ts-- command construction per platform (mock spawner records argv), AppleScript escaping, partial vs total failure, unsupported platform, plus tool-level tests (merge-no-wipe, echo,mode: noneno-op, configured voice honored) withTHATCH_DB_PATHpointed at a tempdir. -
tests/tool-defs.test.ts-- registry count and name list (hardcoded literals that fail loudly when the surface changes).
tests/qa/auto/uc-059-tool-prefixing.ts asserts the full bare-name list; the
three new names are in its expected array.
User
- Guide: Behavior Engine
- Guide: Cli
- Guide: Code Review
- Guide: Commands
- Guide: Cross Session Chat
- Guide: Deduplication
- Guide: Default Behaviors
- Guide: Extraction
- Guide: Hygiene
- Guide: Memory
- Guide: Notifications
- Guide: Prediction Engine
- Guide: Overview
- Guide: Setup
- Guide: Skills
- Guide: Watchers
Developer
Dev Feature Guides
- Feature: Behavior Engine
- Feature: Cicd
- Feature: Cli
- Feature: Commands
- Feature: Compaction Recovery
- Feature: Cross Session Chat
- Feature: Database
- Feature: Deduplication
- Feature: Extraction
- Feature: Hygiene
- Feature: Memory Store
- Feature: Multi Host
- Feature: Notifications
- Feature: Nudge Pipeline
- Feature: Opencode Plugin
- Feature: Prediction Engine
- Feature: Qa System
- Feature: Overview
- Feature: Repo Identity
- Feature: Session Lifecycle
- Feature: Setup
- Feature: Sideband
- Feature: Watchers