@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 +1 -1
- package/dist/src/claude.js +54 -8
- package/dist/src/on_stop.js +3 -3
- package/dist/src/opencode.js +117 -15
- package/dist/src/rule-content.js +2 -2
- package/package.json +1 -1
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
|
|
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
|
|
package/dist/src/claude.js
CHANGED
|
@@ -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
|
|
6
|
-
// Stop → "finished" (
|
|
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
|
-
//
|
|
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
|
|
50
|
-
*
|
|
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
|
-
|
|
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]);
|
package/dist/src/on_stop.js
CHANGED
|
@@ -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) {
|
package/dist/src/opencode.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
});
|
package/dist/src/rule-content.js
CHANGED
|
@@ -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
|
|
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`
|
|
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