@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 CHANGED
@@ -13,10 +13,13 @@ waiting for you**, **errors out**, or when an overnight **loop completes / halts
13
13
  - [Install — two equally-supported paths](#install--two-equally-supported-paths)
14
14
  - [Pairing — step by step](#pairing--step-by-step)
15
15
  - [The three triggers](#the-three-triggers)
16
+ - [Hand off to your server — `launch` / `autopilot`](#hand-off-to-your-server--launch--autopilot)
16
17
  - [Auto-push to git (opt-in)](#auto-push-to-git-opt-in)
17
18
  - [Settings sync — the `config` command](#settings-sync--the-config-command)
18
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)
19
21
  - [Troubleshooting](#troubleshooting)
22
+ - [Changelog](#changelog)
20
23
  - [Develop](#develop)
21
24
  - [Layout](#layout)
22
25
 
@@ -105,6 +108,12 @@ saved in `~/.gocode/credentials` → the built-in default
105
108
  (`https://oh.jeltechsolutions.com`). You only need `--server` for a self-hosted
106
109
  or staging GoCode server.
107
110
 
111
+ **Redistributing under your own server.** The built-in default is not baked in
112
+ as a fixed author box — set `GOCODE_DEFAULT_SERVER` to point fresh, unpaired
113
+ installs at your OWN GoCode server without patching the source or having every
114
+ user pass `--server`/`GOCODE_SERVER`. It only changes the *fallback* default;
115
+ already-paired machines keep the server in their `~/.gocode/credentials`.
116
+
108
117
  ## The three triggers
109
118
 
110
119
  | Trigger | Mechanism | Fires when |
@@ -117,6 +126,21 @@ The installed rule/skill tells the agent **not** to call the MCP tool for
117
126
  done/idle/error pings — those are owned by the deterministic hook (A), so you
118
127
  never get double-pinged.
119
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
+
120
144
  > **OpenCode's hook (A)** is a small `session.idle` plugin written to
121
145
  > `~/.config/opencode/plugin/gocode-notify.js`; on each `session.idle` event it
122
146
  > fire-and-forgets `gocode-notify on-stop --source opencode` (the same
@@ -133,6 +157,75 @@ never get double-pinged.
133
157
  > `setup`/`setup --force`. `send` and `test` remain callable on their own for
134
158
  > scripts and power users.
135
159
 
160
+ ## Hand off to your server — `launch` / `autopilot`
161
+
162
+ > **New in 0.2.0.** Notify can now do more than ping your phone — it can **hand a
163
+ > big task off to your GoCode server** to run as an autonomous Autopilot loop.
164
+ > Close your laptop; the work keeps going server-side in your isolated sandbox,
165
+ > shows up in the GoCode phone app like any other Autopilot run, and your phone
166
+ > buzzes when it's done (or needs you).
167
+
168
+ This is the opposite direction of the three notify triggers above: instead of
169
+ *your local agent pinging your phone*, `launch` *starts a fresh server-side
170
+ build* from an explicit, self-contained task. It reuses the **same `gck_`
171
+ pairing** — no new login. It reads **no conversation content** (only the task
172
+ string you hand it), so it ships in this base package (see the trust note below).
173
+
174
+ ### CLI
175
+
176
+ ```bash
177
+ # Start a loop from a plain task (server synthesizes a PRD, then builds):
178
+ gocode-notify launch "Refactor the auth module to use sessions, add tests" \
179
+ --repo owner/repo
180
+
181
+ # alias:
182
+ gocode-notify autopilot "Add a CSV export endpoint with tests" --repo owner/repo
183
+
184
+ # Or hand it a pre-written checkbox PRD instead of a task string:
185
+ gocode-notify launch --prd-file ./my-prd.md --repo owner/repo
186
+ ```
187
+
188
+ | Flag | Meaning |
189
+ |---|---|
190
+ | `<task>` (positional) | Plain-language task the server loop builds. Mutually exclusive with `--prd-file`. |
191
+ | `--prd-file <path>` | A pre-written checkbox PRD (base64-encoded and sent as `prd_markdown`). |
192
+ | `--repo owner/repo` | Optional repo context for synthesis / the loop's target. |
193
+ | `--branch <suffix>` | Optional branch suffix the loop pushes to. |
194
+ | `--model "Profile"` | Optional LLM-profile name override. |
195
+ | `--runner-kind <kind>` | Optional runner override (default `openhands-conversation`). |
196
+ | `--server <URL>` | Same server-resolution precedence as every other command. |
197
+ | `--agent-driven` | Emit one machine-readable JSON step line (for installers/agents). |
198
+
199
+ On success it prints the **loop id**, a **deep link** that opens the run in the
200
+ app, and a "running on your GoCode server — close your laptop; your phone will
201
+ buzz when it's done" line, then exits `0`. Unlike the fire-and-forget
202
+ `send`/`on-stop` hooks, `launch` is an **explicit interactive action**: it
203
+ reports real failures (not paired / unreachable / 4xx) with a clear message and a
204
+ **non-zero exit**, and it does **not** queue to the offline outbox (a stale
205
+ launch surfacing hours later could duplicate work).
206
+
207
+ ### MCP tool
208
+
209
+ Your desktop agent (Cursor / Claude Code / OpenCode) can fire a remote loop
210
+ mid-session via the third MCP tool, **`gocode_launch_autopilot`**
211
+ (`{ task (required), repo?, branch? }`). Its description tells the agent to call
212
+ it **only when you explicitly ask to offload / run in the background / overnight**
213
+ — never for a normal task it can do right there. On success it relays the loop id
214
+ + deep link; on failure it returns an actionable re-pair hint.
215
+
216
+ > **Heads-up the rule states:** the server loop is a **fresh agent with no access
217
+ > to your IDE's open/unsaved files** — so the task must be **self-contained** and
218
+ > point at a repo the server can build from a clean checkout. If the work depends
219
+ > on local uncommitted state, commit/push first, then hand it off.
220
+
221
+ ### Trust boundary
222
+
223
+ `launch` reads **only the explicit task string** (and optional repo/branch) you
224
+ hand it — it does **not** read or upload your IDE conversation transcript, and it
225
+ touches only `~/.gocode/credentials` (the `gck_` key it already uses for notify).
226
+ It POSTs to **your own configurable GoCode server**. So the base package's "never
227
+ reads your conversation content" promise still holds.
228
+
136
229
  ## Auto-push to git (opt-in)
137
230
 
138
231
  GoCode Notify can **auto-commit and push your work after every agent turn** —
@@ -239,7 +332,7 @@ Valid keys (mirror of the canonical schema):
239
332
 
240
333
  | Key | Type | Meaning |
241
334
  |---|---|---|
242
- | `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 |
243
336
  | `min_duration_seconds` | int ≥ 0 | Only notify if the turn ran ≥ N seconds (0 = always) |
244
337
  | `quiet_hours.enabled` / `quiet_hours.start` / `quiet_hours.end` / `quiet_hours.tz` | bool / `HH:MM` / `HH:MM` / IANA tz | Do-not-disturb window |
245
338
  | `auto_push.enabled` | bool | Master auto-push switch (OFF by default) |
@@ -281,6 +374,41 @@ The ready-to-copy version with comments lives at
281
374
  loop scripts. Both lines are fire-and-forget (`|| true` + the CLI's 5s
282
375
  self-timeout), so a failed or slow push can never block or fail your loop.
283
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
+
284
412
  ## Troubleshooting
285
413
 
286
414
  **Start here:** `gocode-notify status` prints a one-screen report — whether
@@ -310,6 +438,56 @@ For the device/secret/publish/deploy steps that are **not** part of this package
310
438
  npm publish, real-device E2E), see
311
439
  [`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md).
312
440
 
441
+ ## Changelog
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
+
466
+ ### 0.2.0
467
+
468
+ - **New: `launch` / `autopilot` command** — hand a large, multi-step task off to
469
+ your GoCode server to run as an autonomous Autopilot loop. Reuses the existing
470
+ `gck_` pairing; prints a loop id + app deep link; explicit failures exit
471
+ non-zero (no offline queueing). See
472
+ [Hand off to your server](#hand-off-to-your-server--launch--autopilot).
473
+ - **New: `gocode_launch_autopilot` MCP tool** — the third tool on the stdio MCP
474
+ server, so a desktop agent can fire a remote loop mid-session (explicit offload
475
+ only). The installed Cursor rule / Claude skill / OpenCode snippet teach the
476
+ agent *when* to offload and that the server loop is a fresh, self-contained
477
+ agent with no access to local files.
478
+ - **App monitoring** — IDE-launched loops are discovered by the phone app
479
+ (`GET /api/v1/ralph/active`), badged "Launched from <IDE>", and deep-link from
480
+ the completion/halt push into the run — full parity with app-launched loops.
481
+ - **Fix:** `--version` now reports the real package version. `src/version.ts` had
482
+ drifted (published `0.1.6` reported `0.1.3`); it is now kept in lockstep with
483
+ `package.json`, and the publish workflow refuses to ship a mismatch.
484
+
485
+ ### 0.1.x
486
+
487
+ - Phone notifications for any coding agent via three triggers (runtime hook, MCP
488
+ tool, loop snippet), opt-in auto-push to git with an AI-written commit message,
489
+ and server-synced settings.
490
+
313
491
  ## Develop
314
492
 
315
493
  ```bash
@@ -331,7 +509,8 @@ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
331
509
  | `src/cli.ts` | `gocode-notify` bin entrypoint + command dispatcher (incl. `on-stop`, `config`) |
332
510
  | `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
333
511
  | `src/claude.ts` / `src/cursor.ts` / `src/opencode.ts` | Per-client config writers (hooks + MCP + rule/skill); hooks call the `on-stop` dispatcher. OpenCode uses a `session.idle` plugin instead of a hooks file |
334
- | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server |
512
+ | `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server (incl. `gocode_launch_autopilot`) |
513
+ | `src/launch.ts` | Shared `launch()` core behind the `launch`/`autopilot` command + the MCP tool |
335
514
  | `src/push.ts` | Auto-push flow behind the `on-stop` dispatcher — git add/commit/push, FF-only |
336
515
  | `src/commit_message.ts` | AI commit-message resolution chain + deterministic fallback |
337
516
  | `src/config.ts` | `config get\|set\|pull` — the dev-machine settings editor (server-synced) |
package/dist/src/cli.js CHANGED
@@ -6,10 +6,12 @@
6
6
  // later milestones. For now the entrypoint resolves help/version and reports
7
7
  // not-yet-implemented for the known commands so the bin is wired and testable.
8
8
  import { realpathSync } from "node:fs";
9
+ import { readFile } from "node:fs/promises";
9
10
  import { fileURLToPath } from "node:url";
10
11
  import { VERSION } from "./version.js";
11
12
  import { login } from "./login.js";
12
13
  import { resolveServerUrl } from "./creds.js";
14
+ import { launch } from "./launch.js";
13
15
  import { send, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
14
16
  import { enqueue, flush } from "./outbox.js";
15
17
  import { gatherStatus, formatStatus, formatStatusSteps } from "./status.js";
@@ -19,6 +21,7 @@ import { setup } from "./setup.js";
19
21
  import { uninstall } from "./uninstall.js";
20
22
  import { cmdConfig } from "./config.js";
21
23
  import { onStop } from "./on_stop.js";
24
+ import { gatherDoctor, formatDoctor } from "./doctor.js";
22
25
  /** Subcommands the finished CLI will expose (see PRD §4.1). */
23
26
  export const COMMANDS = [
24
27
  "login",
@@ -26,11 +29,54 @@ export const COMMANDS = [
26
29
  "test",
27
30
  "setup",
28
31
  "status",
32
+ "doctor",
29
33
  "mcp",
30
34
  "uninstall",
31
35
  "config",
32
36
  "on-stop",
37
+ "launch",
33
38
  ];
39
+ /** Aliases that route to a canonical command in {@link runAsync}. */
40
+ export const COMMAND_ALIASES = {
41
+ autopilot: "launch",
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
+ }
34
80
  export function printHelp() {
35
81
  console.log([
36
82
  `gocode-notify v${VERSION}`,
@@ -45,10 +91,12 @@ export function printHelp() {
45
91
  " test Send a canned test push",
46
92
  " setup Pair + detect runtimes + write configs",
47
93
  " status Report credentials / server / detected runtimes",
94
+ " doctor Self-diagnostic checklist with exact fix commands",
48
95
  " mcp Run as an MCP server over stdio",
49
96
  " uninstall Remove entries this tool added",
50
97
  " config Get/set Notify settings (get | set <key> <value> | pull)",
51
98
  " on-stop End-of-turn dispatcher (auto-push or finished ping)",
99
+ " launch Hand a big task off to your GoCode server (alias: autopilot)",
52
100
  "",
53
101
  " -h, --help Show this help",
54
102
  " -v, --version Print the version",
@@ -89,6 +137,30 @@ function flagString(flags, name) {
89
137
  const v = flags.get(name);
90
138
  return typeof v === "string" ? v : undefined;
91
139
  }
140
+ /**
141
+ * Collect the bare positional arguments from an argv slice, mirroring the exact
142
+ * value-consumption logic of {@link parseFlags} so a positional is never confused
143
+ * with a flag's value. A `--flag value` pair consumes `value`; a `--flag=value`
144
+ * or a trailing/boolean `--flag` consumes nothing. Everything left over (an arg
145
+ * not starting with `--` and not eaten as a flag value) is a positional. Used by
146
+ * `launch`, whose task string is positional (PRD §4.2).
147
+ */
148
+ export function collectPositionals(argv) {
149
+ const positionals = [];
150
+ for (let i = 0; i < argv.length; i++) {
151
+ const arg = argv[i];
152
+ if (arg.startsWith("--")) {
153
+ if (arg.indexOf("=") !== -1)
154
+ continue;
155
+ const next = argv[i + 1];
156
+ if (next !== undefined && !next.startsWith("--"))
157
+ i++;
158
+ continue;
159
+ }
160
+ positionals.push(arg);
161
+ }
162
+ return positionals;
163
+ }
92
164
  /** Handle `gocode-notify login [--code N] [--label "..."] [--server URL] [--agent-driven]`. */
93
165
  export async function cmdLogin(args, deps = {}) {
94
166
  const flags = parseFlags(args);
@@ -183,6 +255,10 @@ export async function cmdSend(args, deps = {}) {
183
255
  const dedupeKey = flagString(flags, "dedupe-key");
184
256
  if (dedupeKey)
185
257
  payload.dedupe_key = dedupeKey;
258
+ // --autopilot forces the "Autopilot" tray badge on (loop kinds are badged
259
+ // automatically server-side; this is for an explicit non-loop Autopilot send).
260
+ if (flagBool(flags, "autopilot"))
261
+ payload.autopilot = true;
186
262
  const server = await resolveServerUrl(flagString(flags, "server"), deps);
187
263
  const sendOpts = {
188
264
  home: deps.home,
@@ -288,6 +364,26 @@ export async function cmdStatus(args, deps = {}) {
288
364
  }
289
365
  return 0;
290
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
+ }
291
387
  /** True when a flag is present as a bare boolean or explicit `=true`. */
292
388
  function flagBool(flags, name) {
293
389
  const v = flags.get(name);
@@ -393,6 +489,17 @@ export async function cmdOnStop(args, deps = {}) {
393
489
  const flags = parseFlags(args);
394
490
  const agent = isAgentDriven(flags);
395
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)();
396
503
  const server = await resolveServerUrl(flagString(flags, "server"), deps);
397
504
  const run = deps.onStopImpl ?? onStop;
398
505
  const result = await run({
@@ -405,6 +512,7 @@ export async function cmdOnStop(args, deps = {}) {
405
512
  fetchImpl: deps.fetchImpl,
406
513
  timeoutMs: deps.timeoutMs,
407
514
  timestamp: deps.timestamp,
515
+ hookStdin,
408
516
  });
409
517
  const delivered = result.mode === "push"
410
518
  ? result.push?.notified === true
@@ -415,7 +523,11 @@ export async function cmdOnStop(args, deps = {}) {
415
523
  ? `auto-push: ${result.push?.outcome ?? "unknown"}${delivered ? " (notified)" : ""}`
416
524
  : result.mode === "dry-run-send"
417
525
  ? "dry-run: would send finished (auto-push off)"
418
- : `finished ${delivered ? "delivered" : "not delivered"}`;
526
+ : result.mode === "deduped"
527
+ ? "deduped: another source already notified for this run"
528
+ : result.mode === "autopilot-suppressed"
529
+ ? "autopilot-suppressed: an Autopilot loop owns this turn's ping"
530
+ : `finished ${delivered ? "delivered" : "not delivered"}`;
419
531
  if (agent) {
420
532
  sink({ step: "on-stop", ok: true, detail });
421
533
  }
@@ -425,6 +537,96 @@ export async function cmdOnStop(args, deps = {}) {
425
537
  // PRD §0.5 / §4.4: a stop hook must NEVER block the turn — always exit 0.
426
538
  return 0;
427
539
  }
540
+ /**
541
+ * Handle `gocode-notify launch "<task>" [--repo owner/repo] [--branch suffix]
542
+ * [--prd-file path.md] [--model "Profile"] [--runner-kind K] [--server URL]
543
+ * [--agent-driven]` (alias `autopilot`). Hands a big, multi-step task off to the
544
+ * user's GoCode server to run as an autonomous Autopilot loop (PRD §4.2).
545
+ *
546
+ * The positional task string maps to `message`; `--prd-file` reads a file and
547
+ * sends it as `prd_markdown` instead — the two are mutually exclusive (a usage
548
+ * error → exit 2, before any network). Unlike the fire-and-forget `send`/`on-stop`
549
+ * hooks, `launch` is an explicit, interactive action: it reports real failures
550
+ * with a NON-zero exit (like `test`) and never queues to the offline outbox.
551
+ */
552
+ export async function cmdLaunch(args, deps = {}) {
553
+ const flags = parseFlags(args);
554
+ const agent = isAgentDriven(flags);
555
+ const sink = deps.sink ?? stdoutSink;
556
+ const fail = (detail, code) => {
557
+ if (agent)
558
+ sink({ step: "launch", ok: false, detail });
559
+ else
560
+ console.error(`gocode-notify launch: ${detail}`);
561
+ return code;
562
+ };
563
+ // Drop the `--agent-driven` boolean from positionals so it is never mistaken
564
+ // for the task string; everything else not consumed as a flag value is a task.
565
+ const positionals = collectPositionals(args);
566
+ const task = positionals.length > 0 ? positionals.join(" ") : undefined;
567
+ const prdFile = flagString(flags, "prd-file");
568
+ // Exactly-one-of the two task sources — caught locally before any network so
569
+ // the user gets a clear usage error (the server enforces the same rule, 422).
570
+ if (task !== undefined && prdFile !== undefined) {
571
+ return fail("provide either a task string OR --prd-file, not both", 2);
572
+ }
573
+ if (task === undefined && prdFile === undefined) {
574
+ return fail('a task is required: gocode-notify launch "<task>" (or --prd-file <path>)', 2);
575
+ }
576
+ const input = {};
577
+ if (prdFile !== undefined) {
578
+ try {
579
+ input.prdMarkdown = await readFile(prdFile, "utf8");
580
+ }
581
+ catch (err) {
582
+ return fail(`could not read --prd-file ${prdFile}: ${err instanceof Error ? err.message : String(err)}`, 2);
583
+ }
584
+ input.prdFilename = prdFile.split("/").pop() || prdFile;
585
+ }
586
+ else {
587
+ input.message = task;
588
+ }
589
+ const repo = flagString(flags, "repo");
590
+ if (repo)
591
+ input.selectedRepository = repo;
592
+ const branch = flagString(flags, "branch");
593
+ if (branch)
594
+ input.branchSuffix = branch;
595
+ const model = flagString(flags, "model");
596
+ if (model)
597
+ input.modelOverride = model;
598
+ const runnerKind = flagString(flags, "runner-kind");
599
+ if (runnerKind)
600
+ input.runnerKind = runnerKind;
601
+ const launchFn = deps.launchImpl ?? launch;
602
+ const result = await launchFn(input, {
603
+ home: deps.home,
604
+ fetchImpl: deps.fetchImpl,
605
+ timeoutMs: deps.timeoutMs,
606
+ timestamp: deps.timestamp,
607
+ env: deps.env,
608
+ serverFlag: flagString(flags, "server"),
609
+ });
610
+ if (!result.ok) {
611
+ // launch()'s error strings are already actionable (re-pair hint, status, etc.).
612
+ return fail(result.error ?? "launch failed", 1);
613
+ }
614
+ const loop = result.loop_id ?? "(id pending)";
615
+ const link = result.deep_link;
616
+ const tail = "Running on your GoCode server — close your laptop; your phone will buzz when it's done.";
617
+ if (agent) {
618
+ const where = link ? ` (${link})` : "";
619
+ sink({ step: "launch", ok: true, detail: `launched loop ${loop}${where}` });
620
+ }
621
+ else {
622
+ console.log(`✓ Autopilot launched on your GoCode server.`);
623
+ console.log(` Loop: ${loop}`);
624
+ if (link)
625
+ console.log(` Open: ${link}`);
626
+ console.log(` ${tail}`);
627
+ }
628
+ return 0;
629
+ }
428
630
  /** Synchronous dispatcher: help/version/unknown + not-yet-implemented commands. */
429
631
  export function run(argv) {
430
632
  const cmd = argv[0];
@@ -458,6 +660,8 @@ export async function runAsync(argv) {
458
660
  return cmdTest(argv.slice(1));
459
661
  if (cmd === "status")
460
662
  return cmdStatus(argv.slice(1));
663
+ if (cmd === "doctor")
664
+ return cmdDoctor(argv.slice(1));
461
665
  if (cmd === "setup")
462
666
  return cmdSetup(argv.slice(1));
463
667
  if (cmd === "mcp")
@@ -468,6 +672,8 @@ export async function runAsync(argv) {
468
672
  return cmdConfig(argv.slice(1));
469
673
  if (cmd === "on-stop")
470
674
  return cmdOnStop(argv.slice(1));
675
+ if (cmd === "launch" || cmd === "autopilot")
676
+ return cmdLaunch(argv.slice(1));
471
677
  return run(argv);
472
678
  }
473
679
  /**
package/dist/src/creds.js CHANGED
@@ -7,12 +7,41 @@
7
7
  // Server-URL precedence (PRD §4.2, the acceptance criterion for this task):
8
8
  // --server flag > GOCODE_SERVER env > credentials file > built-in default
9
9
  //
10
+ // The "built-in default" itself is configurable at distribution/build time via
11
+ // GOCODE_DEFAULT_SERVER (PRD §6/§7 — "never hardcode the author's box for the
12
+ // world's installs"): an open-source redistribution can point new, unpaired
13
+ // installs at its OWN GoCode server without patching this source literal.
14
+ //
10
15
  // Zero runtime deps — only Node built-ins, to match the package's zero-dep rule.
11
16
  import { promises as fs } from "node:fs";
12
17
  import os from "node:os";
13
18
  import path from "node:path";
14
- /** Built-in default server, used when nothing else resolves a URL. */
19
+ /**
20
+ * Compiled-in fallback server, used ONLY when neither the caller, the env, the
21
+ * credentials file, nor the GOCODE_DEFAULT_SERVER distribution override resolves
22
+ * a URL. Open-source forks set GOCODE_DEFAULT_SERVER (see {@link builtinDefaultServer})
23
+ * rather than editing this literal, so no fork is forced to ship the author's box.
24
+ */
15
25
  export const DEFAULT_SERVER = "https://oh.jeltechsolutions.com";
26
+ /**
27
+ * Env var that overrides the built-in default server at distribution/build time.
28
+ * Distinct from `GOCODE_SERVER` (a per-run user override that outranks the
29
+ * credentials file): `GOCODE_DEFAULT_SERVER` only sets the *fallback* a fresh,
30
+ * unpaired install resolves to, so a redistribution can rebrand the default
31
+ * target without touching paired users' `~/.gocode/credentials`.
32
+ */
33
+ export const DEFAULT_SERVER_ENV = "GOCODE_DEFAULT_SERVER";
34
+ /**
35
+ * The effective built-in default: the `GOCODE_DEFAULT_SERVER` distribution
36
+ * override when it is set to a non-empty value, else the compiled-in
37
+ * {@link DEFAULT_SERVER} literal. Read live (not cached at import) so a
38
+ * redistribution/build/CI can set it without re-importing the module, and so
39
+ * tests can exercise the override. The result is trimmed + trailing-slash-stripped.
40
+ */
41
+ export function builtinDefaultServer() {
42
+ const override = firstNonEmpty(process.env[DEFAULT_SERVER_ENV]);
43
+ return normalizeServer(override ?? DEFAULT_SERVER);
44
+ }
16
45
  /**
17
46
  * Resolve the home directory. Prefers an explicit override, then `$HOME`
18
47
  * (so tests can point at a temp dir via the env), then `os.homedir()`.
@@ -129,9 +158,9 @@ export async function writeConfig(config, opts) {
129
158
  * The result is trimmed and has trailing slashes stripped.
130
159
  */
131
160
  export function resolveServer(sources) {
132
- const chosen = firstNonEmpty(sources.flag, sources.env, sources.creds?.server, sources.default ?? DEFAULT_SERVER);
161
+ const chosen = firstNonEmpty(sources.flag, sources.env, sources.creds?.server, sources.default ?? builtinDefaultServer());
133
162
  // The default is always non-empty, so `chosen` is defined here.
134
- return normalizeServer(chosen ?? DEFAULT_SERVER);
163
+ return normalizeServer(chosen ?? builtinDefaultServer());
135
164
  }
136
165
  /**
137
166
  * Convenience wrapper: resolve the server URL using the `--server` flag, the
Binary file