@trygocode/notify 0.1.6 → 0.2.0
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 +106 -1
- package/dist/src/cli.js +131 -1
- package/dist/src/creds.js +32 -3
- package/dist/src/dedup_lock.js +0 -0
- package/dist/src/launch.js +218 -0
- package/dist/src/mcp.js +93 -10
- package/dist/src/on_stop.js +27 -0
- package/dist/src/rule-content.js +52 -1
- package/dist/src/send.js +6 -2
- package/dist/src/setup.js +20 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,10 +13,12 @@ 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)
|
|
19
20
|
- [Troubleshooting](#troubleshooting)
|
|
21
|
+
- [Changelog](#changelog)
|
|
20
22
|
- [Develop](#develop)
|
|
21
23
|
- [Layout](#layout)
|
|
22
24
|
|
|
@@ -105,6 +107,12 @@ saved in `~/.gocode/credentials` → the built-in default
|
|
|
105
107
|
(`https://oh.jeltechsolutions.com`). You only need `--server` for a self-hosted
|
|
106
108
|
or staging GoCode server.
|
|
107
109
|
|
|
110
|
+
**Redistributing under your own server.** The built-in default is not baked in
|
|
111
|
+
as a fixed author box — set `GOCODE_DEFAULT_SERVER` to point fresh, unpaired
|
|
112
|
+
installs at your OWN GoCode server without patching the source or having every
|
|
113
|
+
user pass `--server`/`GOCODE_SERVER`. It only changes the *fallback* default;
|
|
114
|
+
already-paired machines keep the server in their `~/.gocode/credentials`.
|
|
115
|
+
|
|
108
116
|
## The three triggers
|
|
109
117
|
|
|
110
118
|
| Trigger | Mechanism | Fires when |
|
|
@@ -133,6 +141,75 @@ never get double-pinged.
|
|
|
133
141
|
> `setup`/`setup --force`. `send` and `test` remain callable on their own for
|
|
134
142
|
> scripts and power users.
|
|
135
143
|
|
|
144
|
+
## Hand off to your server — `launch` / `autopilot`
|
|
145
|
+
|
|
146
|
+
> **New in 0.2.0.** Notify can now do more than ping your phone — it can **hand a
|
|
147
|
+
> big task off to your GoCode server** to run as an autonomous Autopilot loop.
|
|
148
|
+
> Close your laptop; the work keeps going server-side in your isolated sandbox,
|
|
149
|
+
> shows up in the GoCode phone app like any other Autopilot run, and your phone
|
|
150
|
+
> buzzes when it's done (or needs you).
|
|
151
|
+
|
|
152
|
+
This is the opposite direction of the three notify triggers above: instead of
|
|
153
|
+
*your local agent pinging your phone*, `launch` *starts a fresh server-side
|
|
154
|
+
build* from an explicit, self-contained task. It reuses the **same `gck_`
|
|
155
|
+
pairing** — no new login. It reads **no conversation content** (only the task
|
|
156
|
+
string you hand it), so it ships in this base package (see the trust note below).
|
|
157
|
+
|
|
158
|
+
### CLI
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
# Start a loop from a plain task (server synthesizes a PRD, then builds):
|
|
162
|
+
gocode-notify launch "Refactor the auth module to use sessions, add tests" \
|
|
163
|
+
--repo owner/repo
|
|
164
|
+
|
|
165
|
+
# alias:
|
|
166
|
+
gocode-notify autopilot "Add a CSV export endpoint with tests" --repo owner/repo
|
|
167
|
+
|
|
168
|
+
# Or hand it a pre-written checkbox PRD instead of a task string:
|
|
169
|
+
gocode-notify launch --prd-file ./my-prd.md --repo owner/repo
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
| Flag | Meaning |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `<task>` (positional) | Plain-language task the server loop builds. Mutually exclusive with `--prd-file`. |
|
|
175
|
+
| `--prd-file <path>` | A pre-written checkbox PRD (base64-encoded and sent as `prd_markdown`). |
|
|
176
|
+
| `--repo owner/repo` | Optional repo context for synthesis / the loop's target. |
|
|
177
|
+
| `--branch <suffix>` | Optional branch suffix the loop pushes to. |
|
|
178
|
+
| `--model "Profile"` | Optional LLM-profile name override. |
|
|
179
|
+
| `--runner-kind <kind>` | Optional runner override (default `openhands-conversation`). |
|
|
180
|
+
| `--server <URL>` | Same server-resolution precedence as every other command. |
|
|
181
|
+
| `--agent-driven` | Emit one machine-readable JSON step line (for installers/agents). |
|
|
182
|
+
|
|
183
|
+
On success it prints the **loop id**, a **deep link** that opens the run in the
|
|
184
|
+
app, and a "running on your GoCode server — close your laptop; your phone will
|
|
185
|
+
buzz when it's done" line, then exits `0`. Unlike the fire-and-forget
|
|
186
|
+
`send`/`on-stop` hooks, `launch` is an **explicit interactive action**: it
|
|
187
|
+
reports real failures (not paired / unreachable / 4xx) with a clear message and a
|
|
188
|
+
**non-zero exit**, and it does **not** queue to the offline outbox (a stale
|
|
189
|
+
launch surfacing hours later could duplicate work).
|
|
190
|
+
|
|
191
|
+
### MCP tool
|
|
192
|
+
|
|
193
|
+
Your desktop agent (Cursor / Claude Code / OpenCode) can fire a remote loop
|
|
194
|
+
mid-session via the third MCP tool, **`gocode_launch_autopilot`**
|
|
195
|
+
(`{ task (required), repo?, branch? }`). Its description tells the agent to call
|
|
196
|
+
it **only when you explicitly ask to offload / run in the background / overnight**
|
|
197
|
+
— never for a normal task it can do right there. On success it relays the loop id
|
|
198
|
+
+ deep link; on failure it returns an actionable re-pair hint.
|
|
199
|
+
|
|
200
|
+
> **Heads-up the rule states:** the server loop is a **fresh agent with no access
|
|
201
|
+
> to your IDE's open/unsaved files** — so the task must be **self-contained** and
|
|
202
|
+
> point at a repo the server can build from a clean checkout. If the work depends
|
|
203
|
+
> on local uncommitted state, commit/push first, then hand it off.
|
|
204
|
+
|
|
205
|
+
### Trust boundary
|
|
206
|
+
|
|
207
|
+
`launch` reads **only the explicit task string** (and optional repo/branch) you
|
|
208
|
+
hand it — it does **not** read or upload your IDE conversation transcript, and it
|
|
209
|
+
touches only `~/.gocode/credentials` (the `gck_` key it already uses for notify).
|
|
210
|
+
It POSTs to **your own configurable GoCode server**. So the base package's "never
|
|
211
|
+
reads your conversation content" promise still holds.
|
|
212
|
+
|
|
136
213
|
## Auto-push to git (opt-in)
|
|
137
214
|
|
|
138
215
|
GoCode Notify can **auto-commit and push your work after every agent turn** —
|
|
@@ -310,6 +387,33 @@ For the device/secret/publish/deploy steps that are **not** part of this package
|
|
|
310
387
|
npm publish, real-device E2E), see
|
|
311
388
|
[`docs/GOCODE_NOTIFY_MANUAL_STEPS.md`](../../docs/GOCODE_NOTIFY_MANUAL_STEPS.md).
|
|
312
389
|
|
|
390
|
+
## Changelog
|
|
391
|
+
|
|
392
|
+
### 0.2.0
|
|
393
|
+
|
|
394
|
+
- **New: `launch` / `autopilot` command** — hand a large, multi-step task off to
|
|
395
|
+
your GoCode server to run as an autonomous Autopilot loop. Reuses the existing
|
|
396
|
+
`gck_` pairing; prints a loop id + app deep link; explicit failures exit
|
|
397
|
+
non-zero (no offline queueing). See
|
|
398
|
+
[Hand off to your server](#hand-off-to-your-server--launch--autopilot).
|
|
399
|
+
- **New: `gocode_launch_autopilot` MCP tool** — the third tool on the stdio MCP
|
|
400
|
+
server, so a desktop agent can fire a remote loop mid-session (explicit offload
|
|
401
|
+
only). The installed Cursor rule / Claude skill / OpenCode snippet teach the
|
|
402
|
+
agent *when* to offload and that the server loop is a fresh, self-contained
|
|
403
|
+
agent with no access to local files.
|
|
404
|
+
- **App monitoring** — IDE-launched loops are discovered by the phone app
|
|
405
|
+
(`GET /api/v1/ralph/active`), badged "Launched from <IDE>", and deep-link from
|
|
406
|
+
the completion/halt push into the run — full parity with app-launched loops.
|
|
407
|
+
- **Fix:** `--version` now reports the real package version. `src/version.ts` had
|
|
408
|
+
drifted (published `0.1.6` reported `0.1.3`); it is now kept in lockstep with
|
|
409
|
+
`package.json`, and the publish workflow refuses to ship a mismatch.
|
|
410
|
+
|
|
411
|
+
### 0.1.x
|
|
412
|
+
|
|
413
|
+
- Phone notifications for any coding agent via three triggers (runtime hook, MCP
|
|
414
|
+
tool, loop snippet), opt-in auto-push to git with an AI-written commit message,
|
|
415
|
+
and server-synced settings.
|
|
416
|
+
|
|
313
417
|
## Develop
|
|
314
418
|
|
|
315
419
|
```bash
|
|
@@ -331,7 +435,8 @@ Zero runtime dependencies beyond the MCP SDK (Node built-in `fetch`/`fs`/
|
|
|
331
435
|
| `src/cli.ts` | `gocode-notify` bin entrypoint + command dispatcher (incl. `on-stop`, `config`) |
|
|
332
436
|
| `src/setup.ts` | Installer orchestration (pair → detect → write configs) |
|
|
333
437
|
| `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 |
|
|
438
|
+
| `src/send.ts` / `src/login.ts` / `src/mcp.ts` | Core send, pairing, and MCP server (incl. `gocode_launch_autopilot`) |
|
|
439
|
+
| `src/launch.ts` | Shared `launch()` core behind the `launch`/`autopilot` command + the MCP tool |
|
|
335
440
|
| `src/push.ts` | Auto-push flow behind the `on-stop` dispatcher — git add/commit/push, FF-only |
|
|
336
441
|
| `src/commit_message.ts` | AI commit-message resolution chain + deterministic fallback |
|
|
337
442
|
| `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";
|
|
@@ -30,7 +32,12 @@ export const COMMANDS = [
|
|
|
30
32
|
"uninstall",
|
|
31
33
|
"config",
|
|
32
34
|
"on-stop",
|
|
35
|
+
"launch",
|
|
33
36
|
];
|
|
37
|
+
/** Aliases that route to a canonical command in {@link runAsync}. */
|
|
38
|
+
export const COMMAND_ALIASES = {
|
|
39
|
+
autopilot: "launch",
|
|
40
|
+
};
|
|
34
41
|
export function printHelp() {
|
|
35
42
|
console.log([
|
|
36
43
|
`gocode-notify v${VERSION}`,
|
|
@@ -49,6 +56,7 @@ export function printHelp() {
|
|
|
49
56
|
" uninstall Remove entries this tool added",
|
|
50
57
|
" config Get/set Notify settings (get | set <key> <value> | pull)",
|
|
51
58
|
" on-stop End-of-turn dispatcher (auto-push or finished ping)",
|
|
59
|
+
" launch Hand a big task off to your GoCode server (alias: autopilot)",
|
|
52
60
|
"",
|
|
53
61
|
" -h, --help Show this help",
|
|
54
62
|
" -v, --version Print the version",
|
|
@@ -89,6 +97,30 @@ function flagString(flags, name) {
|
|
|
89
97
|
const v = flags.get(name);
|
|
90
98
|
return typeof v === "string" ? v : undefined;
|
|
91
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Collect the bare positional arguments from an argv slice, mirroring the exact
|
|
102
|
+
* value-consumption logic of {@link parseFlags} so a positional is never confused
|
|
103
|
+
* with a flag's value. A `--flag value` pair consumes `value`; a `--flag=value`
|
|
104
|
+
* or a trailing/boolean `--flag` consumes nothing. Everything left over (an arg
|
|
105
|
+
* not starting with `--` and not eaten as a flag value) is a positional. Used by
|
|
106
|
+
* `launch`, whose task string is positional (PRD §4.2).
|
|
107
|
+
*/
|
|
108
|
+
export function collectPositionals(argv) {
|
|
109
|
+
const positionals = [];
|
|
110
|
+
for (let i = 0; i < argv.length; i++) {
|
|
111
|
+
const arg = argv[i];
|
|
112
|
+
if (arg.startsWith("--")) {
|
|
113
|
+
if (arg.indexOf("=") !== -1)
|
|
114
|
+
continue;
|
|
115
|
+
const next = argv[i + 1];
|
|
116
|
+
if (next !== undefined && !next.startsWith("--"))
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
positionals.push(arg);
|
|
121
|
+
}
|
|
122
|
+
return positionals;
|
|
123
|
+
}
|
|
92
124
|
/** Handle `gocode-notify login [--code N] [--label "..."] [--server URL] [--agent-driven]`. */
|
|
93
125
|
export async function cmdLogin(args, deps = {}) {
|
|
94
126
|
const flags = parseFlags(args);
|
|
@@ -183,6 +215,10 @@ export async function cmdSend(args, deps = {}) {
|
|
|
183
215
|
const dedupeKey = flagString(flags, "dedupe-key");
|
|
184
216
|
if (dedupeKey)
|
|
185
217
|
payload.dedupe_key = dedupeKey;
|
|
218
|
+
// --autopilot forces the "Autopilot" tray badge on (loop kinds are badged
|
|
219
|
+
// automatically server-side; this is for an explicit non-loop Autopilot send).
|
|
220
|
+
if (flagBool(flags, "autopilot"))
|
|
221
|
+
payload.autopilot = true;
|
|
186
222
|
const server = await resolveServerUrl(flagString(flags, "server"), deps);
|
|
187
223
|
const sendOpts = {
|
|
188
224
|
home: deps.home,
|
|
@@ -415,7 +451,9 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
415
451
|
? `auto-push: ${result.push?.outcome ?? "unknown"}${delivered ? " (notified)" : ""}`
|
|
416
452
|
: result.mode === "dry-run-send"
|
|
417
453
|
? "dry-run: would send finished (auto-push off)"
|
|
418
|
-
:
|
|
454
|
+
: result.mode === "deduped"
|
|
455
|
+
? "deduped: another source already notified for this run"
|
|
456
|
+
: `finished ${delivered ? "delivered" : "not delivered"}`;
|
|
419
457
|
if (agent) {
|
|
420
458
|
sink({ step: "on-stop", ok: true, detail });
|
|
421
459
|
}
|
|
@@ -425,6 +463,96 @@ export async function cmdOnStop(args, deps = {}) {
|
|
|
425
463
|
// PRD §0.5 / §4.4: a stop hook must NEVER block the turn — always exit 0.
|
|
426
464
|
return 0;
|
|
427
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Handle `gocode-notify launch "<task>" [--repo owner/repo] [--branch suffix]
|
|
468
|
+
* [--prd-file path.md] [--model "Profile"] [--runner-kind K] [--server URL]
|
|
469
|
+
* [--agent-driven]` (alias `autopilot`). Hands a big, multi-step task off to the
|
|
470
|
+
* user's GoCode server to run as an autonomous Autopilot loop (PRD §4.2).
|
|
471
|
+
*
|
|
472
|
+
* The positional task string maps to `message`; `--prd-file` reads a file and
|
|
473
|
+
* sends it as `prd_markdown` instead — the two are mutually exclusive (a usage
|
|
474
|
+
* error → exit 2, before any network). Unlike the fire-and-forget `send`/`on-stop`
|
|
475
|
+
* hooks, `launch` is an explicit, interactive action: it reports real failures
|
|
476
|
+
* with a NON-zero exit (like `test`) and never queues to the offline outbox.
|
|
477
|
+
*/
|
|
478
|
+
export async function cmdLaunch(args, deps = {}) {
|
|
479
|
+
const flags = parseFlags(args);
|
|
480
|
+
const agent = isAgentDriven(flags);
|
|
481
|
+
const sink = deps.sink ?? stdoutSink;
|
|
482
|
+
const fail = (detail, code) => {
|
|
483
|
+
if (agent)
|
|
484
|
+
sink({ step: "launch", ok: false, detail });
|
|
485
|
+
else
|
|
486
|
+
console.error(`gocode-notify launch: ${detail}`);
|
|
487
|
+
return code;
|
|
488
|
+
};
|
|
489
|
+
// Drop the `--agent-driven` boolean from positionals so it is never mistaken
|
|
490
|
+
// for the task string; everything else not consumed as a flag value is a task.
|
|
491
|
+
const positionals = collectPositionals(args);
|
|
492
|
+
const task = positionals.length > 0 ? positionals.join(" ") : undefined;
|
|
493
|
+
const prdFile = flagString(flags, "prd-file");
|
|
494
|
+
// Exactly-one-of the two task sources — caught locally before any network so
|
|
495
|
+
// the user gets a clear usage error (the server enforces the same rule, 422).
|
|
496
|
+
if (task !== undefined && prdFile !== undefined) {
|
|
497
|
+
return fail("provide either a task string OR --prd-file, not both", 2);
|
|
498
|
+
}
|
|
499
|
+
if (task === undefined && prdFile === undefined) {
|
|
500
|
+
return fail('a task is required: gocode-notify launch "<task>" (or --prd-file <path>)', 2);
|
|
501
|
+
}
|
|
502
|
+
const input = {};
|
|
503
|
+
if (prdFile !== undefined) {
|
|
504
|
+
try {
|
|
505
|
+
input.prdMarkdown = await readFile(prdFile, "utf8");
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
return fail(`could not read --prd-file ${prdFile}: ${err instanceof Error ? err.message : String(err)}`, 2);
|
|
509
|
+
}
|
|
510
|
+
input.prdFilename = prdFile.split("/").pop() || prdFile;
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
input.message = task;
|
|
514
|
+
}
|
|
515
|
+
const repo = flagString(flags, "repo");
|
|
516
|
+
if (repo)
|
|
517
|
+
input.selectedRepository = repo;
|
|
518
|
+
const branch = flagString(flags, "branch");
|
|
519
|
+
if (branch)
|
|
520
|
+
input.branchSuffix = branch;
|
|
521
|
+
const model = flagString(flags, "model");
|
|
522
|
+
if (model)
|
|
523
|
+
input.modelOverride = model;
|
|
524
|
+
const runnerKind = flagString(flags, "runner-kind");
|
|
525
|
+
if (runnerKind)
|
|
526
|
+
input.runnerKind = runnerKind;
|
|
527
|
+
const launchFn = deps.launchImpl ?? launch;
|
|
528
|
+
const result = await launchFn(input, {
|
|
529
|
+
home: deps.home,
|
|
530
|
+
fetchImpl: deps.fetchImpl,
|
|
531
|
+
timeoutMs: deps.timeoutMs,
|
|
532
|
+
timestamp: deps.timestamp,
|
|
533
|
+
env: deps.env,
|
|
534
|
+
serverFlag: flagString(flags, "server"),
|
|
535
|
+
});
|
|
536
|
+
if (!result.ok) {
|
|
537
|
+
// launch()'s error strings are already actionable (re-pair hint, status, etc.).
|
|
538
|
+
return fail(result.error ?? "launch failed", 1);
|
|
539
|
+
}
|
|
540
|
+
const loop = result.loop_id ?? "(id pending)";
|
|
541
|
+
const link = result.deep_link;
|
|
542
|
+
const tail = "Running on your GoCode server — close your laptop; your phone will buzz when it's done.";
|
|
543
|
+
if (agent) {
|
|
544
|
+
const where = link ? ` (${link})` : "";
|
|
545
|
+
sink({ step: "launch", ok: true, detail: `launched loop ${loop}${where}` });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
console.log(`✓ Autopilot launched on your GoCode server.`);
|
|
549
|
+
console.log(` Loop: ${loop}`);
|
|
550
|
+
if (link)
|
|
551
|
+
console.log(` Open: ${link}`);
|
|
552
|
+
console.log(` ${tail}`);
|
|
553
|
+
}
|
|
554
|
+
return 0;
|
|
555
|
+
}
|
|
428
556
|
/** Synchronous dispatcher: help/version/unknown + not-yet-implemented commands. */
|
|
429
557
|
export function run(argv) {
|
|
430
558
|
const cmd = argv[0];
|
|
@@ -468,6 +596,8 @@ export async function runAsync(argv) {
|
|
|
468
596
|
return cmdConfig(argv.slice(1));
|
|
469
597
|
if (cmd === "on-stop")
|
|
470
598
|
return cmdOnStop(argv.slice(1));
|
|
599
|
+
if (cmd === "launch" || cmd === "autopilot")
|
|
600
|
+
return cmdLaunch(argv.slice(1));
|
|
471
601
|
return run(argv);
|
|
472
602
|
}
|
|
473
603
|
/**
|
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
|
-
/**
|
|
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 ??
|
|
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 ??
|
|
163
|
+
return normalizeServer(chosen ?? builtinDefaultServer());
|
|
135
164
|
}
|
|
136
165
|
/**
|
|
137
166
|
* Convenience wrapper: resolve the server URL using the `--server` flag, the
|
|
Binary file
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// Internal launch() — the single code path every desktop trigger (CLI / MCP)
|
|
2
|
+
// funnels through to POST `/api/v1/notify/launch`, which starts a server-side
|
|
3
|
+
// Autopilot loop for the `gck_` key's bound user (Remote Autopilot Launch PRD
|
|
4
|
+
// §4.1). It mirrors `send()` as the shared core (PRD §4 "all three funnel
|
|
5
|
+
// through the same internal launch()").
|
|
6
|
+
//
|
|
7
|
+
// Contract (PRD §4.2 / §4.3 "launch is explicit, not fire-and-forget"):
|
|
8
|
+
// - Reads the `gck_` key from `~/.gocode/credentials` (same path as `send()`).
|
|
9
|
+
// - Resolves the server URL with full precedence (flag → env → creds →
|
|
10
|
+
// default) via `resolveServerUrl`.
|
|
11
|
+
// - Auto-detects the IDE label (best-effort, label only, falls back to "ide").
|
|
12
|
+
// - POSTs the task/PRD; on success returns the 202 projection
|
|
13
|
+
// `{ ok, loop_id?, project_id?, conversation_id?, prd_title?, origin?,
|
|
14
|
+
// deep_link? }`; on any failure returns `{ ok: false, error }`.
|
|
15
|
+
// - NEVER throws / rejects — always resolves to a {@link LaunchResult}. The
|
|
16
|
+
// CLI/MCP layer decides the exit code / how to relay it (unlike `send`,
|
|
17
|
+
// launch reports real failures with a non-zero exit, but the *core* still
|
|
18
|
+
// returns a result rather than throwing).
|
|
19
|
+
// - Does NOT queue to the offline outbox — a stale launch surfacing hours
|
|
20
|
+
// later would be surprising and could duplicate work (PRD §4.2).
|
|
21
|
+
//
|
|
22
|
+
// Failures are appended to `~/.gocode/notify.log` (the same trail `send` uses).
|
|
23
|
+
// Zero runtime deps — Node built-ins only, matching the package rule.
|
|
24
|
+
import { builtinDefaultServer, readCredentials, resolveServerUrl, } from "./creds.js";
|
|
25
|
+
import { appendLog } from "./send.js";
|
|
26
|
+
/** The launch endpoint path on the notify router (PRD §3.2). */
|
|
27
|
+
export const LAUNCH_PATH = "/api/v1/notify/launch";
|
|
28
|
+
/**
|
|
29
|
+
* Default request timeout. Longer than `send`'s 5s hard cap because the server
|
|
30
|
+
* may run a PRD-synthesis turn before spawning the loop (PRD §3.2 step 5). Still
|
|
31
|
+
* bounded so a wedged server can never hang the desktop agent indefinitely.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* Canonical IDE labels the server understands (PRD §3.2). The label is sanitized
|
|
36
|
+
* + capped server-side; this is just the best-effort client classification.
|
|
37
|
+
*/
|
|
38
|
+
export const IDE_LABEL_FALLBACK = "ide";
|
|
39
|
+
function errMessage(err) {
|
|
40
|
+
if (err instanceof Error)
|
|
41
|
+
return err.message;
|
|
42
|
+
return String(err);
|
|
43
|
+
}
|
|
44
|
+
/** Strip trailing slashes so `${server}${path}` is always clean. */
|
|
45
|
+
function normalizeServer(url) {
|
|
46
|
+
return url.trim().replace(/\/+$/, "");
|
|
47
|
+
}
|
|
48
|
+
/** True when `env` has any key beginning with `prefix` (case-sensitive). */
|
|
49
|
+
function hasEnvPrefix(env, prefix) {
|
|
50
|
+
for (const key of Object.keys(env)) {
|
|
51
|
+
if (key.startsWith(prefix) && env[key] !== undefined && env[key] !== "") {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Best-effort classify the IDE the agent runs in from its environment (PRD
|
|
59
|
+
* §4.1). Cursor exports `CURSOR_*`, Claude Code `CLAUDECODE`/`CLAUDE_*`, OpenCode
|
|
60
|
+
* `OPENCODE_*`. Returns one of the canonical labels (`cursor` / `claude_code` /
|
|
61
|
+
* `opencode`) or {@link IDE_LABEL_FALLBACK} when none is recognised. Never throws.
|
|
62
|
+
*/
|
|
63
|
+
export function detectIdeLabel(env = process.env) {
|
|
64
|
+
// Claude Code sets CLAUDECODE=1 plus assorted CLAUDE_* vars.
|
|
65
|
+
if (env.CLAUDECODE || hasEnvPrefix(env, "CLAUDE_CODE") || hasEnvPrefix(env, "CLAUDE")) {
|
|
66
|
+
return "claude_code";
|
|
67
|
+
}
|
|
68
|
+
if (hasEnvPrefix(env, "CURSOR"))
|
|
69
|
+
return "cursor";
|
|
70
|
+
if (hasEnvPrefix(env, "OPENCODE"))
|
|
71
|
+
return "opencode";
|
|
72
|
+
return IDE_LABEL_FALLBACK;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Build the JSON request body for `/notify/launch`. Exactly one of `message` /
|
|
76
|
+
* `prd_markdown` is included; `prd_markdown` is base64-encoded from the raw
|
|
77
|
+
* input. Optional fields are dropped when unset. The server forces `origin`
|
|
78
|
+
* itself (its schema is `extra="forbid"`), so we never send it — including it
|
|
79
|
+
* would be rejected with a 422.
|
|
80
|
+
*/
|
|
81
|
+
function buildBody(input, ide) {
|
|
82
|
+
const body = {};
|
|
83
|
+
if (input.message && input.message.trim() !== "") {
|
|
84
|
+
body.message = input.message;
|
|
85
|
+
}
|
|
86
|
+
else if (input.prdMarkdown && input.prdMarkdown !== "") {
|
|
87
|
+
body.prd_markdown = Buffer.from(input.prdMarkdown, "utf8").toString("base64");
|
|
88
|
+
}
|
|
89
|
+
const optional = [
|
|
90
|
+
["prdFilename", "prd_filename"],
|
|
91
|
+
["selectedRepository", "selected_repository"],
|
|
92
|
+
["branchSuffix", "branch_suffix"],
|
|
93
|
+
["runnerKind", "runner_kind"],
|
|
94
|
+
["modelOverride", "model_override"],
|
|
95
|
+
];
|
|
96
|
+
for (const [from, to] of optional) {
|
|
97
|
+
const v = input[from];
|
|
98
|
+
if (typeof v === "string" && v.trim() !== "")
|
|
99
|
+
body[to] = v;
|
|
100
|
+
}
|
|
101
|
+
if (ide && ide.trim() !== "")
|
|
102
|
+
body.ide = ide;
|
|
103
|
+
return body;
|
|
104
|
+
}
|
|
105
|
+
async function failure(reason, opts, extra = {}) {
|
|
106
|
+
await appendLog(`LAUNCH FAIL: ${reason}`, opts);
|
|
107
|
+
return { ok: false, error: reason, ...extra };
|
|
108
|
+
}
|
|
109
|
+
/** Pull a readable reason out of a FastAPI error body (`{detail: ...}`). */
|
|
110
|
+
function extractDetail(parsed) {
|
|
111
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
112
|
+
return undefined;
|
|
113
|
+
const detail = parsed.detail;
|
|
114
|
+
if (typeof detail === "string")
|
|
115
|
+
return detail;
|
|
116
|
+
if (detail && typeof detail === "object") {
|
|
117
|
+
const reason = detail.reason;
|
|
118
|
+
const text = detail.detail;
|
|
119
|
+
const parts = [reason, text].filter((p) => typeof p === "string");
|
|
120
|
+
if (parts.length)
|
|
121
|
+
return parts.join(": ");
|
|
122
|
+
try {
|
|
123
|
+
return JSON.stringify(detail);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Launch a server-side Autopilot loop. Resolves (never rejects) to a
|
|
133
|
+
* {@link LaunchResult}. The caller is responsible for exit code / messaging.
|
|
134
|
+
*/
|
|
135
|
+
export async function launch(input, opts = {}) {
|
|
136
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_LAUNCH_TIMEOUT_MS;
|
|
137
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
138
|
+
const env = opts.env ?? process.env;
|
|
139
|
+
// Local exactly-one-of guard — gives the CLI/MCP a clear error before any
|
|
140
|
+
// network round-trip (the server enforces the same rule with a 422).
|
|
141
|
+
const hasMessage = !!(input.message && input.message.trim() !== "");
|
|
142
|
+
const hasPrd = !!(input.prdMarkdown && input.prdMarkdown !== "");
|
|
143
|
+
if (hasMessage === hasPrd) {
|
|
144
|
+
const reason = hasMessage
|
|
145
|
+
? "provide either a task message OR a PRD, not both"
|
|
146
|
+
: "a task message or PRD is required";
|
|
147
|
+
return failure(reason, opts);
|
|
148
|
+
}
|
|
149
|
+
let creds;
|
|
150
|
+
try {
|
|
151
|
+
creds = await readCredentials(opts);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return failure(`credentials unreadable: ${errMessage(err)}`, opts);
|
|
155
|
+
}
|
|
156
|
+
if (!creds) {
|
|
157
|
+
return failure("not paired — run `gocode-notify login` first", opts);
|
|
158
|
+
}
|
|
159
|
+
const server = normalizeServer(opts.server ?? (await resolveServerUrl(opts.serverFlag, opts)) ?? builtinDefaultServer());
|
|
160
|
+
const url = `${server}${LAUNCH_PATH}`;
|
|
161
|
+
const ide = input.ide && input.ide.trim() !== "" ? input.ide.trim() : detectIdeLabel(env);
|
|
162
|
+
const controller = new AbortController();
|
|
163
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetchImpl(url, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: {
|
|
168
|
+
"content-type": "application/json",
|
|
169
|
+
authorization: `Bearer ${creds.api_key}`,
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify(buildBody(input, ide)),
|
|
172
|
+
signal: controller.signal,
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
let detail;
|
|
176
|
+
try {
|
|
177
|
+
detail = extractDetail(await res.json());
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Non-JSON / empty error body — fall back to the bare status.
|
|
181
|
+
}
|
|
182
|
+
const suffix = detail ? `: ${detail}` : "";
|
|
183
|
+
return failure(`server responded ${res.status}${suffix}`, opts, {
|
|
184
|
+
status: res.status,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
let parsed = {};
|
|
188
|
+
try {
|
|
189
|
+
parsed = (await res.json());
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
// A 2xx with an unparseable body still counts as launched, but we have no
|
|
193
|
+
// ids to relay — surface that rather than pretending we got the projection.
|
|
194
|
+
return { ok: true, status: res.status, ide };
|
|
195
|
+
}
|
|
196
|
+
const str = (k) => typeof parsed[k] === "string" ? parsed[k] : undefined;
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
status: res.status,
|
|
200
|
+
loop_id: str("loop_id"),
|
|
201
|
+
project_id: str("project_id"),
|
|
202
|
+
conversation_id: str("conversation_id"),
|
|
203
|
+
prd_title: str("prd_title"),
|
|
204
|
+
origin: str("origin"),
|
|
205
|
+
deep_link: str("deep_link"),
|
|
206
|
+
ide,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const reason = controller.signal.aborted
|
|
211
|
+
? `timeout after ${timeoutMs}ms`
|
|
212
|
+
: `request failed: ${errMessage(err)}`;
|
|
213
|
+
return failure(reason, opts);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
}
|
|
218
|
+
}
|
package/dist/src/mcp.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// gocode-notify MCP server mode (`gocode-notify mcp`) — PRD §4.3.
|
|
2
2
|
//
|
|
3
|
-
// A minimal stdio MCP server exposing
|
|
4
|
-
// tools = more agent confusion):
|
|
5
|
-
// - gocode_notify
|
|
6
|
-
// - gocode_notify_status
|
|
3
|
+
// A minimal stdio MCP server exposing THREE tools (keep it tiny + the
|
|
4
|
+
// descriptions sharp — more tools = more agent confusion):
|
|
5
|
+
// - gocode_notify → on-demand push (thin wrapper over internal send())
|
|
6
|
+
// - gocode_notify_status → creds-present + server-reachable self-diagnosis
|
|
7
|
+
// - gocode_launch_autopilot → hand a BIG task off to the user's GoCode server
|
|
8
|
+
// to run as a server-side Autopilot loop (launch())
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
// `gatherStatus()`), so behaviour is identical across every trigger
|
|
10
|
-
// loop / MCP / CLI). We use the official MCP TypeScript SDK low-level `Server`
|
|
10
|
+
// All handlers funnel through the SAME internals as the CLI (`send()` /
|
|
11
|
+
// `gatherStatus()` / `launch()`), so behaviour is identical across every trigger
|
|
12
|
+
// (hook / loop / MCP / CLI). We use the official MCP TypeScript SDK low-level `Server`
|
|
11
13
|
// with raw JSON-Schema tool definitions — no zod in our OWN code — so the
|
|
12
14
|
// package's direct dependency stays just `@modelcontextprotocol/sdk`.
|
|
13
15
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -17,17 +19,21 @@ import { VERSION } from "./version.js";
|
|
|
17
19
|
import { resolveServerUrl } from "./creds.js";
|
|
18
20
|
import { send, isNotifyKind, NOTIFY_KINDS, } from "./send.js";
|
|
19
21
|
import { gatherStatus } from "./status.js";
|
|
22
|
+
import { launch } from "./launch.js";
|
|
20
23
|
/** Server identity advertised in the MCP `initialize` handshake. */
|
|
21
24
|
export const SERVER_NAME = "gocode-notify";
|
|
22
25
|
/** Tool name an agent calls to send an on-demand push. */
|
|
23
26
|
export const NOTIFY_TOOL = "gocode_notify";
|
|
24
27
|
/** Tool name an agent calls to self-diagnose pairing/reachability. */
|
|
25
28
|
export const STATUS_TOOL = "gocode_notify_status";
|
|
29
|
+
/** Tool name an agent calls to offload a big task to the user's server. */
|
|
30
|
+
export const LAUNCH_TOOL = "gocode_launch_autopilot";
|
|
26
31
|
/**
|
|
27
|
-
* The
|
|
32
|
+
* The three tools this server exposes (PRD §4.3). Declared as a plain constant so
|
|
28
33
|
* the handshake smoke test can assert the exact shape without spinning the
|
|
29
34
|
* transport. `gocode_notify`'s schema mirrors the documented args
|
|
30
|
-
* `{ kind?, title, body?, project? }
|
|
35
|
+
* `{ kind?, title, body?, project? }` (only `title` is required);
|
|
36
|
+
* `gocode_launch_autopilot`'s schema is `{ task(req), repo?, branch? }`.
|
|
31
37
|
*/
|
|
32
38
|
export const TOOLS = [
|
|
33
39
|
{
|
|
@@ -70,6 +76,38 @@ export const TOOLS = [
|
|
|
70
76
|
additionalProperties: false,
|
|
71
77
|
},
|
|
72
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: LAUNCH_TOOL,
|
|
81
|
+
description: "Hand a LARGE, multi-step coding task off to the user's GoCode server to run " +
|
|
82
|
+
"as an autonomous Autopilot loop — so it keeps running after the user closes " +
|
|
83
|
+
"their laptop, and their phone is notified when it's done. Call this ONLY " +
|
|
84
|
+
"when the user EXPLICITLY asks to offload/hand-off work to their server or to " +
|
|
85
|
+
"run something in the background / overnight / after they close their machine " +
|
|
86
|
+
'(e.g. "run this on the server", "do this overnight", "hand this off so I ' +
|
|
87
|
+
'can shut down"). Do NOT call it for normal tasks you can do right here — ' +
|
|
88
|
+
"those stay in this IDE session. The server loop is a FRESH agent with NO " +
|
|
89
|
+
"access to this IDE's open files / unsaved state, so pass a self-contained " +
|
|
90
|
+
"task description (and a repo) it can act on from a clean checkout.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
task: {
|
|
95
|
+
type: "string",
|
|
96
|
+
description: "Plain-language task for the server loop to build. Required.",
|
|
97
|
+
},
|
|
98
|
+
repo: {
|
|
99
|
+
type: "string",
|
|
100
|
+
description: "Optional owner/repo context for the loop.",
|
|
101
|
+
},
|
|
102
|
+
branch: {
|
|
103
|
+
type: "string",
|
|
104
|
+
description: "Optional branch suffix the loop pushes to.",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
required: ["task"],
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
73
111
|
];
|
|
74
112
|
function textResult(text) {
|
|
75
113
|
return { content: [{ type: "text", text }] };
|
|
@@ -143,7 +181,49 @@ export async function handleStatus(deps = {}) {
|
|
|
143
181
|
return { content: [{ type: "text", text: lines.join("\n") }], isError: !ok };
|
|
144
182
|
}
|
|
145
183
|
/**
|
|
146
|
-
*
|
|
184
|
+
* Handle a `gocode_launch_autopilot` tool call. Validates `task` (required,
|
|
185
|
+
* non-empty), then funnels through the shared internal {@link launch} — the SAME
|
|
186
|
+
* code path as the CLI `launch` command — to start a server-side Autopilot loop
|
|
187
|
+
* for the `gck_` key's bound user. The `origin: ide-launch` + detected `ide`
|
|
188
|
+
* label are set inside `launch()`/server-side, so the agent never smuggles them.
|
|
189
|
+
* Like the notify tool, it does NOT queue offline — a remote launch is explicit
|
|
190
|
+
* and must report real failures back so the agent can re-pair / retry.
|
|
191
|
+
*/
|
|
192
|
+
export async function handleLaunch(args, deps = {}) {
|
|
193
|
+
const a = args ?? {};
|
|
194
|
+
const task = typeof a.task === "string" ? a.task.trim() : "";
|
|
195
|
+
if (task === "") {
|
|
196
|
+
return errorResult("gocode_launch_autopilot: `task` is required.");
|
|
197
|
+
}
|
|
198
|
+
const input = { message: task };
|
|
199
|
+
if (typeof a.repo === "string" && a.repo.trim() !== "") {
|
|
200
|
+
input.selectedRepository = a.repo.trim();
|
|
201
|
+
}
|
|
202
|
+
if (typeof a.branch === "string" && a.branch.trim() !== "") {
|
|
203
|
+
input.branchSuffix = a.branch.trim();
|
|
204
|
+
}
|
|
205
|
+
const result = await launch(input, {
|
|
206
|
+
home: deps.home,
|
|
207
|
+
fetchImpl: deps.fetchImpl,
|
|
208
|
+
timeoutMs: deps.timeoutMs,
|
|
209
|
+
serverFlag: deps.serverFlag,
|
|
210
|
+
});
|
|
211
|
+
if (!result.ok) {
|
|
212
|
+
return errorResult(`Could not launch Autopilot loop: ${result.error}. ` +
|
|
213
|
+
"If this machine is not paired, ask the user to run `gocode-notify login`.");
|
|
214
|
+
}
|
|
215
|
+
const loop = result.loop_id ?? "(id pending)";
|
|
216
|
+
const lines = [
|
|
217
|
+
`Autopilot launched on your GoCode server (loop ${loop}).`,
|
|
218
|
+
];
|
|
219
|
+
if (result.deep_link)
|
|
220
|
+
lines.push(`Open in the app: ${result.deep_link}`);
|
|
221
|
+
lines.push("It's running on your server — you can close your laptop; your phone will " +
|
|
222
|
+
"buzz when it's done or needs you.");
|
|
223
|
+
return textResult(lines.join("\n"));
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Build the MCP {@link Server} with the three tools registered. Pure (does not
|
|
147
227
|
* touch the network or connect a transport) so the handshake smoke test can
|
|
148
228
|
* drive it over an in-memory transport.
|
|
149
229
|
*/
|
|
@@ -158,6 +238,9 @@ export function createMcpServer(deps = {}) {
|
|
|
158
238
|
if (name === STATUS_TOOL) {
|
|
159
239
|
return handleStatus(deps);
|
|
160
240
|
}
|
|
241
|
+
if (name === LAUNCH_TOOL) {
|
|
242
|
+
return handleLaunch(args, deps);
|
|
243
|
+
}
|
|
161
244
|
return errorResult(`Unknown tool: ${name}`);
|
|
162
245
|
});
|
|
163
246
|
return server;
|
package/dist/src/on_stop.js
CHANGED
|
@@ -29,6 +29,7 @@ import { resolveNotifySettings } from "./config.js";
|
|
|
29
29
|
import { deriveRepoIdentity } from "./repo_key.js";
|
|
30
30
|
import { pushOnStop, } from "./push.js";
|
|
31
31
|
import { appendLog, send } from "./send.js";
|
|
32
|
+
import { checkDedupLock } from "./dedup_lock.js";
|
|
32
33
|
/** Slice the merged settings down to what the push flow consumes. */
|
|
33
34
|
function toPushSettings(settings) {
|
|
34
35
|
return {
|
|
@@ -114,6 +115,32 @@ export async function onStop(opts = {}) {
|
|
|
114
115
|
await logLine(`dry-run: would send finished (auto-push off, source: ${source}, settings: ${resolved.source})`);
|
|
115
116
|
return { mode: "dry-run-send", settingsSource: resolved.source, repo };
|
|
116
117
|
}
|
|
118
|
+
// 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.
|
|
120
|
+
// If another source (e.g. the Cursor `stop` hook vs this Claude `Stop` hook)
|
|
121
|
+
// already claimed the bucket within the window, skip OUR local send — the
|
|
122
|
+
// first arrival's notification stands. Best-effort + fail-open: the check
|
|
123
|
+
// never throws, and on any doubt it returns "send" (the server is the
|
|
124
|
+
// authoritative coalescer — guardrail §3). Scoped to the plain-send path so
|
|
125
|
+
// the auto-push git flow above is never skipped.
|
|
126
|
+
const dedupCheck = opts.dedupCheck ?? checkDedupLock;
|
|
127
|
+
let decision = "send";
|
|
128
|
+
try {
|
|
129
|
+
decision = await dedupCheck({
|
|
130
|
+
repoKey: repo?.repo_key,
|
|
131
|
+
kind: "finished",
|
|
132
|
+
source,
|
|
133
|
+
windowMs: opts.dedupWindowMs,
|
|
134
|
+
home: opts.home,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
decision = "send"; // defence in depth — never let dedup block the send
|
|
139
|
+
}
|
|
140
|
+
if (decision === "suppress") {
|
|
141
|
+
await logLine(`dedup fast-path → suppressed duplicate finished (source: ${source}, settings: ${resolved.source})`);
|
|
142
|
+
return { mode: "deduped", settingsSource: resolved.source, repo };
|
|
143
|
+
}
|
|
117
144
|
const payload = { kind: "finished", source };
|
|
118
145
|
if (repo?.repo_label)
|
|
119
146
|
payload.project = repo.repo_label;
|
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",
|
|
@@ -50,6 +50,10 @@ function buildBody(payload) {
|
|
|
50
50
|
if (typeof v === "string" && v !== "")
|
|
51
51
|
body[field] = v;
|
|
52
52
|
}
|
|
53
|
+
// Boolean field — only included when explicitly true so a normal send keeps
|
|
54
|
+
// its minimal body (and the server's `autopilot` default of false applies).
|
|
55
|
+
if (payload.autopilot === true)
|
|
56
|
+
body.autopilot = true;
|
|
53
57
|
return body;
|
|
54
58
|
}
|
|
55
59
|
/**
|
|
@@ -99,7 +103,7 @@ export async function send(payload, opts = {}) {
|
|
|
99
103
|
if (!creds) {
|
|
100
104
|
return failure("not paired — run `gocode-notify login` first", opts);
|
|
101
105
|
}
|
|
102
|
-
const server = normalizeServer(opts.server ?? creds.server ??
|
|
106
|
+
const server = normalizeServer(opts.server ?? creds.server ?? builtinDefaultServer());
|
|
103
107
|
const url = `${server}/api/v1/notify/send`;
|
|
104
108
|
const controller = new AbortController();
|
|
105
109
|
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/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Single source of truth for the CLI version. Keep in sync with package.json.
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.2.0";
|
package/package.json
CHANGED