@trygocode/notify 0.1.3 → 0.1.5

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.
package/README.md CHANGED
@@ -109,7 +109,7 @@ or staging GoCode server.
109
109
 
110
110
  | Trigger | Mechanism | Fires when |
111
111
  |---|---|---|
112
- | **(A) Runtime hook** | Cursor `stop` / Claude Code `Stop`+`Notification`+`SubagentStop` / OpenCode `session.idle` plugin | Agent finishes a turn, goes idle, or errors — **automatic, the killer feature** |
112
+ | **(A) Runtime hook** | Cursor `stop` / Claude Code `Stop`+`Notification` / OpenCode `session.idle` plugin | The MAIN agent finishes a turn, goes idle, or errors — **automatic, the killer feature** |
113
113
  | **(B) MCP tool** | `gocode_notify` tool the agent calls | You *explicitly* ask "ping me when X is done" mid-task |
114
114
  | **(C) Loop shell hook** | one line in your loop's completion/halt path | A Ralph/Homer loop reaches `completed` / `halted` |
115
115
 
@@ -2,12 +2,16 @@
2
2
  // Claude Code. It does three things, all idempotently and without clobbering the
3
3
  // user's existing config:
4
4
  //
5
- // 1. MERGE three fire-and-forget hooks into `~/.claude/settings.json`:
6
- // Stop → "finished" (a turn completed)
5
+ // 1. MERGE two fire-and-forget hooks into `~/.claude/settings.json`:
6
+ // Stop → "finished" (the MAIN turn completed)
7
7
  // Notification → "awaiting_input" (the agent needs the user)
8
- // SubagentStop → "finished" (a subagent completed)
9
- // Each hook shells out to `gocode-notify send … || true` so a failed push
8
+ // Each hook shells out to `gocode-notify … || true` so a failed push
10
9
  // NEVER blocks the agent's turn (PRD §4.4, §5.3).
10
+ // NOTE: we deliberately do NOT register a `SubagentStop` hook — a notify
11
+ // should fire only when the MAIN agent finishes its turn, not on every
12
+ // internal subagent completion (that produced noisy "Subagent done" pings).
13
+ // Any prior `SubagentStop` hook of ours is actively stripped on re-setup
14
+ // (see {@link SUBAGENT_STOP_EVENT} + {@link mergeHooks}).
11
15
  // 2. MERGE an `mcpServers` entry pointing at `npx -y @trygocode/notify mcp`.
12
16
  // 3. WRITE the on-demand skill to `~/.claude/skills/gocode-notify/SKILL.md`
13
17
  // (the anti-double-ping rule, PRD §5.5).
@@ -46,14 +50,33 @@ export const MCP_SERVER_ENTRY = {
46
50
  * plain `finished` ping — exactly one notification per turn. The pre-T-C6 form
47
51
  * was `send --kind finished --source claude_code …`; both old and new commands
48
52
  * contain the {@link HOOK_MARKERS} tokens, so old installs upgrade idempotently
49
- * and uninstall cleanly. `Notification`/`SubagentStop` stay on the plain `send`
50
- * (they are not turn-completion events and never auto-push).
53
+ * and uninstall cleanly. `Notification` stays on the plain `send` (it is not a
54
+ * turn-completion event and never auto-pushes).
55
+ *
56
+ * We intentionally register ONLY `Stop` + `Notification`. `SubagentStop` is NOT
57
+ * here on purpose — see {@link SUBAGENT_STOP_EVENT}.
51
58
  */
52
59
  export const CLAUDE_HOOK_COMMANDS = {
53
60
  Stop: 'npx -y @trygocode/notify on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
54
61
  Notification: 'npx -y @trygocode/notify send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
55
- SubagentStop: 'npx -y @trygocode/notify send --kind finished --source claude_code --title "Subagent done" --dedupe-key "$CLAUDE_SESSION_ID-subagent" || true',
56
62
  };
63
+ /**
64
+ * The Claude event we DELIBERATELY do not register and actively scrub on every
65
+ * setup. We used to fire a `finished` push on `SubagentStop` ("Subagent done"),
66
+ * but a notification should only land when the MAIN agent finishes its turn —
67
+ * not for each internal subagent. `mergeHooks` strips any prior copy of OUR
68
+ * command from this event so existing installs are migrated clean on re-setup,
69
+ * and `uninstallClaudeConfig` also covers it (the uninstall strip is event-set
70
+ * agnostic). Kept as a named constant so the scrub list is single-sourced.
71
+ */
72
+ export const SUBAGENT_STOP_EVENT = "SubagentStop";
73
+ /**
74
+ * Events we make sure carry NONE of our hooks after a merge — events we no
75
+ * longer install but may have written in a prior version. `mergeHooks` walks
76
+ * this set and strips our commands from each, so re-running `setup` migrates an
77
+ * old install (with a stale "Subagent done" hook) to the current clean shape.
78
+ */
79
+ const DEPRECATED_HOOK_EVENTS = [SUBAGENT_STOP_EVENT];
57
80
  /**
58
81
  * Substrings that together identify a hook command as OURS. Used for idempotent
59
82
  * merge (replace, don't duplicate) and for surgical uninstall (remove exactly
@@ -176,12 +199,28 @@ function ourHookGroup(command) {
176
199
  */
177
200
  function mergeHooks(settings) {
178
201
  const hooks = isRecord(settings.hooks) ? settings.hooks : {};
202
+ // Install (or refresh) the events we DO want.
179
203
  for (const [event, command] of Object.entries(CLAUDE_HOOK_COMMANDS)) {
180
204
  const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
181
205
  const preserved = stripOurHooks(existing).groups;
182
206
  preserved.push(ourHookGroup(command));
183
207
  hooks[event] = preserved;
184
208
  }
209
+ // Migrate old installs: strip OUR command from any event we no longer install
210
+ // (e.g. the retired `SubagentStop` "Subagent done" ping). We only remove our
211
+ // own entries — a user's own hook on the same event is preserved. An event
212
+ // left with no groups is deleted so we don't leave an empty array behind.
213
+ for (const event of DEPRECATED_HOOK_EVENTS) {
214
+ if (!Array.isArray(hooks[event]))
215
+ continue;
216
+ const cleaned = stripOurHooks(hooks[event]).groups;
217
+ if (cleaned.length === 0) {
218
+ delete hooks[event];
219
+ }
220
+ else {
221
+ hooks[event] = cleaned;
222
+ }
223
+ }
185
224
  settings.hooks = hooks;
186
225
  }
187
226
  /** Merge our MCP server entry into `settings.mcpServers`. Mutates in place. */
@@ -247,7 +286,14 @@ export async function uninstallClaudeConfig(opts) {
247
286
  let changed = false;
248
287
  if (isRecord(settings.hooks)) {
249
288
  const hooks = settings.hooks;
250
- for (const event of Object.keys(CLAUDE_HOOK_COMMANDS)) {
289
+ // Scrub our hooks from the events we install AND from deprecated events
290
+ // we no longer install but may have written in a prior version (e.g. the
291
+ // retired `SubagentStop` ping) — otherwise an uninstall would orphan it.
292
+ const eventsToScrub = [
293
+ ...Object.keys(CLAUDE_HOOK_COMMANDS),
294
+ ...DEPRECATED_HOOK_EVENTS,
295
+ ];
296
+ for (const event of eventsToScrub) {
251
297
  if (!Array.isArray(hooks[event]))
252
298
  continue;
253
299
  const { groups: kept, removed } = stripOurHooks(hooks[event]);
@@ -106,12 +106,12 @@ export async function onStop(opts = {}) {
106
106
  timestamp: opts.timestamp,
107
107
  home: opts.home,
108
108
  });
109
- await logLine(`auto-push path → ${push.outcome} (settings: ${resolved.source})`);
109
+ await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
110
110
  return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
111
111
  }
112
112
  // ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
113
113
  if (opts.dryRun) {
114
- await logLine(`dry-run: would send finished (auto-push off, settings: ${resolved.source})`);
114
+ await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
115
115
  return { mode: "dry-run-send", settingsSource: resolved.source, repo };
116
116
  }
117
117
  const payload = { kind: "finished", source };
@@ -120,7 +120,7 @@ export async function onStop(opts = {}) {
120
120
  if (opts.dedupeKey)
121
121
  payload.dedupe_key = opts.dedupeKey;
122
122
  const sent = await sendImpl(payload);
123
- await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (settings: ${resolved.source})`);
123
+ await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
124
124
  return { mode: "send", settingsSource: resolved.source, send: sent, repo };
125
125
  }
126
126
  catch (err) {
@@ -77,33 +77,135 @@ export const OPENCODE_STOP_COMMAND = "npx -y @trygocode/notify on-stop --source
77
77
  */
78
78
  const PLUGIN_MARKERS = ["gocode-notify", "--source opencode"];
79
79
  /**
80
- * The `session.idle` plugin written to `<config-dir>/plugin/gocode-notify.js`.
80
+ * Best-effort predicate: does an OpenCode `session.status` payload's `status`
81
+ * value mean "the agent finished this turn"?
82
+ *
83
+ * The `status` value has shifted shape across OpenCode versions — a bare string
84
+ * (`"idle"`) and an object (`{ type | state | status: "idle" }`). We normalise
85
+ * to a lowercase string and treat any idle/done/complete/finish word as
86
+ * end-of-turn, while explicitly REJECTING busy/working/running/active/stream/
87
+ * pending states so we never ping mid-turn.
88
+ *
89
+ * Exported so the generated plugin's behaviour is unit-testable in isolation
90
+ * (the plugin body inlines the identical logic — keep the two in lockstep; the
91
+ * `opencode.test.ts` "statusIsIdle parity" test guards against drift).
92
+ */
93
+ export function opencodeStatusIsIdle(status) {
94
+ if (status == null)
95
+ return false;
96
+ const raw = typeof status === "string"
97
+ ? status
98
+ : isRecord(status)
99
+ ? (status.type ?? status.state ?? status.status ?? "")
100
+ : "";
101
+ const s = String(raw).toLowerCase();
102
+ if (!s)
103
+ return false;
104
+ if (s.includes("busy") ||
105
+ s.includes("work") ||
106
+ s.includes("run") ||
107
+ s.includes("active") ||
108
+ s.includes("stream") ||
109
+ s.includes("pending")) {
110
+ return false;
111
+ }
112
+ return (s.includes("idle") ||
113
+ s.includes("done") ||
114
+ s.includes("complete") ||
115
+ s.includes("finish"));
116
+ }
117
+ /**
118
+ * The end-of-turn plugin written to `<config-dir>/plugin/gocode-notify.js`.
119
+ *
81
120
  * An OpenCode plugin exports an async factory returning `{ event }`; we fire the
82
- * shared `on-stop` dispatcher fire-and-forget on `session.idle`. The child is
83
- * detached + unref'd with stdio ignored so it NEVER blocks the session, and any
84
- * spawn error is swallowed (belt-and-braces with the command's own `|| true`).
121
+ * shared `on-stop` dispatcher fire-and-forget when a session finishes a turn.
122
+ *
123
+ * IMPORTANT why we listen to TWO events:
124
+ * - `session.idle` is the original "turn finished" signal, but as of recent
125
+ * OpenCode builds (≥ ~1.14) it is **deprecated** and no longer reliably
126
+ * emitted in the GUI (upstream moved to `session.status`). Riding it alone
127
+ * meant "Cursor pings me, OpenCode doesn't".
128
+ * - `session.status` (payload `{ sessionID, status }`) is the modern signal.
129
+ * It fires on EVERY status change, so we only treat it as end-of-turn when
130
+ * the status is the idle/finished state (best-effort shape detection — the
131
+ * `status` value has been a string and an object across versions).
132
+ *
133
+ * We subscribe to BOTH so the plugin works on old AND new OpenCode. A small
134
+ * per-session debounce (DEDUPE_MS) coalesces the idle+status pair for the same
135
+ * turn so we never spawn `on-stop` twice; the server's `--dedupe-key` is the
136
+ * second line of defence. The child is detached + unref'd with stdio ignored so
137
+ * it NEVER blocks the session, and any spawn error is swallowed (belt-and-braces
138
+ * with the command's own `|| true`).
85
139
  */
86
- export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode session.idle plugin (auto-generated by @trygocode/notify).
140
+ export const OPENCODE_PLUGIN_CONTENT = `// gocode-notify — OpenCode end-of-turn plugin (auto-generated by @trygocode/notify).
87
141
  // Fires exactly one fire-and-forget phone notification when an OpenCode session
88
- // goes idle (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
142
+ // finishes a turn (the OpenCode equivalent of Claude \`Stop\` / Cursor \`stop\`) by
89
143
  // shelling out to the shared gocode-notify \`on-stop\` dispatcher. It NEVER blocks
90
144
  // the session: the child is detached + unref'd, stdio ignored, errors swallowed.
91
145
  //
146
+ // Listens to BOTH \`session.idle\` (legacy, deprecated in newer OpenCode) and
147
+ // \`session.status\` (modern end-of-turn signal). A per-session debounce stops the
148
+ // two from double-firing for the same turn. See tools/gocode-notify/src/opencode.ts.
149
+ //
92
150
  // Stable markers (do not edit): gocode-notify --source opencode
93
151
  // Managed by @trygocode/notify — \`npx @trygocode/notify uninstall\` removes this file.
94
152
  import { spawn } from "node:child_process";
95
153
 
154
+ // Per-session last-fire timestamps so idle+status for the SAME turn coalesce.
155
+ const lastFiredAt = new Map();
156
+ const DEDUPE_MS = 4000;
157
+
158
+ function fire() {
159
+ try {
160
+ const child = spawn(
161
+ ${JSON.stringify(OPENCODE_STOP_COMMAND)},
162
+ { shell: true, detached: true, stdio: "ignore" },
163
+ );
164
+ child.unref();
165
+ } catch {
166
+ // never block the session on a notification failure
167
+ }
168
+ }
169
+
170
+ // Best-effort: does this \`session.status\` payload mean "the agent finished"?
171
+ // The status value has been a bare string ("idle") and an object ({type|state|
172
+ // status: "idle"}) across OpenCode versions; treat any of those idle-ish shapes
173
+ // as end-of-turn, and explicitly IGNORE busy/working/running states.
174
+ function statusIsIdle(status) {
175
+ if (status == null) return false;
176
+ const s = (typeof status === "string"
177
+ ? status
178
+ : (status.type ?? status.state ?? status.status ?? "")
179
+ ).toString().toLowerCase();
180
+ if (!s) return false;
181
+ if (s.includes("busy") || s.includes("work") || s.includes("run") ||
182
+ s.includes("active") || s.includes("stream") || s.includes("pending")) {
183
+ return false;
184
+ }
185
+ return s.includes("idle") || s.includes("done") ||
186
+ s.includes("complete") || s.includes("finish");
187
+ }
188
+
189
+ function maybeFire(sessionID) {
190
+ const key = sessionID || "_";
191
+ const now = Date.now();
192
+ const prev = lastFiredAt.get(key) ?? 0;
193
+ if (now - prev < DEDUPE_MS) return; // coalesce idle+status for one turn
194
+ lastFiredAt.set(key, now);
195
+ fire();
196
+ }
197
+
96
198
  export const GocodeNotify = async () => ({
97
199
  event: async ({ event }) => {
98
- if (!event || event.type !== "session.idle") return;
99
- try {
100
- const child = spawn(
101
- ${JSON.stringify(OPENCODE_STOP_COMMAND)},
102
- { shell: true, detached: true, stdio: "ignore" },
103
- );
104
- child.unref();
105
- } catch {
106
- // never block the session on a notification failure
200
+ if (!event) return;
201
+ const props = event.properties ?? {};
202
+ if (event.type === "session.idle") {
203
+ maybeFire(props.sessionID);
204
+ return;
205
+ }
206
+ if (event.type === "session.status") {
207
+ if (statusIsIdle(props.status)) maybeFire(props.sessionID);
208
+ return;
107
209
  }
108
210
  },
109
211
  });
@@ -8,7 +8,7 @@
8
8
  // 1. The frontmatter block (Claude SKILL.md `name:`+`description:` vs Cursor
9
9
  // rule `description:`+`alwaysApply:`).
10
10
  // 2. The parenthetical naming WHICH runtime hook owns the automatic pings
11
- // (Claude fires `Stop`+`Notification`+`SubagentStop`; Cursor fires `stop`).
11
+ // (Claude fires `Stop`+`Notification`; Cursor fires `stop`).
12
12
  //
13
13
  // Centralising the prose here means the anti-double-ping invariant ("the hooks
14
14
  // own the automatic done/idle/error pings — only call the MCP tool when the user
@@ -58,7 +58,7 @@ name: gocode-notify
58
58
  description: On-demand phone notifications via the GoCode app. Use ONLY when the user EXPLICITLY asks to be pinged/notified when something finishes (e.g. "text me when the build is done").
59
59
  ---`;
60
60
  /** How Claude Code's automatic-ping hooks are named in the double-notify warning. */
61
- export const CLAUDE_HOOK_DESCRIPTION = "Claude Code `Stop` + `Notification` +\n`SubagentStop`";
61
+ export const CLAUDE_HOOK_DESCRIPTION = "Claude Code `Stop` + `Notification`";
62
62
  /**
63
63
  * Cursor rule frontmatter (PRD §5.5). `alwaysApply: false` + a trigger-y
64
64
  * description so Cursor surfaces it on "notify me / ping me / let me know when".
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",