@sealant/mend 0.1.0 → 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 +26 -8
- package/dist/dashboard.js +571 -0
- package/dist/dotfiles.js +90 -0
- package/dist/main.js +1177 -92
- package/dist/review-model.js +212 -0
- package/dist/review-workflow.js +15 -0
- package/dist/review.js +559 -0
- package/dist/service-init.js +227 -0
- package/dist/shared.js +50 -0
- package/dist/terminal.js +8 -0
- package/dist/tui-theme.js +25 -0
- package/package.json +10 -3
package/dist/main.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
3
|
import * as fs from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import * as net from "node:net";
|
|
3
6
|
import * as os from "node:os";
|
|
4
7
|
import * as path from "node:path";
|
|
5
|
-
|
|
8
|
+
import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
|
|
9
|
+
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
10
|
+
import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
|
|
11
|
+
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
12
|
+
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
13
|
+
const mendCliHome = () => {
|
|
14
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
15
|
+
const preferred = path.join(xdg === undefined || xdg === "" ? path.join(os.homedir(), ".config") : xdg, "mend");
|
|
16
|
+
const legacy = path.join(os.homedir(), ".mend");
|
|
17
|
+
return !fs.existsSync(preferred) && fs.existsSync(legacy) ? legacy : preferred;
|
|
18
|
+
};
|
|
19
|
+
const CONFIG_PATH = path.join(mendCliHome(), "cli.json");
|
|
6
20
|
const loadConfig = () => {
|
|
7
21
|
let fileConfig = {};
|
|
8
22
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
@@ -28,7 +42,41 @@ const green = paint("32");
|
|
|
28
42
|
const amber = paint("33");
|
|
29
43
|
const cobalt = paint("34");
|
|
30
44
|
const say = (line) => process.stdout.write(`${line}\n`);
|
|
31
|
-
const
|
|
45
|
+
const detachKeyEnabled = process.env["MEND_DETACH_KEY"] !== "none";
|
|
46
|
+
const detachHint = () => (detachKeyEnabled ? ` · detach: ${dim("Ctrl+]")}` : "");
|
|
47
|
+
const herdrAgentOf = (harness) => {
|
|
48
|
+
if (harness === "codex" || harness === "claude" || harness === "opencode")
|
|
49
|
+
return harness;
|
|
50
|
+
return null;
|
|
51
|
+
};
|
|
52
|
+
const hintHerdrAttachment = (harness) => {
|
|
53
|
+
const agent = herdrAgentOf(harness);
|
|
54
|
+
if (process.env["HERDR_ENV"] !== "1" || agent === null)
|
|
55
|
+
return () => undefined;
|
|
56
|
+
// Herdr deliberately reads HERDR_AGENT from any member of the foreground
|
|
57
|
+
// process group. A pipe-tethered child carries the hint while Mend owns the
|
|
58
|
+
// pane, without claiming lifecycle authority: Herdr's native screen rules
|
|
59
|
+
// still derive working, idle, and blocked from the bridged agent UI.
|
|
60
|
+
const carrier = spawn(process.execPath, ["-e", "process.stdin.resume();process.stdin.once('end',()=>process.exit(0))"], {
|
|
61
|
+
env: { ...process.env, HERDR_AGENT: agent },
|
|
62
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
63
|
+
});
|
|
64
|
+
let stopped = false;
|
|
65
|
+
const stop = () => {
|
|
66
|
+
if (stopped)
|
|
67
|
+
return;
|
|
68
|
+
stopped = true;
|
|
69
|
+
carrier.stdin?.end();
|
|
70
|
+
carrier.kill();
|
|
71
|
+
};
|
|
72
|
+
process.once("exit", stop);
|
|
73
|
+
return () => {
|
|
74
|
+
process.off("exit", stop);
|
|
75
|
+
stop();
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/** The raw server call — THROWS with a human message; the dashboard renders it. */
|
|
79
|
+
const request = async (config, method, route, body) => {
|
|
32
80
|
const headers = { "content-type": "application/json" };
|
|
33
81
|
if (config.token !== null)
|
|
34
82
|
headers["authorization"] = `Bearer ${config.token}`;
|
|
@@ -42,23 +90,34 @@ const api = async (config, method, route, body) => {
|
|
|
42
90
|
});
|
|
43
91
|
}
|
|
44
92
|
catch {
|
|
45
|
-
|
|
93
|
+
throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
|
|
46
94
|
}
|
|
47
95
|
if (response.status === 401) {
|
|
48
|
-
|
|
96
|
+
throw new Error(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
|
|
49
97
|
}
|
|
50
98
|
const text = await response.text();
|
|
51
99
|
if (!response.ok) {
|
|
100
|
+
let message = `${method} ${route} → ${response.status}`;
|
|
52
101
|
try {
|
|
53
102
|
const parsed = JSON.parse(text);
|
|
54
|
-
|
|
103
|
+
message = parsed.message ?? message;
|
|
55
104
|
}
|
|
56
105
|
catch {
|
|
57
|
-
|
|
106
|
+
// not JSON — keep the status line
|
|
58
107
|
}
|
|
108
|
+
throw new Error(message);
|
|
59
109
|
}
|
|
60
110
|
return JSON.parse(text);
|
|
61
111
|
};
|
|
112
|
+
/** The same call for one-shot commands: any failure prints and exits. */
|
|
113
|
+
const api = async (config, method, route, body) => {
|
|
114
|
+
try {
|
|
115
|
+
return await request(config, method, route, body);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
62
121
|
const worktreePathOf = (storePath, worktree) => path.join(path.dirname(storePath), "worktrees", worktree);
|
|
63
122
|
/** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
|
|
64
123
|
const withSpinner = async (label, work) => {
|
|
@@ -84,20 +143,39 @@ const withSpinner = async (label, work) => {
|
|
|
84
143
|
const adopt = async (config, args) => {
|
|
85
144
|
const nameFlag = args.indexOf("--name");
|
|
86
145
|
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
87
|
-
const
|
|
88
|
-
const
|
|
146
|
+
const authFlagIndex = args.indexOf("--auth");
|
|
147
|
+
const positional = args.filter((a, i) => !a.startsWith("--") &&
|
|
148
|
+
(nameFlag === -1 || i !== nameFlag + 1) &&
|
|
149
|
+
(authFlagIndex === -1 || i !== authFlagIndex + 1));
|
|
150
|
+
// Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
|
|
151
|
+
const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
|
|
89
152
|
const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
|
|
90
|
-
|
|
153
|
+
// Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
|
|
154
|
+
const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
|
|
155
|
+
const auth = authFlagIndex !== -1 && args[authFlagIndex + 1] !== undefined
|
|
156
|
+
? String(args[authFlagIndex + 1])
|
|
157
|
+
: null;
|
|
158
|
+
if (auth !== null && auth !== "ambient" && auth !== "mend-key" && auth !== "bridge") {
|
|
159
|
+
return fail(`--auth takes "ambient", "mend-key", or "bridge", not "${auth}"`);
|
|
160
|
+
}
|
|
91
161
|
const project = await api(config, "POST", "/projects", {
|
|
92
162
|
name: projectName,
|
|
93
163
|
source,
|
|
164
|
+
...(auth === null ? {} : { gitAuthMode: auth }),
|
|
94
165
|
});
|
|
95
166
|
say(`${green("✓")} adopted · ${project.name} · ${dim(project.storePath)}`);
|
|
96
167
|
say(`${dim(" default branch")} ${project.defaultBranch}`);
|
|
168
|
+
// Say which signer did the work — the clone already proved it answers.
|
|
169
|
+
if (project.gitAuthMode === "mend-key") {
|
|
170
|
+
say(`${dim(" git auth")} mend key ${dim("(the machine's deploy key signed this clone)")}`);
|
|
171
|
+
}
|
|
172
|
+
else if (project.gitAuthMode === "bridge") {
|
|
173
|
+
say(`${dim(" git auth")} bridge ${dim("(signed through the connected `mend keys share`)")}`);
|
|
174
|
+
}
|
|
97
175
|
say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
|
|
98
176
|
};
|
|
99
177
|
// ─── session launch ─────────────────────────────────────────────────────────
|
|
100
|
-
const findProject = async (config, explicit) => {
|
|
178
|
+
const findProject = async (config, explicit, adoptCwd = false) => {
|
|
101
179
|
const projects = await api(config, "GET", "/projects");
|
|
102
180
|
if (explicit !== null) {
|
|
103
181
|
const named = projects.find((p) => p.name === explicit);
|
|
@@ -106,18 +184,24 @@ const findProject = async (config, explicit) => {
|
|
|
106
184
|
return named;
|
|
107
185
|
}
|
|
108
186
|
const cwd = process.cwd();
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
187
|
+
const project = matchProjectByCwd(projects, cwd);
|
|
188
|
+
if (project !== undefined)
|
|
189
|
+
return project;
|
|
190
|
+
if (!adoptCwd) {
|
|
113
191
|
return fail(`no adopted project matches ${cwd} — run "mend adopt" here first, or name one with --project`);
|
|
114
192
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
193
|
+
// Launching from an un-adopted repo just adopts it — adoption is cheap and
|
|
194
|
+
// has no ceremony to deserve a separate errand.
|
|
195
|
+
const top = gitTopLevel(cwd);
|
|
196
|
+
if (top === null) {
|
|
197
|
+
return fail(`${cwd} is not inside a git repository — mend adopt <source> adopts one explicitly`);
|
|
198
|
+
}
|
|
199
|
+
const adopted = await api(config, "POST", "/projects", {
|
|
200
|
+
name: normalizeProjectName(path.basename(top)),
|
|
201
|
+
source: top,
|
|
202
|
+
});
|
|
203
|
+
say(`${green("✓")} adopted · ${adopted.name} · ${dim(adopted.storePath)}`);
|
|
204
|
+
return adopted;
|
|
121
205
|
};
|
|
122
206
|
const launch = async (config, harness, args) => {
|
|
123
207
|
const projectFlag = args.indexOf("--project");
|
|
@@ -130,7 +214,7 @@ const launch = async (config, harness, args) => {
|
|
|
130
214
|
if (argv.length === 0) {
|
|
131
215
|
return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
|
|
132
216
|
}
|
|
133
|
-
const project = await findProject(config, explicit);
|
|
217
|
+
const project = await findProject(config, explicit, true);
|
|
134
218
|
const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
|
|
135
219
|
harness,
|
|
136
220
|
label: null,
|
|
@@ -146,14 +230,10 @@ const launch = async (config, harness, args) => {
|
|
|
146
230
|
return supervisedRun(config, session, argv);
|
|
147
231
|
}
|
|
148
232
|
await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
|
|
149
|
-
say(`${green("✓ recording")} · workspace mounts the worktree
|
|
233
|
+
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
150
234
|
say("");
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
say("");
|
|
154
|
-
say(`${green("✓")} session ${settled.session.status} · recorded`);
|
|
155
|
-
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
156
|
-
process.exit(0);
|
|
235
|
+
await attachOrExit(config, session.id, session.harness);
|
|
236
|
+
exitAfterSessionEnd(config, session.id);
|
|
157
237
|
};
|
|
158
238
|
// ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
|
|
159
239
|
/**
|
|
@@ -162,12 +242,19 @@ const launch = async (config, harness, args) => {
|
|
|
162
242
|
* JSON (resize up, end down). Auth happens once at connect (?token=); after
|
|
163
243
|
* that a keystroke is a frame on an open socket — nothing else on the path.
|
|
164
244
|
* Ctrl+] detaches — the session keeps running and can be reattached from
|
|
165
|
-
* anywhere. Resolves when the session settles or the user detaches
|
|
245
|
+
* anywhere. Resolves when the session settles or the user detaches; the
|
|
246
|
+
* caller decides what each outcome means (commands exit, the dashboard
|
|
247
|
+
* resumes).
|
|
166
248
|
*/
|
|
167
|
-
const attachTty = async (config, sessionId, from) => {
|
|
249
|
+
const attachTty = async (config, sessionId, harness, from, processId, options) => {
|
|
168
250
|
const url = new URL(`${config.url}/api/tty`);
|
|
169
251
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
170
|
-
|
|
252
|
+
// Process addressing reaches any PTY in the workspace (a shell); the
|
|
253
|
+
// session form remains the agent's PTY.
|
|
254
|
+
if (processId !== undefined)
|
|
255
|
+
url.searchParams.set("process", processId);
|
|
256
|
+
else
|
|
257
|
+
url.searchParams.set("session", sessionId);
|
|
171
258
|
url.searchParams.set("from", from.toString());
|
|
172
259
|
if (config.token !== null)
|
|
173
260
|
url.searchParams.set("token", config.token);
|
|
@@ -180,51 +267,112 @@ const attachTty = async (config, sessionId, from) => {
|
|
|
180
267
|
});
|
|
181
268
|
}
|
|
182
269
|
catch {
|
|
183
|
-
return
|
|
270
|
+
return "unavailable";
|
|
184
271
|
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
})
|
|
190
|
-
|
|
272
|
+
const stopHerdrHint = hintHerdrAttachment(harness);
|
|
273
|
+
let finishAttachment;
|
|
274
|
+
const finished = new Promise((resolve) => {
|
|
275
|
+
finishAttachment = resolve;
|
|
276
|
+
});
|
|
277
|
+
const onClose = () => finishAttachment?.();
|
|
278
|
+
if (ws.readyState === WebSocket.CLOSED)
|
|
279
|
+
finishAttachment?.();
|
|
280
|
+
else
|
|
281
|
+
ws.addEventListener("close", onClose, { once: true });
|
|
282
|
+
const sendResize = () => {
|
|
283
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
284
|
+
return;
|
|
285
|
+
ws.send(JSON.stringify({
|
|
286
|
+
t: "resize",
|
|
287
|
+
cols: process.stdout.columns ?? 80,
|
|
288
|
+
rows: process.stdout.rows ?? 24,
|
|
289
|
+
}));
|
|
290
|
+
};
|
|
191
291
|
const onWinch = () => sendResize();
|
|
192
|
-
process.stdout.on("resize", onWinch);
|
|
193
292
|
const rawTty = process.stdin.isTTY === true;
|
|
194
|
-
|
|
195
|
-
process.stdin.setRawMode(true);
|
|
196
|
-
process.stdin.resume();
|
|
293
|
+
let rawModeEnabled = false;
|
|
197
294
|
let detached = false;
|
|
295
|
+
const onTtyFrame = (event) => {
|
|
296
|
+
if (typeof event.data !== "string") {
|
|
297
|
+
process.stdout.write(Buffer.from(event.data));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
const frame = JSON.parse(event.data);
|
|
302
|
+
if (typeof frame !== "object" || frame === null || !("t" in frame) || frame.t !== "end") {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Session lifecycle is authoritative. Start detaching immediately instead
|
|
306
|
+
// of holding the user's terminal for the later close handshake and Mend's
|
|
307
|
+
// settle/checkpoint/harvest work.
|
|
308
|
+
ws.close();
|
|
309
|
+
finishAttachment?.();
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
// Unknown text control frame — ignore.
|
|
313
|
+
}
|
|
314
|
+
};
|
|
198
315
|
const onKeys = (data) => {
|
|
199
|
-
if (data.includes(0x1d)) {
|
|
316
|
+
if (detachKeyEnabled && data.includes(0x1d)) {
|
|
200
317
|
// Ctrl+] — detach, leave the session running.
|
|
201
318
|
detached = true;
|
|
202
319
|
ws.close();
|
|
203
320
|
return;
|
|
204
321
|
}
|
|
322
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
323
|
+
return;
|
|
205
324
|
// Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
|
|
206
325
|
ws.send(new Uint8Array(data).buffer);
|
|
207
326
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
process.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
327
|
+
try {
|
|
328
|
+
sendResize();
|
|
329
|
+
process.stdout.on("resize", onWinch);
|
|
330
|
+
// Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
|
|
331
|
+
// socket drops, and the process inside keeps running untouched.
|
|
332
|
+
if (options?.readOnly !== true) {
|
|
333
|
+
if (rawTty) {
|
|
334
|
+
process.stdin.setRawMode(true);
|
|
335
|
+
rawModeEnabled = true;
|
|
336
|
+
}
|
|
337
|
+
process.stdin.resume();
|
|
338
|
+
process.stdin.on("data", onKeys);
|
|
339
|
+
}
|
|
340
|
+
ws.addEventListener("message", onTtyFrame);
|
|
341
|
+
await finished;
|
|
342
|
+
return detached ? "detached" : "ended";
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
process.stdin.off("data", onKeys);
|
|
346
|
+
process.stdout.off("resize", onWinch);
|
|
347
|
+
ws.removeEventListener("message", onTtyFrame);
|
|
348
|
+
ws.removeEventListener("close", onClose);
|
|
349
|
+
if (rawModeEnabled)
|
|
350
|
+
process.stdin.setRawMode(false);
|
|
351
|
+
process.stdin.pause();
|
|
352
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
|
|
353
|
+
ws.close();
|
|
354
|
+
stopHerdrHint();
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
/** How every one-shot command handles an attach outcome: detach says so, settle returns. */
|
|
358
|
+
const finishAttach = (config, sessionId, outcome) => {
|
|
359
|
+
if (outcome === "unavailable") {
|
|
360
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
361
|
+
}
|
|
362
|
+
if (outcome === "detached") {
|
|
223
363
|
say("");
|
|
224
364
|
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${sessionId.slice(0, 8)}`);
|
|
225
365
|
process.exit(0);
|
|
226
366
|
}
|
|
227
367
|
};
|
|
368
|
+
const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
|
|
369
|
+
/** Return terminal control as soon as the terminal reports the observed end. */
|
|
370
|
+
const exitAfterSessionEnd = (config, sessionId) => {
|
|
371
|
+
say("");
|
|
372
|
+
say(`${green("✓")} session ended`);
|
|
373
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
|
|
374
|
+
process.exit(0);
|
|
375
|
+
};
|
|
228
376
|
/** Reattach a terminal to a running session (full scrollback replay, then live). */
|
|
229
377
|
const attach = async (config, args) => {
|
|
230
378
|
const prefix = args.find((a) => !a.startsWith("--"));
|
|
@@ -234,13 +382,707 @@ const attach = async (config, args) => {
|
|
|
234
382
|
const match = sessions.find((s) => s.id.startsWith(prefix));
|
|
235
383
|
if (match === undefined)
|
|
236
384
|
return fail(`no active session matches "${prefix}"`);
|
|
237
|
-
say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}
|
|
385
|
+
say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
|
|
238
386
|
say("");
|
|
239
|
-
await
|
|
387
|
+
await attachOrExit(config, match.id, match.harness);
|
|
388
|
+
exitAfterSessionEnd(config, match.id);
|
|
389
|
+
};
|
|
390
|
+
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
391
|
+
const pickSessionInteractively = async (rows) => {
|
|
392
|
+
say(dim("more than one live session — pick one:"));
|
|
393
|
+
rows.forEach((row, index) => {
|
|
394
|
+
say(` ${index + 1}. ${row.session.harness.padEnd(8)} ${dim(row.session.id.slice(0, 8))} ${row.projectName} ${dim(row.session.branch)}`);
|
|
395
|
+
});
|
|
396
|
+
const readline = await import("node:readline/promises");
|
|
397
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
398
|
+
const answer = await rl.question(" session #: ");
|
|
399
|
+
rl.close();
|
|
400
|
+
const chosen = rows[Number(answer.trim()) - 1];
|
|
401
|
+
if (chosen === undefined)
|
|
402
|
+
return fail(`"${answer.trim()}" is not one of the choices`);
|
|
403
|
+
return chosen.session;
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* The resolution order (docs/SESSION-SERVICES.md): an explicit id wins, then
|
|
407
|
+
* the cwd's project narrows, one candidate is taken, several go to the
|
|
408
|
+
* picker, and a non-interactive caller never gets a silent guess.
|
|
409
|
+
*/
|
|
410
|
+
const resolveLiveSession = async (config, prefix) => {
|
|
411
|
+
const sessions = await api(config, "GET", "/sessions");
|
|
412
|
+
if (prefix !== undefined) {
|
|
413
|
+
const matches = sessions.filter((s) => s.id.startsWith(prefix));
|
|
414
|
+
if (matches.length === 0)
|
|
415
|
+
return fail(`no live session matches "${prefix}"`);
|
|
416
|
+
const exact = matches[0];
|
|
417
|
+
if (matches.length > 1 || exact === undefined) {
|
|
418
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
419
|
+
}
|
|
420
|
+
return exact;
|
|
421
|
+
}
|
|
422
|
+
const projects = await api(config, "GET", "/projects");
|
|
423
|
+
const project = matchProjectByCwd(projects, process.cwd());
|
|
424
|
+
const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
|
|
425
|
+
const only = candidates[0];
|
|
426
|
+
if (only === undefined) {
|
|
427
|
+
return fail("no live session — start one with mend codex|claude|opencode");
|
|
428
|
+
}
|
|
429
|
+
if (candidates.length === 1)
|
|
430
|
+
return only;
|
|
431
|
+
if (process.stdin.isTTY !== true) {
|
|
432
|
+
return fail("several live sessions — name one: mend shell <session-id-prefix>");
|
|
433
|
+
}
|
|
434
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
435
|
+
return pickSessionInteractively(candidates.map((session) => ({
|
|
436
|
+
session,
|
|
437
|
+
projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
|
|
438
|
+
})));
|
|
439
|
+
};
|
|
440
|
+
/**
|
|
441
|
+
* A real interactive shell in the session's CURRENT workspace — same
|
|
442
|
+
* /workspace/repo the agent is editing, same dependencies, same network. Not
|
|
443
|
+
* a shell in the host checkout. The shell holds a workspace lease, so the
|
|
444
|
+
* container survives the agent settling while the shell lives.
|
|
445
|
+
*/
|
|
446
|
+
const shellCommand = async (config, args) => {
|
|
447
|
+
const prefix = args.find((a) => !a.startsWith("--"));
|
|
448
|
+
const session = await resolveLiveSession(config, prefix);
|
|
449
|
+
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
450
|
+
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
451
|
+
say("");
|
|
452
|
+
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
|
|
453
|
+
if (outcome === "unavailable") {
|
|
454
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
455
|
+
}
|
|
240
456
|
say("");
|
|
241
|
-
|
|
457
|
+
if (outcome === "detached") {
|
|
458
|
+
say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
|
|
459
|
+
process.exit(0);
|
|
460
|
+
}
|
|
461
|
+
say(`${green("✓")} shell ended`);
|
|
242
462
|
process.exit(0);
|
|
243
463
|
};
|
|
464
|
+
const serviceUrl = (config, service) => {
|
|
465
|
+
const host = new URL(config.url).hostname || "localhost";
|
|
466
|
+
// A UDP Service has no page to open — the endpoint is the whole fact.
|
|
467
|
+
return service.protocol === "udp"
|
|
468
|
+
? `${host}:${service.hostPort ?? "?"} (udp)`
|
|
469
|
+
: `http://${host}:${service.hostPort ?? "?"}`;
|
|
470
|
+
};
|
|
471
|
+
const printService = (config, service) => {
|
|
472
|
+
const status = service.status === "reachable" ? green(service.status) : amber(service.status);
|
|
473
|
+
say(`${(service.label ?? service.id.slice(0, 8)).padEnd(12)} ${dim(`:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""} →`)} ${serviceUrl(config, service)} ${status} ${dim(service.id.slice(0, 8))}`);
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Adopt an already-listening workspace port as a Service: Mend binds a host
|
|
477
|
+
* port on its private interfaces and pumps every connection into the
|
|
478
|
+
* session's workspace. No supervision — reachability is the observation.
|
|
479
|
+
*/
|
|
480
|
+
const serviceAdd = async (config, args) => {
|
|
481
|
+
const nameFlag = args.indexOf("--name");
|
|
482
|
+
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
483
|
+
const protocol = args.includes("--udp") ? "udp" : "tcp";
|
|
484
|
+
const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
|
|
485
|
+
const portRaw = positional.find((a) => /^\d+$/.test(a));
|
|
486
|
+
if (portRaw === undefined) {
|
|
487
|
+
return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
|
|
488
|
+
}
|
|
489
|
+
const port = Number(portRaw);
|
|
490
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
491
|
+
return fail(`"${portRaw}" is not a port`);
|
|
492
|
+
}
|
|
493
|
+
const prefix = positional.find((a) => a !== portRaw);
|
|
494
|
+
const session = await resolveLiveSession(config, prefix);
|
|
495
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
496
|
+
port,
|
|
497
|
+
name,
|
|
498
|
+
protocol,
|
|
499
|
+
});
|
|
500
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
501
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
502
|
+
if (protocol === "udp") {
|
|
503
|
+
say(dim(` udp — a reply is the only reachability signal; silence just relays`));
|
|
504
|
+
}
|
|
505
|
+
else if (service.status !== "reachable") {
|
|
506
|
+
say(dim(` nothing answered on :${port} yet — the URL goes live when something listens`));
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const serviceList = async (config) => {
|
|
510
|
+
const services = await api(config, "GET", "/services");
|
|
511
|
+
if (services.length === 0) {
|
|
512
|
+
say(dim("no live services — mend service add <port> adopts a listening one"));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
for (const service of services)
|
|
516
|
+
printService(config, service);
|
|
517
|
+
};
|
|
518
|
+
const serviceStop = async (config, args) => {
|
|
519
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
520
|
+
if (needle === undefined)
|
|
521
|
+
return fail("usage: mend service stop <name-or-id-prefix>");
|
|
522
|
+
const services = await api(config, "GET", "/services");
|
|
523
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
524
|
+
if (matches.length === 0)
|
|
525
|
+
return fail(`no live service matches "${needle}"`);
|
|
526
|
+
const match = matches[0];
|
|
527
|
+
if (matches.length > 1 || match === undefined) {
|
|
528
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
529
|
+
}
|
|
530
|
+
const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
|
|
531
|
+
say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
|
|
532
|
+
};
|
|
533
|
+
const findLiveService = async (config, needle) => {
|
|
534
|
+
const services = await api(config, "GET", "/services");
|
|
535
|
+
const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
536
|
+
if (matches.length === 0)
|
|
537
|
+
return fail(`no live service matches "${needle}"`);
|
|
538
|
+
const match = matches[0];
|
|
539
|
+
if (matches.length > 1 || match === undefined) {
|
|
540
|
+
return fail(`"${needle}" is ambiguous — use more of the id`);
|
|
541
|
+
}
|
|
542
|
+
return match;
|
|
543
|
+
};
|
|
544
|
+
/**
|
|
545
|
+
* Start and supervise a Service: the command runs as its own PTY process in
|
|
546
|
+
* the session's workspace (own record = its logs), Mend waits for the
|
|
547
|
+
* declared port to answer, then exposes it like any Service. The command
|
|
548
|
+
* never occupies the agent's terminal or a tool call.
|
|
549
|
+
*/
|
|
550
|
+
const serviceRun = async (config, args) => {
|
|
551
|
+
const dashdash = args.indexOf("--");
|
|
552
|
+
const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
|
|
553
|
+
" mend service run [session] <name> (a declared recipe)";
|
|
554
|
+
// No explicit command = a DECLARED Service: resolve the name against the
|
|
555
|
+
// session worktree's mend.toml and start (or adopt) its recipe.
|
|
556
|
+
if (dashdash === -1) {
|
|
557
|
+
const positionals = args.filter((a) => !a.startsWith("--"));
|
|
558
|
+
const name = positionals.at(-1);
|
|
559
|
+
if (name === undefined)
|
|
560
|
+
return fail(usage);
|
|
561
|
+
const prefix = positionals.length > 1 ? positionals[0] : undefined;
|
|
562
|
+
const session = await resolveLiveSession(config, prefix);
|
|
563
|
+
const recipes = await api(config, "GET", `/sessions/${session.id}/recipes`);
|
|
564
|
+
const recipe = recipes.find((entry) => entry.name === name);
|
|
565
|
+
if (recipe === undefined) {
|
|
566
|
+
const known = recipes.map((entry) => entry.name).join(", ");
|
|
567
|
+
return fail(recipes.length === 0
|
|
568
|
+
? `no mend.toml recipes in this worktree — declare [service.${name}] first`
|
|
569
|
+
: `no recipe named "${name}" — declared: ${known}`);
|
|
570
|
+
}
|
|
571
|
+
if (recipe.command === null) {
|
|
572
|
+
// A port-only recipe is an adopt: something else starts the listener.
|
|
573
|
+
const service = await api(config, "POST", `/sessions/${session.id}/services`, {
|
|
574
|
+
port: recipe.port,
|
|
575
|
+
name: recipe.name,
|
|
576
|
+
protocol: recipe.protocol,
|
|
577
|
+
});
|
|
578
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
579
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const service = await withSpinner(recipe.protocol === "udp"
|
|
583
|
+
? `starting ${recipe.name} (udp :${recipe.port})…`
|
|
584
|
+
: `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
585
|
+
argv: ["sh", "-c", recipe.command],
|
|
586
|
+
port: recipe.port,
|
|
587
|
+
name: recipe.name,
|
|
588
|
+
protocol: recipe.protocol,
|
|
589
|
+
}));
|
|
590
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
591
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
592
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const argv = args.slice(dashdash + 1);
|
|
596
|
+
if (argv.length === 0)
|
|
597
|
+
return fail(usage);
|
|
598
|
+
const head = args.slice(0, dashdash);
|
|
599
|
+
const portFlag = head.indexOf("--port");
|
|
600
|
+
const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
|
|
601
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
602
|
+
return fail(usage);
|
|
603
|
+
const nameFlag = head.indexOf("--name");
|
|
604
|
+
const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
|
|
605
|
+
const protocol = head.includes("--udp") ? "udp" : "tcp";
|
|
606
|
+
const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
|
|
607
|
+
const session = await resolveLiveSession(config, prefix);
|
|
608
|
+
const service = await withSpinner(protocol === "udp"
|
|
609
|
+
? `starting ${name ?? argv[0]} (udp :${port})…`
|
|
610
|
+
: `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
|
|
611
|
+
argv,
|
|
612
|
+
port,
|
|
613
|
+
name,
|
|
614
|
+
protocol,
|
|
615
|
+
}));
|
|
616
|
+
say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
|
|
617
|
+
say(` ${cobalt(serviceUrl(config, service))}`);
|
|
618
|
+
say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Watch a supervised Service's output: record replay, then live tail. A DEAD
|
|
622
|
+
* Service still answers — its record outlives the process — printed once
|
|
623
|
+
* instead of followed.
|
|
624
|
+
*/
|
|
625
|
+
const serviceLogs = async (config, args) => {
|
|
626
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
627
|
+
if (needle === undefined)
|
|
628
|
+
return fail("usage: mend service logs <name-or-id-prefix>");
|
|
629
|
+
const everything = await api(config, "GET", "/services?all=1");
|
|
630
|
+
const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
|
|
631
|
+
if (matches.length === 0)
|
|
632
|
+
return fail(`no service matches "${needle}"`);
|
|
633
|
+
// Prefer the live one; otherwise the newest ended one (list is newest-first).
|
|
634
|
+
const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
|
|
635
|
+
if (service === undefined)
|
|
636
|
+
return fail(`no service matches "${needle}"`);
|
|
637
|
+
if (service.sealantSessionId === null) {
|
|
638
|
+
return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
|
|
639
|
+
}
|
|
640
|
+
if (service.status === "exited" || service.status === "stopped") {
|
|
641
|
+
// Post-mortem: print the record, don't attach.
|
|
642
|
+
const output = await api(config, "GET", `/processes/${service.id}/output`);
|
|
643
|
+
say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
|
|
644
|
+
say("");
|
|
645
|
+
process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
|
|
646
|
+
process.exit(0);
|
|
647
|
+
}
|
|
648
|
+
say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
|
|
649
|
+
say("");
|
|
650
|
+
const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
|
|
651
|
+
readOnly: true,
|
|
652
|
+
});
|
|
653
|
+
if (outcome === "unavailable") {
|
|
654
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
655
|
+
}
|
|
656
|
+
say("");
|
|
657
|
+
say(dim("stream ended"));
|
|
658
|
+
process.exit(0);
|
|
659
|
+
};
|
|
660
|
+
const serviceRestart = async (config, args) => {
|
|
661
|
+
const needle = args.find((a) => !a.startsWith("--"));
|
|
662
|
+
if (needle === undefined)
|
|
663
|
+
return fail("usage: mend service restart <name-or-id-prefix>");
|
|
664
|
+
const service = await findLiveService(config, needle);
|
|
665
|
+
const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
|
|
666
|
+
say(`${green("✓")} restarted · ${restarted.status}`);
|
|
667
|
+
say(` ${cobalt(serviceUrl(config, restarted))}`);
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Scaffold mend.toml from the project's own manifests. Static suggestion,
|
|
671
|
+
* not detection: package.json scripts and compose port mappings become
|
|
672
|
+
* recipe proposals the user confirms and commits. Nothing runs.
|
|
673
|
+
*/
|
|
674
|
+
const serviceInit = async (args) => {
|
|
675
|
+
const root = gitTopLevel(process.cwd()) ?? process.cwd();
|
|
676
|
+
const target = path.join(root, "mend.toml");
|
|
677
|
+
if (fs.existsSync(target)) {
|
|
678
|
+
return fail(`${target} already exists — edit it directly (init never merges)`);
|
|
679
|
+
}
|
|
680
|
+
const rootFiles = fs.readdirSync(root);
|
|
681
|
+
const proposals = [];
|
|
682
|
+
const readRoot = (name) => fs.readFileSync(path.join(root, name), "utf8");
|
|
683
|
+
if (rootFiles.includes("package.json")) {
|
|
684
|
+
proposals.push(...proposeFromPackageJson(readRoot("package.json"), rootFiles));
|
|
685
|
+
}
|
|
686
|
+
// Monorepo sweep: every workspace package, named after its folder.
|
|
687
|
+
const globs = workspaceGlobs(rootFiles.includes("pnpm-workspace.yaml") ? readRoot("pnpm-workspace.yaml") : null, rootFiles.includes("package.json") ? readRoot("package.json") : null);
|
|
688
|
+
for (const glob of globs) {
|
|
689
|
+
const parent = glob.replace(/\/?\*+$/, "");
|
|
690
|
+
const dirs = glob.endsWith("*")
|
|
691
|
+
? fs.existsSync(path.join(root, parent))
|
|
692
|
+
? fs.readdirSync(path.join(root, parent)).map((dir) => path.join(parent, dir))
|
|
693
|
+
: []
|
|
694
|
+
: [glob];
|
|
695
|
+
for (const dir of dirs) {
|
|
696
|
+
const manifest = path.join(root, dir, "package.json");
|
|
697
|
+
if (!fs.existsSync(manifest))
|
|
698
|
+
continue;
|
|
699
|
+
for (const proposal of proposeFromWorkspacePackage(path.basename(dir), fs.readFileSync(manifest, "utf8"), rootFiles)) {
|
|
700
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
701
|
+
proposals.push(proposal);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
// Every compose flavor in the root, aggregated; non-default files need -f.
|
|
707
|
+
for (const composeName of rootFiles.filter(isComposeFile).toSorted()) {
|
|
708
|
+
const isDefault = composeName === "compose.yaml" || composeName === "docker-compose.yml";
|
|
709
|
+
for (const proposal of proposeFromCompose(readRoot(composeName))) {
|
|
710
|
+
if (!proposals.some((existing) => existing.name === proposal.name)) {
|
|
711
|
+
proposals.push({
|
|
712
|
+
...proposal,
|
|
713
|
+
command: isDefault
|
|
714
|
+
? proposal.command
|
|
715
|
+
: proposal.command.replace("docker compose ", `docker compose -f ${composeName} `),
|
|
716
|
+
source: `${composeName}: ${proposal.source}`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
if (proposals.length === 0) {
|
|
722
|
+
return fail("nothing to propose — no server-ish package.json script with a nameable port, no compose ports");
|
|
723
|
+
}
|
|
724
|
+
const toml = renderMendToml(proposals);
|
|
725
|
+
say(dim(`proposed ${target}:`));
|
|
726
|
+
say("");
|
|
727
|
+
process.stdout.write(toml);
|
|
728
|
+
say("");
|
|
729
|
+
if (!args.includes("--yes")) {
|
|
730
|
+
if (process.stdin.isTTY !== true) {
|
|
731
|
+
return fail("non-interactive — pass --yes to write the file");
|
|
732
|
+
}
|
|
733
|
+
const readline = await import("node:readline/promises");
|
|
734
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
735
|
+
const answer = await rl.question("write it? [y/N] ");
|
|
736
|
+
rl.close();
|
|
737
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
738
|
+
say(dim("nothing written"));
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
fs.writeFileSync(target, toml);
|
|
743
|
+
say(`${green("✓")} wrote ${target} — commit it, then: mend service run ${proposals[0]?.name}`);
|
|
744
|
+
};
|
|
745
|
+
const serviceCommand = async (config, args) => {
|
|
746
|
+
const [verb, ...rest] = args;
|
|
747
|
+
switch (verb) {
|
|
748
|
+
case "run":
|
|
749
|
+
return serviceRun(config, rest);
|
|
750
|
+
case "add":
|
|
751
|
+
return serviceAdd(config, rest);
|
|
752
|
+
case "init":
|
|
753
|
+
return serviceInit(rest);
|
|
754
|
+
case "list":
|
|
755
|
+
case undefined:
|
|
756
|
+
return serviceList(config);
|
|
757
|
+
case "logs":
|
|
758
|
+
return serviceLogs(config, rest);
|
|
759
|
+
case "restart":
|
|
760
|
+
return serviceRestart(config, rest);
|
|
761
|
+
case "stop":
|
|
762
|
+
return serviceStop(config, rest);
|
|
763
|
+
default:
|
|
764
|
+
// Sugar: `mend service mysql` reads as `mend service run mysql` — a
|
|
765
|
+
// bare word that isn't a verb is a recipe name. A miss still explains
|
|
766
|
+
// itself (declared recipes are listed in the failure).
|
|
767
|
+
return serviceRun(config, [verb, ...rest]);
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
// ─── keys: the machine's Mend deploy key (docs/GIT-ACCESS.md) ───────────────
|
|
771
|
+
/** Print the public key with the one instruction that makes it useful. */
|
|
772
|
+
const printGitKey = (key) => {
|
|
773
|
+
if (key.publicKey === null)
|
|
774
|
+
return;
|
|
775
|
+
say(key.publicKey);
|
|
776
|
+
if (key.fingerprint !== null)
|
|
777
|
+
say(dim(` ${key.fingerprint}`));
|
|
778
|
+
say(dim(" add this as a deploy key on your git host (GitHub/GitLab/Gitea: repo"));
|
|
779
|
+
say(dim(" settings → deploy keys; grant write access if sessions should push),"));
|
|
780
|
+
say(dim(" then adopt with: mend adopt <ssh-url> --auth mend-key"));
|
|
781
|
+
};
|
|
782
|
+
const keysShow = async (config) => {
|
|
783
|
+
const key = await api(config, "GET", "/keys/git");
|
|
784
|
+
if (!key.exists) {
|
|
785
|
+
say(`no Mend key yet ${dim("— mend keys init generates one (ed25519, stays on the server host)")}`);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
printGitKey(key);
|
|
789
|
+
};
|
|
790
|
+
const keysInit = async (config) => {
|
|
791
|
+
const key = await api(config, "POST", "/keys/git");
|
|
792
|
+
say(`${green("✓")} Mend key ready ${dim("(private half stays on the server host)")}`);
|
|
793
|
+
printGitKey(key);
|
|
794
|
+
};
|
|
795
|
+
/**
|
|
796
|
+
* The ssh-agent bridge, client half (docs/GIT-ACCESS.md decision 2): relay
|
|
797
|
+
* the LOCAL ssh-agent to the Mend server over one standing WebSocket, so
|
|
798
|
+
* bridge-mode git ops sign with a key that never leaves this machine — a
|
|
799
|
+
* hardware key blinks here, not on the server. Agent-protocol messages are
|
|
800
|
+
* relayed verbatim (length-prefixed frames, base64 over JSON); the only
|
|
801
|
+
* inspection is each message's type byte, to say what is being asked.
|
|
802
|
+
*/
|
|
803
|
+
const keysShare = async (config) => {
|
|
804
|
+
const agentSock = process.env["SSH_AUTH_SOCK"];
|
|
805
|
+
if (agentSock === undefined || agentSock === "") {
|
|
806
|
+
return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
|
|
807
|
+
}
|
|
808
|
+
const url = new URL(`${config.url}/api/keys/bridge/ws`);
|
|
809
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
810
|
+
url.searchParams.set("host", os.hostname());
|
|
811
|
+
if (config.token !== null)
|
|
812
|
+
url.searchParams.set("token", config.token);
|
|
813
|
+
/**
|
|
814
|
+
* One complete agent exchange against the local agent: fresh connection,
|
|
815
|
+
* write the framed request verbatim, read one framed response. A hardware
|
|
816
|
+
* key blocks here until the touch — hence the generous timeout.
|
|
817
|
+
*/
|
|
818
|
+
const askLocalAgent = (payload) => new Promise((resolve, reject) => {
|
|
819
|
+
const connection = net.connect(agentSock, () => {
|
|
820
|
+
connection.write(payload);
|
|
821
|
+
});
|
|
822
|
+
const timer = setTimeout(() => {
|
|
823
|
+
connection.destroy();
|
|
824
|
+
reject(new Error("the agent did not answer within 60s — touch missed?"));
|
|
825
|
+
}, 60_000);
|
|
826
|
+
let pending = Buffer.alloc(0);
|
|
827
|
+
connection.on("data", (chunk) => {
|
|
828
|
+
pending = Buffer.concat([pending, chunk]);
|
|
829
|
+
if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
|
|
830
|
+
clearTimeout(timer);
|
|
831
|
+
connection.end();
|
|
832
|
+
resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
connection.on("error", (error) => {
|
|
836
|
+
clearTimeout(timer);
|
|
837
|
+
reject(error);
|
|
838
|
+
});
|
|
839
|
+
});
|
|
840
|
+
// Strictly one request at a time, in arrival order — the agent protocol is
|
|
841
|
+
// request/response and a hardware key cannot answer two touches at once.
|
|
842
|
+
let chain = Promise.resolve();
|
|
843
|
+
const SSH_AGENTC_SIGN_REQUEST = 13;
|
|
844
|
+
const SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
845
|
+
let attempt = 0;
|
|
846
|
+
for (;;) {
|
|
847
|
+
const ws = new WebSocket(url);
|
|
848
|
+
const closed = new Promise((resolve) => {
|
|
849
|
+
ws.addEventListener("close", () => resolve(), { once: true });
|
|
850
|
+
});
|
|
851
|
+
const opened = await new Promise((resolve) => {
|
|
852
|
+
ws.addEventListener("open", () => resolve(true), { once: true });
|
|
853
|
+
ws.addEventListener("error", () => resolve(false), { once: true });
|
|
854
|
+
});
|
|
855
|
+
if (opened) {
|
|
856
|
+
attempt = 0;
|
|
857
|
+
say(`${green("●")} sharing this machine's ssh-agent with ${config.url}`);
|
|
858
|
+
say(dim(` agent: ${agentSock} · signature requests print here · Ctrl-C stops sharing`));
|
|
859
|
+
ws.addEventListener("message", (event) => {
|
|
860
|
+
const text = typeof event.data === "string"
|
|
861
|
+
? event.data
|
|
862
|
+
: Buffer.from(event.data).toString("utf8");
|
|
863
|
+
let frame;
|
|
864
|
+
try {
|
|
865
|
+
frame = JSON.parse(text);
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (frame.t !== "req" ||
|
|
871
|
+
typeof frame.id !== "number" ||
|
|
872
|
+
typeof frame.payload !== "string") {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const id = frame.id;
|
|
876
|
+
const payload = Buffer.from(frame.payload, "base64");
|
|
877
|
+
const type = payload[4];
|
|
878
|
+
const context = typeof frame.context === "string" ? frame.context : "mend";
|
|
879
|
+
chain = chain.then(async () => {
|
|
880
|
+
const started = Date.now();
|
|
881
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
882
|
+
say(`${amber("✎")} signature requested by mend ${dim(`(${context})`)}`);
|
|
883
|
+
say(dim(" waiting — touch your key if it blinks (up to 60s)…"));
|
|
884
|
+
}
|
|
885
|
+
else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
|
|
886
|
+
say(dim(` identities requested (${context})`));
|
|
887
|
+
}
|
|
888
|
+
try {
|
|
889
|
+
const response = await askLocalAgent(payload);
|
|
890
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
891
|
+
return null;
|
|
892
|
+
ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
|
|
893
|
+
if (type === SSH_AGENTC_SIGN_REQUEST) {
|
|
894
|
+
say(`${green("✓")} signed ${dim(`(${((Date.now() - started) / 1000).toFixed(1)}s)`)}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
899
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
900
|
+
ws.send(JSON.stringify({ t: "err", id, message }));
|
|
901
|
+
}
|
|
902
|
+
if (type === SSH_AGENTC_SIGN_REQUEST)
|
|
903
|
+
say(`${amber("✗")} not signed — ${message}`);
|
|
904
|
+
}
|
|
905
|
+
return null;
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
await closed;
|
|
910
|
+
attempt += 1;
|
|
911
|
+
const delay = Math.min(30_000, 1000 * 2 ** Math.min(attempt - 1, 5));
|
|
912
|
+
say(dim(`not connected — retrying in ${Math.round(delay / 1000)}s (Ctrl-C stops)`));
|
|
913
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
const keysCommand = async (config, args) => {
|
|
917
|
+
const [verb] = args;
|
|
918
|
+
switch (verb) {
|
|
919
|
+
case "init":
|
|
920
|
+
return keysInit(config);
|
|
921
|
+
case "share":
|
|
922
|
+
return keysShare(config);
|
|
923
|
+
case "show":
|
|
924
|
+
case undefined:
|
|
925
|
+
return keysShow(config);
|
|
926
|
+
default:
|
|
927
|
+
return fail(`unknown keys command "${verb}" — try: mend keys init | show | share`);
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
const formatDotfileBytes = (bytes) => bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
|
931
|
+
const dotfilesShow = async (config) => {
|
|
932
|
+
const dotfiles = await api(config, "GET", "/dotfiles");
|
|
933
|
+
if (dotfiles.repository === null) {
|
|
934
|
+
say(`repo ${dim("none")}`);
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
const branch = dotfiles.repository.ref ?? "default branch";
|
|
938
|
+
const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
|
|
939
|
+
say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
|
|
940
|
+
}
|
|
941
|
+
if (dotfiles.snapshot === null) {
|
|
942
|
+
say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
const snapshot = dotfiles.snapshot;
|
|
946
|
+
say(`snapshot ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} · from ${snapshot.source} · ${dim(snapshot.sha.slice(0, 7))}`);
|
|
947
|
+
for (const file of snapshot.files) {
|
|
948
|
+
say(` ${file.path.padEnd(36)} ${dim(formatDotfileBytes(file.bytes))}`);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
/**
|
|
952
|
+
* `mend dotfiles sync` — capture home files ON THIS MACHINE and stream them into the server's
|
|
953
|
+
* per-user dotfiles store. This is the whole point of the store: the server may be a VPS whose
|
|
954
|
+
* home directory belongs to a service account, so contents are read here, where they live.
|
|
955
|
+
*/
|
|
956
|
+
const dotfilesSync = async (config, args) => {
|
|
957
|
+
const all = args.includes("--all");
|
|
958
|
+
const requestedPaths = args.filter((arg) => !arg.startsWith("--"));
|
|
959
|
+
const home = os.homedir();
|
|
960
|
+
if (!all && requestedPaths.length === 0) {
|
|
961
|
+
const found = scanDotfileCandidates(home);
|
|
962
|
+
if (found.length === 0) {
|
|
963
|
+
say(dim("no known config files found under ~"));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
let group = "";
|
|
967
|
+
for (const entry of found) {
|
|
968
|
+
if (entry.group !== group) {
|
|
969
|
+
group = entry.group;
|
|
970
|
+
say(dim(group));
|
|
971
|
+
}
|
|
972
|
+
say(` ${entry.path.padEnd(36)} ${dim(formatDotfileBytes(entry.bytes))}`);
|
|
973
|
+
}
|
|
974
|
+
say("");
|
|
975
|
+
say(`sync everything with ${cobalt("mend dotfiles sync --all")}, or pick: ${cobalt("mend dotfiles sync .zshrc .gitconfig")}`);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const selected = all ? scanDotfileCandidates(home).map((entry) => entry.path) : requestedPaths;
|
|
979
|
+
if (selected.length === 0)
|
|
980
|
+
return fail("nothing to sync — no known config files found under ~");
|
|
981
|
+
const read = readSyncFiles(home, selected);
|
|
982
|
+
if ("error" in read)
|
|
983
|
+
return fail(read.error);
|
|
984
|
+
const result = await api(config, "POST", "/dotfiles/snapshot", {
|
|
985
|
+
files: read.files,
|
|
986
|
+
source: os.hostname(),
|
|
987
|
+
merge: false,
|
|
988
|
+
});
|
|
989
|
+
const snapshot = result.snapshot;
|
|
990
|
+
if (snapshot === null)
|
|
991
|
+
return fail("the server accepted the sync but reports no snapshot");
|
|
992
|
+
say(`${green("synced")} ${snapshot.files.length} file${snapshot.files.length === 1 ? "" : "s"} from ${os.hostname()} · ${dim(snapshot.sha.slice(0, 7))} ${dim("— applies from the next session launch")}`);
|
|
993
|
+
};
|
|
994
|
+
const dotfilesCommand = async (config, args) => {
|
|
995
|
+
const [verb, ...rest] = args;
|
|
996
|
+
switch (verb) {
|
|
997
|
+
case "sync":
|
|
998
|
+
return dotfilesSync(config, rest);
|
|
999
|
+
case "show":
|
|
1000
|
+
case undefined:
|
|
1001
|
+
return dotfilesShow(config);
|
|
1002
|
+
default:
|
|
1003
|
+
return fail(`unknown dotfiles command "${verb}" — try: mend dotfiles sync | show`);
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
// ─── completions: live sessions under TAB ───────────────────────────────────
|
|
1007
|
+
/**
|
|
1008
|
+
* The data half of shell completion: one live session per line as
|
|
1009
|
+
* `id<TAB>description`. Scripts adapt the shape (zsh wants `id:desc`, bash
|
|
1010
|
+
* wants bare ids). Never fails — a dead server just completes nothing.
|
|
1011
|
+
*/
|
|
1012
|
+
const completeCommand = async (config, args) => {
|
|
1013
|
+
if (args[0] !== "session")
|
|
1014
|
+
return;
|
|
1015
|
+
try {
|
|
1016
|
+
const [sessions, projects] = await Promise.all([
|
|
1017
|
+
request(config, "GET", "/sessions"),
|
|
1018
|
+
request(config, "GET", "/projects"),
|
|
1019
|
+
]);
|
|
1020
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
1021
|
+
for (const session of sessions) {
|
|
1022
|
+
const project = nameById.get(session.projectId) ?? "";
|
|
1023
|
+
process.stdout.write(`${session.id}\t${session.harness} · ${project} · ${session.branch}\n`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Completion must never surface an error into the user's TAB press.
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
const ZSH_COMPLETIONS = `#compdef mend
|
|
1031
|
+
_mend() {
|
|
1032
|
+
local -a commands
|
|
1033
|
+
commands=(
|
|
1034
|
+
'adopt:adopt a repository into the store'
|
|
1035
|
+
'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
|
|
1036
|
+
'run:new session + arbitrary command'
|
|
1037
|
+
'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
|
|
1038
|
+
'service:reachable ports — add, list, stop'
|
|
1039
|
+
'keys:the machine Mend deploy key — init, show, share'
|
|
1040
|
+
'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
|
|
1041
|
+
'rejoin:attach if live, otherwise resume'
|
|
1042
|
+
'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
|
|
1043
|
+
'ui:the dashboard' 'help:help'
|
|
1044
|
+
)
|
|
1045
|
+
if (( CURRENT == 2 )); then
|
|
1046
|
+
_describe 'command' commands
|
|
1047
|
+
return
|
|
1048
|
+
fi
|
|
1049
|
+
case $words[2] in
|
|
1050
|
+
shell|attach|continue|resume|rejoin)
|
|
1051
|
+
local -a sessions
|
|
1052
|
+
sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
|
|
1053
|
+
(( \${#sessions} )) && _describe 'session' sessions
|
|
1054
|
+
;;
|
|
1055
|
+
esac
|
|
1056
|
+
}
|
|
1057
|
+
_mend "$@"
|
|
1058
|
+
`;
|
|
1059
|
+
const BASH_COMPLETIONS = `_mend() {
|
|
1060
|
+
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1061
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1062
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
|
|
1063
|
+
return
|
|
1064
|
+
fi
|
|
1065
|
+
case \${COMP_WORDS[1]} in
|
|
1066
|
+
shell|attach|continue|resume|rejoin)
|
|
1067
|
+
COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
|
|
1068
|
+
;;
|
|
1069
|
+
esac
|
|
1070
|
+
}
|
|
1071
|
+
complete -F _mend mend
|
|
1072
|
+
`;
|
|
1073
|
+
/** Print the hook for the named shell; the user wires it into their rc file. */
|
|
1074
|
+
const completionsCommand = (args) => {
|
|
1075
|
+
switch (args[0]) {
|
|
1076
|
+
case "zsh":
|
|
1077
|
+
process.stdout.write(ZSH_COMPLETIONS);
|
|
1078
|
+
return;
|
|
1079
|
+
case "bash":
|
|
1080
|
+
process.stdout.write(BASH_COMPLETIONS);
|
|
1081
|
+
return;
|
|
1082
|
+
default:
|
|
1083
|
+
return fail('usage: mend completions zsh|bash — e.g. mend completions zsh > "$fpath[1]/_mend"');
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
244
1086
|
// ─── supervised run: platform workspace + PTY + record ──────────────────────
|
|
245
1087
|
const supervisedRun = async (config, session, argv) => {
|
|
246
1088
|
const launched = await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
|
|
@@ -299,12 +1141,6 @@ const supervisedRun = async (config, session, argv) => {
|
|
|
299
1141
|
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
300
1142
|
process.exit(settled === "failed" ? 1 : 0);
|
|
301
1143
|
};
|
|
302
|
-
/** How each harness takes an instruction as its opening prompt. */
|
|
303
|
-
const CONTINUE_COMMANDS = {
|
|
304
|
-
codex: (instruction) => ["codex", instruction],
|
|
305
|
-
claude: (instruction) => ["claude", instruction],
|
|
306
|
-
opencode: (instruction) => ["opencode", "run", instruction],
|
|
307
|
-
};
|
|
308
1144
|
/**
|
|
309
1145
|
* The second half of the review loop (plan §7.3): find the session with a
|
|
310
1146
|
* pending follow-up, deliver it (which reopens the session), and relaunch the
|
|
@@ -364,17 +1200,13 @@ const continueSession = async (config, args) => {
|
|
|
364
1200
|
// the web session page shows the live terminal throughout.
|
|
365
1201
|
const argv = build(followUp.instruction);
|
|
366
1202
|
await withSpinner("provisioning workspace — the resumed run records like any other…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
|
|
367
|
-
say(`${green("✓ recording")} · workspace mounts the worktree
|
|
368
|
-
say("");
|
|
369
|
-
await attachTty(config, session.id, 0n);
|
|
370
|
-
const settled = await api(config, "GET", `/sessions/${session.id}`);
|
|
1203
|
+
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
371
1204
|
say("");
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
process.exit(0);
|
|
1205
|
+
await attachOrExit(config, session.id, session.harness);
|
|
1206
|
+
exitAfterSessionEnd(config, session.id);
|
|
375
1207
|
};
|
|
376
1208
|
// ─── resume: rejoin a session — same worktree, restored harness state ───────
|
|
377
|
-
const ACTIVE_STATUSES =
|
|
1209
|
+
const ACTIVE_STATUSES = LIVE_STATUSES;
|
|
378
1210
|
/**
|
|
379
1211
|
* Sessions are continuous work, not runs: `mend resume` rejoins one on a
|
|
380
1212
|
* fresh workspace — saved harness state restored, a claude resume is native
|
|
@@ -401,39 +1233,273 @@ const resumeCommand = async (config, args) => {
|
|
|
401
1233
|
say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
|
|
402
1234
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${match.id}`);
|
|
403
1235
|
await withSpinner("resuming — a fresh workspace restores the saved session state…", api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
|
|
404
|
-
say(`${green("✓ recording")} · same worktree, conversation restored
|
|
1236
|
+
say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
|
|
405
1237
|
say("");
|
|
406
|
-
await
|
|
407
|
-
|
|
1238
|
+
await attachOrExit(config, match.id, withHarness ?? match.harness);
|
|
1239
|
+
exitAfterSessionEnd(config, match.id);
|
|
1240
|
+
};
|
|
1241
|
+
// ─── rejoin: one entrypoint for an outer multiplexer ────────────────────────
|
|
1242
|
+
/**
|
|
1243
|
+
* Idempotent entrypoint for outer multiplexers: attach when the session is
|
|
1244
|
+
* live, otherwise restore and resume it before attaching. With no id, the
|
|
1245
|
+
* newest live session wins, falling back to the newest settled session;
|
|
1246
|
+
* --harness narrows the choice.
|
|
1247
|
+
*/
|
|
1248
|
+
const resumeForRejoin = async (config, sessionId, label) => {
|
|
1249
|
+
try {
|
|
1250
|
+
await withSpinner(label, request(config, "POST", `/sessions/${sessionId}/resume`, { harness: null }));
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
const refreshed = await api(config, "GET", `/sessions/${sessionId}`);
|
|
1255
|
+
if (ACTIVE_STATUSES.has(refreshed.session.status))
|
|
1256
|
+
return false;
|
|
1257
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
const rejoinCommand = async (config, args) => {
|
|
1261
|
+
const harnessFlag = args.indexOf("--harness");
|
|
1262
|
+
const harness = harnessFlag !== -1 && args[harnessFlag + 1] !== undefined
|
|
1263
|
+
? String(args[harnessFlag + 1])
|
|
1264
|
+
: null;
|
|
1265
|
+
const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== harnessFlag + 1);
|
|
1266
|
+
const project = await findProject(config, null);
|
|
1267
|
+
const detail = await api(config, "GET", `/projects/${project.id}`);
|
|
1268
|
+
const eligible = detail.sessions
|
|
1269
|
+
.filter((session) => harness === null || session.harness === harness)
|
|
1270
|
+
.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
1271
|
+
const newest = eligible.find((session) => ACTIVE_STATUSES.has(session.status)) ?? eligible[0];
|
|
1272
|
+
const matches = prefix === undefined
|
|
1273
|
+
? newest === undefined
|
|
1274
|
+
? []
|
|
1275
|
+
: [newest]
|
|
1276
|
+
: eligible.filter((session) => session.id.startsWith(prefix));
|
|
1277
|
+
if (matches.length === 0) {
|
|
1278
|
+
const harnessDescription = harness === null ? "" : ` ${harness}`;
|
|
1279
|
+
return fail(prefix === undefined
|
|
1280
|
+
? `no${harnessDescription} session to rejoin in project ${project.name}`
|
|
1281
|
+
: `no${harnessDescription} session matches "${prefix}" in project ${project.name}`);
|
|
1282
|
+
}
|
|
1283
|
+
if (matches.length > 1) {
|
|
1284
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
1285
|
+
}
|
|
1286
|
+
const session = matches[0];
|
|
1287
|
+
if (session === undefined)
|
|
1288
|
+
return fail("session selection failed");
|
|
1289
|
+
say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${ACTIVE_STATUSES.has(session.status) ? "already live" : "restoring"}`);
|
|
1290
|
+
say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
|
|
1291
|
+
let restored = false;
|
|
1292
|
+
if (!ACTIVE_STATUSES.has(session.status)) {
|
|
1293
|
+
restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
|
|
1294
|
+
}
|
|
1295
|
+
say(restored
|
|
1296
|
+
? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
|
|
1297
|
+
: `${green("✓ recording")} · attached to the live session${detachHint()}`);
|
|
408
1298
|
say("");
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1299
|
+
let outcome = await attachTty(config, session.id, session.harness, 0n);
|
|
1300
|
+
if (outcome === "unavailable") {
|
|
1301
|
+
const refreshed = await api(config, "GET", `/sessions/${session.id}`);
|
|
1302
|
+
if (!ACTIVE_STATUSES.has(refreshed.session.status)) {
|
|
1303
|
+
await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
|
|
1304
|
+
}
|
|
1305
|
+
outcome = await attachTty(config, session.id, session.harness, 0n);
|
|
1306
|
+
}
|
|
1307
|
+
finishAttach(config, session.id, outcome);
|
|
1308
|
+
exitAfterSessionEnd(config, session.id);
|
|
412
1309
|
};
|
|
413
|
-
// ───
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
1310
|
+
// ─── projects · sessions: the workbench at a glance ─────────────────────────
|
|
1311
|
+
const projectsCommand = async (config) => {
|
|
1312
|
+
const [projects, active] = await Promise.all([
|
|
1313
|
+
api(config, "GET", "/projects"),
|
|
1314
|
+
api(config, "GET", "/sessions"),
|
|
1315
|
+
]);
|
|
1316
|
+
if (projects.length === 0) {
|
|
1317
|
+
say(dim("no adopted projects — mend adopt brings one in"));
|
|
418
1318
|
return;
|
|
419
1319
|
}
|
|
420
|
-
|
|
421
|
-
|
|
1320
|
+
const liveByProject = new Map();
|
|
1321
|
+
for (const session of active) {
|
|
1322
|
+
liveByProject.set(session.projectId, (liveByProject.get(session.projectId) ?? 0) + 1);
|
|
422
1323
|
}
|
|
1324
|
+
const nameWidth = Math.max(...projects.map((p) => p.name.length));
|
|
1325
|
+
const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length));
|
|
1326
|
+
for (const project of projects) {
|
|
1327
|
+
const live = liveByProject.get(project.id) ?? 0;
|
|
1328
|
+
const liveLabel = live > 0 ? green(`${live} live`) : dim("—");
|
|
1329
|
+
say(`${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
|
|
1330
|
+
}
|
|
1331
|
+
};
|
|
1332
|
+
const printSessionRow = (row) => {
|
|
1333
|
+
const { session, annotation } = row;
|
|
1334
|
+
const live = ACTIVE_STATUSES.has(session.status);
|
|
1335
|
+
const status = session.status.padEnd(9);
|
|
1336
|
+
const facts = [];
|
|
1337
|
+
if (session.label !== null)
|
|
1338
|
+
facts.push(session.label);
|
|
1339
|
+
if (annotation !== undefined && annotation.openComments > 0) {
|
|
1340
|
+
facts.push(amber(`${annotation.openComments} open`));
|
|
1341
|
+
}
|
|
1342
|
+
if (annotation !== undefined && annotation.pendingFollowUp)
|
|
1343
|
+
facts.push(amber("follow-up pending"));
|
|
1344
|
+
say(`${session.harness.padEnd(8)} ${dim(session.id.slice(0, 8))} ${live ? green(status) : dim(status)} ${row.projectName} ${dim(session.branch)}${facts.length > 0 ? ` ${facts.join(dim(" · "))}` : ""}`);
|
|
1345
|
+
};
|
|
1346
|
+
/**
|
|
1347
|
+
* Active sessions by default; --all sweeps every project's detail so settled
|
|
1348
|
+
* sessions arrive with their review facts (open comments, pending follow-up).
|
|
1349
|
+
*/
|
|
1350
|
+
const sessionsCommand = async (config, args) => {
|
|
1351
|
+
const all = args.includes("--all");
|
|
1352
|
+
const json = args.includes("--json");
|
|
1353
|
+
const projectFlag = args.indexOf("--project");
|
|
1354
|
+
const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
|
|
1355
|
+
? String(args[projectFlag + 1])
|
|
1356
|
+
: null;
|
|
1357
|
+
const projects = await api(config, "GET", "/projects");
|
|
1358
|
+
const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
|
|
1359
|
+
if (projectName !== null && scope.length === 0) {
|
|
1360
|
+
return fail(`no adopted project named "${projectName}"`);
|
|
1361
|
+
}
|
|
1362
|
+
let rows;
|
|
1363
|
+
if (all || projectName !== null) {
|
|
1364
|
+
const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
|
|
1365
|
+
rows = details.flatMap((detail) => detail.sessions.map((session) => ({
|
|
1366
|
+
session,
|
|
1367
|
+
projectName: detail.project.name,
|
|
1368
|
+
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
1369
|
+
})));
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
const active = await api(config, "GET", "/sessions");
|
|
1373
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
1374
|
+
rows = active.map((session) => ({
|
|
1375
|
+
session,
|
|
1376
|
+
projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
|
|
1377
|
+
annotation: undefined,
|
|
1378
|
+
}));
|
|
1379
|
+
}
|
|
1380
|
+
if (rows.length === 0) {
|
|
1381
|
+
if (json) {
|
|
1382
|
+
say(JSON.stringify({ version: 1, sessions: [] }, null, 2));
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
say(dim(all ? "no sessions" : "no active sessions — mend sessions --all includes settled ones"));
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
rows.sort((a, b) => {
|
|
1389
|
+
const aLive = ACTIVE_STATUSES.has(a.session.status) ? 1 : 0;
|
|
1390
|
+
const bLive = ACTIVE_STATUSES.has(b.session.status) ? 1 : 0;
|
|
1391
|
+
if (aLive !== bLive)
|
|
1392
|
+
return bLive - aLive;
|
|
1393
|
+
return b.session.createdAt.localeCompare(a.session.createdAt);
|
|
1394
|
+
});
|
|
1395
|
+
if (json) {
|
|
1396
|
+
const payload = {
|
|
1397
|
+
version: 1,
|
|
1398
|
+
sessions: rows.map(({ session, projectName: rowProjectName, annotation }) => ({
|
|
1399
|
+
id: session.id,
|
|
1400
|
+
projectId: session.projectId,
|
|
1401
|
+
projectName: rowProjectName,
|
|
1402
|
+
harness: session.harness,
|
|
1403
|
+
label: session.label,
|
|
1404
|
+
worktree: session.worktree,
|
|
1405
|
+
branch: session.branch,
|
|
1406
|
+
baseSha: session.baseSha,
|
|
1407
|
+
status: session.status,
|
|
1408
|
+
summary: session.summary,
|
|
1409
|
+
createdAt: session.createdAt,
|
|
1410
|
+
reviewUrl: `${config.url.replace(/\/$/, "")}/sessions/${session.id}`,
|
|
1411
|
+
review: annotation === undefined
|
|
1412
|
+
? null
|
|
1413
|
+
: {
|
|
1414
|
+
openComments: annotation.openComments,
|
|
1415
|
+
totalComments: annotation.totalComments,
|
|
1416
|
+
pendingFollowUp: annotation.pendingFollowUp,
|
|
1417
|
+
},
|
|
1418
|
+
})),
|
|
1419
|
+
};
|
|
1420
|
+
say(JSON.stringify(payload, null, 2));
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
for (const row of rows)
|
|
1424
|
+
printSessionRow(row);
|
|
1425
|
+
};
|
|
1426
|
+
// ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
|
|
1427
|
+
const hasNodeFfi = () => {
|
|
1428
|
+
try {
|
|
1429
|
+
createRequire(import.meta.url)("node:ffi");
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
catch {
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
};
|
|
1436
|
+
/**
|
|
1437
|
+
* The dashboard needs @opentui/core, whose Node backend binds the native
|
|
1438
|
+
* renderer over node:ffi — present from Node 26, and only behind
|
|
1439
|
+
* --experimental-ffi. Gate here and re-exec the same argv with the flag so
|
|
1440
|
+
* the user never types it; every other command stays on plain Node >= 22.
|
|
1441
|
+
*/
|
|
1442
|
+
const dashboard = async (config) => {
|
|
1443
|
+
if (process.stdout.isTTY !== true) {
|
|
1444
|
+
say(HELP);
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (!hasNodeFfi()) {
|
|
1448
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
1449
|
+
if (Number.isNaN(major) || major < 26) {
|
|
1450
|
+
return fail(`the dashboard needs Node >= 26 (node:ffi) — this is ${process.version}; every other command still works`);
|
|
1451
|
+
}
|
|
1452
|
+
const rerun = spawnSync(process.execPath, ["--experimental-ffi", "--disable-warning=ExperimentalWarning", ...process.argv.slice(1)], { stdio: "inherit" });
|
|
1453
|
+
process.exit(rerun.status ?? 0);
|
|
1454
|
+
}
|
|
1455
|
+
const { runDashboard } = await import("./dashboard.js");
|
|
1456
|
+
await runDashboard({
|
|
1457
|
+
config,
|
|
1458
|
+
cwd: process.cwd(),
|
|
1459
|
+
api: (method, route, body) => request(config, method, route, body),
|
|
1460
|
+
attachTty: (sessionId, harness) => attachTty(config, sessionId, harness, 0n),
|
|
1461
|
+
});
|
|
423
1462
|
};
|
|
424
1463
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
425
1464
|
const HELP = `mend — the agent workbench
|
|
426
1465
|
|
|
427
|
-
mend
|
|
1466
|
+
mend the dashboard: every project and session, live
|
|
1467
|
+
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
1468
|
+
adopt a repository into the store (default: cwd; any git
|
|
1469
|
+
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
1470
|
+
mend keys init generate the machine's Mend deploy key (ed25519)
|
|
1471
|
+
mend keys show print the public key — add it as a deploy key on your git host
|
|
1472
|
+
mend dotfiles your dotfiles on the server: repo + synced home files
|
|
1473
|
+
mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
|
|
1474
|
+
mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
|
|
1475
|
+
hardware keys sign here; Ctrl-C stops sharing)
|
|
428
1476
|
mend codex|claude|opencode new session worktree + launch the harness in it
|
|
429
1477
|
mend run -- <command...> same, with an arbitrary command
|
|
430
1478
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
1479
|
+
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
1480
|
+
mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
|
|
1481
|
+
start + supervise a server in the session workspace
|
|
1482
|
+
mend service run [session] <name> start a declared Service (mend.toml recipe)
|
|
1483
|
+
mend service <name> shorthand for the above
|
|
1484
|
+
mend service init [--yes] scaffold mend.toml from package.json + compose ports
|
|
1485
|
+
mend service add [session] <port> [--name <n>] [--udp]
|
|
1486
|
+
adopt a listening workspace port — reachable on this machine
|
|
1487
|
+
mend service list every live service and its observed state
|
|
1488
|
+
mend service logs <name-or-id> follow a supervised service's output (replay, then live)
|
|
1489
|
+
mend service restart <name-or-id> re-run its recorded command — same URL
|
|
1490
|
+
mend service stop <name-or-id> stop a service (closes its host port)
|
|
431
1491
|
mend continue [session-id] resume a session with its pending review follow-up
|
|
432
1492
|
mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
|
|
433
|
-
mend
|
|
1493
|
+
mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
|
|
1494
|
+
mend projects adopted projects and their live sessions
|
|
1495
|
+
mend sessions [--all] [--project p] [--json]
|
|
1496
|
+
sessions with review facts; JSON is stable for integrations
|
|
1497
|
+
mend status active sessions (alias of mend sessions)
|
|
1498
|
+
mend completions zsh|bash print the TAB-completion hook (live session ids under TAB)
|
|
434
1499
|
|
|
435
1500
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
436
|
-
|
|
1501
|
+
detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
|
|
1502
|
+
config file: ~/.config/mend/cli.json { "url": ..., "token": ... }
|
|
437
1503
|
`;
|
|
438
1504
|
const main = async () => {
|
|
439
1505
|
const [command, ...rest] = process.argv.slice(2);
|
|
@@ -448,13 +1514,32 @@ const main = async () => {
|
|
|
448
1514
|
return launch(config, command, rest);
|
|
449
1515
|
case "attach":
|
|
450
1516
|
return attach(config, rest);
|
|
1517
|
+
case "shell":
|
|
1518
|
+
return shellCommand(config, rest);
|
|
1519
|
+
case "service":
|
|
1520
|
+
return serviceCommand(config, rest);
|
|
1521
|
+
case "keys":
|
|
1522
|
+
return keysCommand(config, rest);
|
|
1523
|
+
case "dotfiles":
|
|
1524
|
+
return dotfilesCommand(config, rest);
|
|
1525
|
+
case "completions":
|
|
1526
|
+
return completionsCommand(rest);
|
|
1527
|
+
case "__complete":
|
|
1528
|
+
return completeCommand(config, rest);
|
|
451
1529
|
case "continue":
|
|
452
1530
|
return continueSession(config, rest);
|
|
453
1531
|
case "resume":
|
|
454
1532
|
return resumeCommand(config, rest);
|
|
1533
|
+
case "rejoin":
|
|
1534
|
+
return rejoinCommand(config, rest);
|
|
1535
|
+
case "projects":
|
|
1536
|
+
return projectsCommand(config);
|
|
1537
|
+
case "sessions":
|
|
455
1538
|
case "status":
|
|
456
|
-
return
|
|
1539
|
+
return sessionsCommand(config, rest);
|
|
457
1540
|
case undefined:
|
|
1541
|
+
case "ui":
|
|
1542
|
+
return dashboard(config);
|
|
458
1543
|
case "help":
|
|
459
1544
|
case "--help":
|
|
460
1545
|
say(HELP);
|