@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.
- package/README.md +181 -2
- package/dist/src/cli.js +207 -1
- package/dist/src/creds.js +32 -3
- package/dist/src/dedup_lock.js +0 -0
- package/dist/src/doctor.js +262 -0
- package/dist/src/launch.js +218 -0
- package/dist/src/mcp.js +93 -10
- package/dist/src/on_stop.js +323 -7
- package/dist/src/rule-content.js +52 -1
- package/dist/src/send.js +14 -3
- package/dist/src/setup.js +20 -0
- 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/dist/src/on_stop.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 →
|
|
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) {
|
package/dist/src/rule-content.js
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
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({
|
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.1
|
|
2
|
+
export const VERSION = "0.3.1";
|
package/package.json
CHANGED