@trygocode/notify 0.1.6 → 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.
@@ -25,10 +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";
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
+ }
32
80
  /** Slice the merged settings down to what the push flow consumes. */
33
81
  function toPushSettings(settings) {
34
82
  return {
@@ -36,6 +84,214 @@ function toPushSettings(settings) {
36
84
  commit_message: settings.commit_message,
37
85
  };
38
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
+ }
39
295
  /**
40
296
  * The end-of-turn dispatcher (PRD §2.2). Resolves settings, then either delegates
41
297
  * to the auto-push flow (which sends its own notification) OR fires the plain
@@ -70,6 +326,21 @@ export async function onStop(opts = {}) {
70
326
  timestamp: opts.timestamp,
71
327
  }));
72
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
+ }
73
344
  // ── Step 1: derive repo identity (never throws — local fallback on failure). ──
74
345
  let repo;
75
346
  try {
@@ -97,7 +368,7 @@ export async function onStop(opts = {}) {
97
368
  settings: toPushSettings(settings),
98
369
  source,
99
370
  cwd,
100
- project: repo?.repo_label,
371
+ project: projectLabel(repo, cwd),
101
372
  dedupeKey: opts.dedupeKey,
102
373
  dryRun: opts.dryRun,
103
374
  server: opts.server,
@@ -109,18 +380,63 @@ export async function onStop(opts = {}) {
109
380
  await logLine(`auto-push path → ${push.outcome} (source: ${source}, settings: ${resolved.source})`);
110
381
  return { mode: "push", settingsSource: resolved.source, push, repo, detail: push.detail };
111
382
  }
112
- // ── Step 3b: auto-push off → the plain `finished` notification (legacy flow). ──
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);
113
392
  if (opts.dryRun) {
114
- await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
393
+ await logLine(`dry-run: would send ${sendKind} (auto-push off, source: ${source}, settings: ${resolved.source})`);
115
394
  return { mode: "dry-run-send", settingsSource: resolved.source, repo };
116
395
  }
117
- const payload = { kind: "finished", source };
118
- if (repo?.repo_label)
119
- payload.project = repo.repo_label;
396
+ // Client fast-path cross-source dedup (T-N2 / PRD §2.2): before the plain
397
+ // send, consult a short-TTL lock keyed by repo+kind+minute-bucket.
398
+ // If another source (e.g. the Cursor `stop` hook vs this Claude `Stop` hook)
399
+ // already claimed the bucket within the window, skip OUR local send — the
400
+ // first arrival's notification stands. Best-effort + fail-open: the check
401
+ // never throws, and on any doubt it returns "send" (the server is the
402
+ // authoritative coalescer — guardrail §3). Scoped to the plain-send path so
403
+ // the auto-push git flow above is never skipped.
404
+ const dedupCheck = opts.dedupCheck ?? checkDedupLock;
405
+ let decision = "send";
406
+ try {
407
+ decision = await dedupCheck({
408
+ repoKey: repo?.repo_key,
409
+ kind: sendKind,
410
+ source,
411
+ windowMs: opts.dedupWindowMs,
412
+ home: opts.home,
413
+ });
414
+ }
415
+ catch {
416
+ decision = "send"; // defence in depth — never let dedup block the send
417
+ }
418
+ if (decision === "suppress") {
419
+ await logLine(`dedup fast-path → suppressed duplicate ${sendKind} (source: ${source}, settings: ${resolved.source})`);
420
+ return { mode: "deduped", settingsSource: resolved.source, repo };
421
+ }
422
+ const payload = { kind: sendKind, source };
423
+ const project = projectLabel(repo, cwd);
424
+ if (project)
425
+ payload.project = project;
120
426
  if (opts.dedupeKey)
121
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;
122
438
  const sent = await sendImpl(payload);
123
- await logLine(`send path → finished ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
439
+ await logLine(`send path → ${sendKind} ${sent.ok ? "delivered" : "failed"} (source: ${source}, settings: ${resolved.source})`);
124
440
  return { mode: "send", settingsSource: resolved.source, send: sent, repo };
125
441
  }
126
442
  catch (err) {
@@ -19,7 +19,10 @@
19
19
  /**
20
20
  * Build the §5.5 rule/skill Markdown for one client. The body is byte-identical
21
21
  * across clients except for the frontmatter and the named hook mechanism, so the
22
- * anti-double-ping guidance stays in lockstep.
22
+ * anti-double-ping guidance stays in lockstep. The launch/offload guidance
23
+ * ({@link buildLaunchGuidance}, PRD §4.4) is appended verbatim — also client-
24
+ * agnostic — so a single `gocode-notify setup` wires notify + launch guidance
25
+ * together into the same rule/skill file.
23
26
  */
24
27
  export function buildRuleContent({ frontmatter, hookDescription }) {
25
28
  return `${frontmatter}
@@ -47,6 +50,54 @@ done". Then call it once, at that moment, with a clear \`title\`/\`body\`.
47
50
  If \`gocode_notify_status\` reports no credentials, tell the user to run
48
51
  \`npx @trygocode/notify login\` and pair from the GoCode app's "Connect a coding
49
52
  agent" screen. Do not retry more than twice.
53
+
54
+ ${buildLaunchGuidance()}`;
55
+ }
56
+ /**
57
+ * Build the launch/offload guidance section (PRD §4.4) shipped into the SAME
58
+ * rule/skill file as the notify guidance above. It teaches the desktop agent
59
+ * *when* and *how* to hand a large task off to the user's GoCode server via the
60
+ * `gocode_launch_autopilot` MCP tool: the trigger phrases that mean "offload",
61
+ * a one-paragraph what-it-does the agent can relay, and the hard guardrails —
62
+ * the most important being **fresh server agent, no local file access — pass a
63
+ * self-contained task** (the server loop is a brand-new agent with NO access to
64
+ * the IDE's open/unsaved files).
65
+ *
66
+ * This is client-agnostic prose (no frontmatter, no hook mention), so it stays
67
+ * byte-identical across Cursor and Claude — keeping the launch guidance in
68
+ * lockstep exactly like the notify body. Exported as the new `rule-content.ts`
69
+ * surface the per-runtime writers ({@link buildRuleContent}) compose in.
70
+ */
71
+ export function buildLaunchGuidance() {
72
+ return `## Offload a big task to your GoCode server (gocode_launch_autopilot)
73
+
74
+ You ALSO have a \`gocode_launch_autopilot\` MCP tool that hands a LARGE,
75
+ multi-step coding task off to the user's own GoCode server, where it runs as an
76
+ autonomous Autopilot loop. The user can close this IDE and shut their laptop; the
77
+ loop keeps running server-side in their isolated sandbox, shows up in the GoCode
78
+ phone app, and their phone is pinged when it finishes or needs them.
79
+
80
+ ## When to offload (trigger phrases)
81
+
82
+ Reach for \`gocode_launch_autopilot\` ONLY when the user EXPLICITLY asks to run
83
+ the work elsewhere / in the background / after they step away — e.g. "run this on
84
+ the server", "do this overnight", "hand this off", "I'm closing my laptop, keep
85
+ going", "offload this", "kick this off on GoCode", "fire an Autopilot loop for
86
+ this".
87
+
88
+ ## Offload guardrails
89
+
90
+ - **Only on an explicit ask.** Never silently move a task off this local session —
91
+ a normal task you can do right here STAYS here.
92
+ - **Big tasks only.** Offload is for long, multi-step builds, not quick edits.
93
+ - **Fresh server agent, no local file access — pass a self-contained task.** The
94
+ server loop is a BRAND-NEW agent with NO access to this IDE's open files or
95
+ unsaved/uncommitted state. Give it a complete, self-contained task description
96
+ (and a repo) it can act on from a clean checkout. If the task depends on local
97
+ uncommitted work, tell the user to commit/push first, then hand it off.
98
+ - **After launching, stop.** Relay the loop id + that they can watch it in the
99
+ GoCode phone app + that they'll be pinged when it's done — then STOP. Don't keep
100
+ "working" locally on a task that is now running on the server.
50
101
  `;
51
102
  }
52
103
  /**
package/dist/src/send.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // separate tasks; this stays self-contained and dependency-free.
13
13
  import { promises as fs } from "node:fs";
14
14
  import path from "node:path";
15
- import { DEFAULT_SERVER, gocodeDir, readCredentials, } from "./creds.js";
15
+ import { builtinDefaultServer, gocodeDir, readCredentials, } from "./creds.js";
16
16
  /** Canonical notification kinds accepted by `/notify/send` (PRD §3.2). */
17
17
  export const NOTIFY_KINDS = [
18
18
  "finished",
@@ -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,11 +52,15 @@ 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;
52
59
  }
60
+ // Boolean field — only included when explicitly true so a normal send keeps
61
+ // its minimal body (and the server's `autopilot` default of false applies).
62
+ if (payload.autopilot === true)
63
+ body.autopilot = true;
53
64
  return body;
54
65
  }
55
66
  /**
@@ -99,7 +110,7 @@ export async function send(payload, opts = {}) {
99
110
  if (!creds) {
100
111
  return failure("not paired — run `gocode-notify login` first", opts);
101
112
  }
102
- const server = normalizeServer(opts.server ?? creds.server ?? DEFAULT_SERVER);
113
+ const server = normalizeServer(opts.server ?? creds.server ?? builtinDefaultServer());
103
114
  const url = `${server}/api/v1/notify/send`;
104
115
  const controller = new AbortController();
105
116
  const timer = setTimeout(() => controller.abort(), timeoutMs);
package/dist/src/setup.js CHANGED
@@ -85,9 +85,29 @@ export async function setup(opts = {}) {
85
85
  // ── Step 3: write configs for each DETECTED runtime ───────────────────────
86
86
  // Undetected runtimes are skipped silently (PRD §5.2) — only what's installed
87
87
  // gets configured.
88
+ //
89
+ // OPT-OUT: GOCODE_NOTIFY_SKIP_RUNTIMES is a comma-separated, case-insensitive
90
+ // list of runtime names to NOT configure even when detected (e.g.
91
+ // "Claude Code"). Use case: a user who runs Claude Code *inside* Cursor only
92
+ // wants Cursor's hooks — installing Claude's identical stop/notify hooks too
93
+ // would fire a SECOND, duplicate push for every turn. This is opt-in by env,
94
+ // so default behaviour (configure all detected runtimes) is unchanged for
95
+ // everyone who doesn't set it.
96
+ const skipRuntimes = new Set((process.env.GOCODE_NOTIFY_SKIP_RUNTIMES ?? "")
97
+ .split(",")
98
+ .map((s) => s.trim().toLowerCase())
99
+ .filter((s) => s.length > 0));
88
100
  const writeConfig = opts.writeConfig ?? defaultConfigWriter;
89
101
  const configs = [];
90
102
  for (const runtime of detected.filter((r) => r.detected)) {
103
+ if (skipRuntimes.has(runtime.name.toLowerCase())) {
104
+ steps.push({
105
+ step: `config:${runtime.name}`,
106
+ ok: true,
107
+ detail: "skipped (GOCODE_NOTIFY_SKIP_RUNTIMES)",
108
+ });
109
+ continue;
110
+ }
91
111
  const result = await writeConfig(runtime, pathOpts);
92
112
  configs.push(result);
93
113
  steps.push({
@@ -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.1.3";
2
+ export const VERSION = "0.3.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trygocode/notify",
3
- "version": "0.1.6",
3
+ "version": "0.3.1",
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",