@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `gocode-notify doctor` — self-diagnostic checklist (PRD §8.6 R10 / T-COV4).
|
|
2
|
+
//
|
|
3
|
+
// Prints a human-readable checklist of the installation health so users know
|
|
4
|
+
// immediately what is missing and how to fix it. Checks (in order):
|
|
5
|
+
//
|
|
6
|
+
// 1. Paired? credentials exist and are valid.
|
|
7
|
+
// 2. Server reachable? the bound (or default) server answers.
|
|
8
|
+
// 3. Runner hooks installed? Claude Code Stop/Notification + Cursor stop.
|
|
9
|
+
// 4. `gocode-notify` on PATH? resolvable in non-login shells.
|
|
10
|
+
//
|
|
11
|
+
// Every ✗ check prints the EXACT fix command so users never have to guess.
|
|
12
|
+
// Always exits 0 — it is a report, not a gate.
|
|
13
|
+
//
|
|
14
|
+
// The gather / format split mirrors `status.ts`:
|
|
15
|
+
// `gatherDoctor` → pure async data collection (injectable deps, fully testable)
|
|
16
|
+
// `formatDoctor` → pure sync rendering to string lines
|
|
17
|
+
// `cmdDoctor` → thin CLI wire-up in `cli.ts`
|
|
18
|
+
//
|
|
19
|
+
// Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
|
|
20
|
+
import { promises as fs } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { readCredentials, resolveServerUrl } from "./creds.js";
|
|
23
|
+
import { probeServer, DEFAULT_PROBE_TIMEOUT_MS, UNPAIRED_WARNING } from "./status.js";
|
|
24
|
+
// Re-export UNPAIRED_WARNING so existing `from "./doctor.js"` imports keep
|
|
25
|
+
// working after the constant moved to status.ts (T-COV1).
|
|
26
|
+
export { UNPAIRED_WARNING } from "./status.js";
|
|
27
|
+
// ─── Hook markers ──────────────────────────────────────────────────────────
|
|
28
|
+
// We detect our hook entries by looking for these stable tokens in the command
|
|
29
|
+
// strings. The same tokens are used by claude.ts / cursor.ts for idempotent
|
|
30
|
+
// install / uninstall (kept in sync via the HOOK_SOURCE comments there).
|
|
31
|
+
/** Tokens that identify a gocode-notify Claude Code hook in settings.json. */
|
|
32
|
+
const CLAUDE_HOOK_TOKEN = "--source claude_code";
|
|
33
|
+
/** Token that identifies our Cursor stop hook in hooks.json. */
|
|
34
|
+
const CURSOR_HOOK_TOKEN = "--source cursor";
|
|
35
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
36
|
+
function resolveHomeDir(opts) {
|
|
37
|
+
return opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? "~";
|
|
38
|
+
}
|
|
39
|
+
/** Read a JSON file; returns null on any error (absent, bad JSON, bad perms). */
|
|
40
|
+
async function readJsonFile(p) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await fs.readFile(p, "utf8");
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** True when `cmd` appears on any entry in `pathEnv` (colon-separated). */
|
|
50
|
+
async function commandOnPath(cmd, pathEnv) {
|
|
51
|
+
if (!pathEnv)
|
|
52
|
+
return false;
|
|
53
|
+
const names = process.platform === "win32" ? [cmd, `${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd];
|
|
54
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
55
|
+
if (!dir)
|
|
56
|
+
continue;
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.stat(path.join(dir, name));
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// not in this dir — keep looking
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Recursively extract all `command` strings from a Claude/Cursor hooks value.
|
|
71
|
+
*
|
|
72
|
+
* Claude settings.json hook events have this shape:
|
|
73
|
+
* hooks.Stop = [ { matcher: string, hooks: [ { type: "command", command: string } ] } ]
|
|
74
|
+
*
|
|
75
|
+
* Cursor hooks.json stop entries have this shape:
|
|
76
|
+
* stop = [ { command: string } ]
|
|
77
|
+
*
|
|
78
|
+
* This walks the structure recursively so both formats are handled without
|
|
79
|
+
* needing a per-runtime parser.
|
|
80
|
+
*/
|
|
81
|
+
function extractCommands(value) {
|
|
82
|
+
if (!value)
|
|
83
|
+
return [];
|
|
84
|
+
if (typeof value === "string")
|
|
85
|
+
return [value];
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
const cmds = [];
|
|
88
|
+
for (const item of value)
|
|
89
|
+
cmds.push(...extractCommands(item));
|
|
90
|
+
return cmds;
|
|
91
|
+
}
|
|
92
|
+
if (typeof value === "object") {
|
|
93
|
+
const obj = value;
|
|
94
|
+
const cmds = [];
|
|
95
|
+
// If this object has a `command` string field, it's a hook entry.
|
|
96
|
+
if (typeof obj.command === "string")
|
|
97
|
+
cmds.push(obj.command);
|
|
98
|
+
// Recurse into `hooks` sub-array (Claude Code's two-level nesting).
|
|
99
|
+
if (obj.hooks)
|
|
100
|
+
cmds.push(...extractCommands(obj.hooks));
|
|
101
|
+
return cmds;
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
/** Extract all hook command strings from a hooks event value (array or object). */
|
|
106
|
+
function flattenHookCommands(hooksValue) {
|
|
107
|
+
return extractCommands(hooksValue);
|
|
108
|
+
}
|
|
109
|
+
// ─── Core gather ────────────────────────────────────────────────────────────
|
|
110
|
+
/**
|
|
111
|
+
* Collect the full {@link DoctorReport}. Never throws — errors are surfaced as
|
|
112
|
+
* failing checks with actionable fix commands.
|
|
113
|
+
*/
|
|
114
|
+
export async function gatherDoctor(opts = {}) {
|
|
115
|
+
const checks = [];
|
|
116
|
+
const home = resolveHomeDir(opts);
|
|
117
|
+
// ── 1. Paired? ─────────────────────────────────────────────────────────
|
|
118
|
+
let pairedOk = false;
|
|
119
|
+
let pairedDetail = "not paired";
|
|
120
|
+
try {
|
|
121
|
+
const creds = await readCredentials({ home: opts.home });
|
|
122
|
+
if (creds) {
|
|
123
|
+
pairedOk = true;
|
|
124
|
+
pairedDetail = `paired as ${creds.user_id} (${creds.label})`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
pairedDetail = "credentials file unreadable";
|
|
129
|
+
}
|
|
130
|
+
checks.push({
|
|
131
|
+
key: "paired",
|
|
132
|
+
label: "Paired?",
|
|
133
|
+
ok: pairedOk,
|
|
134
|
+
detail: pairedDetail,
|
|
135
|
+
fixCmd: pairedOk ? undefined : "gocode-notify login",
|
|
136
|
+
});
|
|
137
|
+
// ── 2. Server reachable? ───────────────────────────────────────────────
|
|
138
|
+
const serverUrl = await resolveServerUrl(opts.serverFlag, { home: opts.home });
|
|
139
|
+
const probe = await probeServer(serverUrl, {
|
|
140
|
+
fetchImpl: opts.fetchImpl,
|
|
141
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS,
|
|
142
|
+
});
|
|
143
|
+
checks.push({
|
|
144
|
+
key: "server",
|
|
145
|
+
label: "Server reachable?",
|
|
146
|
+
ok: probe.reachable,
|
|
147
|
+
detail: `${serverUrl} (${probe.detail})`,
|
|
148
|
+
fixCmd: probe.reachable
|
|
149
|
+
? undefined
|
|
150
|
+
: "# Check your network or run: gocode-notify login --server <url>",
|
|
151
|
+
});
|
|
152
|
+
// ── 3a. Claude Code Stop hook ──────────────────────────────────────────
|
|
153
|
+
const claudeSettingsPath = path.join(home, ".claude", "settings.json");
|
|
154
|
+
const claudeSettings = await readJsonFile(claudeSettingsPath);
|
|
155
|
+
const claudeHooks = claudeSettings &&
|
|
156
|
+
typeof claudeSettings === "object" &&
|
|
157
|
+
"hooks" in claudeSettings
|
|
158
|
+
? claudeSettings.hooks
|
|
159
|
+
: null;
|
|
160
|
+
const claudeStopCommands = flattenHookCommands(claudeHooks &&
|
|
161
|
+
typeof claudeHooks === "object" &&
|
|
162
|
+
"Stop" in claudeHooks
|
|
163
|
+
? claudeHooks.Stop
|
|
164
|
+
: null);
|
|
165
|
+
const claudeStopInstalled = claudeStopCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
166
|
+
checks.push({
|
|
167
|
+
key: "hook:claude-stop",
|
|
168
|
+
label: "Claude Code Stop hook installed?",
|
|
169
|
+
ok: claudeStopInstalled,
|
|
170
|
+
detail: claudeStopInstalled
|
|
171
|
+
? `found in ${claudeSettingsPath}`
|
|
172
|
+
: claudeHooks === null
|
|
173
|
+
? "~/.claude/settings.json not found or no hooks"
|
|
174
|
+
: "hook not found",
|
|
175
|
+
fixCmd: claudeStopInstalled ? undefined : "gocode-notify setup",
|
|
176
|
+
});
|
|
177
|
+
// ── 3b. Claude Code Notification hook ──────────────────────────────────
|
|
178
|
+
const claudeNotifyCommands = flattenHookCommands(claudeHooks &&
|
|
179
|
+
typeof claudeHooks === "object" &&
|
|
180
|
+
"Notification" in claudeHooks
|
|
181
|
+
? claudeHooks.Notification
|
|
182
|
+
: null);
|
|
183
|
+
const claudeNotifyInstalled = claudeNotifyCommands.some((c) => c.includes(CLAUDE_HOOK_TOKEN));
|
|
184
|
+
checks.push({
|
|
185
|
+
key: "hook:claude-notification",
|
|
186
|
+
label: "Claude Code Notification hook installed?",
|
|
187
|
+
ok: claudeNotifyInstalled,
|
|
188
|
+
detail: claudeNotifyInstalled
|
|
189
|
+
? `found in ${claudeSettingsPath}`
|
|
190
|
+
: "hook not found",
|
|
191
|
+
fixCmd: claudeNotifyInstalled ? undefined : "gocode-notify setup",
|
|
192
|
+
});
|
|
193
|
+
// ── 3c. Cursor stop hook ───────────────────────────────────────────────
|
|
194
|
+
const cursorHooksPath = path.join(home, ".cursor", "hooks.json");
|
|
195
|
+
const cursorHooks = await readJsonFile(cursorHooksPath);
|
|
196
|
+
// Pass the stop array directly (not wrapped in an object) so extractCommands
|
|
197
|
+
// can find the {command: ...} entries inside it.
|
|
198
|
+
const cursorStopValue = cursorHooks &&
|
|
199
|
+
typeof cursorHooks === "object" &&
|
|
200
|
+
"stop" in cursorHooks
|
|
201
|
+
? cursorHooks.stop
|
|
202
|
+
: null;
|
|
203
|
+
const cursorStopEntries = flattenHookCommands(cursorStopValue);
|
|
204
|
+
const cursorStopInstalled = cursorStopEntries.some((c) => c.includes(CURSOR_HOOK_TOKEN));
|
|
205
|
+
checks.push({
|
|
206
|
+
key: "hook:cursor-stop",
|
|
207
|
+
label: "Cursor stop hook installed?",
|
|
208
|
+
ok: cursorStopInstalled,
|
|
209
|
+
detail: cursorStopInstalled
|
|
210
|
+
? `found in ${cursorHooksPath}`
|
|
211
|
+
: cursorHooks === null
|
|
212
|
+
? "~/.cursor/hooks.json not found"
|
|
213
|
+
: "hook not found",
|
|
214
|
+
fixCmd: cursorStopInstalled ? undefined : "gocode-notify setup",
|
|
215
|
+
});
|
|
216
|
+
// ── 4. gocode-notify on PATH? ──────────────────────────────────────────
|
|
217
|
+
const pathEnv = opts.pathEnv ?? process.env.PATH;
|
|
218
|
+
const onPath = await commandOnPath("gocode-notify", pathEnv);
|
|
219
|
+
checks.push({
|
|
220
|
+
key: "path",
|
|
221
|
+
label: "gocode-notify on PATH (non-login shells)?",
|
|
222
|
+
ok: onPath,
|
|
223
|
+
detail: onPath
|
|
224
|
+
? "gocode-notify is resolvable"
|
|
225
|
+
: "not found on PATH — hooks may fail in non-login shells",
|
|
226
|
+
fixCmd: onPath
|
|
227
|
+
? undefined
|
|
228
|
+
: "# Add npm global bin to PATH, e.g.:\n echo 'export PATH=\"$(npm root -g)/../.bin:$PATH\"' >> ~/.bashrc",
|
|
229
|
+
});
|
|
230
|
+
return { checks };
|
|
231
|
+
}
|
|
232
|
+
// ─── Formatting ─────────────────────────────────────────────────────────────
|
|
233
|
+
function mark(ok) {
|
|
234
|
+
return ok ? "✓" : "✗";
|
|
235
|
+
}
|
|
236
|
+
// UNPAIRED_WARNING is defined in status.ts and re-exported above (T-COV1).
|
|
237
|
+
/**
|
|
238
|
+
* Render a {@link DoctorReport} as human-readable lines. Each failing check
|
|
239
|
+
* includes the exact fix command. Always friendly — never emits an error tone
|
|
240
|
+
* for passing checks.
|
|
241
|
+
*/
|
|
242
|
+
export function formatDoctor(report) {
|
|
243
|
+
const lines = ["gocode-notify doctor", ""];
|
|
244
|
+
for (const check of report.checks) {
|
|
245
|
+
lines.push(`${mark(check.ok)} ${check.label}`);
|
|
246
|
+
lines.push(` ${check.detail}`);
|
|
247
|
+
if (!check.ok && check.fixCmd) {
|
|
248
|
+
lines.push(` Fix: ${check.fixCmd}`);
|
|
249
|
+
}
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
// Surface the unpaired warning loudly (T-COV1 requirement: "surface ⚠️ loop
|
|
253
|
+
// completion pushes will NOT reach your phone" when not paired).
|
|
254
|
+
const pairedCheck = report.checks.find((c) => c.key === "paired");
|
|
255
|
+
if (pairedCheck && !pairedCheck.ok) {
|
|
256
|
+
lines.push(UNPAIRED_WARNING);
|
|
257
|
+
lines.push("");
|
|
258
|
+
}
|
|
259
|
+
const allOk = report.checks.every((c) => c.ok);
|
|
260
|
+
lines.push(allOk ? "All checks passed." : "Some checks failed — see fix commands above.");
|
|
261
|
+
return lines;
|
|
262
|
+
}
|
|
@@ -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;
|