@trygocode/notify 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,189 @@
1
+ // `notify_copy` — the ONE place the desktop banner's wording is built so it
2
+ // reads WORD-FOR-WORD identical to the phone push (GOCODE_NOTIFY_DESKTOP PRD
3
+ // §3 "same title/body as the phone"). This is a faithful port of the server's
4
+ // decoration (`dispatcher._decorated_title` / `_decorated_body` +
5
+ // `notify_routes._DEFAULT_TITLES` / `_DEFAULT_BODIES`) AND the Flutter client's
6
+ // `decorateNotificationTitle` / `sourceLabelFor` (`push_notification_service.dart`
7
+ // / `firebase_push_notification_service.dart`). All three MUST agree — there is a
8
+ // cross-check test (`notify_copy.test.ts`) that pins these tables against the
9
+ // server/Flutter strings so a future edit on one side can't silently drift.
10
+ //
11
+ // Why mirror server-side copy in the CLI at all? The desktop banner fires
12
+ // LOCALLY (it never round-trips the server — that would add latency + a failure
13
+ // mode to a best-effort banner), so the CLI must reproduce the exact string the
14
+ // server would have rendered for the phone. Keeping it in its own tiny module
15
+ // (rather than inline in `on_stop.ts`) makes the "single source of truth, pinned
16
+ // by a test" contract explicit.
17
+ //
18
+ // Zero runtime deps — pure string logic, no imports.
19
+ /**
20
+ * Max display length for a project / chat name folded into the title or body.
21
+ * Mirrors the server's `_NOTIFICATION_NAME_MAX_LEN` and the Flutter client's
22
+ * `kNotificationNameMaxLen` so a long name truncates to the SAME string the
23
+ * phone shows.
24
+ */
25
+ export const NOTIFICATION_NAME_MAX_LEN = 40;
26
+ /**
27
+ * Per-kind status emoji prefixed onto the decorated title. Mirrors the server's
28
+ * `_STATUS_EMOJI` and the Flutter client's `_statusEmojiFor`. Unknown kinds get
29
+ * no emoji (fall through), exactly like the server.
30
+ */
31
+ const STATUS_EMOJI = {
32
+ finished: "✅",
33
+ loop_completed: "✅",
34
+ error: "❌",
35
+ awaiting_input: "❓",
36
+ ralph_waiting: "❓",
37
+ loop_halted: "⚠️",
38
+ };
39
+ /**
40
+ * Per-kind DEFAULT title used when the caller supplies no explicit title — the
41
+ * funnel/stop-hook path always omits a title, so this is what the phone shows.
42
+ * Mirrors the server's `notify_routes._DEFAULT_TITLES` (chat kinds) +
43
+ * `dispatcher.LOOP_KIND_COPY` (loop kinds) EXACTLY. Keep these strings in sync
44
+ * with the server — the cross-check test pins them.
45
+ */
46
+ export const DEFAULT_TITLES = {
47
+ finished: "Agent finished",
48
+ error: "Agent hit an error",
49
+ awaiting_input: "Agent needs you",
50
+ loop_completed: "Autopilot finished",
51
+ loop_halted: "Autopilot halted — needs you",
52
+ };
53
+ /**
54
+ * Per-kind DEFAULT body used when the caller supplies no explicit body. Mirrors
55
+ * the server's `notify_routes._DEFAULT_BODIES` + `dispatcher.LOOP_KIND_COPY`.
56
+ */
57
+ export const DEFAULT_BODIES = {
58
+ finished: "Your coding run finished — tap to review.",
59
+ error: "Your coding run hit an error — tap to see what happened.",
60
+ awaiting_input: "Your coding run is waiting on you — tap to respond.",
61
+ loop_completed: "Your Autopilot loop finished — tap to review.",
62
+ loop_halted: "Your Autopilot loop stopped and needs your input — tap to resolve.",
63
+ };
64
+ /** Loop kinds whose decorated title carries the "Autopilot" prefix (no project). */
65
+ const AUTOPILOT_KINDS = new Set(["loop_completed", "loop_halted", "ralph_waiting"]);
66
+ /**
67
+ * Human-readable source label for the title prefix. `"cursor"` → `"Cursor"`,
68
+ * `"claude_code"`/`"claude"` → `"Claude"`. Mirrors the Flutter client's
69
+ * `sourceLabelFor` and the server's `_source_label` EXACTLY: a known token maps
70
+ * to its brand; `"manual"`/`"app"`/`"cli"` return null (no prefix); any other
71
+ * token is title-cased defensively. Returns null when there is no label.
72
+ */
73
+ export function sourceLabelFor(source) {
74
+ if (source == null)
75
+ return undefined;
76
+ const s = source.trim().toLowerCase();
77
+ if (s === "")
78
+ return undefined;
79
+ switch (s) {
80
+ case "gocode":
81
+ return "GoCode";
82
+ case "cursor":
83
+ return "Cursor";
84
+ case "claude_code":
85
+ case "claude":
86
+ return "Claude";
87
+ case "opencode":
88
+ return "OpenCode";
89
+ case "ralph":
90
+ return "Ralph";
91
+ case "homer":
92
+ return "Homer";
93
+ // In-app origins carry no brand prefix (mirror Flutter/`_source_label`).
94
+ case "manual":
95
+ case "app":
96
+ case "cli":
97
+ return undefined;
98
+ default: {
99
+ // Title-case an unknown token so a new server source still renders
100
+ // something sensible (mirrors Flutter's defensive default + the server).
101
+ const t = source.trim();
102
+ return t.charAt(0).toUpperCase() + t.slice(1);
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Trim + cap a project / chat name at {@link NOTIFICATION_NAME_MAX_LEN},
108
+ * appending an ellipsis when truncated. Returns undefined when the name is
109
+ * empty/whitespace so a blank field never folds an empty segment into the
110
+ * title/body. Mirrors the server's `_truncate_notification_name` and the
111
+ * client's `truncateNotificationName`.
112
+ */
113
+ export function truncateNotificationName(name) {
114
+ if (name == null)
115
+ return undefined;
116
+ const n = name.trim();
117
+ if (n === "")
118
+ return undefined;
119
+ if (n.length <= NOTIFICATION_NAME_MAX_LEN)
120
+ return n;
121
+ // Reserve one char for the ellipsis so the result length equals the cap.
122
+ return n.slice(0, NOTIFICATION_NAME_MAX_LEN - 1) + "…";
123
+ }
124
+ /**
125
+ * Build the OS-rendered banner title — WORD-FOR-WORD identical to the phone push.
126
+ * Format: `<emoji> <source label> · <project> · Autopilot — <title>`, omitting
127
+ * any empty segment. A faithful port of the server's `_decorated_title` and the
128
+ * Flutter client's `decorateNotificationTitle` (the project segment is suppressed
129
+ * for autopilot/loop kinds, exactly like both). Idempotent on the emoji.
130
+ */
131
+ export function decorateTitle(input) {
132
+ const kind = input.kind;
133
+ const autopilot = AUTOPILOT_KINDS.has(kind);
134
+ const rawTitle = input.title != null && input.title.trim() !== ""
135
+ ? input.title
136
+ : DEFAULT_TITLES[kind] ?? "";
137
+ const emoji = STATUS_EMOJI[kind];
138
+ const label = sourceLabelFor(input.source);
139
+ // Project is folded into the title for IDE/funnel pushes, but NOT for
140
+ // autopilot pushes (whose title already reads "<tool> · Autopilot — …").
141
+ const projectLabel = autopilot ? undefined : truncateNotificationName(input.project);
142
+ const prefixParts = [];
143
+ if (label)
144
+ prefixParts.push(label);
145
+ if (projectLabel)
146
+ prefixParts.push(projectLabel);
147
+ if (autopilot)
148
+ prefixParts.push("Autopilot");
149
+ const prefix = prefixParts.join(" · ");
150
+ // Defend against a caller that already prepended the emoji to the raw title.
151
+ let base = rawTitle.replace(/^\s+/, "");
152
+ if (emoji && base.startsWith(emoji)) {
153
+ base = base.slice(emoji.length).replace(/^\s+/, "");
154
+ }
155
+ const emojiS = emoji ?? "";
156
+ if (prefix) {
157
+ const left = `${emojiS} ${prefix}`.trim();
158
+ if (base === "")
159
+ return left;
160
+ return `${left} — ${base}`;
161
+ }
162
+ // No source/autopilot prefix.
163
+ if (base === "")
164
+ return emojiS;
165
+ return emojiS ? `${emojiS} ${base}`.trim() : base;
166
+ }
167
+ /**
168
+ * Build the OS-rendered banner body — matching the phone push. Folds the project
169
+ * (+ optional chat title) into the body: `<project> · <body>` or
170
+ * `<project> › <chat> · <body>`. A faithful port of the server's `_decorated_body`
171
+ * and the client's `decorateNotificationBody`. When no body is supplied the
172
+ * per-kind {@link DEFAULT_BODIES} is used so the banner is never body-less (the
173
+ * phone is never title-only either).
174
+ */
175
+ export function decorateBody(input) {
176
+ const raw = input.body != null && input.body.trim() !== ""
177
+ ? input.body
178
+ : DEFAULT_BODIES[input.kind] ?? "";
179
+ const base = raw.trim() !== "" ? raw.trim() : undefined;
180
+ const proj = truncateNotificationName(input.project);
181
+ const conv = truncateNotificationName(input.chat);
182
+ const contextParts = [proj, conv].filter((p) => p != null);
183
+ if (contextParts.length === 0)
184
+ return base ?? "";
185
+ const context = contextParts.join(" › ");
186
+ if (base == null)
187
+ return context;
188
+ return `${context} · ${base}`;
189
+ }
@@ -33,6 +33,8 @@ import { deriveRepoIdentity } from "./repo_key.js";
33
33
  import { pushOnStop, } from "./push.js";
34
34
  import { appendLog, send } from "./send.js";
35
35
  import { checkDedupLock } from "./dedup_lock.js";
36
+ import { notifyDesktop, } from "./desktop_notify.js";
37
+ import { decorateTitle, decorateBody } from "./notify_copy.js";
36
38
  /**
37
39
  * Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
38
40
  * Best-effort: returns `undefined` on absent/empty/unparseable input or when the
@@ -118,6 +120,74 @@ export function cursorStopStatusToKind(status) {
118
120
  export function shouldSuppressAbortedStop(source, status) {
119
121
  return source === "cursor" && status === "aborted";
120
122
  }
123
+ /**
124
+ * Human-readable label for a notification kind, used in the desktop banner body
125
+ * (PRD §3). Mirrors the phone notification's tone so the computer banner reads
126
+ * the same. Falls back to the raw kind for any future/unknown value.
127
+ */
128
+ export function kindLabel(kind) {
129
+ switch (kind) {
130
+ case "finished":
131
+ return "Finished";
132
+ case "error":
133
+ return "Error";
134
+ case "awaiting_input":
135
+ return "Needs your input";
136
+ case "loop_completed":
137
+ return "Autopilot finished";
138
+ case "loop_halted":
139
+ return "Autopilot halted — needs you";
140
+ case "ralph_waiting":
141
+ return "Autopilot waiting";
142
+ default:
143
+ return String(kind);
144
+ }
145
+ }
146
+ /**
147
+ * Map a notification `source` (the IDE that raised the turn) to the macOS app
148
+ * name `open -a <app>` should launch, and the Windows process name. Returns
149
+ * undefined for non-IDE / unknown sources so the banner is display-only rather
150
+ * than opening the wrong thing. We intentionally cover ONLY the editors that run
151
+ * a stop hook (Cursor / Claude Code / OpenCode) — Autopilot/Ralph/Homer loops
152
+ * have no local IDE window to focus, and `manual`/webhook sources aren't IDEs.
153
+ */
154
+ export function ideAppNameFor(source) {
155
+ if (!source)
156
+ return undefined;
157
+ switch (source.trim().toLowerCase()) {
158
+ case "cursor":
159
+ return "Cursor";
160
+ case "claude":
161
+ case "claude_code":
162
+ // Claude Code is a CLI inside a terminal/VS Code; "Visual Studio Code" is
163
+ // the closest reliable window to focus when it runs inside VS Code. When
164
+ // it runs in a bare terminal there is no stable app to open, so callers
165
+ // fall back gracefully (the click just no-ops rather than mis-opening).
166
+ return "Visual Studio Code";
167
+ case "opencode":
168
+ return "Visual Studio Code";
169
+ default:
170
+ return undefined;
171
+ }
172
+ }
173
+ /**
174
+ * Build the best-effort click target for the desktop banner (PRD §3.6): clicking
175
+ * the banner opens the IDE window for the project at `cwd`. Returns undefined
176
+ * when we can't name an IDE for `source` (then the banner is display-only — a
177
+ * graceful no-click, never a dead/mis-routed click). The `cwd` is the project
178
+ * directory the agent ran in, so `open -a <ide> <cwd>` focuses (or opens) that
179
+ * IDE on the right project window. `ideChatId` is carried for future use (deep
180
+ * link to the exact chat) but not required to open the window.
181
+ */
182
+ export function clickTarget(source, cwd, _ideChatId) {
183
+ const app = ideAppNameFor(source);
184
+ if (!app)
185
+ return undefined;
186
+ const projectPath = cwd && cwd.trim() !== "" ? cwd : undefined;
187
+ if (!projectPath)
188
+ return undefined;
189
+ return { app, projectPath };
190
+ }
121
191
  /** Slice the merged settings down to what the push flow consumes. */
122
192
  function toPushSettings(settings) {
123
193
  return {
@@ -493,6 +563,46 @@ export async function onStop(opts = {}) {
493
563
  timeoutMs: opts.timeoutMs,
494
564
  timestamp: opts.timestamp,
495
565
  }));
566
+ const desktopImpl = opts.notifyDesktopImpl ?? notifyDesktop;
567
+ /**
568
+ * Fire a branded local desktop banner for this turn (PRD §3) when
569
+ * `settings.desktop.enabled` is true. Best-effort + total: never throws, and
570
+ * the result is purely informational (the phone push is the source of truth).
571
+ * Gated on the SAME server-synced settings as the phone push so a toggle on
572
+ * the app or terminal flips the computer banner too. Returns undefined when
573
+ * desktop is disabled (so the result field is omitted).
574
+ */
575
+ const fireDesktop = async (settings, banner, source, ideChatId) => {
576
+ if (settings.desktop?.enabled === false)
577
+ return undefined;
578
+ if (opts.dryRun) {
579
+ await logLine(`dry-run: would show desktop banner "${banner.title ?? ""}"`);
580
+ return undefined;
581
+ }
582
+ try {
583
+ const res = await desktopImpl({
584
+ ...banner,
585
+ sound: settings.desktop?.sound !== false,
586
+ // Best-effort click target (PRD §3.6): clicking the banner opens the
587
+ // IDE window for THIS project (the agent's cwd), the same IDE that
588
+ // raised the turn. Omitted when we can't name the IDE or the path —
589
+ // then the banner is display-only (graceful, never a dead click).
590
+ click: clickTarget(source, cwd, ideChatId),
591
+ }, {
592
+ home: opts.home,
593
+ timeoutMs: opts.timeoutMs,
594
+ timestamp: opts.timestamp,
595
+ });
596
+ await logLine(`desktop banner → ${res.ok ? "shown" : "failed"} (${res.platform})`);
597
+ return res;
598
+ }
599
+ catch (err) {
600
+ // desktop banner must never block the turn — but leave a breadcrumb so a
601
+ // throwing (injected/future) impl is diagnosable from the notify log.
602
+ await logLine(`desktop banner threw — ignored: ${err instanceof Error ? err.message : String(err)}`);
603
+ return undefined;
604
+ }
605
+ };
496
606
  try {
497
607
  // ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
498
608
  // When an Autopilot loop has marked that it owns this turn, it sends its OWN
@@ -564,7 +674,26 @@ export async function onStop(opts = {}) {
564
674
  home: opts.home,
565
675
  });
566
676
  await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
567
- return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
677
+ // Branded desktop banner mirroring the push notification (PRD §3). Only
678
+ // fire when the push flow actually notified the phone (i.e. it pushed,
679
+ // hit a conflict, or was rejected) — not on no-op outcomes (disabled /
680
+ // not-a-repo / clean-tree / protected-skip) where the phone got nothing.
681
+ let desktop;
682
+ if (push.notified === true) {
683
+ const project = projectLabel(repo, cwd);
684
+ const isError = push.outcome === "conflict-aborted" || push.outcome === "push-rejected";
685
+ const pushKind = isError ? "error" : "finished";
686
+ // Decorate WORD-FOR-WORD like the phone push (PRD §3): the auto-push
687
+ // path leaves `title` to the server default per kind, and folds the
688
+ // project into title + body exactly as `dispatcher._decorated_*` does.
689
+ const rawBody = push.detail || (isError ? "Auto-push needs you" : `Pushed to ${push.branch ?? "branch"}`);
690
+ desktop = await fireDesktop(settings, {
691
+ title: decorateTitle({ kind: pushKind, source, project }),
692
+ body: decorateBody({ kind: pushKind, body: rawBody, project }),
693
+ kind: pushKind,
694
+ }, source);
695
+ }
696
+ return { mode: "push", settingsSource: resolved.source, push, desktop, repo, detail: push.detail };
568
697
  }
569
698
  // ── Step 3b: auto-push off → the plain notification (legacy flow). ──
570
699
  // Derive the notification kind from the Cursor stop hook's stdin JSON (T-CUR1
@@ -622,9 +751,27 @@ export async function onStop(opts = {}) {
622
751
  const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
623
752
  if (chatTitle)
624
753
  payload.chat = chatTitle;
625
- const sent = await sendImpl(payload);
754
+ // Fire the phone push and the branded desktop banner in parallel (PRD §3):
755
+ // the computer gets told the same instant the phone does, and a slow banner
756
+ // never delays the phone send. The banner reuses the SAME project label,
757
+ // kind, and (when present) chat title that the phone notification carries.
758
+ // Decorate the banner WORD-FOR-WORD like the phone push (PRD §3): same
759
+ // emoji + source label + project folded into the title, and the per-kind
760
+ // default body with project (+ chat title) folded in — exactly what the
761
+ // server's `_decorated_title`/`_decorated_body` produce for this push. The
762
+ // CLI omits an explicit title (like the phone send) so the per-kind default
763
+ // ("Agent finished" / …) flows through identically on both surfaces.
764
+ const desktopBanner = {
765
+ title: decorateTitle({ kind: sendKind, source, project }),
766
+ body: decorateBody({ kind: sendKind, project, chat: chatTitle }),
767
+ kind: sendKind,
768
+ };
769
+ const [sent, desktop] = await Promise.all([
770
+ sendImpl(payload),
771
+ fireDesktop(settings, desktopBanner, source, ideChatId),
772
+ ]);
626
773
  await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
627
- return { mode: "send", settingsSource: resolved.source, send: sent, repo };
774
+ return { mode: "send", settingsSource: resolved.source, send: sent, desktop, repo };
628
775
  }
629
776
  catch (err) {
630
777
  // Defence in depth: the dispatcher must never throw. Degrade to a logged
@@ -8,14 +8,20 @@
8
8
  // "command": ["npx","-y","@trygocode/notify","mcp"],
9
9
  // "enabled": true }
10
10
  // `command` is a single ARRAY (binary + args), plus `type` + `enabled`.
11
- // 2. WRITE a `session.idle` plugin to `<config-dir>/plugin/gocode-notify.js`.
12
- // OpenCode has NO stop-hook command array; instead a plugin subscribes to
13
- // the `session.idle` event (the OpenCode equivalent of Cursor `stop` /
14
- // Claude `Stop`) and fires the SHARED `on-stop` dispatcher:
15
- // gocode-notify on-stop --source opencode --dedupe-key opencode-idle || true
16
- // The child is detached + unref'd and errors are swallowed (`|| true`), so a
17
- // notification/push failure can never block the session — exactly one ping
18
- // per idle, in lockstep with Claude/Cursor (same `on-stop` dispatcher).
11
+ // 2. WRITE an event plugin to `<config-dir>/plugin/gocode-notify.js` that
12
+ // subscribes to TWO event classes:
13
+ // a) END-OF-TURN — `session.idle` (legacy) + `session.status` (modern),
14
+ // the OpenCode equivalent of Cursor `stop` / Claude `Stop`. Fires the
15
+ // SHARED `on-stop` dispatcher a `finished` ping.
16
+ // b) QUESTION `permission.asked` (the "asking now" edge), the OpenCode
17
+ // equivalent of Claude's `Notification` hook. Fires a plain
18
+ // `awaiting_input` ping ("Agent needs you") so a real question shows as
19
+ // a QUESTION, not a misleading `finished`. (Cursor has NO such event;
20
+ // OpenCode genuinely exposes it via permission events. We do NOT listen
21
+ // to permission.updated/replied — those also fire after the user
22
+ // answers and would re-ping a resolved question.)
23
+ // Each child is detached + unref'd and errors are swallowed (`|| true`), so a
24
+ // notification/push failure can never block the session.
19
25
  //
20
26
  // The on-demand rule/skill (Claude SKILL.md / Cursor rule) is SKIPPED for
21
27
  // OpenCode: OpenCode has no auto-loaded standalone per-file rule mechanism (the
@@ -72,6 +78,21 @@ export const OPENCODE_MCP_ENTRY = {
72
78
  // resolved version forever and never auto-updates (npm/cli#6664). Pinning
73
79
  // `@latest` makes the hook always fetch the newest publish so users self-update.
74
80
  export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify@latest on-stop --source opencode --dedupe-key opencode-idle || true";
81
+ /**
82
+ * The shell command the plugin fires when OpenCode raises a PERMISSION request —
83
+ * the OpenCode equivalent of "the agent needs the user" (Claude's `Notification`
84
+ * hook / the Cursor AskQuestion hook). Unlike Cursor, OpenCode genuinely exposes
85
+ * this signal: the `permission.asked` event fires the instant the agent pauses to
86
+ * ask the user to approve/answer something. We map it to a plain `awaiting_input`
87
+ * ping ("Agent needs you") — the SAME kind Claude Code fires — so a real question
88
+ * surfaces as a QUESTION notification, not a misleading `finished`. (We listen to
89
+ * `permission.asked` ONLY, never `permission.updated`/`permission.replied`, which
90
+ * also fire AFTER the user answers and would re-ping a resolved question.)
91
+ *
92
+ * Ends in `|| true` so a failed push can never block the session; carries a
93
+ * distinct `--dedupe-key` so it coalesces with itself but not with the idle ping.
94
+ */
95
+ export const OPENCODE_ASK_COMMAND = 'npx -y @trygocode/notify@latest send --kind awaiting_input --source opencode --title "Agent needs you" --dedupe-key opencode-permission || true';
75
96
  /**
76
97
  * Substrings that together identify our plugin file as OURS. Used to keep
77
98
  * uninstall surgical: we only delete the plugin file when BOTH markers are
@@ -157,13 +178,15 @@ import { spawn } from "node:child_process";
157
178
  // Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
158
179
  const lastFiredAt = new Map();
159
180
  const DEDUPE_MS = 4000;
181
+ // Separate per-session debounce for permission/question events so a rapid burst
182
+ // of permission.asked events for one prompt coalesces into a single ping, while
183
+ // staying INDEPENDENT of the end-of-turn debounce (a question must not be
184
+ // suppressed by a recent finished ping — different event class).
185
+ const lastAskedAt = new Map();
160
186
 
161
- function fire() {
187
+ function fire(command) {
162
188
  try {
163
- const child = spawn(
164
- ${JSON.stringify(OPENCODE_STOP_COMMAND)},
165
- { shell: true, detached: true, stdio: "ignore" },
166
- );
189
+ const child = spawn(command, { shell: true, detached: true, stdio: "ignore" });
167
190
  child.unref();
168
191
  } catch {
169
192
  // never block the session on a notification failure
@@ -195,13 +218,39 @@ function maybeFire(sessionID) {
195
218
  const prev = lastFiredAt.get(key) ?? 0;
196
219
  if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
197
220
  lastFiredAt.set(key, now);
198
- fire();
221
+ fire(${JSON.stringify(OPENCODE_STOP_COMMAND)});
222
+ }
223
+
224
+ // The agent paused to ask the user (permission.asked). Fire an "Agent needs
225
+ // you" question ping. Debounced separately from the end-of-turn ping so a burst
226
+ // of asks for one prompt coalesces into one.
227
+ function maybeAsk(sessionID) {
228
+ const key = sessionID || "_";
229
+ const now = Date.now();
230
+ const prev = lastAskedAt.get(key) ?? 0;
231
+ if (now - prev < DEDUPE_MS) return;
232
+ lastAskedAt.set(key, now);
233
+ fire(${JSON.stringify(OPENCODE_ASK_COMMAND)});
199
234
  }
200
235
 
201
236
  export const GocodeNotify = async () => ({
202
237
  event: async ({ event }) => {
203
238
  if (!event) return;
204
239
  const props = event.properties ?? {};
240
+ // QUESTION signal — the agent is waiting on the user (OpenCode's real
241
+ // "needs you" event; Cursor has no equivalent). Fired BEFORE the idle/status
242
+ // checks so a permission request is never misread as a plain end-of-turn.
243
+ //
244
+ // We listen ONLY to \`permission.asked\` — the moment the agent RAISES a
245
+ // request. We deliberately do NOT listen to \`permission.updated\` /
246
+ // \`permission.replied\`: those are state transitions that ALSO fire AFTER the
247
+ // user answers, which would re-emit "Agent needs you" once the question is
248
+ // already resolved — exactly the false-notification class this fix exists to
249
+ // avoid. \`permission.asked\` is the unambiguous "asking now" edge.
250
+ if (event.type === "permission.asked") {
251
+ maybeAsk(props.sessionID);
252
+ return;
253
+ }
205
254
  if (event.type === "session.idle") {
206
255
  maybeFire(props.sessionID);
207
256
  return;
@@ -332,7 +381,7 @@ export async function writeOpenCodeConfig(runtime, opts) {
332
381
  runtime: name,
333
382
  written,
334
383
  skipped: false,
335
- detail: "merged mcp.gocode-notify entry; wrote session.idle plugin (rule skipped — OpenCode has no standalone rule file)",
384
+ detail: "merged mcp.gocode-notify entry; wrote end-of-turn + permission(question) plugin (rule skipped — OpenCode has no standalone rule file)",
336
385
  };
337
386
  }
338
387
  catch (err) {
@@ -47,6 +47,12 @@ export const DEFAULT_NOTIFY_SETTINGS = {
47
47
  command: null,
48
48
  max_diff_bytes: 61440,
49
49
  },
50
+ // Branded desktop banners ON by default (PRD §3): installing the package opts
51
+ // you into computer notifications; flip `desktop.enabled false` to silence.
52
+ desktop: {
53
+ enabled: true,
54
+ sound: true,
55
+ },
50
56
  };
51
57
  /**
52
58
  * Every dotted setting key the CLI accepts in `config set <key> <value>`, with
@@ -74,6 +80,9 @@ export const KEY_SPECS = {
74
80
  "commit_message.mode": { kind: "enum", enumValues: ["auto", "ai", "deterministic"] },
75
81
  "commit_message.command": { kind: "string_or_null" },
76
82
  "commit_message.max_diff_bytes": { kind: "int_pos" },
83
+ // Branded desktop banners (GOCODE_NOTIFY_DESKTOP PRD §3) — ON by default.
84
+ "desktop.enabled": { kind: "boolean" },
85
+ "desktop.sound": { kind: "boolean" },
77
86
  };
78
87
  /** Sorted list of every valid `config set` key — for help text + error messages. */
79
88
  export function validKeys() {
@@ -1,2 +1,2 @@
1
1
  // Single source of truth for the CLI version. Keep in sync with package.json.
2
- export const VERSION = "0.3.4";
2
+ export const VERSION = "0.5.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.3.4",
4
- "description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
3
+ "version": "0.5.0",
4
+ "description": "Free phone + branded desktop notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/src",
12
+ "assets",
12
13
  "snippets",
13
14
  "README.md"
14
15
  ],
@@ -26,6 +27,8 @@
26
27
  "notifications",
27
28
  "push",
28
29
  "fcm",
30
+ "desktop-notifications",
31
+ "toast",
29
32
  "mcp",
30
33
  "cli",
31
34
  "claude-code",