@trygocode/notify 0.2.0 → 0.3.2

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.
@@ -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,326 @@ 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
+ // The user wants the plain project/folder name (e.g. "alerc8"), not the
98
+ // `owner/repo` form. `repo_label` from a git origin is `owner/repo`, and the
99
+ // `local:` fallback already strips the prefix in deriveRepoIdentity — so take
100
+ // the LAST path segment as the display name. Guard against a trailing slash.
101
+ if (label) {
102
+ const lastSeg = label.replace(/\/+$/, "").split("/").pop()?.trim();
103
+ if (lastSeg)
104
+ return lastSeg;
105
+ return label;
106
+ }
107
+ // Fall back to the cwd basename — but SKIP tool/config dot-dirs. The stop hook
108
+ // can run with a cwd inside `.cursor` (or `.git`, `.vscode`, …), whose basename
109
+ // would otherwise become the project name and render as "Cursor · .cursor"
110
+ // (the IDE name shown twice). Walk up past any leading-dot segment to the first
111
+ // real project folder so the label is the actual repo dir, not its tooling dir.
112
+ let dir = path.resolve(cwd);
113
+ for (let i = 0; i < 6; i++) {
114
+ const base = path.basename(dir).trim();
115
+ if (!base)
116
+ break; // reached filesystem root
117
+ if (!base.startsWith("."))
118
+ return base; // first non-dot folder wins
119
+ const parent = path.dirname(dir);
120
+ if (parent === dir)
121
+ break; // no more parents
122
+ dir = parent;
123
+ }
124
+ return undefined;
125
+ }
126
+ /**
127
+ * The project root the stop hook should reason about. Cursor (and Claude) run
128
+ * stop hooks with `process.cwd()` set to the IDE's hook/config dir (e.g.
129
+ * `~/.cursor`), NOT the user's open project — so deriving the repo from the bare
130
+ * cwd yields ".cursor" instead of the real repo (the bug in the 2026-06-12
131
+ * screenshot). The hook stdin, however, carries the real workspace root. Prefer
132
+ * it (mirrors what `@trygocode/sync` already does), falling back to the cwd only
133
+ * when the stdin omits it.
134
+ *
135
+ * Accepts the same field spellings sync consumes:
136
+ * `workspace_path` / `workspaceRoots[0]` / `workspace_roots[0]` / `cwd` /
137
+ * `workspaceFolders[0].path|uri`. Returns undefined when nothing usable is
138
+ * present, so the caller keeps the process cwd.
139
+ */
140
+ export function workspaceRootFromHookStdin(hookStdin) {
141
+ if (!hookStdin || hookStdin.trim() === "")
142
+ return undefined;
143
+ let p;
144
+ try {
145
+ p = JSON.parse(hookStdin);
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ const str = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
151
+ const firstOf = (v) => {
152
+ if (Array.isArray(v) && v.length > 0) {
153
+ const f = v[0];
154
+ if (typeof f === "string")
155
+ return str(f);
156
+ if (f && typeof f === "object") {
157
+ const o = f;
158
+ // VS Code/Cursor sometimes use {path} or {uri: "file:///…"}.
159
+ return str(o.path) ?? str(o.fsPath) ?? fromFileUri(str(o.uri));
160
+ }
161
+ }
162
+ return undefined;
163
+ };
164
+ return (str(p.workspace_path) ??
165
+ str(p.workspacePath) ??
166
+ firstOf(p.workspaceRoots) ??
167
+ firstOf(p.workspace_roots) ??
168
+ firstOf(p.workspaceFolders) ??
169
+ str(p.cwd) ??
170
+ undefined);
171
+ }
172
+ /** Convert a `file:///abs/path` URI to a plain path; passthrough otherwise. */
173
+ function fromFileUri(uri) {
174
+ if (!uri)
175
+ return undefined;
176
+ if (uri.startsWith("file://")) {
177
+ try {
178
+ return decodeURIComponent(new URL(uri).pathname) || undefined;
179
+ }
180
+ catch {
181
+ return undefined;
182
+ }
183
+ }
184
+ return uri;
185
+ }
186
+ /**
187
+ * Derive the SAME stable `external_chat_id` that `@trygocode/sync` assigns to a
188
+ * synced transcript, so a per-turn notification can deep-link straight to that
189
+ * chat on tap. MUST stay byte-identical to gocode-sync's `deriveExternalChatId`:
190
+ * sha256 of `source\0(lower workspace)\0(session)`, first 32 hex chars.
191
+ *
192
+ * We re-derive (rather than import gocode-sync) because notify is a separate
193
+ * zero-dep package; the hash is tiny and pinned by a parity test.
194
+ */
195
+ export function deriveIdeChatId(input) {
196
+ const basis = [
197
+ input.source.toLowerCase().trim(),
198
+ input.workspacePath.toLowerCase().trim(),
199
+ input.ideSessionId.trim(),
200
+ ].join("\u0000");
201
+ return createHash("sha256").update(basis).digest("hex").slice(0, 32);
202
+ }
203
+ /**
204
+ * Best-effort: from the Cursor/Claude stop-hook stdin JSON + cwd, compute the
205
+ * synced chat's `external_chat_id` so the notification deep-links to it. Returns
206
+ * undefined when the payload lacks a stable session id (then the push falls back
207
+ * to Home, exactly as before). Never throws.
208
+ *
209
+ * Session id resolution mirrors the capture adapters:
210
+ * - Cursor: `conversation_id` / `conversationId`, else the `transcript_path`
211
+ * filename (minus `.jsonl`).
212
+ * - Claude: `session_id` / `sessionId`, else the `transcript_path` filename.
213
+ * Workspace mirrors capture: payload workspace/cwd, else the hook cwd.
214
+ */
215
+ export function ideChatIdFromHookStdin(source, cwd, hookStdin) {
216
+ if (!hookStdin || hookStdin.trim() === "")
217
+ return undefined;
218
+ let p;
219
+ try {
220
+ const parsed = JSON.parse(hookStdin);
221
+ if (!parsed || typeof parsed !== "object")
222
+ return undefined;
223
+ p = parsed;
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ const str = (...vals) => {
229
+ for (const v of vals)
230
+ if (typeof v === "string" && v.trim() !== "")
231
+ return v;
232
+ return undefined;
233
+ };
234
+ const transcriptPath = str(p.transcript_path, p.transcriptPath);
235
+ const sessionFromPath = transcriptPath
236
+ ? path.basename(transcriptPath).replace(/\.jsonl$/i, "") || undefined
237
+ : undefined;
238
+ const ideSessionId = str(p.conversation_id, p.conversationId, p.session_id, p.sessionId, sessionFromPath);
239
+ if (!ideSessionId)
240
+ return undefined;
241
+ const workspacePath = str(p.workspace_path, Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : undefined, p.cwd, cwd);
242
+ if (!workspacePath)
243
+ return undefined;
244
+ // The capture side stores `source: "cursor" | "claude_code"`; the hook passes
245
+ // `--source cursor|claude_code`, so they already match. Normalise just in case.
246
+ const normSource = source === "claude" ? "claude_code" : source;
247
+ return deriveIdeChatId({
248
+ source: normSource,
249
+ workspacePath: path.resolve(workspacePath),
250
+ ideSessionId,
251
+ });
252
+ }
253
+ /**
254
+ * Best-effort: derive a short chat title from the transcript named in the hook
255
+ * stdin, mirroring gocode-sync's `deriveTitleFromMessages` (first user message's
256
+ * first meaningful line). Used to name WHICH chat finished in the push body.
257
+ * Reads at most the first ~64KB of the JSONL (titles come from the first user
258
+ * turn). Returns undefined on any problem — never throws.
259
+ */
260
+ export async function chatTitleFromHookStdin(hookStdin) {
261
+ if (!hookStdin || hookStdin.trim() === "")
262
+ return undefined;
263
+ let transcriptPath;
264
+ try {
265
+ const p = JSON.parse(hookStdin);
266
+ const v = p.transcript_path ?? p.transcriptPath;
267
+ if (typeof v === "string" && v.trim() !== "")
268
+ transcriptPath = v;
269
+ }
270
+ catch {
271
+ return undefined;
272
+ }
273
+ if (!transcriptPath)
274
+ return undefined;
275
+ let raw;
276
+ try {
277
+ raw = await fs.readFile(transcriptPath, "utf8");
278
+ }
279
+ catch {
280
+ return undefined;
281
+ }
282
+ // Prefer the IDE's OWN assigned chat name when the transcript carries one, so
283
+ // the push names the chat exactly as the user sees it in the sidebar (rather
284
+ // than a synthesized first-line title). Verified empirically (2026-06-12):
285
+ // - Claude Code writes `{"type":"ai-title","aiTitle":"<sidebar name>"}`
286
+ // records, refined over the chat's life — so take the LAST one.
287
+ // - Cursor agent transcripts carry NO title record (only role/message), so
288
+ // this scan no-ops for Cursor and we fall through to synthesis below.
289
+ // Defensive: also accept a legacy Claude `{"type":"summary","summary":"…"}`
290
+ // record so an older/newer format degrades gracefully instead of throwing.
291
+ {
292
+ let assigned;
293
+ for (const line of raw.split("\n")) {
294
+ const t = line.trim();
295
+ if (!t || (!t.includes("ai-title") && !t.includes("summary")))
296
+ continue;
297
+ let rec;
298
+ try {
299
+ rec = JSON.parse(t);
300
+ }
301
+ catch {
302
+ continue;
303
+ }
304
+ const type = String(rec.type ?? "");
305
+ let cand;
306
+ if (type === "ai-title")
307
+ cand = rec.aiTitle ?? rec.ai_title;
308
+ else if (type === "summary")
309
+ cand = rec.summary;
310
+ if (typeof cand === "string" && cand.trim() !== "") {
311
+ assigned = cand.trim(); // keep scanning → last assigned title wins
312
+ }
313
+ }
314
+ if (assigned) {
315
+ if (assigned.length > 80)
316
+ assigned = assigned.slice(0, 79).trimEnd() + "…";
317
+ return assigned;
318
+ }
319
+ }
320
+ // Synthesis fallback (no IDE-assigned title): name the chat after the LAST
321
+ // user prompt — the one that JUST finished — not the first prompt of the
322
+ // conversation. This reminds the user where they left off (per 2026-06-12
323
+ // request). We keep the last good title and return it after the full scan.
324
+ let lastTitle;
325
+ for (const line of raw.split("\n")) {
326
+ const t = line.trim();
327
+ if (!t)
328
+ continue;
329
+ let rec;
330
+ try {
331
+ rec = JSON.parse(t);
332
+ }
333
+ catch {
334
+ continue;
335
+ }
336
+ // role: top-level `role` (Cursor) or nested `message.role` / `type` (Claude).
337
+ const nested = rec.message && typeof rec.message === "object"
338
+ ? rec.message
339
+ : undefined;
340
+ const role = String(rec.role ?? nested?.role ?? rec.type ?? "");
341
+ if (role !== "user" && role !== "human")
342
+ continue;
343
+ // content: string or block array, inline or nested.
344
+ let content = "";
345
+ const src = rec.content ?? rec.text ?? nested?.content;
346
+ if (typeof src === "string")
347
+ content = src;
348
+ else if (Array.isArray(src)) {
349
+ for (const b of src) {
350
+ if (typeof b === "string")
351
+ content += b + "\n";
352
+ else if (b && typeof b === "object" && typeof b.text === "string")
353
+ content += String(b.text) + "\n";
354
+ }
355
+ }
356
+ // Strip the harness `<user_query>` / `<timestamp>` plumbing so the title is
357
+ // the human's words (mirrors gocode-sync's sanitizer, minimal form).
358
+ content = content
359
+ .replace(/<timestamp(?:\s[^>]*)?>[\s\S]*?<\/timestamp>/gi, "")
360
+ .replace(/<image_files(?:\s[^>]*)?>[\s\S]*?<\/image_files>/gi, "")
361
+ .replace(/<open_and_recently_viewed_files(?:\s[^>]*)?>[\s\S]*?<\/open_and_recently_viewed_files>/gi, "")
362
+ .replace(/\[Image\](?!\()\s*/g, "")
363
+ .replace(/<\/?user_query(?:\s[^>]*)?>/gi, "");
364
+ const firstLine = content
365
+ .split("\n")
366
+ .map((l) => l.trim())
367
+ .find((l) => l !== "");
368
+ if (!firstLine)
369
+ continue;
370
+ let title = firstLine.replace(/^[#>\-*+\s]+/, "").replace(/[*_`]+/g, "").trim();
371
+ if (title === "")
372
+ continue;
373
+ if (title.length > 80)
374
+ title = title.slice(0, 79).trimEnd() + "…";
375
+ lastTitle = title; // keep scanning → LAST user prompt wins
376
+ }
377
+ return lastTitle;
378
+ }
379
+ /**
380
+ * Env var an Autopilot (Ralph/Homer) loop exports to mark that IT owns the
381
+ * current turn's notification (T-N7 / PRD §3.2). When truthy, {@link onStop}
382
+ * suppresses its per-turn ping entirely: the loop sends its OWN
383
+ * `loop_completed`/`loop_halted` Autopilot ping (`_loop_inner.sh
384
+ * push_notify_local`), so any stop-hook-driven `finished`/`push` ping for the
385
+ * same turn would be a duplicate of it.
386
+ *
387
+ * Defensive by design: today a Ralph `claude-tmux` iteration runs `claude` in its
388
+ * own tmux pane (Claude Code has no `Stop` hook here) and is NOT a Cursor turn, so
389
+ * the only per-turn hook (`cursor stop`) never fires for a loop — no double-fire
390
+ * exists to suppress. This gate is the READING half of the contract (the loop
391
+ * script exports the marker, T-N5) so that if a future loop arrangement DOES trip
392
+ * a stop hook, the duplicate is gated off at the source.
393
+ */
394
+ export const AUTOPILOT_OWNS_TURN_ENV = "GOCODE_AUTOPILOT_OWNS_TURN";
395
+ /**
396
+ * True when {@link AUTOPILOT_OWNS_TURN_ENV} is set to a truthy value. Treats the
397
+ * usual falsy strings (`""`, `0`, `false`, `no`, `off`, any case) as not-owned so
398
+ * an accidental empty/`0` export never silently eats every per-turn ping.
399
+ */
400
+ export function autopilotOwnsTurn(env = process.env) {
401
+ const raw = env[AUTOPILOT_OWNS_TURN_ENV];
402
+ if (raw == null)
403
+ return false;
404
+ const v = raw.trim().toLowerCase();
405
+ return v !== "" && v !== "0" && v !== "false" && v !== "no" && v !== "off";
406
+ }
40
407
  /**
41
408
  * The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
42
409
  * to the auto-push flow (which sends its own notification) OR fires the plain
@@ -48,7 +415,12 @@ function toPushSettings(settings) {
48
415
  */
49
416
  export async function onStop(opts = {}) {
50
417
  const source = opts.source ?? "unknown";
51
- const cwd = opts.cwd ?? process.cwd();
418
+ // Effective project root: prefer the workspace the hook stdin names (Cursor &
419
+ // Claude run hooks from the IDE's config dir, so process.cwd() is e.g.
420
+ // `~/.cursor` — using it makes the project show as ".cursor"). An explicit
421
+ // --cwd flag still wins (tests / manual invocations). Falls back to cwd.
422
+ const rawCwd = opts.cwd ?? process.cwd();
423
+ const cwd = opts.cwd ?? workspaceRootFromHookStdin(opts.hookStdin) ?? rawCwd;
52
424
  const deriveRepo = opts.deriveRepo ?? deriveRepoIdentity;
53
425
  const resolveSettings = opts.resolveSettings ?? resolveNotifySettings;
54
426
  const logLine = async (line) => {
@@ -71,6 +443,21 @@ export async function onStop(opts = {}) {
71
443
  timestamp: opts.timestamp,
72
444
  }));
73
445
  try {
446
+ // ── Step 0: Autopilot-owns-turn gate (T-N7 / PRD §3.2). ──
447
+ // When an Autopilot loop has marked that it owns this turn, it sends its OWN
448
+ // loop_completed/loop_halted Autopilot ping, so ANY per-turn stop-hook ping
449
+ // here (plain `finished` OR the auto-push notification) would be a duplicate.
450
+ // Suppress entirely — no send, no git push — before doing any other work.
451
+ // Best-effort + safe: gated behind an explicit truthy env marker only, so a
452
+ // normal hand-driven turn (no marker) is never affected.
453
+ if (autopilotOwnsTurn(opts.env)) {
454
+ await logLine(`autopilot owns turn (${AUTOPILOT_OWNS_TURN_ENV}) → suppressed per-turn ${source} ping (loop ping is authoritative)`);
455
+ return {
456
+ mode: "autopilot-suppressed",
457
+ settingsSource: "default",
458
+ detail: "autopilot loop owns the turn — per-turn ping suppressed",
459
+ };
460
+ }
74
461
  // ── Step 1: derive repo identity (never throws — local fallback on failure). ──
75
462
  let repo;
76
463
  try {
@@ -98,7 +485,7 @@ export async function onStop(opts = {}) {
98
485
  settings: toPushSettings(settings),
99
486
  source,
100
487
  cwd,
101
- project: repo?.repo_label,
488
+ project: projectLabel(repo, cwd),
102
489
  dedupeKey: opts.dedupeKey,
103
490
  dryRun: opts.dryRun,
104
491
  server: opts.server,
@@ -110,13 +497,21 @@ export async function onStop(opts = {}) {
110
497
  await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
111
498
  return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
112
499
  }
113
- // ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
500
+ // ── Step 3b: auto-push off → the plain notification (legacy flow). ──
501
+ // Derive the notification kind from the Cursor stop hook's stdin JSON (T-CUR1
502
+ // / PRD §8.5). The status maps:
503
+ // completed → finished (agent turned cleanly)
504
+ // aborted → awaiting_input (agent yielded back to the human)
505
+ // error → error (agent hit an error)
506
+ // absent → finished (back-compat: no stdin or unrecognised status)
507
+ const hookStatus = parseCursorStopStatus(opts.hookStdin);
508
+ const sendKind = cursorStopStatusToKind(hookStatus);
114
509
  if (opts.dryRun) {
115
- await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
510
+ await logLine(`dry-run: would send ${sendKind} (auto-push off, source: ${source}, settings: ${resolved.source})`);
116
511
  return { mode: "dry-run-send", settingsSource: resolved.source, repo };
117
512
  }
118
513
  // Client fast-path cross-source dedup (T-N2 / PRD §2.2): before the plain
119
- // `finished` send, consult a short-TTL lock keyed by repo+kind+minute-bucket.
514
+ // send, consult a short-TTL lock keyed by repo+kind+minute-bucket.
120
515
  // If another source (e.g. the Cursor `stop` hook vs this Claude `Stop` hook)
121
516
  // already claimed the bucket within the window, skip OUR local send — the
122
517
  // first arrival's notification stands. Best-effort + fail-open: the check
@@ -128,7 +523,7 @@ export async function onStop(opts = {}) {
128
523
  try {
129
524
  decision = await dedupCheck({
130
525
  repoKey: repo?.repo_key,
131
- kind: "finished",
526
+ kind: sendKind,
132
527
  source,
133
528
  windowMs: opts.dedupWindowMs,
134
529
  home: opts.home,
@@ -138,16 +533,27 @@ export async function onStop(opts = {}) {
138
533
  decision = "send"; // defence in depth — never let dedup block the send
139
534
  }
140
535
  if (decision === "suppress") {
141
- await logLine(`dedup fast-path → suppressed duplicate finished (source: ${source}, settings: ${resolved.source})`);
536
+ await logLine(`dedup fast-path → suppressed duplicate ${sendKind} (source: ${source}, settings: ${resolved.source})`);
142
537
  return { mode: "deduped", settingsSource: resolved.source, repo };
143
538
  }
144
- const payload = { kind: "finished", source };
145
- if (repo?.repo_label)
146
- payload.project = repo.repo_label;
539
+ const payload = { kind: sendKind, source };
540
+ const project = projectLabel(repo, cwd);
541
+ if (project)
542
+ payload.project = project;
147
543
  if (opts.dedupeKey)
148
544
  payload.dedupe_key = opts.dedupeKey;
545
+ // Deep-link target: the synced chat's id (so tapping the push opens the
546
+ // chat, not just Home). Best-effort — omitted when the hook payload has no
547
+ // stable session id (push then falls back to Home, as before).
548
+ const ideChatId = ideChatIdFromHookStdin(source, cwd, opts.hookStdin);
549
+ if (ideChatId)
550
+ payload.ide_chat_id = ideChatId;
551
+ // Name WHICH chat finished in the body (best-effort; omitted if unreadable).
552
+ const chatTitle = await chatTitleFromHookStdin(opts.hookStdin);
553
+ if (chatTitle)
554
+ payload.chat = chatTitle;
149
555
  const sent = await sendImpl(payload);
150
- await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
556
+ await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
151
557
  return { mode: "send", settingsSource: resolved.source, send: sent, repo };
152
558
  }
153
559
  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;
@@ -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
  /**
@@ -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.0";
2
+ export const VERSION = "0.3.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Free phone notifications for any coding agent (Cursor, Claude Code, OpenCode, Ralph/Homer) via the GoCode app.",
5
5
  "license": "MIT",
6
6
  "type": "module",