@trygocode/notify 0.1.1 → 0.1.3

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
@@ -1,76 +1,21 @@
1
- <p align="center">
2
- <img src="assets/icon-512.png" alt="GoCode Notify" width="104" height="104" />
3
- </p>
1
+ # @trygocode/notify
4
2
 
5
- <h1 align="center">@trygocode/notify</h1>
3
+ Free phone notifications for any coding agent — **Cursor, Claude Code, OpenCode,
4
+ or any Ralph/Homer loop** — delivered to your phone via the
5
+ [GoCode](https://oh.jeltechsolutions.com) app + FCM.
6
6
 
7
- <p align="center">
8
- <strong>Get a push notification on your phone the moment your AI coding agent finishes.</strong><br/>
9
- Cursor · Claude Code · OpenCode · Ralph Wiggum / Autopilot script loops installed with <em>one</em> command.
10
- </p>
11
-
12
- <p align="center">
13
- <a href="https://www.npmjs.com/package/@trygocode/notify"><img alt="npm" src="https://img.shields.io/npm/v/@trygocode/notify?color=5EE6A8&label=npm"></a>
14
- <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
15
- <img alt="price" src="https://img.shields.io/badge/price-free-5EE6A8">
16
- <img alt="node" src="https://img.shields.io/badge/node-%3E%3D18-339933?logo=node.js&logoColor=white">
17
- <img alt="works with" src="https://img.shields.io/badge/works%20with-Cursor%20%C2%B7%20Claude%20Code%20%C2%B7%20OpenCode-111">
18
- </p>
19
-
20
- ```bash
21
- npx @trygocode/notify@latest setup
22
- ```
23
-
24
- <p align="center">
25
- <img src="assets/banner.png" alt="GoCode Notify" width="640" />
26
- </p>
27
-
28
- > **Free.** `@trygocode/notify` and the **[GoCode](https://oh.jeltechsolutions.com)** phone app
29
- > that receives the pushes are both free to use.
30
- >
31
- > **You need the GoCode app.** Notifications are delivered to your phone through the free GoCode
32
- > app — it's the pairing target and the thing your phone buzzes with. Install it, pair once
33
- > (a 6-digit code), done. Without it there's nowhere for the pushes to land.
34
-
35
- ---
36
-
37
- ## Why?
38
-
39
- You kick off a long agent run, then walk away to make coffee, take a call, or
40
- context-switch to something else. Now you're stuck in the loop of *checking back
41
- every 30 seconds* to see if it's done — or worse, it finished 20 minutes ago and
42
- you didn't notice.
43
-
44
- **`@trygocode/notify` pings your phone the instant your agent finishes a turn, goes
45
- idle waiting for you, errors out, or an overnight loop completes/halts.** Walk
46
- away. Your phone tells you when it needs you.
47
-
48
- - 🆓 **Free.** The package and the GoCode phone app are free. No card, no trial.
49
- - ⚡ **One command.** `npx @trygocode/notify@latest setup` — no server to host, no config files to hand-edit.
50
- - 🔒 **Push-only & private.** The paired key can *only* send notifications to your phone. It can't read your chats, code, or settings. Revoke it any time.
51
- - 🧩 **Auto-detects your tools.** Wires up Cursor, Claude Code, and OpenCode in one go — never clobbering your existing hooks/MCP config.
52
- - 🪶 **Never blocks your agent.** Every send is fire-and-forget with a hard timeout + an offline queue. A slow push can't slow your work.
53
-
54
- ## Works with
55
-
56
- | Tool | How it hooks in |
57
- |---|---|
58
- | **Cursor** | `stop` hook |
59
- | **Claude Code** | `Stop` + `Notification` + `SubagentStop` hooks |
60
- | **OpenCode** | runtime hook |
61
- | **Ralph Wiggum / Autopilot script loops** | opt-in completion/halt snippet |
62
-
63
- > **Requires the free [GoCode](https://oh.jeltechsolutions.com) phone app.** It's the one-time
64
- > pairing target and where every push lands. Install GoCode, pair once, done — both the app and
65
- > this package are free.
7
+ You get an automatic push when your agent **finishes a turn**, **goes idle
8
+ waiting for you**, **errors out**, or when an overnight **loop completes / halts**
9
+ so you can walk away and let your phone tell you when it needs you.
66
10
 
67
11
  ## Contents
68
12
 
69
13
  - [Install — two equally-supported paths](#install--two-equally-supported-paths)
70
14
  - [Pairing — step by step](#pairing--step-by-step)
71
15
  - [The three triggers](#the-three-triggers)
72
- - [Loop opt-in snippet (trigger C)](#loop-opt-in-snippet-trigger-c)
73
- - [GoCode platform settings (auto-push, pull-before-push & more)](#gocode-platform-settings)
16
+ - [Auto-push to git (opt-in)](#auto-push-to-git-opt-in)
17
+ - [Settings sync the `config` command](#settings-sync--the-config-command)
18
+ - [Ralph/Homer opt-in snippet (trigger C)](#ralphhomer-opt-in-snippet-trigger-c)
74
19
  - [Troubleshooting](#troubleshooting)
75
20
  - [Develop](#develop)
76
21
  - [Layout](#layout)
@@ -93,9 +38,10 @@ This runs the interactive installer: it prompts for the 6-digit pairing code
93
38
  Claude Code / Cursor / OpenCode. Re-run any time — it's idempotent; pass
94
39
  `--force` to re-pair.
95
40
 
96
- > First time? You'll need the free **[GoCode](https://oh.jeltechsolutions.com)**
97
- > app on your phone to receive the pushes and to generate the 6-digit pairing
98
- > code (Settings **"Connect a coding agent"**).
41
+ > Until the package is published to npm, point `npx` at the local tarball
42
+ > (`npm pack` in this directory, then `npx ./gocode-notify-*.tgz setup`) or a
43
+ > path install. The npm publish is a user-owned step — see
44
+ > [`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md).
99
45
 
100
46
  ### Path 2 — paste a prompt into your AI agent and let it install
101
47
 
@@ -163,20 +109,162 @@ or staging GoCode server.
163
109
 
164
110
  | Trigger | Mechanism | Fires when |
165
111
  |---|---|---|
166
- | **(A) Runtime hook** | Cursor `stop` / Claude Code `Stop`+`Notification`+`SubagentStop` | Agent finishes a turn, goes idle, or errors — **automatic, the killer feature** |
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** |
167
113
  | **(B) MCP tool** | `gocode_notify` tool the agent calls | You *explicitly* ask "ping me when X is done" mid-task |
168
- | **(C) Loop shell hook** | one line in your loop's completion/halt path | A Ralph Wiggum / Autopilot script loop reaches `completed` / `halted` |
114
+ | **(C) Loop shell hook** | one line in your loop's completion/halt path | A Ralph/Homer loop reaches `completed` / `halted` |
169
115
 
170
116
  The installed rule/skill tells the agent **not** to call the MCP tool for
171
117
  done/idle/error pings — those are owned by the deterministic hook (A), so you
172
118
  never get double-pinged.
173
119
 
174
- ## Loop opt-in snippet (trigger C)
120
+ > **OpenCode's hook (A)** is a small `session.idle` plugin written to
121
+ > `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
122
+ > fire-and-forgets `gocode-notify on-stop --source opencode` (the same
123
+ > dispatcher as Cursor/Claude), so OpenCode is a fully-supported runtime with
124
+ > identical behaviour.
125
+
126
+ > **Under the hood (since auto-push landed):** the runtime hook (A) no longer
127
+ > shells straight to `send`. It now calls a single end-of-turn **dispatcher**,
128
+ > `gocode-notify on-stop --source <runtime> || true`, which decides per repo:
129
+ > if auto-push is enabled for this repo it runs the push flow (and sends ONE
130
+ > notification carrying the commit summary); otherwise it sends the ordinary
131
+ > `finished` ping exactly as before. One hook entry, one notification per turn.
132
+ > Existing installs upgrade their hook automatically on the next
133
+ > `setup`/`setup --force`. `send` and `test` remain callable on their own for
134
+ > scripts and power users.
135
+
136
+ ## Auto-push to git (opt-in)
137
+
138
+ GoCode Notify can **auto-commit and push your work after every agent turn** —
139
+ with an **AI-written commit message** describing what just changed — so an
140
+ overnight Cursor/Claude/Ralph session lands as a tidy series of pushed commits
141
+ and your phone tells you each time, commit summary included.
142
+
143
+ It is **OFF by default** and deliberately conservative (see the safety posture
144
+ below). The full design lives in
145
+ [`docs/GOCODE_NOTIFY_AUTO_PUSH_AND_SYNC.md`](../../docs/GOCODE_NOTIFY_AUTO_PUSH_AND_SYNC.md).
146
+
147
+ ### Enable it
148
+
149
+ From the **GoCode app** (Settings → **GoCode Notify** → *Auto-push after every
150
+ prompt*; a first-enable dialog explains the behaviour), or from the terminal:
151
+
152
+ ```bash
153
+ # Turn auto-push on for ALL repos (global default):
154
+ gocode-notify config set auto_push.enabled true
155
+
156
+ # Or scope it to just the current repo:
157
+ gocode-notify config set auto_push.enabled true --repo
158
+ ```
175
159
 
176
- For power users running an autonomous loop **they control** a *Ralph Wiggum*-style
177
- "keep prompting until done" loop, an Autopilot script, a `while :; do done`
178
- one-liner, or any custom driver drop these two lines into the loop's
179
- completion/halt path:
160
+ Either editor writes to the server (the single source of truth); the change is
161
+ picked up by every linked machine on its next turn (within the 60s settings
162
+ cache, or immediately after `gocode-notify config pull`).
163
+
164
+ ### What happens on each turn (when enabled)
165
+
166
+ The `on-stop` dispatcher runs this best-effort push flow (it **always exits 0**
167
+ so a git hiccup can never block or fail your agent's turn):
168
+
169
+ 1. Resolve the merged Notify settings for this repo (cached ~60s).
170
+ 2. If `auto_push.enabled` is not true for the repo → no-op (plain ping).
171
+ 3. Verify it's a git repo; resolve the target branch (per-repo override →
172
+ global default → current checked-out branch).
173
+ 4. **Branch guard:** never push to `main`/`master` unless you have separately
174
+ set `auto_push.allow_protected true`. Protected + not allowed → skip, no push.
175
+ 5. `git add -A`; if the tree is clean → exit 0 (no spurious notification).
176
+ 6. Compose the commit message (AI, with deterministic fallback — see below).
177
+ 7. `git commit` then `git push <remote> <branch>` — **fast-forward only, never
178
+ `--force`.** A non-FF rejection sends an `error` notification ("auto-push
179
+ rejected — pull + retry") and exits 0.
180
+ 8. Send ONE `finished` push whose body carries the commit summary, branch, and
181
+ short SHA.
182
+
183
+ ### The AI commit message (free) + deterministic fallback
184
+
185
+ The commit subject/body is summarised **using credentials you already have on
186
+ the machine** — so it costs GoCode nothing. Resolution chain (first that works
187
+ wins, hard **8s** cap):
188
+
189
+ 1. `commit_message.mode = "deterministic"` → skip AI, use the template below.
190
+ 2. Otherwise an explicit `commit_message.command` (e.g. `"claude -p"`,
191
+ `"cursor-agent --print"`, `"ollama run llama3.2"`) — the staged diff is
192
+ piped in on stdin with a fixed prompt; output is sanitised (code fences /
193
+ "Here is…" preambles stripped, subject clamped to ≤72 chars).
194
+ 3. Auto-detect from `--source`: `claude` on PATH for Claude Code, `cursor-agent`
195
+ for Cursor, else `ollama` if present.
196
+ 4. **Deterministic fallback** (always available — used if the above fail or time
197
+ out): `<type>: <N> file(s) changed on <branch>` + a `git diff --stat` block,
198
+ where `<type>` is inferred from the changed paths (`docs:`/`test:`/`chore:`/
199
+ `feat:`). The push **never** fails because the AI summary was unavailable.
200
+
201
+ ### Try it without writing anything
202
+
203
+ ```bash
204
+ # Print exactly what the end-of-turn dispatcher WOULD do (no git writes, no push):
205
+ gocode-notify on-stop --dry-run
206
+ ```
207
+
208
+ ### Safety posture (conservative defaults)
209
+
210
+ - **OFF by default**, opt-in, with a first-time confirmation in the app.
211
+ - **Branch guard ON:** `main`/`master` are never auto-pushed unless
212
+ `auto_push.allow_protected` is explicitly true.
213
+ - **No force-push, ever** — plain fast-forward `git push`; non-FF → error
214
+ notification, no rewrite.
215
+ - **Never blocks the agent** — every step is best-effort and exits 0, the same
216
+ contract as `send`.
217
+
218
+ ## Settings sync — the `config` command
219
+
220
+ Notify behaviour used to be hard-wired; now it's a small set of **per-user
221
+ settings stored on the server** and editable from either the phone app or the
222
+ terminal. Whatever you change in one place propagates to the other (and to your
223
+ other linked machines).
224
+
225
+ ```bash
226
+ gocode-notify config get [--repo] # print the merged settings (JSON/pretty)
227
+ gocode-notify config set <key> <value> [--repo] # validate, write to server, refresh cache
228
+ gocode-notify config pull # force-refresh the local cache from server
229
+ ```
230
+
231
+ - `--repo` scopes a read or write to the **current repo's** per-project
232
+ override (keyed by a stable `repo_key` derived from the `origin` remote)
233
+ instead of your global defaults. Per-repo overrides deep-merge over the global
234
+ blob.
235
+ - Writes go to the server **and** update the local cache so the next stop-hook
236
+ is immediate. Unknown keys are rejected with the list of valid keys.
237
+
238
+ Valid keys (mirror of the canonical schema):
239
+
240
+ | Key | Type | Meaning |
241
+ |---|---|---|
242
+ | `kinds.finished` / `kinds.error` / `kinds.awaiting_input` / `kinds.loop_completed` / `kinds.loop_halted` | bool | Per-kind notification toggles |
243
+ | `min_duration_seconds` | int ≥ 0 | Only notify if the turn ran ≥ N seconds (0 = always) |
244
+ | `quiet_hours.enabled` / `quiet_hours.start` / `quiet_hours.end` / `quiet_hours.tz` | bool / `HH:MM` / `HH:MM` / IANA tz | Do-not-disturb window |
245
+ | `auto_push.enabled` | bool | Master auto-push switch (OFF by default) |
246
+ | `auto_push.default_branch` | string \| null | Global default push branch (null = current branch) |
247
+ | `auto_push.branch` | string \| null | **`--repo` only** — per-repo push branch override |
248
+ | `auto_push.allow_protected` | bool | Permit auto-push to `main`/`master` (default false) |
249
+ | `auto_push.remote` | string | Git remote to push to (default `origin`) |
250
+ | `auto_push.skip_git_hooks` | bool | Pass `--no-verify` to the auto-commit (default false) |
251
+ | `commit_message.mode` | `auto` \| `ai` \| `deterministic` | Commit-message strategy |
252
+ | `commit_message.command` | string \| null | Explicit local summariser command |
253
+ | `commit_message.max_diff_bytes` | int > 0 | Diff byte cap fed to the summariser (default 61440) |
254
+
255
+ ```bash
256
+ # Examples
257
+ gocode-notify config set quiet_hours "23:00-07:00" # shorthand: enable + set window
258
+ gocode-notify config set min_duration_seconds 30
259
+ gocode-notify config set kinds.error false
260
+ gocode-notify config set auto_push.branch dev --repo # push this repo to "dev"
261
+ ```
262
+
263
+ ## Ralph/Homer opt-in snippet (trigger C)
264
+
265
+ For power users running a loop **they control** (this repo's `ralph`/`homer`
266
+ skills, a `while :; do … done` one-liner, or any custom driver), drop these two
267
+ lines into the loop's completion/halt path:
180
268
 
181
269
  ```bash
182
270
  # At loop completion:
@@ -193,24 +281,6 @@ The ready-to-copy version with comments lives at
193
281
  loop scripts. Both lines are fire-and-forget (`|| true` + the CLI's 5s
194
282
  self-timeout), so a failed or slow push can never block or fail your loop.
195
283
 
196
- ## GoCode platform settings
197
-
198
- This package is the *notifier*. The richer automation lives in the free **GoCode**
199
- phone app (Settings → per-project + global), which uses these notifications as its
200
- signalling layer. Once paired, the app gives you:
201
-
202
- | Setting | What it does |
203
- |---|---|
204
- | **Notification preferences** | Choose which events ping your phone — done / idle / error, loop completed, loop halted, agent questions, merge-ready — globally or per project. |
205
- | **Auto-push to git** | Opt-in: after a turn, the agent commits + pushes to a chosen branch so your work is never stranded on one machine and you can pick up anywhere. Off by default. |
206
- | **Pull-before-push (safe)** | When auto-push is on, a `git pull --rebase --autostash` runs first so remote changes merge cleanly. On a real conflict it aborts safely (nothing lost) and pings your phone. |
207
- | **AI-Solve conflicts** | If a push hits a conflict, the notification deep-links into the chat with two actions: **Acknowledge** or **AI-Solve** — your agent diagnoses local vs remote and resolves it with no code loss, asking you only when it's unsure. |
208
- | **Review & merge / Create PR** | When an Autopilot loop finishes on its own branch, you get a merge-ready ping; choose **Merge & push** (solo repos) or **Create PR** (teams), with a remembered default branch. |
209
- | **Default LLM & loop model** | Per-project defaults for the agent model and the Autopilot loop model. |
210
-
211
- > These toggles are configured **in the GoCode app**, not in this npm package — the
212
- > package just delivers the pings that drive them. All of it is free.
213
-
214
284
  ## Troubleshooting
215
285
 
216
286
  **Start here:** `gocode-notify status` prints a one-screen report — whether
@@ -225,9 +295,9 @@ been written. Most issues below are diagnosable from that output.
225
295
  | `status` shows **Server: not reachable** | Network/DNS/firewall, or a wrong server URL. Confirm you can reach `https://oh.jeltechsolutions.com`; check the `--server` flag / `GOCODE_SERVER` env / the `server` field in `~/.gocode/credentials`. |
226
296
  | **No push arrives** even though `test` exits 0 | The send is fire-and-forget and exits 0 even on failure — check `~/.gocode/notify.log` for the real error. Also confirm push permissions are granted in the GoCode app and the device token is registered (re-open the app once after signing in). |
227
297
  | **Double pings** (two notifications per event) | The agent is calling the `gocode_notify` MCP tool *and* the runtime hook is firing. Re-run `setup` so the anti-double-ping rule/skill is installed; it tells the agent not to notify for automatic done/idle/error events. |
228
- | **Hook doesn't fire** in Cursor / Claude Code | Re-run `setup` and check `status` shows "config written" for that runtime. Restart the agent app so it reloads `~/.cursor/hooks.json` / `~/.claude/settings.json`. The hooks are merged, never clobbered — your existing hooks are preserved. |
298
+ | **Hook doesn't fire** in Cursor / Claude Code / OpenCode | Re-run `setup` and check `status` shows "config written" for that runtime. Restart the agent app so it reloads `~/.cursor/hooks.json` / `~/.claude/settings.json` / `~/.config/opencode/plugin/gocode-notify.js`. The hooks are merged, never clobbered — your existing hooks are preserved. |
229
299
  | **Pushes queue up while offline** then arrive later | Expected. Sends made while the server is unreachable are enqueued to `~/.gocode/outbox/` (size-capped, drop-oldest) and flushed best-effort on the next `send`. A missed "done" ping is acceptable; a blocked agent is not. |
230
- | **`npx @trygocode/notify` can't find the package** | Make sure you're online and using the scoped name exactly: `npx @trygocode/notify@latest setup`. Clear a stale npx cache with `npx clear-npx-cache` (or `rm -rf ~/.npm/_npx`) and retry. |
300
+ | **`npx @trygocode/notify` can't find the package** | Until it's published to npm, run from a local tarball: `npm pack` in `tools/gocode-notify/`, then `npx ./gocode-notify-*.tgz <command>`. See [`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md). |
231
301
  | **Want it gone** | `gocode-notify uninstall` removes exactly the hook/MCP/rule entries this tool added (nothing else). Delete `~/.gocode/` to also drop the stored credentials, and revoke the key from the app's **"Connected agents"** screen. |
232
302
 
233
303
  **Logs & files.** Failures are appended to `~/.gocode/notify.log` (size-capped,
@@ -235,15 +305,15 @@ rotated to `notify.log.1`). Credentials live in `~/.gocode/credentials` (chmod
235
305
  `600`); non-secret prefs in `~/.gocode/config.json`; the offline queue in
236
306
  `~/.gocode/outbox/`.
237
307
 
238
- Found a bug or have a feature idea? Please
239
- [open an issue](https://github.com/joseph-lewis/gocode-notify/issues) issues are
240
- welcome and usually get a reply within a day or two.
308
+ For the device/secret/publish/deploy steps that are **not** part of this package
309
+ (Android `google-services.json`, iOS APNs, server service-account, WSL rebuild,
310
+ npm publish, real-device E2E), see
311
+ [`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md).
241
312
 
242
313
  ## Develop
243
314
 
244
315
  ```bash
245
- git clone https://github.com/joseph-lewis/gocode-notify.git
246
- cd gocode-notify
316
+ cd tools/gocode-notify
247
317
  npm install
248
318
  npm run build # compile TypeScript -> dist/
249
319
  npm test # builds, then runs node --test on dist/test/
@@ -258,9 +328,13 @@ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
258
328
 
259
329
  | Path | Purpose |
260
330
  |---|---|
261
- | `src/cli.ts` | `gocode-notify` bin entrypoint + command dispatcher |
331
+ | `src/cli.ts` | `gocode-notify` bin entrypoint + command dispatcher (incl. `on-stop`, `config`) |
262
332
  | `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
263
- | `src/claude.ts` / `src/cursor.ts` | Per-client config writers (hooks + MCP + rule/skill) |
333
+ | `src/claude.ts` / `src/cursor.ts` / `src/opencode.ts` | Per-client config writers (hooks + MCP + rule/skill); hooks call the `on-stop` dispatcher. OpenCode uses a `session.idle` plugin instead of a hooks file |
264
334
  | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server |
335
+ | `src/push.ts` | Auto-push flow behind the `on-stop` dispatcher — git add/commit/push, FF-only |
336
+ | `src/commit_message.ts` | AI commit-message resolution chain + deterministic fallback |
337
+ | `src/config.ts` | `config get\|set\|pull` — the dev-machine settings editor (server-synced) |
338
+ | `src/settings.ts` | Canonical Notify settings schema + valid-key specs (mirrors the server + app) |
265
339
  | `snippets/ralph-homer.sh` | Opt-in loop completion/halt snippet (trigger C) |
266
340
  | `test/` | `node:test` smoke + unit tests |
@@ -36,22 +36,38 @@ export const MCP_SERVER_ENTRY = {
36
36
  args: ["-y", "@trygocode/notify", "mcp"],
37
37
  };
38
38
  /**
39
- * The hook command for each Claude Code event (PRD §5.3, verbatim shape). Every
40
- * command ends in `|| true` so a notification failure can never block the
41
- * agent's turn, and carries a per-session `--dedupe-key` so overlapping triggers
42
- * (e.g. Cursor `stop` + Claude `Stop`) coalesce server-side.
39
+ * The hook command for each Claude Code event (PRD §2.2, §5.3). Every command
40
+ * ends in `|| true` so a notification failure can never block the agent's turn,
41
+ * and carries a per-session `--dedupe-key` so overlapping triggers (e.g. Cursor
42
+ * `stop` + Claude `Stop`) coalesce server-side.
43
+ *
44
+ * `Stop` calls the `on-stop` DISPATCHER (PRD §2.2): it resolves settings and
45
+ * either runs the auto-push flow (which sends its own notification) or fires the
46
+ * plain `finished` ping — exactly one notification per turn. The pre-T-C6 form
47
+ * was `send --kind finished --source claude_code …`; both old and new commands
48
+ * 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).
43
51
  */
44
52
  export const CLAUDE_HOOK_COMMANDS = {
45
- Stop: 'gocode-notify send --kind finished --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
46
- Notification: 'gocode-notify send --kind awaiting_input --source claude_code --title "Agent needs you" --dedupe-key "$CLAUDE_SESSION_ID-notify" || true',
47
- SubagentStop: 'gocode-notify send --kind finished --source claude_code --title "Subagent done" --dedupe-key "$CLAUDE_SESSION_ID-subagent" || true',
53
+ Stop: 'npx -y @trygocode/notify on-stop --source claude_code --dedupe-key "$CLAUDE_SESSION_ID-stop" || true',
54
+ 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',
48
56
  };
49
57
  /**
50
58
  * Substrings that together identify a hook command as OURS. Used for idempotent
51
59
  * merge (replace, don't duplicate) and for surgical uninstall (remove exactly
52
- * ours). A command must contain BOTH to be considered ours.
60
+ * ours). A command must contain BOTH to be considered ours. These tokens are
61
+ * present in BOTH the legacy `send --source claude_code` commands and the new
62
+ * `on-stop --source claude_code` Stop command, so they match old AND new
63
+ * installs (PRD §2.2).
53
64
  */
54
- const HOOK_MARKERS = ["gocode-notify", "--source claude_code"];
65
+ // Match OUR claude hook commands across ALL historical forms (current
66
+ // `npx -y @trygocode/notify …`, published-0.1.2 `npx … notify send …`, and legacy
67
+ // bare `gocode-notify …`). `--source claude_code` is the stable discriminator;
68
+ // the name token matches whichever invocation style produced the entry.
69
+ const HOOK_NAME_TOKENS = ["@trygocode/notify", "gocode-notify", "notify send"];
70
+ const HOOK_SOURCE_TOKEN = "--source claude_code";
55
71
  /**
56
72
  * The on-demand skill written to `~/.claude/skills/gocode-notify/SKILL.md`
57
73
  * (PRD §5.5). The crucial content is the anti-double-ping rule: the automatic
@@ -119,9 +135,10 @@ async function writeJsonFile(file, value) {
119
135
  }
120
136
  /** True when a single hook ENTRY (`{ type, command }`) is one we wrote. */
121
137
  function isOurHookCommand(h) {
122
- return (isRecord(h) &&
123
- typeof h.command === "string" &&
124
- HOOK_MARKERS.every((m) => h.command.includes(m)));
138
+ if (!isRecord(h) || typeof h.command !== "string")
139
+ return false;
140
+ const cmd = h.command;
141
+ return cmd.includes(HOOK_SOURCE_TOKEN) && HOOK_NAME_TOKENS.some((t) => cmd.includes(t));
125
142
  }
126
143
  /**
127
144
  * Strip OUR hook entries out of an event's group array, operating at the
package/dist/src/cli.js CHANGED
@@ -18,6 +18,7 @@ import { serveStdio } from "./mcp.js";
18
18
  import { setup } from "./setup.js";
19
19
  import { uninstall } from "./uninstall.js";
20
20
  import { cmdConfig } from "./config.js";
21
+ import { onStop } from "./on_stop.js";
21
22
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
22
23
  export const COMMANDS = [
23
24
  "login",
@@ -28,6 +29,7 @@ export const COMMANDS = [
28
29
  "mcp",
29
30
  "uninstall",
30
31
  "config",
32
+ "on-stop",
31
33
  ];
32
34
  export function printHelp() {
33
35
  console.log([
@@ -46,6 +48,7 @@ export function printHelp() {
46
48
  " mcp Run as an MCP server over stdio",
47
49
  " uninstall Remove entries this tool added",
48
50
  " config Get/set Notify settings (get | set <key> <value> | pull)",
51
+ " on-stop End-of-turn dispatcher (auto-push or finished ping)",
49
52
  "",
50
53
  " -h, --help Show this help",
51
54
  " -v, --version Print the version",
@@ -375,6 +378,53 @@ export async function cmdMcp(args, deps = {}) {
375
378
  await serveStdio({ ...deps, serverFlag: flagString(flags, "server") });
376
379
  return 0;
377
380
  }
381
+ /**
382
+ * Handle `gocode-notify on-stop --source S [--dedupe-key D] [--cwd DIR]
383
+ * [--server URL] [--dry-run] [--agent-driven]` (PRD §2.2). The single command
384
+ * the runtime stop hooks call: it resolves settings and either runs the
385
+ * auto-push flow (which sends its own notification) OR fires the plain
386
+ * `finished` ping — never both, so a turn yields exactly one notification.
387
+ *
388
+ * Like every hook entry it follows the PRD §0.5 non-blocking contract: it
389
+ * ALWAYS exits 0, even on a failed push/send, so a slow or failed notification
390
+ * can never block the agent's turn.
391
+ */
392
+ export async function cmdOnStop(args, deps = {}) {
393
+ const flags = parseFlags(args);
394
+ const agent = isAgentDriven(flags);
395
+ const sink = deps.sink ?? stdoutSink;
396
+ const server = await resolveServerUrl(flagString(flags, "server"), deps);
397
+ const run = deps.onStopImpl ?? onStop;
398
+ const result = await run({
399
+ home: deps.home,
400
+ source: flagString(flags, "source"),
401
+ cwd: flagString(flags, "cwd"),
402
+ dedupeKey: flagString(flags, "dedupe-key"),
403
+ server,
404
+ dryRun: flagBool(flags, "dry-run"),
405
+ fetchImpl: deps.fetchImpl,
406
+ timeoutMs: deps.timeoutMs,
407
+ timestamp: deps.timestamp,
408
+ });
409
+ const delivered = result.mode === "push"
410
+ ? result.push?.notified === true
411
+ : result.mode === "send"
412
+ ? result.send?.ok === true
413
+ : undefined;
414
+ const detail = result.mode === "push"
415
+ ? `auto-push: ${result.push?.outcome ?? "unknown"}${delivered ? " (notified)" : ""}`
416
+ : result.mode === "dry-run-send"
417
+ ? "dry-run: would send finished (auto-push off)"
418
+ : `finished ${delivered ? "delivered" : "not delivered"}`;
419
+ if (agent) {
420
+ sink({ step: "on-stop", ok: true, detail });
421
+ }
422
+ else {
423
+ console.log(`✓ on-stop (${result.mode}): ${detail}`);
424
+ }
425
+ // PRD §0.5 / §4.4: a stop hook must NEVER block the turn — always exit 0.
426
+ return 0;
427
+ }
378
428
  /** Synchronous dispatcher: help/version/unknown + not-yet-implemented commands. */
379
429
  export function run(argv) {
380
430
  const cmd = argv[0];
@@ -416,6 +466,8 @@ export async function runAsync(argv) {
416
466
  return cmdUninstall(argv.slice(1));
417
467
  if (cmd === "config")
418
468
  return cmdConfig(argv.slice(1));
469
+ if (cmd === "on-stop")
470
+ return cmdOnStop(argv.slice(1));
419
471
  return run(argv);
420
472
  }
421
473
  /**
@@ -36,18 +36,34 @@ export const MCP_SERVER_ENTRY = {
36
36
  args: ["-y", "@trygocode/notify", "mcp"],
37
37
  };
38
38
  /**
39
- * The Cursor `stop` hook command (PRD §5.4, verbatim shape). Ends in `|| true`
40
- * so a notification failure can never block the agent's turn, and carries a
39
+ * The Cursor `stop` hook command (PRD §2.2, §5.4). Calls the `on-stop`
40
+ * DISPATCHER (not `send` directly): on-stop resolves settings and either runs
41
+ * the auto-push flow (which sends its own notification) or fires the plain
42
+ * `finished` ping — exactly one notification per turn. Ends in `|| true` so a
43
+ * notification/push failure can never block the agent's turn, and carries a
41
44
  * `--dedupe-key` so overlapping triggers (e.g. Cursor `stop` + Claude `Stop`)
42
45
  * coalesce server-side.
46
+ *
47
+ * The pre-T-C6 form was `gocode-notify send --kind finished --source cursor …`;
48
+ * both old and new commands contain the {@link HOOK_MARKERS} tokens, so an old
49
+ * install still upgrades idempotently and uninstalls cleanly (PRD §2.2).
43
50
  */
44
- export const CURSOR_STOP_COMMAND = "gocode-notify send --kind finished --source cursor --dedupe-key cursor-stop || true";
51
+ export const CURSOR_STOP_COMMAND = "npx -y @trygocode/notify on-stop --source cursor --dedupe-key cursor-stop || true";
45
52
  /**
46
53
  * Substrings that together identify a `stop` hook entry as OURS. Used for
47
54
  * idempotent merge (replace, don't duplicate) and for surgical uninstall (remove
48
- * exactly ours). A command must contain BOTH to be considered ours.
55
+ * exactly ours). A command must contain BOTH to be considered ours. These tokens
56
+ * are present in BOTH the legacy `send --source cursor` command and the new
57
+ * `on-stop --source cursor` command, so they match old AND new installs (§2.2).
49
58
  */
50
- const HOOK_MARKERS = ["gocode-notify", "--source cursor"];
59
+ // Match OUR cursor stop hook across ALL historical command forms so upgrades
60
+ // replace (never duplicate) and uninstall stays surgical. The `--source cursor`
61
+ // token is the stable discriminator; the first token matches whichever invocation
62
+ // style produced the entry: the current `npx -y @trygocode/notify …`, the
63
+ // published-0.1.2 `npx … notify send …`, or the legacy bare `gocode-notify …`.
64
+ // A command is ours if it has `--source cursor` AND any of these name tokens.
65
+ const HOOK_NAME_TOKENS = ["@trygocode/notify", "gocode-notify", "notify send"];
66
+ const HOOK_SOURCE_TOKEN = "--source cursor";
51
67
  /**
52
68
  * The on-demand rule written to `~/.cursor/rules/gocode-notify.md` (PRD §5.5).
53
69
  * The crucial content is the anti-double-ping rule: the automatic pings are
@@ -121,9 +137,10 @@ async function writeJsonFile(file, value) {
121
137
  }
122
138
  /** True when a single `stop` hook entry (`{ command }`) is one we wrote. */
123
139
  function isOurStopHook(h) {
124
- return (isRecord(h) &&
125
- typeof h.command === "string" &&
126
- HOOK_MARKERS.every((m) => h.command.includes(m)));
140
+ if (!isRecord(h) || typeof h.command !== "string")
141
+ return false;
142
+ const cmd = h.command;
143
+ return cmd.includes(HOOK_SOURCE_TOKEN) && HOOK_NAME_TOKENS.some((t) => cmd.includes(t));
127
144
  }
128
145
  /**
129
146
  * Strip OUR entries out of the `stop` array. Returns the cleaned array plus
package/dist/src/mcp.js CHANGED
@@ -19,35 +19,6 @@ import { send, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
19
19
  import { gatherStatus } from "./status.js";
20
20
  /** Server identity advertised in the MCP `initialize` handshake. */
21
21
  export const SERVER_NAME = "gocode-notify";
22
- /**
23
- * Public website for this MCP server, surfaced in the MCP `initialize`
24
- * handshake (`Implementation.websiteUrl`, SEP-973). Clients that support it
25
- * (MCP Inspector today; more clients over time) link the server to its docs.
26
- */
27
- export const SERVER_WEBSITE_URL = "https://github.com/joseph-lewis/gocode-notify";
28
- /**
29
- * Icons advertised in the MCP handshake (`Implementation.icons`, SEP-973) so
30
- * clients can show the GoCode mark next to this server in their UI. Served from
31
- * the public repo's `assets/` over GitHub's raw CDN so there's nothing to host.
32
- *
33
- * NOTE: as of 2026, Cursor does not yet RENDER custom MCP server icons (it uses
34
- * hardcoded marks for a few popular servers), so this stays invisible there for
35
- * now — but it already shows in MCP Inspector and other spec-compliant clients,
36
- * and will light up in Cursor automatically once it adds rendering. Cheap +
37
- * future-proof; no behaviour depends on it.
38
- */
39
- export const SERVER_ICONS = [
40
- {
41
- src: "https://raw.githubusercontent.com/joseph-lewis/gocode-notify/main/assets/icon-128.png",
42
- mimeType: "image/png",
43
- sizes: ["128x128"],
44
- },
45
- {
46
- src: "https://raw.githubusercontent.com/joseph-lewis/gocode-notify/main/assets/icon-512.png",
47
- mimeType: "image/png",
48
- sizes: ["512x512"],
49
- },
50
- ];
51
22
  /** Tool name an agent calls to send an on-demand push. */
52
23
  export const NOTIFY_TOOL = "gocode_notify";
53
24
  /** Tool name an agent calls to self-diagnose pairing/reachability. */
@@ -177,15 +148,7 @@ export async function handleStatus(deps = {}) {
177
148
  * drive it over an in-memory transport.
178
149
  */
179
150
  export function createMcpServer(deps = {}) {
180
- const server = new Server({
181
- name: SERVER_NAME,
182
- version: VERSION,
183
- // SEP-973 server metadata: icons + website for client UIs that support it
184
- // (MCP Inspector now; more clients over time). Ignored by clients that
185
- // don't read them.
186
- icons: [...SERVER_ICONS],
187
- websiteUrl: SERVER_WEBSITE_URL,
188
- }, { capabilities: { tools: {} } });
151
+ const server = new Server({ name: SERVER_NAME, version: VERSION }, { capabilities: { tools: {} } });
189
152
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...TOOLS] }));
190
153
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
191
154
  const { name, arguments: args } = req.params;