@trygocode/notify 0.2.0 → 0.3.1
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 +75 -1
- package/dist/src/cli.js +77 -1
- package/dist/src/doctor.js +262 -0
- package/dist/src/on_stop.js +299 -10
- package/dist/src/send.js +8 -1
- package/dist/src/status.js +18 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
- package/snippets/ralph-homer.sh +329 -9
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ waiting for you**, **errors out**, or when an overnight **loop completes / halts
|
|
|
17
17
|
- [Auto-push to git (opt-in)](#auto-push-to-git-opt-in)
|
|
18
18
|
- [Settings sync — the `config` command](#settings-sync--the-config-command)
|
|
19
19
|
- [Ralph/Homer opt-in snippet (trigger C)](#ralphhomer-opt-in-snippet-trigger-c)
|
|
20
|
+
- [Notify from any custom script (the minimal one-liner)](#notify-from-any-custom-script-the-minimal-one-liner)
|
|
20
21
|
- [Troubleshooting](#troubleshooting)
|
|
21
22
|
- [Changelog](#changelog)
|
|
22
23
|
- [Develop](#develop)
|
|
@@ -125,6 +126,21 @@ The installed rule/skill tells the agent **not** to call the MCP tool for
|
|
|
125
126
|
done/idle/error pings — those are owned by the deterministic hook (A), so you
|
|
126
127
|
never get double-pinged.
|
|
127
128
|
|
|
129
|
+
> **Cursor `stop.status` → kind mapping (since 0.3.0).** Cursor's `stop` hook
|
|
130
|
+
> carries a `status` field in its stdin JSON. The `on-stop` dispatcher maps it
|
|
131
|
+
> to the right notification kind automatically:
|
|
132
|
+
>
|
|
133
|
+
> | Cursor `stop.status` | Notification kind | Meaning |
|
|
134
|
+
> |---|---|---|
|
|
135
|
+
> | `completed` | `finished` | Agent turned cleanly (default — same as before 0.3.0) |
|
|
136
|
+
> | `aborted` | `awaiting_input` | Agent yielded back to you — may need your input |
|
|
137
|
+
> | `error` | `error` | Agent hit an error |
|
|
138
|
+
> | absent / unrecognised | `finished` | Back-compatible fallback |
|
|
139
|
+
>
|
|
140
|
+
> Before 0.3.0, Cursor only ever sent `finished` regardless of what the agent
|
|
141
|
+
> did. The status mapping requires no hook re-installation — run
|
|
142
|
+
> `gocode-notify setup --force` once to apply the updated hook command.
|
|
143
|
+
|
|
128
144
|
> **OpenCode's hook (A)** is a small `session.idle` plugin written to
|
|
129
145
|
> `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
|
|
130
146
|
> fire-and-forgets `gocode-notify on-stop --source opencode` (the same
|
|
@@ -316,7 +332,7 @@ Valid keys (mirror of the canonical schema):
|
|
|
316
332
|
|
|
317
333
|
| Key | Type | Meaning |
|
|
318
334
|
|---|---|---|
|
|
319
|
-
| `kinds.finished` / `kinds.error` / `kinds.awaiting_input` / `kinds.loop_completed` / `kinds.loop_halted` | bool | Per-kind notification toggles |
|
|
335
|
+
| `kinds.finished` / `kinds.error` / `kinds.awaiting_input` / `kinds.loop_completed` / `kinds.loop_halted` / `kinds.ralph_waiting` | bool | Per-kind notification toggles |
|
|
320
336
|
| `min_duration_seconds` | int ≥ 0 | Only notify if the turn ran ≥ N seconds (0 = always) |
|
|
321
337
|
| `quiet_hours.enabled` / `quiet_hours.start` / `quiet_hours.end` / `quiet_hours.tz` | bool / `HH:MM` / `HH:MM` / IANA tz | Do-not-disturb window |
|
|
322
338
|
| `auto_push.enabled` | bool | Master auto-push switch (OFF by default) |
|
|
@@ -358,6 +374,41 @@ The ready-to-copy version with comments lives at
|
|
|
358
374
|
loop scripts. Both lines are fire-and-forget (`|| true` + the CLI's 5s
|
|
359
375
|
self-timeout), so a failed or slow push can never block or fail your loop.
|
|
360
376
|
|
|
377
|
+
## Notify from any custom script (the minimal one-liner)
|
|
378
|
+
|
|
379
|
+
Any shell script — a cron job, a `Makefile` target, a post-build hook, or
|
|
380
|
+
someone else's automation — can send a push with a single line, no configuration
|
|
381
|
+
beyond a one-time `gocode-notify login` pairing:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
gocode-notify send --kind finished --source <name> || true
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Replace `<name>` with a short identifier for the script (e.g. `ci`, `build`,
|
|
388
|
+
`deploy`). The `|| true` guard ensures a failed or slow push **never** blocks the
|
|
389
|
+
calling script (the CLI also self-times-out in 5 seconds).
|
|
390
|
+
|
|
391
|
+
> **Discovery hint:** `gocode-notify status` always prints this line at the
|
|
392
|
+
> bottom of its report so you can copy-paste it even when you don't have the
|
|
393
|
+
> README handy.
|
|
394
|
+
|
|
395
|
+
**Other useful kinds for custom scripts:**
|
|
396
|
+
|
|
397
|
+
| Kind | When to use |
|
|
398
|
+
|---|---|
|
|
399
|
+
| `finished` | Script completed cleanly |
|
|
400
|
+
| `error` | Script hit an error |
|
|
401
|
+
| `awaiting_input` | Script paused; a human is needed |
|
|
402
|
+
| `loop_completed` | Long-running loop finished all work |
|
|
403
|
+
| `loop_halted` | Long-running loop stopped; human needed |
|
|
404
|
+
|
|
405
|
+
Pass `--title "My script"` and `--body "extra detail"` to customise the
|
|
406
|
+
notification text. Use `--project "$(basename "$PWD")"` to badge it with the
|
|
407
|
+
project name on your phone. All flags are optional — only `--kind` is required.
|
|
408
|
+
|
|
409
|
+
See `snippets/ralph-homer.sh` for the full Ralph/Homer lifecycle pattern
|
|
410
|
+
(completed / halted / stall-edge / resumed).
|
|
411
|
+
|
|
361
412
|
## Troubleshooting
|
|
362
413
|
|
|
363
414
|
**Start here:** `gocode-notify status` prints a one-screen report — whether
|
|
@@ -389,6 +440,29 @@ npm publish, real-device E2E), see
|
|
|
389
440
|
|
|
390
441
|
## Changelog
|
|
391
442
|
|
|
443
|
+
### 0.3.0
|
|
444
|
+
|
|
445
|
+
- **New: Cursor `stop.status` → kind mapping (T-CUR1/T-CUR4).** The `on-stop`
|
|
446
|
+
dispatcher now reads the Cursor `stop` hook's stdin JSON and maps `status` to
|
|
447
|
+
the right notification kind: `completed→finished`, `aborted→awaiting_input`,
|
|
448
|
+
`error→error`. Back-compatible: absent/unrecognised status → `finished`.
|
|
449
|
+
Run `gocode-notify setup --force` to pick up the updated hook command.
|
|
450
|
+
- **New: `ralph_waiting` + `ralph_resumed` kinds (T-C1).** `NOTIFY_KINDS` now
|
|
451
|
+
includes the Ralph/Homer offline-stall lifecycle kinds. `ralph_waiting` fires
|
|
452
|
+
once on the stall edge (server drops repeats until a recovery event re-arms
|
|
453
|
+
it); `ralph_resumed` is a silent control event that resets the stall state
|
|
454
|
+
machine. See `snippets/ralph-homer.sh` for the edge-trigger pattern.
|
|
455
|
+
- **Kind taxonomy (T-S1).** The server now classifies every kind into push-worthy
|
|
456
|
+
vs. silent-info buckets: `ralph_question` / `ralph_advanced` are never pushed
|
|
457
|
+
(Oracle-answerable questions are silent); `ralph_halted` / `ralph_completed` /
|
|
458
|
+
`ralph_waiting` (edge) are the only loop kinds that reach FCM.
|
|
459
|
+
- **Foreground suppression (T-S2/T-S3).** The server suppresses a push when the
|
|
460
|
+
app is open on that exact chat (`POST /api/v1/notify/presence` heartbeat, 45s
|
|
461
|
+
TTL). Different-chat or closed app → push goes out as normal.
|
|
462
|
+
- **Offline/stall debounce (T-S4).** Server-side edge-triggered state machine:
|
|
463
|
+
a loop retrying every 60s on a quota outage pushes once on stall and stays
|
|
464
|
+
silent until it recovers and stalls again.
|
|
465
|
+
|
|
392
466
|
### 0.2.0
|
|
393
467
|
|
|
394
468
|
- **New: `launch` / `autopilot` command** — hand a large, multi-step task off to
|
package/dist/src/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import { setup } from "./setup.js";
|
|
|
21
21
|
import { uninstall } from "./uninstall.js";
|
|
22
22
|
import { cmdConfig } from "./config.js";
|
|
23
23
|
import { onStop } from "./on_stop.js";
|
|
24
|
+
import { gatherDoctor, formatDoctor } from "./doctor.js";
|
|
24
25
|
/** Subcommands the finished CLI will expose (see PRD §4.1). */
|
|
25
26
|
export const COMMANDS = [
|
|
26
27
|
"login",
|
|
@@ -28,6 +29,7 @@ export const COMMANDS = [
|
|
|
28
29
|
"test",
|
|
29
30
|
"setup",
|
|
30
31
|
"status",
|
|
32
|
+
"doctor",
|
|
31
33
|
"mcp",
|
|
32
34
|
"uninstall",
|
|
33
35
|
"config",
|
|
@@ -38,6 +40,43 @@ export const COMMANDS = [
|
|
|
38
40
|
export const COMMAND_ALIASES = {
|
|
39
41
|
autopilot: "launch",
|
|
40
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* Read all available data from `process.stdin` when it is a pipe (not a TTY),
|
|
45
|
+
* returning the full content as a string. Best-effort: returns an empty string on
|
|
46
|
+
* any error or if stdin is a TTY (no data to consume). Used by {@link cmdOnStop}
|
|
47
|
+
* to consume the Cursor `stop` hook's JSON payload (T-CUR1 / PRD §8.5).
|
|
48
|
+
*
|
|
49
|
+
* The read is bounded to {@link MAX_STDIN_BYTES} to prevent a misbehaving hook
|
|
50
|
+
* from blocking with an enormous payload. The function resolves (never rejects)
|
|
51
|
+
* so it can never block the hook's turn.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_STDIN_BYTES = 65536; // 64 KiB — far more than any hook JSON
|
|
54
|
+
export async function readStdinIfPipe(stream) {
|
|
55
|
+
const src = stream ?? process.stdin;
|
|
56
|
+
// In a TTY / non-pipe context there is nothing to read — return early so we
|
|
57
|
+
// never block waiting for input on an interactive terminal.
|
|
58
|
+
if (src.isTTY)
|
|
59
|
+
return "";
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let totalBytes = 0;
|
|
63
|
+
src.on("data", (chunk) => {
|
|
64
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
65
|
+
totalBytes += buf.byteLength;
|
|
66
|
+
if (totalBytes <= MAX_STDIN_BYTES)
|
|
67
|
+
chunks.push(buf);
|
|
68
|
+
});
|
|
69
|
+
src.on("end", () => {
|
|
70
|
+
try {
|
|
71
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
resolve("");
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
src.on("error", () => resolve(""));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
41
80
|
export function printHelp() {
|
|
42
81
|
console.log([
|
|
43
82
|
`gocode-notify v${VERSION}`,
|
|
@@ -52,6 +91,7 @@ export function printHelp() {
|
|
|
52
91
|
" test Send a canned test push",
|
|
53
92
|
" setup Pair + detect runtimes + write configs",
|
|
54
93
|
" status Report credentials / server / detected runtimes",
|
|
94
|
+
" doctor Self-diagnostic checklist with exact fix commands",
|
|
55
95
|
" mcp Run as an MCP server over stdio",
|
|
56
96
|
" uninstall Remove entries this tool added",
|
|
57
97
|
" config Get/set Notify settings (get | set <key> <value> | pull)",
|
|
@@ -324,6 +364,26 @@ export async function cmdStatus(args, deps = {}) {
|
|
|
324
364
|
}
|
|
325
365
|
return 0;
|
|
326
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Handle `gocode-notify doctor [--server URL]` (PRD §8.6 R10 / T-COV4).
|
|
369
|
+
* Prints a checklist: (1) paired? (2) server reachable? (3) which runner hooks
|
|
370
|
+
* installed (Cursor stop / Claude Stop + Notification)? (4) gocode-notify on
|
|
371
|
+
* PATH for non-login shells? Each ✗ item prints the exact fix command.
|
|
372
|
+
* Always exits 0 — it is an informational report, not a gate.
|
|
373
|
+
*/
|
|
374
|
+
export async function cmdDoctor(args, deps = {}) {
|
|
375
|
+
const flags = parseFlags(args);
|
|
376
|
+
const report = await gatherDoctor({
|
|
377
|
+
home: deps.home,
|
|
378
|
+
serverFlag: flagString(flags, "server") ?? deps.serverFlag,
|
|
379
|
+
fetchImpl: deps.fetchImpl,
|
|
380
|
+
timeoutMs: deps.timeoutMs,
|
|
381
|
+
pathEnv: deps.pathEnv,
|
|
382
|
+
});
|
|
383
|
+
for (const line of formatDoctor(report))
|
|
384
|
+
console.log(line);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
327
387
|
/** True when a flag is present as a bare boolean or explicit `=true`. */
|
|
328
388
|
function flagBool(flags, name) {
|
|
329
389
|
const v = flags.get(name);
|
|
@@ -429,6 +489,17 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
429
489
|
const flags = parseFlags(args);
|
|
430
490
|
const agent = isAgentDriven(flags);
|
|
431
491
|
const sink = deps.sink ?? stdoutSink;
|
|
492
|
+
// Read the hook's stdin JSON (T-CUR1 / PRD §8.5). The Cursor `stop` hook pipes
|
|
493
|
+
// its event data to the process stdin; we consume it here so on-stop can map
|
|
494
|
+
// the `status` field to the correct notification kind. Best-effort: errors and
|
|
495
|
+
// TTY contexts return an empty string (falls back to `finished` in on_stop.ts).
|
|
496
|
+
//
|
|
497
|
+
// Injection priority: deps.hookStdin (test stub) > deps.readStdin (test reader)
|
|
498
|
+
// > real stdin read. An explicit empty string from deps.hookStdin means "no
|
|
499
|
+
// stdin provided" (do not read from the real process.stdin).
|
|
500
|
+
const hookStdin = deps.hookStdin !== undefined
|
|
501
|
+
? deps.hookStdin
|
|
502
|
+
: await (deps.readStdin ?? readStdinIfPipe)();
|
|
432
503
|
const server = await resolveServerUrl(flagString(flags, "server"), deps);
|
|
433
504
|
const run = deps.onStopImpl ?? onStop;
|
|
434
505
|
const result = await run({
|
|
@@ -441,6 +512,7 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
441
512
|
fetchImpl: deps.fetchImpl,
|
|
442
513
|
timeoutMs: deps.timeoutMs,
|
|
443
514
|
timestamp: deps.timestamp,
|
|
515
|
+
hookStdin,
|
|
444
516
|
});
|
|
445
517
|
const delivered = result.mode === "push"
|
|
446
518
|
? result.push?.notified === true
|
|
@@ -453,7 +525,9 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
453
525
|
? "dry-run: would send finished (auto-push off)"
|
|
454
526
|
: result.mode === "deduped"
|
|
455
527
|
? "deduped: another source already notified for this run"
|
|
456
|
-
:
|
|
528
|
+
: result.mode === "autopilot-suppressed"
|
|
529
|
+
? "autopilot-suppressed: an Autopilot loop owns this turn's ping"
|
|
530
|
+
: `finished ${delivered ? "delivered" : "not delivered"}`;
|
|
457
531
|
if (agent) {
|
|
458
532
|
sink({ step: "on-stop", ok: true, detail });
|
|
459
533
|
}
|
|
@@ -586,6 +660,8 @@ export async function runAsync(argv) {
|
|
|
586
660
|
return cmdTest(argv.slice(1));
|
|
587
661
|
if (cmd === "status")
|
|
588
662
|
return cmdStatus(argv.slice(1));
|
|
663
|
+
if (cmd === "doctor")
|
|
664
|
+
return cmdDoctor(argv.slice(1));
|
|
589
665
|
if (cmd === "setup")
|
|
590
666
|
return cmdSetup(argv.slice(1));
|
|
591
667
|
if (cmd === "mcp")
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `gocode-notify doctor` — self-diagnostic checklist (PRD §8.6 R10 / T-COV4).
|
|
2
|
+
//
|
|
3
|
+
// Prints a human-readable checklist of the installation health so users know
|
|
4
|
+
// immediately what is missing and how to fix it. Checks (in order):
|
|
5
|
+
//
|
|
6
|
+
// 1. Paired? credentials exist and are valid.
|
|
7
|
+
// 2. Server reachable? the bound (or default) server answers.
|
|
8
|
+
// 3. Runner hooks installed? Claude Code Stop/Notification + Cursor stop.
|
|
9
|
+
// 4. `gocode-notify` on PATH? resolvable in non-login shells.
|
|
10
|
+
//
|
|
11
|
+
// Every ✗ check prints the EXACT fix command so users never have to guess.
|
|
12
|
+
// Always exits 0 — it is a report, not a gate.
|
|
13
|
+
//
|
|
14
|
+
// The gather / format split mirrors `status.ts`:
|
|
15
|
+
// `gatherDoctor` → pure async data collection (injectable deps, fully testable)
|
|
16
|
+
// `formatDoctor` → pure sync rendering to string lines
|
|
17
|
+
// `cmdDoctor` → thin CLI wire-up in `cli.ts`
|
|
18
|
+
//
|
|
19
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { readCredentials, resolveServerUrl } from "./creds.js";
|
|
23
|
+
import { probeServer, DEFAULT_PROBE_TIMEOUT_MS, UNPAIRED_WARNING } from "./status.js";
|
|
24
|
+
// Re-export UNPAIRED_WARNING so existing `from "./doctor.js"` imports keep
|
|
25
|
+
// working after the constant moved to status.ts (T-COV1).
|
|
26
|
+
export { UNPAIRED_WARNING } from "./status.js";
|
|
27
|
+
// ─── Hook markers ──────────────────────────────────────────────────────────
|
|
28
|
+
// We detect our hook entries by looking for these stable tokens in the command
|
|
29
|
+
// strings. The same tokens are used by claude.ts / cursor.ts for idempotent
|
|
30
|
+
// install / uninstall (kept in sync via the HOOK_SOURCE comments there).
|
|
31
|
+
/** Tokens that identify a gocode-notify Claude Code hook in settings.json. */
|
|
32
|
+
const CLAUDE_HOOK_TOKEN = "--source claude_code";
|
|
33
|
+
/** Token that identifies our Cursor stop hook in hooks.json. */
|
|
34
|
+
const CURSOR_HOOK_TOKEN = "--source cursor";
|
|
35
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
36
|
+
function resolveHomeDir(opts) {
|
|
37
|
+
return opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? "~";
|
|
38
|
+
}
|
|
39
|
+
/** Read a JSON file; returns null on any error (absent, bad JSON, bad perms). */
|
|
40
|
+
async function readJsonFile(p) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await fs.readFile(p, "utf8");
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True when `cmd` appears on any entry in `pathEnv` (colon-separated). */
|
|
50
|
+
async function commandOnPath(cmd, pathEnv) {
|
|
51
|
+
if (!pathEnv)
|
|
52
|
+
return false;
|
|
53
|
+
const names = process.platform === "win32" ? [cmd, `${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd];
|
|
54
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
55
|
+
if (!dir)
|
|
56
|
+
continue;
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.stat(path.join(dir, name));
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not in this dir — keep looking
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Recursively extract all `command` strings from a Claude/Cursor hooks value.
|
|
71
|
+
*
|
|
72
|
+
* Claude settings.json hook events have this shape:
|
|
73
|
+
* hooks.Stop = [ { matcher: string, hooks: [ { type: "command", command: string } ] } ]
|
|
74
|
+
*
|
|
75
|
+
* Cursor hooks.json stop entries have this shape:
|
|
76
|
+
* stop = [ { command: string } ]
|
|
77
|
+
*
|
|
78
|
+
* This walks the structure recursively so both formats are handled without
|
|
79
|
+
* needing a per-runtime parser.
|
|
80
|
+
*/
|
|
81
|
+
function extractCommands(value) {
|
|
82
|
+
if (!value)
|
|
83
|
+
return [];
|
|
84
|
+
if (typeof value === "string")
|
|
85
|
+
return [value];
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
const cmds = [];
|
|
88
|
+
for (const item of value)
|
|
89
|
+
cmds.push(...extractCommands(item));
|
|
90
|
+
return cmds;
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === "object") {
|
|
93
|
+
const obj = value;
|
|
94
|
+
const cmds = [];
|
|
95
|
+
// If this object has a `command` string field, it's a hook entry.
|
|
96
|
+
if (typeof obj.command === "string")
|
|
97
|
+
cmds.push(obj.command);
|
|
98
|
+
// Recurse into `hooks` sub-array (Claude Code's two-level nesting).
|
|
99
|
+
if (obj.hooks)
|
|
100
|
+
cmds.push(...extractCommands(obj.hooks));
|
|
101
|
+
return cmds;
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
/** Extract all hook command strings from a hooks event value (array or object). */
|
|
106
|
+
function flattenHookCommands(hooksValue) {
|
|
107
|
+
return extractCommands(hooksValue);
|
|
108
|
+
}
|
|
109
|
+
// ─── Core gather ────────────────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Collect the full {@link DoctorReport}. Never throws — errors are surfaced as
|
|
112
|
+
* failing checks with actionable fix commands.
|
|
113
|
+
*/
|
|
114
|
+
export async function gatherDoctor(opts = {}) {
|
|
115
|
+
const checks = [];
|
|
116
|
+
const home = resolveHomeDir(opts);
|
|
117
|
+
// ── 1. Paired? ─────────────────────────────────────────────────────────
|
|
118
|
+
let pairedOk = false;
|
|
119
|
+
let pairedDetail = "not paired";
|
|
120
|
+
try {
|
|
121
|
+
const creds = await readCredentials({ home: opts.home });
|
|
122
|
+
if (creds) {
|
|
123
|
+
pairedOk = true;
|
|
124
|
+
pairedDetail = `paired as ${creds.user_id} (${creds.label})`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
pairedDetail = "credentials file unreadable";
|
|
129
|
+
}
|
|
130
|
+
checks.push({
|
|
131
|
+
key: "paired",
|
|
132
|
+
label: "Paired?",
|
|
133
|
+
ok: pairedOk,
|
|
134
|
+
detail: pairedDetail,
|
|
135
|
+
fixCmd: pairedOk ? undefined : "gocode-notify login",
|
|
136
|
+
});
|
|
137
|
+
// ── 2. Server reachable? ───────────────────────────────────────────────
|
|
138
|
+
const serverUrl = await resolveServerUrl(opts.serverFlag, { home: opts.home });
|
|
139
|
+
const probe = await probeServer(serverUrl, {
|
|
140
|
+
fetchImpl: opts.fetchImpl,
|
|
141
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
142
|
+
});
|
|
143
|
+
checks.push({
|
|
144
|
+
key: "server",
|
|
145
|
+
label: "Server reachable?",
|
|
146
|
+
ok: probe.reachable,
|
|
147
|
+
detail: `${serverUrl} (${probe.detail})`,
|
|
148
|
+
fixCmd: probe.reachable
|
|
149
|
+
? undefined
|
|
150
|
+
: "# Check your network or run: gocode-notify login --server <url>",
|
|
151
|
+
});
|
|
152
|
+
// ── 3a. Claude Code Stop hook ──────────────────────────────────────────
|
|
153
|
+
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
154
|
+
const claudeSettings = await readJsonFile(claudeSettingsPath);
|
|
155
|
+
const claudeHooks = claudeSettings &&
|
|
156
|
+
typeof claudeSettings === "object" &&
|
|
157
|
+
"hooks" in claudeSettings
|
|
158
|
+
? claudeSettings.hooks
|
|
159
|
+
: null;
|
|
160
|
+
const claudeStopCommands = flattenHookCommands(claudeHooks &&
|
|
161
|
+
typeof claudeHooks === "object" &&
|
|
162
|
+
"Stop" in claudeHooks
|
|
163
|
+
? claudeHooks.Stop
|
|
164
|
+
: null);
|
|
165
|
+
const claudeStopInstalled = claudeStopCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
166
|
+
checks.push({
|
|
167
|
+
key: "hook:claude-stop",
|
|
168
|
+
label: "Claude Code Stop hook installed?",
|
|
169
|
+
ok: claudeStopInstalled,
|
|
170
|
+
detail: claudeStopInstalled
|
|
171
|
+
? `found in ${claudeSettingsPath}`
|
|
172
|
+
: claudeHooks === null
|
|
173
|
+
? "~/.claude/settings.json not found or no hooks"
|
|
174
|
+
: "hook not found",
|
|
175
|
+
fixCmd: claudeStopInstalled ? undefined : "gocode-notify setup",
|
|
176
|
+
});
|
|
177
|
+
// ── 3b. Claude Code Notification hook ──────────────────────────────────
|
|
178
|
+
const claudeNotifyCommands = flattenHookCommands(claudeHooks &&
|
|
179
|
+
typeof claudeHooks === "object" &&
|
|
180
|
+
"Notification" in claudeHooks
|
|
181
|
+
? claudeHooks.Notification
|
|
182
|
+
: null);
|
|
183
|
+
const claudeNotifyInstalled = claudeNotifyCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
184
|
+
checks.push({
|
|
185
|
+
key: "hook:claude-notification",
|
|
186
|
+
label: "Claude Code Notification hook installed?",
|
|
187
|
+
ok: claudeNotifyInstalled,
|
|
188
|
+
detail: claudeNotifyInstalled
|
|
189
|
+
? `found in ${claudeSettingsPath}`
|
|
190
|
+
: "hook not found",
|
|
191
|
+
fixCmd: claudeNotifyInstalled ? undefined : "gocode-notify setup",
|
|
192
|
+
});
|
|
193
|
+
// ── 3c. Cursor stop hook ───────────────────────────────────────────────
|
|
194
|
+
const cursorHooksPath = path.join(home, ".cursor", "hooks.json");
|
|
195
|
+
const cursorHooks = await readJsonFile(cursorHooksPath);
|
|
196
|
+
// Pass the stop array directly (not wrapped in an object) so extractCommands
|
|
197
|
+
// can find the {command: ...} entries inside it.
|
|
198
|
+
const cursorStopValue = cursorHooks &&
|
|
199
|
+
typeof cursorHooks === "object" &&
|
|
200
|
+
"stop" in cursorHooks
|
|
201
|
+
? cursorHooks.stop
|
|
202
|
+
: null;
|
|
203
|
+
const cursorStopEntries = flattenHookCommands(cursorStopValue);
|
|
204
|
+
const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
|
|
205
|
+
checks.push({
|
|
206
|
+
key: "hook:cursor-stop",
|
|
207
|
+
label: "Cursor stop hook installed?",
|
|
208
|
+
ok: cursorStopInstalled,
|
|
209
|
+
detail: cursorStopInstalled
|
|
210
|
+
? `found in ${cursorHooksPath}`
|
|
211
|
+
: cursorHooks === null
|
|
212
|
+
? "~/.cursor/hooks.json not found"
|
|
213
|
+
: "hook not found",
|
|
214
|
+
fixCmd: cursorStopInstalled ? undefined : "gocode-notify setup",
|
|
215
|
+
});
|
|
216
|
+
// ── 4. gocode-notify on PATH? ──────────────────────────────────────────
|
|
217
|
+
const pathEnv = opts.pathEnv ?? process.env.PATH;
|
|
218
|
+
const onPath = await commandOnPath("gocode-notify", pathEnv);
|
|
219
|
+
checks.push({
|
|
220
|
+
key: "path",
|
|
221
|
+
label: "gocode-notify on PATH (non-login shells)?",
|
|
222
|
+
ok: onPath,
|
|
223
|
+
detail: onPath
|
|
224
|
+
? "gocode-notify is resolvable"
|
|
225
|
+
: "not found on PATH — hooks may fail in non-login shells",
|
|
226
|
+
fixCmd: onPath
|
|
227
|
+
? undefined
|
|
228
|
+
: "# Add npm global bin to PATH, e.g.:\n echo 'export PATH=\"$(npm root -g)/../.bin:$PATH\"' >> ~/.bashrc",
|
|
229
|
+
});
|
|
230
|
+
return { checks };
|
|
231
|
+
}
|
|
232
|
+
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
233
|
+
function mark(ok) {
|
|
234
|
+
return ok ? "✓" : "✗";
|
|
235
|
+
}
|
|
236
|
+
// UNPAIRED_WARNING is defined in status.ts and re-exported above (T-COV1).
|
|
237
|
+
/**
|
|
238
|
+
* Render a {@link DoctorReport} as human-readable lines. Each failing check
|
|
239
|
+
* includes the exact fix command. Always friendly — never emits an error tone
|
|
240
|
+
* for passing checks.
|
|
241
|
+
*/
|
|
242
|
+
export function formatDoctor(report) {
|
|
243
|
+
const lines = ["gocode-notify doctor", ""];
|
|
244
|
+
for (const check of report.checks) {
|
|
245
|
+
lines.push(`${mark(check.ok)} ${check.label}`);
|
|
246
|
+
lines.push(` ${check.detail}`);
|
|
247
|
+
if (!check.ok && check.fixCmd) {
|
|
248
|
+
lines.push(` Fix: ${check.fixCmd}`);
|
|
249
|
+
}
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
// Surface the unpaired warning loudly (T-COV1 requirement: "surface ⚠️ loop
|
|
253
|
+
// completion pushes will NOT reach your phone" when not paired).
|
|
254
|
+
const pairedCheck = report.checks.find((c) => c.key === "paired");
|
|
255
|
+
if (pairedCheck && !pairedCheck.ok) {
|
|
256
|
+
lines.push(UNPAIRED_WARNING);
|
|
257
|
+
lines.push("");
|
|
258
|
+
}
|
|
259
|
+
const allOk = report.checks.every((c) => c.ok);
|
|
260
|
+
lines.push(allOk ? "All checks passed." : "Some checks failed — see fix commands above.");
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
package/dist/src/on_stop.js
CHANGED
|
@@ -25,11 +25,58 @@
|
|
|
25
25
|
// injectable so the dispatcher is unit-testable with zero network / git / fs.
|
|
26
26
|
//
|
|
27
27
|
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import { createHash } from "node:crypto";
|
|
28
31
|
import { resolveNotifySettings } from "./config.js";
|
|
29
32
|
import { deriveRepoIdentity } from "./repo_key.js";
|
|
30
33
|
import { pushOnStop, } from "./push.js";
|
|
31
34
|
import { appendLog, send } from "./send.js";
|
|
32
35
|
import { checkDedupLock } from "./dedup_lock.js";
|
|
36
|
+
/**
|
|
37
|
+
* Parse the Cursor `stop` hook stdin JSON and extract the `status` field.
|
|
38
|
+
* Best-effort: returns `undefined` on absent/empty/unparseable input or when the
|
|
39
|
+
* `status` field is missing, so the caller falls back to `finished` gracefully.
|
|
40
|
+
* Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function parseCursorStopStatus(stdin) {
|
|
43
|
+
if (!stdin || stdin.trim() === "")
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(stdin);
|
|
47
|
+
if (typeof parsed === "object" &&
|
|
48
|
+
parsed !== null &&
|
|
49
|
+
"status" in parsed &&
|
|
50
|
+
typeof parsed.status === "string") {
|
|
51
|
+
const s = parsed.status;
|
|
52
|
+
if (s === "completed" || s === "aborted" || s === "error")
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Unparseable stdin → fall back to `finished` (back-compat)
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Map a Cursor `stop` status to the appropriate {@link NotifyKind} (T-CUR1 / PRD §8.5):
|
|
63
|
+
* - `completed` → `finished` (agent turned cleanly)
|
|
64
|
+
* - `aborted` → `awaiting_input` (agent yielded back to the human)
|
|
65
|
+
* - `error` → `error` (agent hit an error)
|
|
66
|
+
* - `undefined` → `finished` (back-compat: absent/unrecognised)
|
|
67
|
+
*/
|
|
68
|
+
export function cursorStopStatusToKind(status) {
|
|
69
|
+
switch (status) {
|
|
70
|
+
case "completed":
|
|
71
|
+
return "finished";
|
|
72
|
+
case "aborted":
|
|
73
|
+
return "awaiting_input";
|
|
74
|
+
case "error":
|
|
75
|
+
return "error";
|
|
76
|
+
default:
|
|
77
|
+
return "finished"; // back-compat: absent/unrecognised → finished
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
/** Slice the merged settings down to what the push flow consumes. */
|
|
34
81
|
function toPushSettings(settings) {
|
|
35
82
|
return {
|
|
@@ -37,6 +84,214 @@ function toPushSettings(settings) {
|
|
|
37
84
|
commit_message: settings.commit_message,
|
|
38
85
|
};
|
|
39
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the `project` label for a per-turn ping (T-N4 / PRD §3.4). Prefers the
|
|
89
|
+
* derived repo identity's `repo_label`; when that is blank — the repo-derive
|
|
90
|
+
* threw (so `repo` is undefined) or a non-git cwd produced an empty label — falls
|
|
91
|
+
* back to the cwd basename so the phone ALWAYS shows SOMETHING ("better-than-
|
|
92
|
+
* nothing"). Returns `undefined` only when even the basename is empty (e.g. cwd
|
|
93
|
+
* is the filesystem root), so the caller still omits the field gracefully.
|
|
94
|
+
*/
|
|
95
|
+
export function projectLabel(repo, cwd) {
|
|
96
|
+
const label = repo?.repo_label?.trim();
|
|
97
|
+
if (label)
|
|
98
|
+
return label;
|
|
99
|
+
// Fall back to the cwd basename — but SKIP tool/config dot-dirs. The stop hook
|
|
100
|
+
// can run with a cwd inside `.cursor` (or `.git`, `.vscode`, …), whose basename
|
|
101
|
+
// would otherwise become the project name and render as "Cursor · .cursor"
|
|
102
|
+
// (the IDE name shown twice). Walk up past any leading-dot segment to the first
|
|
103
|
+
// real project folder so the label is the actual repo dir, not its tooling dir.
|
|
104
|
+
let dir = path.resolve(cwd);
|
|
105
|
+
for (let i = 0; i < 6; i++) {
|
|
106
|
+
const base = path.basename(dir).trim();
|
|
107
|
+
if (!base)
|
|
108
|
+
break; // reached filesystem root
|
|
109
|
+
if (!base.startsWith("."))
|
|
110
|
+
return base; // first non-dot folder wins
|
|
111
|
+
const parent = path.dirname(dir);
|
|
112
|
+
if (parent === dir)
|
|
113
|
+
break; // no more parents
|
|
114
|
+
dir = parent;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Derive the SAME stable `external_chat_id` that `@trygocode/sync` assigns to a
|
|
120
|
+
* synced transcript, so a per-turn notification can deep-link straight to that
|
|
121
|
+
* chat on tap. MUST stay byte-identical to gocode-sync's `deriveExternalChatId`:
|
|
122
|
+
* sha256 of `source\0(lower workspace)\0(session)`, first 32 hex chars.
|
|
123
|
+
*
|
|
124
|
+
* We re-derive (rather than import gocode-sync) because notify is a separate
|
|
125
|
+
* zero-dep package; the hash is tiny and pinned by a parity test.
|
|
126
|
+
*/
|
|
127
|
+
export function deriveIdeChatId(input) {
|
|
128
|
+
const basis = [
|
|
129
|
+
input.source.toLowerCase().trim(),
|
|
130
|
+
input.workspacePath.toLowerCase().trim(),
|
|
131
|
+
input.ideSessionId.trim(),
|
|
132
|
+
].join("\u0000");
|
|
133
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 32);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Best-effort: from the Cursor/Claude stop-hook stdin JSON + cwd, compute the
|
|
137
|
+
* synced chat's `external_chat_id` so the notification deep-links to it. Returns
|
|
138
|
+
* undefined when the payload lacks a stable session id (then the push falls back
|
|
139
|
+
* to Home, exactly as before). Never throws.
|
|
140
|
+
*
|
|
141
|
+
* Session id resolution mirrors the capture adapters:
|
|
142
|
+
* - Cursor: `conversation_id` / `conversationId`, else the `transcript_path`
|
|
143
|
+
* filename (minus `.jsonl`).
|
|
144
|
+
* - Claude: `session_id` / `sessionId`, else the `transcript_path` filename.
|
|
145
|
+
* Workspace mirrors capture: payload workspace/cwd, else the hook cwd.
|
|
146
|
+
*/
|
|
147
|
+
export function ideChatIdFromHookStdin(source, cwd, hookStdin) {
|
|
148
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
149
|
+
return undefined;
|
|
150
|
+
let p;
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(hookStdin);
|
|
153
|
+
if (!parsed || typeof parsed !== "object")
|
|
154
|
+
return undefined;
|
|
155
|
+
p = parsed;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
const str = (...vals) => {
|
|
161
|
+
for (const v of vals)
|
|
162
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
163
|
+
return v;
|
|
164
|
+
return undefined;
|
|
165
|
+
};
|
|
166
|
+
const transcriptPath = str(p.transcript_path, p.transcriptPath);
|
|
167
|
+
const sessionFromPath = transcriptPath
|
|
168
|
+
? path.basename(transcriptPath).replace(/\.jsonl$/i, "") || undefined
|
|
169
|
+
: undefined;
|
|
170
|
+
const ideSessionId = str(p.conversation_id, p.conversationId, p.session_id, p.sessionId, sessionFromPath);
|
|
171
|
+
if (!ideSessionId)
|
|
172
|
+
return undefined;
|
|
173
|
+
const workspacePath = str(p.workspace_path, Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined, p.cwd, cwd);
|
|
174
|
+
if (!workspacePath)
|
|
175
|
+
return undefined;
|
|
176
|
+
// The capture side stores `source: "cursor" | "claude_code"`; the hook passes
|
|
177
|
+
// `--source cursor|claude_code`, so they already match. Normalise just in case.
|
|
178
|
+
const normSource = source === "claude" ? "claude_code" : source;
|
|
179
|
+
return deriveIdeChatId({
|
|
180
|
+
source: normSource,
|
|
181
|
+
workspacePath: path.resolve(workspacePath),
|
|
182
|
+
ideSessionId,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Best-effort: derive a short chat title from the transcript named in the hook
|
|
187
|
+
* stdin, mirroring gocode-sync's `deriveTitleFromMessages` (first user message's
|
|
188
|
+
* first meaningful line). Used to name WHICH chat finished in the push body.
|
|
189
|
+
* Reads at most the first ~64KB of the JSONL (titles come from the first user
|
|
190
|
+
* turn). Returns undefined on any problem — never throws.
|
|
191
|
+
*/
|
|
192
|
+
export async function chatTitleFromHookStdin(hookStdin) {
|
|
193
|
+
if (!hookStdin || hookStdin.trim() === "")
|
|
194
|
+
return undefined;
|
|
195
|
+
let transcriptPath;
|
|
196
|
+
try {
|
|
197
|
+
const p = JSON.parse(hookStdin);
|
|
198
|
+
const v = p.transcript_path ?? p.transcriptPath;
|
|
199
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
200
|
+
transcriptPath = v;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
if (!transcriptPath)
|
|
206
|
+
return undefined;
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await fs.readFile(transcriptPath, "utf8");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
for (const line of raw.split("\n")) {
|
|
215
|
+
const t = line.trim();
|
|
216
|
+
if (!t)
|
|
217
|
+
continue;
|
|
218
|
+
let rec;
|
|
219
|
+
try {
|
|
220
|
+
rec = JSON.parse(t);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// role: top-level `role` (Cursor) or nested `message.role` / `type` (Claude).
|
|
226
|
+
const nested = rec.message && typeof rec.message === "object"
|
|
227
|
+
? rec.message
|
|
228
|
+
: undefined;
|
|
229
|
+
const role = String(rec.role ?? nested?.role ?? rec.type ?? "");
|
|
230
|
+
if (role !== "user" && role !== "human")
|
|
231
|
+
continue;
|
|
232
|
+
// content: string or block array, inline or nested.
|
|
233
|
+
let content = "";
|
|
234
|
+
const src = rec.content ?? rec.text ?? nested?.content;
|
|
235
|
+
if (typeof src === "string")
|
|
236
|
+
content = src;
|
|
237
|
+
else if (Array.isArray(src)) {
|
|
238
|
+
for (const b of src) {
|
|
239
|
+
if (typeof b === "string")
|
|
240
|
+
content += b + "\n";
|
|
241
|
+
else if (b && typeof b === "object" && typeof b.text === "string")
|
|
242
|
+
content += String(b.text) + "\n";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Strip the harness `<user_query>` / `<timestamp>` plumbing so the title is
|
|
246
|
+
// the human's words (mirrors gocode-sync's sanitizer, minimal form).
|
|
247
|
+
content = content
|
|
248
|
+
.replace(/<timestamp(?:\s[^>]*)?>[\s\S]*?<\/timestamp>/gi, "")
|
|
249
|
+
.replace(/<image_files(?:\s[^>]*)?>[\s\S]*?<\/image_files>/gi, "")
|
|
250
|
+
.replace(/\[Image\](?!\()\s*/g, "")
|
|
251
|
+
.replace(/<\/?user_query(?:\s[^>]*)?>/gi, "");
|
|
252
|
+
const firstLine = content
|
|
253
|
+
.split("\n")
|
|
254
|
+
.map((l) => l.trim())
|
|
255
|
+
.find((l) => l !== "");
|
|
256
|
+
if (!firstLine)
|
|
257
|
+
continue;
|
|
258
|
+
let title = firstLine.replace(/^[#>\-*+\s]+/, "").replace(/[*_`]+/g, "").trim();
|
|
259
|
+
if (title === "")
|
|
260
|
+
continue;
|
|
261
|
+
if (title.length > 80)
|
|
262
|
+
title = title.slice(0, 79).trimEnd() + "…";
|
|
263
|
+
return title;
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Env var an Autopilot (Ralph/Homer) loop exports to mark that IT owns the
|
|
269
|
+
* current turn's notification (T-N7 / PRD §3.2). When truthy, {@link onStop}
|
|
270
|
+
* suppresses its per-turn ping entirely: the loop sends its OWN
|
|
271
|
+
* `loop_completed`/`loop_halted` Autopilot ping (`_loop_inner.sh
|
|
272
|
+
* push_notify_local`), so any stop-hook-driven `finished`/`push` ping for the
|
|
273
|
+
* same turn would be a duplicate of it.
|
|
274
|
+
*
|
|
275
|
+
* Defensive by design: today a Ralph `claude-tmux` iteration runs `claude` in its
|
|
276
|
+
* own tmux pane (Claude Code has no `Stop` hook here) and is NOT a Cursor turn, so
|
|
277
|
+
* the only per-turn hook (`cursor stop`) never fires for a loop — no double-fire
|
|
278
|
+
* exists to suppress. This gate is the READING half of the contract (the loop
|
|
279
|
+
* script exports the marker, T-N5) so that if a future loop arrangement DOES trip
|
|
280
|
+
* a stop hook, the duplicate is gated off at the source.
|
|
281
|
+
*/
|
|
282
|
+
export const AUTOPILOT_OWNS_TURN_ENV = "GOCODE_AUTOPILOT_OWNS_TURN";
|
|
283
|
+
/**
|
|
284
|
+
* True when {@link AUTOPILOT_OWNS_TURN_ENV} is set to a truthy value. Treats the
|
|
285
|
+
* usual falsy strings (`""`, `0`, `false`, `no`, `off`, any case) as not-owned so
|
|
286
|
+
* an accidental empty/`0` export never silently eats every per-turn ping.
|
|
287
|
+
*/
|
|
288
|
+
export function autopilotOwnsTurn(env = process.env) {
|
|
289
|
+
const raw = env[AUTOPILOT_OWNS_TURN_ENV];
|
|
290
|
+
if (raw == null)
|
|
291
|
+
return false;
|
|
292
|
+
const v = raw.trim().toLowerCase();
|
|
293
|
+
return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
|
|
294
|
+
}
|
|
40
295
|
/**
|
|
41
296
|
* The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
|
|
42
297
|
* to the auto-push flow (which sends its own notification) OR fires the plain
|
|
@@ -71,6 +326,21 @@ export async function onStop(opts = {}) {
|
|
|
71
326
|
timestamp: opts.timestamp,
|
|
72
327
|
}));
|
|
73
328
|
try {
|
|
329
|
+
// ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
|
|
330
|
+
// When an Autopilot loop has marked that it owns this turn, it sends its OWN
|
|
331
|
+
// loop_completed/loop_halted Autopilot ping, so ANY per-turn stop-hook ping
|
|
332
|
+
// here (plain `finished` OR the auto-push notification) would be a duplicate.
|
|
333
|
+
// Suppress entirely — no send, no git push — before doing any other work.
|
|
334
|
+
// Best-effort + safe: gated behind an explicit truthy env marker only, so a
|
|
335
|
+
// normal hand-driven turn (no marker) is never affected.
|
|
336
|
+
if (autopilotOwnsTurn(opts.env)) {
|
|
337
|
+
await logLine(`autopilot owns turn (${AUTOPILOT_OWNS_TURN_ENV}) → suppressed per-turn ${source} ping (loop ping is authoritative)`);
|
|
338
|
+
return {
|
|
339
|
+
mode: "autopilot-suppressed",
|
|
340
|
+
settingsSource: "default",
|
|
341
|
+
detail: "autopilot loop owns the turn — per-turn ping suppressed",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
74
344
|
// ── Step 1: derive repo identity (never throws — local fallback on failure). ──
|
|
75
345
|
let repo;
|
|
76
346
|
try {
|
|
@@ -98,7 +368,7 @@ export async function onStop(opts = {}) {
|
|
|
98
368
|
settings: toPushSettings(settings),
|
|
99
369
|
source,
|
|
100
370
|
cwd,
|
|
101
|
-
project: repo
|
|
371
|
+
project: projectLabel(repo, cwd),
|
|
102
372
|
dedupeKey: opts.dedupeKey,
|
|
103
373
|
dryRun: opts.dryRun,
|
|
104
374
|
server: opts.server,
|
|
@@ -110,13 +380,21 @@ export async function onStop(opts = {}) {
|
|
|
110
380
|
await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
|
|
111
381
|
return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
|
|
112
382
|
}
|
|
113
|
-
// ── Step 3b: auto-push off → the plain
|
|
383
|
+
// ── Step 3b: auto-push off → the plain notification (legacy flow). ──
|
|
384
|
+
// Derive the notification kind from the Cursor stop hook's stdin JSON (T-CUR1
|
|
385
|
+
// / PRD §8.5). The status maps:
|
|
386
|
+
// completed → finished (agent turned cleanly)
|
|
387
|
+
// aborted → awaiting_input (agent yielded back to the human)
|
|
388
|
+
// error → error (agent hit an error)
|
|
389
|
+
// absent → finished (back-compat: no stdin or unrecognised status)
|
|
390
|
+
const hookStatus = parseCursorStopStatus(opts.hookStdin);
|
|
391
|
+
const sendKind = cursorStopStatusToKind(hookStatus);
|
|
114
392
|
if (opts.dryRun) {
|
|
115
|
-
await logLine(`dry-run: would send
|
|
393
|
+
await logLine(`dry-run: would send ${sendKind} (auto-push off, source: ${source}, settings: ${resolved.source})`);
|
|
116
394
|
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
117
395
|
}
|
|
118
396
|
// Client fast-path cross-source dedup (T-N2 / PRD §2.2): before the plain
|
|
119
|
-
//
|
|
397
|
+
// send, consult a short-TTL lock keyed by repo+kind+minute-bucket.
|
|
120
398
|
// If another source (e.g. the Cursor `stop` hook vs this Claude `Stop` hook)
|
|
121
399
|
// already claimed the bucket within the window, skip OUR local send — the
|
|
122
400
|
// first arrival's notification stands. Best-effort + fail-open: the check
|
|
@@ -128,7 +406,7 @@ export async function onStop(opts = {}) {
|
|
|
128
406
|
try {
|
|
129
407
|
decision = await dedupCheck({
|
|
130
408
|
repoKey: repo?.repo_key,
|
|
131
|
-
kind:
|
|
409
|
+
kind: sendKind,
|
|
132
410
|
source,
|
|
133
411
|
windowMs: opts.dedupWindowMs,
|
|
134
412
|
home: opts.home,
|
|
@@ -138,16 +416,27 @@ export async function onStop(opts = {}) {
|
|
|
138
416
|
decision = "send"; // defence in depth — never let dedup block the send
|
|
139
417
|
}
|
|
140
418
|
if (decision === "suppress") {
|
|
141
|
-
await logLine(`dedup fast-path → suppressed duplicate
|
|
419
|
+
await logLine(`dedup fast-path → suppressed duplicate ${sendKind} (source: ${source}, settings: ${resolved.source})`);
|
|
142
420
|
return { mode: "deduped", settingsSource: resolved.source, repo };
|
|
143
421
|
}
|
|
144
|
-
const payload = { kind:
|
|
145
|
-
|
|
146
|
-
|
|
422
|
+
const payload = { kind: sendKind, source };
|
|
423
|
+
const project = projectLabel(repo, cwd);
|
|
424
|
+
if (project)
|
|
425
|
+
payload.project = project;
|
|
147
426
|
if (opts.dedupeKey)
|
|
148
427
|
payload.dedupe_key = opts.dedupeKey;
|
|
428
|
+
// Deep-link target: the synced chat's id (so tapping the push opens the
|
|
429
|
+
// chat, not just Home). Best-effort — omitted when the hook payload has no
|
|
430
|
+
// stable session id (push then falls back to Home, as before).
|
|
431
|
+
const ideChatId = ideChatIdFromHookStdin(source, cwd, opts.hookStdin);
|
|
432
|
+
if (ideChatId)
|
|
433
|
+
payload.ide_chat_id = ideChatId;
|
|
434
|
+
// Name WHICH chat finished in the body (best-effort; omitted if unreadable).
|
|
435
|
+
const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
|
|
436
|
+
if (chatTitle)
|
|
437
|
+
payload.chat = chatTitle;
|
|
149
438
|
const sent = await sendImpl(payload);
|
|
150
|
-
await logLine(`send path →
|
|
439
|
+
await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
|
|
151
440
|
return { mode: "send", settingsSource: resolved.source, send: sent, repo };
|
|
152
441
|
}
|
|
153
442
|
catch (err) {
|
package/dist/src/send.js
CHANGED
|
@@ -20,6 +20,13 @@ export const NOTIFY_KINDS = [
|
|
|
20
20
|
"awaiting_input",
|
|
21
21
|
"loop_completed",
|
|
22
22
|
"loop_halted",
|
|
23
|
+
// Ralph/Homer lifecycle kinds (PRD §4 — Notify Human-Gating PRD 2026-06-08).
|
|
24
|
+
// ralph_waiting: offline/quota stall edge — pushed once on the stall edge;
|
|
25
|
+
// server drops repeats until a resumed/completed/halted re-arms the edge.
|
|
26
|
+
// ralph_resumed: stall recovered, loop running again — NEVER pushed (silent
|
|
27
|
+
// control event that resets the server-side stall state machine to ARMED).
|
|
28
|
+
"ralph_waiting",
|
|
29
|
+
"ralph_resumed",
|
|
23
30
|
];
|
|
24
31
|
/** True when `value` is one of the canonical {@link NOTIFY_KINDS}. */
|
|
25
32
|
export function isNotifyKind(value) {
|
|
@@ -45,7 +52,7 @@ function normalizeServer(url) {
|
|
|
45
52
|
/** Build the JSON body, dropping any undefined/empty optional fields. */
|
|
46
53
|
function buildBody(payload) {
|
|
47
54
|
const body = { kind: payload.kind };
|
|
48
|
-
for (const field of ["title", "body", "source", "project", "dedupe_key"]) {
|
|
55
|
+
for (const field of ["title", "body", "source", "project", "dedupe_key", "ide_chat_id", "chat"]) {
|
|
49
56
|
const v = payload[field];
|
|
50
57
|
if (typeof v === "string" && v !== "")
|
|
51
58
|
body[field] = v;
|
package/dist/src/status.js
CHANGED
|
@@ -84,6 +84,18 @@ export async function gatherStatus(opts = {}) {
|
|
|
84
84
|
function mark(ok) {
|
|
85
85
|
return ok ? "✓" : "✗";
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* One-liner hint appended at the bottom of {@link formatStatus}.
|
|
89
|
+
* Kept as an exported constant so tests can match against the exact text.
|
|
90
|
+
*/
|
|
91
|
+
export const STATUS_ONELINER_HINT = "To notify from your own script, add: gocode-notify send --kind finished --source <name> || true";
|
|
92
|
+
/**
|
|
93
|
+
* Warning surfaced when credentials are absent (T-COV1: make the silent no-op LOUD).
|
|
94
|
+
* Defined here so both `status` and `doctor` can use the same text without a
|
|
95
|
+
* circular import (doctor.ts already imports from status.ts).
|
|
96
|
+
* Exported so tests can match against the exact text.
|
|
97
|
+
*/
|
|
98
|
+
export const UNPAIRED_WARNING = "⚠️ loop completion pushes will NOT reach your phone — run `gocode-notify login`";
|
|
87
99
|
/** Render a {@link StatusReport} as human-readable lines (one per element). */
|
|
88
100
|
export function formatStatus(report) {
|
|
89
101
|
const lines = ["gocode-notify status", ""];
|
|
@@ -98,6 +110,11 @@ export function formatStatus(report) {
|
|
|
98
110
|
lines.push(`${mark(false)} Credentials: not paired — run \`gocode-notify login\``);
|
|
99
111
|
}
|
|
100
112
|
lines.push(` path: ${c.path}`);
|
|
113
|
+
// T-COV1: Surface the loud unpaired warning so loop scripts that check
|
|
114
|
+
// `gocode-notify status` never silently miss that pushes are disabled.
|
|
115
|
+
if (!c.present) {
|
|
116
|
+
lines.push("", UNPAIRED_WARNING);
|
|
117
|
+
}
|
|
101
118
|
lines.push(`${mark(report.server.reachable)} Server: ${report.server.url} (${report.server.detail})`);
|
|
102
119
|
lines.push("", "Runtimes:");
|
|
103
120
|
for (const r of report.runtimes) {
|
|
@@ -109,6 +126,7 @@ export function formatStatus(report) {
|
|
|
109
126
|
lines.push(` ${mark(true)} ${r.name}: detected (${cfg})`);
|
|
110
127
|
lines.push(` config: ${r.configPath}`);
|
|
111
128
|
}
|
|
129
|
+
lines.push("", STATUS_ONELINER_HINT);
|
|
112
130
|
return lines;
|
|
113
131
|
}
|
|
114
132
|
/**
|
package/dist/src/version.js
CHANGED
|
@@ -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.
|
|
2
|
+
export const VERSION = "0.3.1";
|
package/package.json
CHANGED
package/snippets/ralph-homer.sh
CHANGED
|
@@ -1,21 +1,341 @@
|
|
|
1
1
|
# gocode-notify — Ralph/Homer loop opt-in snippet (PRD §5.6, trigger C)
|
|
2
2
|
#
|
|
3
3
|
# OPT-IN. This snippet is NOT auto-injected by `gocode-notify setup`; the
|
|
4
|
-
# installer never edits your loop scripts without consent. Paste
|
|
5
|
-
#
|
|
6
|
-
# `ralph`/`homer` skills, Geoffrey Huntley's `while :; do … done`
|
|
7
|
-
# or your own driver) to get
|
|
4
|
+
# installer never edits your loop scripts without consent. Paste the relevant
|
|
5
|
+
# stanzas into the completion/halt/stall paths of any loop you control (this
|
|
6
|
+
# repo's `ralph`/`homer` skills, Geoffrey Huntley's `while :; do … done`
|
|
7
|
+
# one-liner, or your own driver) to get phone pushes for the right events.
|
|
8
8
|
#
|
|
9
9
|
# Prereq: you've already paired this machine once with
|
|
10
10
|
# npx @trygocode/notify@latest login --code <CODE>
|
|
11
11
|
# (get <CODE> from the GoCode app → "Connect a coding agent").
|
|
12
12
|
#
|
|
13
|
-
#
|
|
13
|
+
# All calls are fire-and-forget: the `|| true` guarantees a failed/slow push
|
|
14
14
|
# can never block or fail your loop (the CLI also self-times-out in 5s).
|
|
15
|
+
#
|
|
16
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
# KIND CONTRACT (PRD §4 "Notify Human-Gating")
|
|
18
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
#
|
|
20
|
+
# The *kind* is the contract. The GoCode server classifies every kind into
|
|
21
|
+
# exactly one push-worthiness bucket. Callers only need to emit the RIGHT kind;
|
|
22
|
+
# the server handles foreground-suppression, stall-dedup, and FCM delivery.
|
|
23
|
+
#
|
|
24
|
+
# Kind Push-worthy? When to emit
|
|
25
|
+
# ───────────────── ──────────── ────────────────────────────────────────
|
|
26
|
+
# loop_completed ✅ YES Loop finished all work cleanly.
|
|
27
|
+
# ralph_completed ✅ YES Ralph-specific alias for loop_completed.
|
|
28
|
+
# loop_halted ✅ YES Loop paused — a HUMAN is needed.
|
|
29
|
+
# ralph_halted ✅ YES Ralph-specific alias for loop_halted.
|
|
30
|
+
# Covers: user_blocking question, max
|
|
31
|
+
# consecutive failures.
|
|
32
|
+
# ralph_synth_question ✅ YES Setup question raised BEFORE the loop
|
|
33
|
+
# starts — genuinely needs a human.
|
|
34
|
+
# ralph_waiting ✅ EDGE ONLY Offline / quota stall. Emit ONLY on the
|
|
35
|
+
# stall EDGE (first failure). Server drops
|
|
36
|
+
# repeats until a resumed/completed/halted
|
|
37
|
+
# re-arms the edge. See pattern below.
|
|
38
|
+
# ralph_resumed 🔕 NO push Stall recovered; loop running again.
|
|
39
|
+
# Silent control event: its only effect is
|
|
40
|
+
# to reset the server-side stall state
|
|
41
|
+
# machine back to ARMED so the NEXT genuine
|
|
42
|
+
# stall edge will push again.
|
|
43
|
+
# ralph_question 🔕 NO push In-loop question the Oracle answers
|
|
44
|
+
# autonomously. Server logs it for the
|
|
45
|
+
# dashboard but NEVER sends to FCM. Do NOT
|
|
46
|
+
# emit this on oracle-answerable paths.
|
|
47
|
+
# ralph_advanced 🔕 NO push Queue advanced to next PRD (info only).
|
|
48
|
+
# finished ✅ YES Generic agent-finished (non-loop hooks).
|
|
49
|
+
# awaiting_input ✅ YES Generic agent needs the human (non-loop).
|
|
50
|
+
# error ✅ YES Generic agent hit an error.
|
|
51
|
+
#
|
|
52
|
+
# Rule: only emit push-worthy kinds on the paths where a HUMAN is genuinely
|
|
53
|
+
# needed. Oracle-answerable questions and routine loop events must be silent.
|
|
54
|
+
#
|
|
55
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
# PATTERN 1 — Loop completion (all work done cleanly)
|
|
57
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# Use loop_completed (or ralph_completed for Ralph-specific loops).
|
|
60
|
+
gocode-notify send --kind loop_completed --source ralph \
|
|
61
|
+
--project "$(basename "$PWD")" || true
|
|
62
|
+
|
|
63
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
64
|
+
# PATTERN 2 — Loop halt (a HUMAN is needed — user_blocking / max-failures)
|
|
65
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
#
|
|
67
|
+
# Emit ralph_halted ONLY when the loop is genuinely paused awaiting human action:
|
|
68
|
+
# - A user_blocking question was raised (the Oracle cannot answer it).
|
|
69
|
+
# - The loop hit max consecutive failures and stopped.
|
|
70
|
+
#
|
|
71
|
+
# Do NOT emit ralph_halted for oracle-answerable (blocking) questions — the
|
|
72
|
+
# Oracle handles those autonomously; emitting halted would be a false alarm.
|
|
73
|
+
|
|
74
|
+
gocode-notify send --kind ralph_halted --source ralph \
|
|
75
|
+
--project "$(basename "$PWD")" \
|
|
76
|
+
--title "Ralph halted — needs you" || true
|
|
77
|
+
|
|
78
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
# PATTERN 3 — Offline / quota stall (edge-triggered, NOT every retry)
|
|
80
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
#
|
|
82
|
+
# A loop that polls every ~60s on a network/quota outage must push ONCE when
|
|
83
|
+
# it first stalls, stay SILENT while still stalled, and push AGAIN only after
|
|
84
|
+
# it recovers and then stalls again. Never spam on every retry.
|
|
85
|
+
#
|
|
86
|
+
# Caller-side responsibility: only fire ralph_waiting on the EDGE (first stall),
|
|
87
|
+
# not on every subsequent retry. The server also deduplicates repeats as a
|
|
88
|
+
# defense-in-depth backstop.
|
|
89
|
+
#
|
|
90
|
+
# Shell pattern (adapt to your loop's retry logic):
|
|
91
|
+
|
|
92
|
+
_stall_notified=0 # reset to 0 at loop start or after recovery
|
|
93
|
+
|
|
94
|
+
# Inside your retry / offline-backoff loop:
|
|
95
|
+
if [[ "$_stall_notified" -eq 0 ]]; then
|
|
96
|
+
# First time we're stalled — notify on the edge.
|
|
97
|
+
gocode-notify send --kind ralph_waiting --source ralph \
|
|
98
|
+
--project "$(basename "$PWD")" \
|
|
99
|
+
--title "Ralph waiting — offline or quota" || true
|
|
100
|
+
_stall_notified=1
|
|
101
|
+
fi
|
|
102
|
+
sleep 60
|
|
103
|
+
|
|
104
|
+
# When the endpoint recovers — emit ralph_resumed to reset the server edge state.
|
|
105
|
+
# This re-arms the next genuine stall edge so it will push again.
|
|
106
|
+
# Then reset the caller-side flag.
|
|
107
|
+
_stall_notified=0
|
|
108
|
+
gocode-notify send --kind ralph_resumed --source ralph \
|
|
109
|
+
--project "$(basename "$PWD")" || true
|
|
110
|
+
|
|
111
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
# PATTERN 4 — Oracle-answerable question (SILENT — do NOT push)
|
|
113
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
114
|
+
#
|
|
115
|
+
# When the loop raises a question that the Oracle will answer autonomously
|
|
116
|
+
# (a normal "blocking" question, not "user_blocking"), emit NOTHING. The
|
|
117
|
+
# Oracle will handle it and the loop will continue with no human in the picture.
|
|
118
|
+
#
|
|
119
|
+
# Previously: some loops emitted `ralph_question` here. DO NOT do this —
|
|
120
|
+
# ralph_question is classified as a non-push info kind server-side (it is
|
|
121
|
+
# logged for the dashboard but never sent to FCM), and the caller should stop
|
|
122
|
+
# emitting it entirely on oracle-answerable paths to minimize noise.
|
|
123
|
+
#
|
|
124
|
+
# Summary:
|
|
125
|
+
# user_blocking halt → push (ralph_halted)
|
|
126
|
+
# oracle-answerable → push NOTHING (or at most ralph_question, which is
|
|
127
|
+
# server-side demoted to a silent info event)
|
|
128
|
+
|
|
129
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
# PATTERN 5 — Crash-safe terminal notify via trap (PRD §8.6)
|
|
131
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
#
|
|
133
|
+
# Problem: a loop killed by an unexpected signal (SIGTERM, SIGKILL, SIGINT),
|
|
134
|
+
# an unhandled error, or a shell `exit` on a non-zero status will terminate
|
|
135
|
+
# WITHOUT ever reaching the explicit halt/completed path — so the user's phone
|
|
136
|
+
# is never notified, leaving them unaware the loop stopped.
|
|
137
|
+
#
|
|
138
|
+
# Solution: register a bash `trap` at the TOP of your loop script that fires a
|
|
139
|
+
# best-effort `loop_halted` push on ANY unexpected exit. The `|| true` guard
|
|
140
|
+
# ensures the trap itself can never cause a secondary failure.
|
|
141
|
+
#
|
|
142
|
+
# HOW IT WORKS
|
|
143
|
+
# ─────────────
|
|
144
|
+
# 1. Set _LOOP_EXITED=0 at the top of your script (before the main loop).
|
|
145
|
+
# 2. At every NORMAL exit path (completed / halted / stall) flip the flag to 1
|
|
146
|
+
# BEFORE sending the final push, so the trap knows a push already went out.
|
|
147
|
+
# 3. The trap only fires a push when _LOOP_EXITED == 0 (i.e. the script reached
|
|
148
|
+
# the EXIT signal without a controlled terminal notification).
|
|
149
|
+
#
|
|
150
|
+
# SIGNALS COVERED
|
|
151
|
+
# ────────────────
|
|
152
|
+
# Bash's EXIT pseudo-signal fires on:
|
|
153
|
+
# • Normal exit (fall-through past the last line) ← covered by flag
|
|
154
|
+
# • `exit N` from any point in the script ← covered by flag
|
|
155
|
+
# • SIGTERM (graceful kill, e.g. `kill <pid>`) ← ✅ caught
|
|
156
|
+
# • SIGINT (Ctrl-C / terminal close) ← ✅ caught
|
|
157
|
+
# • Unhandled ERR (set -e + unset command) ← ✅ caught
|
|
158
|
+
#
|
|
159
|
+
# NOTE: SIGKILL (kill -9) cannot be trapped by any process — that's a kernel
|
|
160
|
+
# hard-kill. Nothing can be done for SIGKILL; document this for your users.
|
|
161
|
+
#
|
|
162
|
+
# ─── PASTE THIS BLOCK AT THE TOP OF YOUR LOOP SCRIPT ────────────────────────
|
|
163
|
+
|
|
164
|
+
# 1. Flag: 0 = no terminal push has gone out yet; 1 = push already sent.
|
|
165
|
+
_LOOP_EXITED=0
|
|
166
|
+
|
|
167
|
+
# 2. Crash-safe trap — fires on EXIT (normal fall-through, `exit N`, SIGTERM,
|
|
168
|
+
# SIGINT, unhandled ERR). Sends loop_halted if no controlled push went out.
|
|
169
|
+
_loop_crash_trap() {
|
|
170
|
+
if [[ "${_LOOP_EXITED:-0}" -eq 0 ]]; then
|
|
171
|
+
# Unexpected exit — best-effort push, never block.
|
|
172
|
+
gocode-notify send --kind loop_halted --source ralph \
|
|
173
|
+
--project "$(basename "$PWD")" \
|
|
174
|
+
--title "Loop exited unexpectedly — check logs" || true
|
|
175
|
+
fi
|
|
176
|
+
}
|
|
177
|
+
trap '_loop_crash_trap' EXIT
|
|
15
178
|
|
|
16
|
-
#
|
|
17
|
-
gocode-notify send --kind loop_completed --source ralph --project "$(basename "$PWD")" || true
|
|
179
|
+
# ─── END OF TOP-OF-SCRIPT BLOCK ──────────────────────────────────────────────
|
|
18
180
|
|
|
19
|
-
#
|
|
20
|
-
|
|
181
|
+
# ─── AT EACH NORMAL TERMINAL EXIT PATH, set the flag THEN send the push ──────
|
|
182
|
+
|
|
183
|
+
# Example — loop completed cleanly:
|
|
184
|
+
_LOOP_EXITED=1
|
|
185
|
+
gocode-notify send --kind loop_completed --source ralph \
|
|
186
|
+
--project "$(basename "$PWD")" || true
|
|
187
|
+
|
|
188
|
+
# Example — loop halted (user_blocking / max-failures):
|
|
189
|
+
_LOOP_EXITED=1
|
|
190
|
+
gocode-notify send --kind ralph_halted --source ralph \
|
|
191
|
+
--project "$(basename "$PWD")" \
|
|
21
192
|
--title "Ralph halted — needs you" || true
|
|
193
|
+
|
|
194
|
+
# Example — explicit clean exit (no push needed, but mark so trap is silent):
|
|
195
|
+
_LOOP_EXITED=1
|
|
196
|
+
|
|
197
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
198
|
+
# MINIMAL COPY-PASTE (drop-in for any loop script)
|
|
199
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
200
|
+
#
|
|
201
|
+
# Add these 6 lines to the top of your script and the trap handles the rest:
|
|
202
|
+
#
|
|
203
|
+
# _LOOP_EXITED=0
|
|
204
|
+
# _loop_crash_trap() {
|
|
205
|
+
# [[ "${_LOOP_EXITED:-0}" -eq 0 ]] && \
|
|
206
|
+
# gocode-notify send --kind loop_halted --source ralph \
|
|
207
|
+
# --project "$(basename "$PWD")" \
|
|
208
|
+
# --title "Loop exited unexpectedly — check logs" || true
|
|
209
|
+
# }
|
|
210
|
+
# trap '_loop_crash_trap' EXIT
|
|
211
|
+
#
|
|
212
|
+
# Then at every terminal exit path, set _LOOP_EXITED=1 before the final push.
|
|
213
|
+
|
|
214
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
# PATTERN 6 — `terminal_halt` helper (PRD §8.7 R12)
|
|
216
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
#
|
|
218
|
+
# PROBLEM this solves:
|
|
219
|
+
# When a loop has many exit paths (task-complete, user_blocking halt,
|
|
220
|
+
# max-consecutive-failures, stall, explicit stop) each one must:
|
|
221
|
+
# 1. Set _LOOP_EXITED=1 (so the crash trap stays silent)
|
|
222
|
+
# 2. Send the right push kind
|
|
223
|
+
# 3. Break out of the main loop
|
|
224
|
+
# Duplicating those 3 steps at every exit point is error-prone — it is easy
|
|
225
|
+
# to add a new `break` or `exit` path and forget the push.
|
|
226
|
+
#
|
|
227
|
+
# SOLUTION — one-call `terminal_halt <kind> <detail>`:
|
|
228
|
+
# Centralise the 3 steps in a single helper. Every exit path calls the
|
|
229
|
+
# helper; the push is guaranteed by construction.
|
|
230
|
+
#
|
|
231
|
+
# TERMINAL KINDS — the three categories an exit can belong to:
|
|
232
|
+
#
|
|
233
|
+
# ┌──────────────────────┬────────────────────────────────────────────────┐
|
|
234
|
+
# │ Kind │ When to use │
|
|
235
|
+
# ├──────────────────────┼────────────────────────────────────────────────┤
|
|
236
|
+
# │ loop_halted │ Loop stopped; a HUMAN is needed. │
|
|
237
|
+
# │ (ralph_halted) │ • user_blocking question raised │
|
|
238
|
+
# │ │ • max consecutive failures reached │
|
|
239
|
+
# │ │ • explicit operator stop (.homer/STOP file) │
|
|
240
|
+
# ├──────────────────────┼────────────────────────────────────────────────┤
|
|
241
|
+
# │ loop_completed │ Loop finished all work cleanly — all tasks │
|
|
242
|
+
# │ (ralph_completed) │ checked off or tracker fully satisfied. │
|
|
243
|
+
# ├──────────────────────┼────────────────────────────────────────────────┤
|
|
244
|
+
# │ ralph_waiting │ Loop cannot proceed right now (offline / │
|
|
245
|
+
# │ │ quota stall) — emit on the EDGE only (first │
|
|
246
|
+
# │ │ stall). Use PATTERN 3's _stall_notified guard │
|
|
247
|
+
# │ │ before calling terminal_halt with this kind. │
|
|
248
|
+
# └──────────────────────┴────────────────────────────────────────────────┘
|
|
249
|
+
#
|
|
250
|
+
# SIGNATURE:
|
|
251
|
+
# terminal_halt <kind> <detail>
|
|
252
|
+
#
|
|
253
|
+
# <kind> — one of the terminal kinds above (loop_halted, loop_completed,
|
|
254
|
+
# ralph_halted, ralph_completed, ralph_waiting, …)
|
|
255
|
+
# <detail> — short human-readable description that becomes --title / body text
|
|
256
|
+
# on the push notification. Keep it under 80 chars.
|
|
257
|
+
#
|
|
258
|
+
# HOW TO USE — paste the function definition at the top of your loop script
|
|
259
|
+
# (right after the _LOOP_EXITED + trap block), then call `terminal_halt` at
|
|
260
|
+
# every exit point instead of inline _LOOP_EXITED=1 + gocode-notify + break.
|
|
261
|
+
#
|
|
262
|
+
# ─── PASTE THIS FUNCTION DEFINITION AT THE TOP OF YOUR LOOP SCRIPT ───────────
|
|
263
|
+
|
|
264
|
+
terminal_halt() {
|
|
265
|
+
# terminal_halt <kind> <detail>
|
|
266
|
+
# Marks the loop as exiting (silences crash trap), sends the push, then
|
|
267
|
+
# breaks out of the enclosing loop. Safe to call from inside `while`/`for`.
|
|
268
|
+
local _th_kind="${1:-loop_halted}"
|
|
269
|
+
local _th_detail="${2:-Loop exited}"
|
|
270
|
+
_LOOP_EXITED=1
|
|
271
|
+
gocode-notify send --kind "$_th_kind" --source ralph \
|
|
272
|
+
--project "$(basename "$PWD")" \
|
|
273
|
+
--title "$_th_detail" || true
|
|
274
|
+
# `break` exits the innermost loop. If you call terminal_halt from a nested
|
|
275
|
+
# function inside the main loop, use `return` + then call `break` in the
|
|
276
|
+
# caller, or replace this with `exit 0` for a top-level script.
|
|
277
|
+
break
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
# ─── END OF FUNCTION DEFINITION ──────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
# ─── USAGE EXAMPLES — replace the inline _LOOP_EXITED=1 + send + break ───────
|
|
283
|
+
|
|
284
|
+
# Example — loop completed cleanly:
|
|
285
|
+
# terminal_halt loop_completed "All tasks finished"
|
|
286
|
+
|
|
287
|
+
# Example — loop halted (user_blocking question):
|
|
288
|
+
# terminal_halt ralph_halted "User action required — see .homer/questions/"
|
|
289
|
+
|
|
290
|
+
# Example — loop halted (max consecutive failures):
|
|
291
|
+
# terminal_halt ralph_halted "Max consecutive failures reached — check logs"
|
|
292
|
+
|
|
293
|
+
# Example — stall edge (wrap in the _stall_notified guard from PATTERN 3):
|
|
294
|
+
# if [[ "$_stall_notified" -eq 0 ]]; then
|
|
295
|
+
# terminal_halt ralph_waiting "Offline / quota stall — retrying when ready"
|
|
296
|
+
# # NOTE: terminal_halt breaks the loop here. For a stall you may prefer
|
|
297
|
+
# # to stay in the loop and retry — in that case emit ralph_waiting inline
|
|
298
|
+
# # (PATTERN 3) and do NOT call terminal_halt (which would exit the loop).
|
|
299
|
+
# fi
|
|
300
|
+
|
|
301
|
+
# ─── COMPLETE BOILERPLATE (combine PATTERN 5 + PATTERN 6) ────────────────────
|
|
302
|
+
#
|
|
303
|
+
# Minimal drop-in for a new loop script — covers crash-safe trap + terminal_halt:
|
|
304
|
+
#
|
|
305
|
+
# _LOOP_EXITED=0
|
|
306
|
+
#
|
|
307
|
+
# _loop_crash_trap() {
|
|
308
|
+
# [[ "${_LOOP_EXITED:-0}" -eq 0 ]] && \
|
|
309
|
+
# gocode-notify send --kind loop_halted --source ralph \
|
|
310
|
+
# --project "$(basename "$PWD")" \
|
|
311
|
+
# --title "Loop exited unexpectedly — check logs" || true
|
|
312
|
+
# }
|
|
313
|
+
# trap '_loop_crash_trap' EXIT
|
|
314
|
+
#
|
|
315
|
+
# terminal_halt() {
|
|
316
|
+
# local _th_kind="${1:-loop_halted}"
|
|
317
|
+
# local _th_detail="${2:-Loop exited}"
|
|
318
|
+
# _LOOP_EXITED=1
|
|
319
|
+
# gocode-notify send --kind "$_th_kind" --source ralph \
|
|
320
|
+
# --project "$(basename "$PWD")" \
|
|
321
|
+
# --title "$_th_detail" || true
|
|
322
|
+
# break
|
|
323
|
+
# }
|
|
324
|
+
#
|
|
325
|
+
# # Main loop
|
|
326
|
+
# while true; do
|
|
327
|
+
# # ... your loop body ...
|
|
328
|
+
#
|
|
329
|
+
# if all_tasks_done; then
|
|
330
|
+
# terminal_halt loop_completed "All tasks finished"
|
|
331
|
+
# fi
|
|
332
|
+
# if user_blocking_halt; then
|
|
333
|
+
# terminal_halt ralph_halted "User action required"
|
|
334
|
+
# fi
|
|
335
|
+
# if max_failures_exceeded; then
|
|
336
|
+
# terminal_halt ralph_halted "Max consecutive failures reached"
|
|
337
|
+
# fi
|
|
338
|
+
# done
|
|
339
|
+
#
|
|
340
|
+
# Any path that exits without calling terminal_halt is caught by the trap.
|
|
341
|
+
# Any path that calls terminal_halt gets exactly one push, then breaks cleanly.
|