@sealant/mend 0.1.0 → 0.1.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 +21 -3
- package/dist/dashboard.js +546 -0
- package/dist/main.js +409 -90
- package/dist/review-model.js +212 -0
- package/dist/review-workflow.js +15 -0
- package/dist/review.js +559 -0
- package/dist/shared.js +48 -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,7 +1,10 @@
|
|
|
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";
|
|
3
5
|
import * as os from "node:os";
|
|
4
6
|
import * as path from "node:path";
|
|
7
|
+
import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
|
|
5
8
|
const CONFIG_PATH = path.join(os.homedir(), ".mend", "cli.json");
|
|
6
9
|
const loadConfig = () => {
|
|
7
10
|
let fileConfig = {};
|
|
@@ -28,7 +31,41 @@ const green = paint("32");
|
|
|
28
31
|
const amber = paint("33");
|
|
29
32
|
const cobalt = paint("34");
|
|
30
33
|
const say = (line) => process.stdout.write(`${line}\n`);
|
|
31
|
-
const
|
|
34
|
+
const detachKeyEnabled = process.env["MEND_DETACH_KEY"] !== "none";
|
|
35
|
+
const detachHint = () => (detachKeyEnabled ? ` · detach: ${dim("Ctrl+]")}` : "");
|
|
36
|
+
const herdrAgentOf = (harness) => {
|
|
37
|
+
if (harness === "codex" || harness === "claude" || harness === "opencode")
|
|
38
|
+
return harness;
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
const hintHerdrAttachment = (harness) => {
|
|
42
|
+
const agent = herdrAgentOf(harness);
|
|
43
|
+
if (process.env["HERDR_ENV"] !== "1" || agent === null)
|
|
44
|
+
return () => undefined;
|
|
45
|
+
// Herdr deliberately reads HERDR_AGENT from any member of the foreground
|
|
46
|
+
// process group. A pipe-tethered child carries the hint while Mend owns the
|
|
47
|
+
// pane, without claiming lifecycle authority: Herdr's native screen rules
|
|
48
|
+
// still derive working, idle, and blocked from the bridged agent UI.
|
|
49
|
+
const carrier = spawn(process.execPath, ["-e", "process.stdin.resume();process.stdin.once('end',()=>process.exit(0))"], {
|
|
50
|
+
env: { ...process.env, HERDR_AGENT: agent },
|
|
51
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
52
|
+
});
|
|
53
|
+
let stopped = false;
|
|
54
|
+
const stop = () => {
|
|
55
|
+
if (stopped)
|
|
56
|
+
return;
|
|
57
|
+
stopped = true;
|
|
58
|
+
carrier.stdin?.end();
|
|
59
|
+
carrier.kill();
|
|
60
|
+
};
|
|
61
|
+
process.once("exit", stop);
|
|
62
|
+
return () => {
|
|
63
|
+
process.off("exit", stop);
|
|
64
|
+
stop();
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
/** The raw server call — THROWS with a human message; the dashboard renders it. */
|
|
68
|
+
const request = async (config, method, route, body) => {
|
|
32
69
|
const headers = { "content-type": "application/json" };
|
|
33
70
|
if (config.token !== null)
|
|
34
71
|
headers["authorization"] = `Bearer ${config.token}`;
|
|
@@ -42,23 +79,34 @@ const api = async (config, method, route, body) => {
|
|
|
42
79
|
});
|
|
43
80
|
}
|
|
44
81
|
catch {
|
|
45
|
-
|
|
82
|
+
throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
|
|
46
83
|
}
|
|
47
84
|
if (response.status === 401) {
|
|
48
|
-
|
|
85
|
+
throw new Error(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
|
|
49
86
|
}
|
|
50
87
|
const text = await response.text();
|
|
51
88
|
if (!response.ok) {
|
|
89
|
+
let message = `${method} ${route} → ${response.status}`;
|
|
52
90
|
try {
|
|
53
91
|
const parsed = JSON.parse(text);
|
|
54
|
-
|
|
92
|
+
message = parsed.message ?? message;
|
|
55
93
|
}
|
|
56
94
|
catch {
|
|
57
|
-
|
|
95
|
+
// not JSON — keep the status line
|
|
58
96
|
}
|
|
97
|
+
throw new Error(message);
|
|
59
98
|
}
|
|
60
99
|
return JSON.parse(text);
|
|
61
100
|
};
|
|
101
|
+
/** The same call for one-shot commands: any failure prints and exits. */
|
|
102
|
+
const api = async (config, method, route, body) => {
|
|
103
|
+
try {
|
|
104
|
+
return await request(config, method, route, body);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
62
110
|
const worktreePathOf = (storePath, worktree) => path.join(path.dirname(storePath), "worktrees", worktree);
|
|
63
111
|
/** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
|
|
64
112
|
const withSpinner = async (label, work) => {
|
|
@@ -85,9 +133,11 @@ const adopt = async (config, args) => {
|
|
|
85
133
|
const nameFlag = args.indexOf("--name");
|
|
86
134
|
const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
|
|
87
135
|
const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
|
|
88
|
-
|
|
136
|
+
// Bare `mend adopt` adopts the repo the cwd is inside, not the subdirectory.
|
|
137
|
+
const rawSource = positional[0] ?? gitTopLevel(process.cwd()) ?? ".";
|
|
89
138
|
const source = /^(https?|git|ssh):|^git@/.test(rawSource) ? rawSource : path.resolve(rawSource);
|
|
90
|
-
|
|
139
|
+
// Derived defaults are normalized ("Mend" → "mend"); explicit --name is sent as typed.
|
|
140
|
+
const projectName = name ?? normalizeProjectName(path.basename(source, ".git"));
|
|
91
141
|
const project = await api(config, "POST", "/projects", {
|
|
92
142
|
name: projectName,
|
|
93
143
|
source,
|
|
@@ -97,7 +147,7 @@ const adopt = async (config, args) => {
|
|
|
97
147
|
say(`${dim(" sessions start with:")} mend codex ${dim("(from anywhere —")} --project ${project.name}${dim(")")}`);
|
|
98
148
|
};
|
|
99
149
|
// ─── session launch ─────────────────────────────────────────────────────────
|
|
100
|
-
const findProject = async (config, explicit) => {
|
|
150
|
+
const findProject = async (config, explicit, adoptCwd = false) => {
|
|
101
151
|
const projects = await api(config, "GET", "/projects");
|
|
102
152
|
if (explicit !== null) {
|
|
103
153
|
const named = projects.find((p) => p.name === explicit);
|
|
@@ -106,18 +156,24 @@ const findProject = async (config, explicit) => {
|
|
|
106
156
|
return named;
|
|
107
157
|
}
|
|
108
158
|
const cwd = process.cwd();
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
159
|
+
const project = matchProjectByCwd(projects, cwd);
|
|
160
|
+
if (project !== undefined)
|
|
161
|
+
return project;
|
|
162
|
+
if (!adoptCwd) {
|
|
113
163
|
return fail(`no adopted project matches ${cwd} — run "mend adopt" here first, or name one with --project`);
|
|
114
164
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
165
|
+
// Launching from an un-adopted repo just adopts it — adoption is cheap and
|
|
166
|
+
// has no ceremony to deserve a separate errand.
|
|
167
|
+
const top = gitTopLevel(cwd);
|
|
168
|
+
if (top === null) {
|
|
169
|
+
return fail(`${cwd} is not inside a git repository — mend adopt <source> adopts one explicitly`);
|
|
170
|
+
}
|
|
171
|
+
const adopted = await api(config, "POST", "/projects", {
|
|
172
|
+
name: normalizeProjectName(path.basename(top)),
|
|
173
|
+
source: top,
|
|
174
|
+
});
|
|
175
|
+
say(`${green("✓")} adopted · ${adopted.name} · ${dim(adopted.storePath)}`);
|
|
176
|
+
return adopted;
|
|
121
177
|
};
|
|
122
178
|
const launch = async (config, harness, args) => {
|
|
123
179
|
const projectFlag = args.indexOf("--project");
|
|
@@ -130,7 +186,7 @@ const launch = async (config, harness, args) => {
|
|
|
130
186
|
if (argv.length === 0) {
|
|
131
187
|
return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
|
|
132
188
|
}
|
|
133
|
-
const project = await findProject(config, explicit);
|
|
189
|
+
const project = await findProject(config, explicit, true);
|
|
134
190
|
const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
|
|
135
191
|
harness,
|
|
136
192
|
label: null,
|
|
@@ -146,14 +202,10 @@ const launch = async (config, harness, args) => {
|
|
|
146
202
|
return supervisedRun(config, session, argv);
|
|
147
203
|
}
|
|
148
204
|
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
|
|
205
|
+
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
150
206
|
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);
|
|
207
|
+
await attachOrExit(config, session.id, session.harness);
|
|
208
|
+
exitAfterSessionEnd(config, session.id);
|
|
157
209
|
};
|
|
158
210
|
// ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
|
|
159
211
|
/**
|
|
@@ -162,9 +214,11 @@ const launch = async (config, harness, args) => {
|
|
|
162
214
|
* JSON (resize up, end down). Auth happens once at connect (?token=); after
|
|
163
215
|
* that a keystroke is a frame on an open socket — nothing else on the path.
|
|
164
216
|
* Ctrl+] detaches — the session keeps running and can be reattached from
|
|
165
|
-
* anywhere. Resolves when the session settles or the user detaches
|
|
217
|
+
* anywhere. Resolves when the session settles or the user detaches; the
|
|
218
|
+
* caller decides what each outcome means (commands exit, the dashboard
|
|
219
|
+
* resumes).
|
|
166
220
|
*/
|
|
167
|
-
const attachTty = async (config, sessionId, from) => {
|
|
221
|
+
const attachTty = async (config, sessionId, harness, from) => {
|
|
168
222
|
const url = new URL(`${config.url}/api/tty`);
|
|
169
223
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
170
224
|
url.searchParams.set("session", sessionId);
|
|
@@ -180,51 +234,108 @@ const attachTty = async (config, sessionId, from) => {
|
|
|
180
234
|
});
|
|
181
235
|
}
|
|
182
236
|
catch {
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
})
|
|
190
|
-
|
|
237
|
+
return "unavailable";
|
|
238
|
+
}
|
|
239
|
+
const stopHerdrHint = hintHerdrAttachment(harness);
|
|
240
|
+
let finishAttachment;
|
|
241
|
+
const finished = new Promise((resolve) => {
|
|
242
|
+
finishAttachment = resolve;
|
|
243
|
+
});
|
|
244
|
+
const onClose = () => finishAttachment?.();
|
|
245
|
+
if (ws.readyState === WebSocket.CLOSED)
|
|
246
|
+
finishAttachment?.();
|
|
247
|
+
else
|
|
248
|
+
ws.addEventListener("close", onClose, { once: true });
|
|
249
|
+
const sendResize = () => {
|
|
250
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
251
|
+
return;
|
|
252
|
+
ws.send(JSON.stringify({
|
|
253
|
+
t: "resize",
|
|
254
|
+
cols: process.stdout.columns ?? 80,
|
|
255
|
+
rows: process.stdout.rows ?? 24,
|
|
256
|
+
}));
|
|
257
|
+
};
|
|
191
258
|
const onWinch = () => sendResize();
|
|
192
|
-
process.stdout.on("resize", onWinch);
|
|
193
259
|
const rawTty = process.stdin.isTTY === true;
|
|
194
|
-
|
|
195
|
-
process.stdin.setRawMode(true);
|
|
196
|
-
process.stdin.resume();
|
|
260
|
+
let rawModeEnabled = false;
|
|
197
261
|
let detached = false;
|
|
262
|
+
const onTtyFrame = (event) => {
|
|
263
|
+
if (typeof event.data !== "string") {
|
|
264
|
+
process.stdout.write(Buffer.from(event.data));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const frame = JSON.parse(event.data);
|
|
269
|
+
if (typeof frame !== "object" || frame === null || !("t" in frame) || frame.t !== "end") {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Session lifecycle is authoritative. Start detaching immediately instead
|
|
273
|
+
// of holding the user's terminal for the later close handshake and Mend's
|
|
274
|
+
// settle/checkpoint/harvest work.
|
|
275
|
+
ws.close();
|
|
276
|
+
finishAttachment?.();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
// Unknown text control frame — ignore.
|
|
280
|
+
}
|
|
281
|
+
};
|
|
198
282
|
const onKeys = (data) => {
|
|
199
|
-
if (data.includes(0x1d)) {
|
|
283
|
+
if (detachKeyEnabled && data.includes(0x1d)) {
|
|
200
284
|
// Ctrl+] — detach, leave the session running.
|
|
201
285
|
detached = true;
|
|
202
286
|
ws.close();
|
|
203
287
|
return;
|
|
204
288
|
}
|
|
289
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
290
|
+
return;
|
|
205
291
|
// Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
|
|
206
292
|
ws.send(new Uint8Array(data).buffer);
|
|
207
293
|
};
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
294
|
+
try {
|
|
295
|
+
sendResize();
|
|
296
|
+
process.stdout.on("resize", onWinch);
|
|
297
|
+
if (rawTty) {
|
|
298
|
+
process.stdin.setRawMode(true);
|
|
299
|
+
rawModeEnabled = true;
|
|
300
|
+
}
|
|
301
|
+
process.stdin.resume();
|
|
302
|
+
process.stdin.on("data", onKeys);
|
|
303
|
+
ws.addEventListener("message", onTtyFrame);
|
|
304
|
+
await finished;
|
|
305
|
+
return detached ? "detached" : "ended";
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
process.stdin.off("data", onKeys);
|
|
309
|
+
process.stdout.off("resize", onWinch);
|
|
310
|
+
ws.removeEventListener("message", onTtyFrame);
|
|
311
|
+
ws.removeEventListener("close", onClose);
|
|
312
|
+
if (rawModeEnabled)
|
|
313
|
+
process.stdin.setRawMode(false);
|
|
314
|
+
process.stdin.pause();
|
|
315
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
|
|
316
|
+
ws.close();
|
|
317
|
+
stopHerdrHint();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
/** How every one-shot command handles an attach outcome: detach says so, settle returns. */
|
|
321
|
+
const finishAttach = (config, sessionId, outcome) => {
|
|
322
|
+
if (outcome === "unavailable") {
|
|
323
|
+
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
324
|
+
}
|
|
325
|
+
if (outcome === "detached") {
|
|
223
326
|
say("");
|
|
224
327
|
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${sessionId.slice(0, 8)}`);
|
|
225
328
|
process.exit(0);
|
|
226
329
|
}
|
|
227
330
|
};
|
|
331
|
+
const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
|
|
332
|
+
/** Return terminal control as soon as the terminal reports the observed end. */
|
|
333
|
+
const exitAfterSessionEnd = (config, sessionId) => {
|
|
334
|
+
say("");
|
|
335
|
+
say(`${green("✓")} session ended`);
|
|
336
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
|
|
337
|
+
process.exit(0);
|
|
338
|
+
};
|
|
228
339
|
/** Reattach a terminal to a running session (full scrollback replay, then live). */
|
|
229
340
|
const attach = async (config, args) => {
|
|
230
341
|
const prefix = args.find((a) => !a.startsWith("--"));
|
|
@@ -234,12 +345,10 @@ const attach = async (config, args) => {
|
|
|
234
345
|
const match = sessions.find((s) => s.id.startsWith(prefix));
|
|
235
346
|
if (match === undefined)
|
|
236
347
|
return fail(`no active session matches "${prefix}"`);
|
|
237
|
-
say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}
|
|
238
|
-
say("");
|
|
239
|
-
await attachTty(config, match.id, 0n);
|
|
348
|
+
say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
|
|
240
349
|
say("");
|
|
241
|
-
|
|
242
|
-
|
|
350
|
+
await attachOrExit(config, match.id, match.harness);
|
|
351
|
+
exitAfterSessionEnd(config, match.id);
|
|
243
352
|
};
|
|
244
353
|
// ─── supervised run: platform workspace + PTY + record ──────────────────────
|
|
245
354
|
const supervisedRun = async (config, session, argv) => {
|
|
@@ -299,12 +408,6 @@ const supervisedRun = async (config, session, argv) => {
|
|
|
299
408
|
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
300
409
|
process.exit(settled === "failed" ? 1 : 0);
|
|
301
410
|
};
|
|
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
411
|
/**
|
|
309
412
|
* The second half of the review loop (plan §7.3): find the session with a
|
|
310
413
|
* pending follow-up, deliver it (which reopens the session), and relaunch the
|
|
@@ -364,17 +467,13 @@ const continueSession = async (config, args) => {
|
|
|
364
467
|
// the web session page shows the live terminal throughout.
|
|
365
468
|
const argv = build(followUp.instruction);
|
|
366
469
|
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}`);
|
|
470
|
+
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
371
471
|
say("");
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
process.exit(0);
|
|
472
|
+
await attachOrExit(config, session.id, session.harness);
|
|
473
|
+
exitAfterSessionEnd(config, session.id);
|
|
375
474
|
};
|
|
376
475
|
// ─── resume: rejoin a session — same worktree, restored harness state ───────
|
|
377
|
-
const ACTIVE_STATUSES =
|
|
476
|
+
const ACTIVE_STATUSES = LIVE_STATUSES;
|
|
378
477
|
/**
|
|
379
478
|
* Sessions are continuous work, not runs: `mend resume` rejoins one on a
|
|
380
479
|
* fresh workspace — saved harness state restored, a claude resume is native
|
|
@@ -401,38 +500,251 @@ const resumeCommand = async (config, args) => {
|
|
|
401
500
|
say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
|
|
402
501
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${match.id}`);
|
|
403
502
|
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
|
|
503
|
+
say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
|
|
405
504
|
say("");
|
|
406
|
-
await
|
|
407
|
-
|
|
505
|
+
await attachOrExit(config, match.id, withHarness ?? match.harness);
|
|
506
|
+
exitAfterSessionEnd(config, match.id);
|
|
507
|
+
};
|
|
508
|
+
// ─── rejoin: one entrypoint for an outer multiplexer ────────────────────────
|
|
509
|
+
/**
|
|
510
|
+
* Idempotent entrypoint for outer multiplexers: attach when the session is
|
|
511
|
+
* live, otherwise restore and resume it before attaching. With no id, the
|
|
512
|
+
* newest live session wins, falling back to the newest settled session;
|
|
513
|
+
* --harness narrows the choice.
|
|
514
|
+
*/
|
|
515
|
+
const resumeForRejoin = async (config, sessionId, label) => {
|
|
516
|
+
try {
|
|
517
|
+
await withSpinner(label, request(config, "POST", `/sessions/${sessionId}/resume`, { harness: null }));
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
const refreshed = await api(config, "GET", `/sessions/${sessionId}`);
|
|
522
|
+
if (ACTIVE_STATUSES.has(refreshed.session.status))
|
|
523
|
+
return false;
|
|
524
|
+
return fail(error instanceof Error ? error.message : String(error));
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
const rejoinCommand = async (config, args) => {
|
|
528
|
+
const harnessFlag = args.indexOf("--harness");
|
|
529
|
+
const harness = harnessFlag !== -1 && args[harnessFlag + 1] !== undefined
|
|
530
|
+
? String(args[harnessFlag + 1])
|
|
531
|
+
: null;
|
|
532
|
+
const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== harnessFlag + 1);
|
|
533
|
+
const project = await findProject(config, null);
|
|
534
|
+
const detail = await api(config, "GET", `/projects/${project.id}`);
|
|
535
|
+
const eligible = detail.sessions
|
|
536
|
+
.filter((session) => harness === null || session.harness === harness)
|
|
537
|
+
.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
538
|
+
const newest = eligible.find((session) => ACTIVE_STATUSES.has(session.status)) ?? eligible[0];
|
|
539
|
+
const matches = prefix === undefined
|
|
540
|
+
? newest === undefined
|
|
541
|
+
? []
|
|
542
|
+
: [newest]
|
|
543
|
+
: eligible.filter((session) => session.id.startsWith(prefix));
|
|
544
|
+
if (matches.length === 0) {
|
|
545
|
+
const harnessDescription = harness === null ? "" : ` ${harness}`;
|
|
546
|
+
return fail(prefix === undefined
|
|
547
|
+
? `no${harnessDescription} session to rejoin in project ${project.name}`
|
|
548
|
+
: `no${harnessDescription} session matches "${prefix}" in project ${project.name}`);
|
|
549
|
+
}
|
|
550
|
+
if (matches.length > 1) {
|
|
551
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
552
|
+
}
|
|
553
|
+
const session = matches[0];
|
|
554
|
+
if (session === undefined)
|
|
555
|
+
return fail("session selection failed");
|
|
556
|
+
say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${ACTIVE_STATUSES.has(session.status) ? "already live" : "restoring"}`);
|
|
557
|
+
say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
|
|
558
|
+
let restored = false;
|
|
559
|
+
if (!ACTIVE_STATUSES.has(session.status)) {
|
|
560
|
+
restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
|
|
561
|
+
}
|
|
562
|
+
say(restored
|
|
563
|
+
? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
|
|
564
|
+
: `${green("✓ recording")} · attached to the live session${detachHint()}`);
|
|
408
565
|
say("");
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
566
|
+
let outcome = await attachTty(config, session.id, session.harness, 0n);
|
|
567
|
+
if (outcome === "unavailable") {
|
|
568
|
+
const refreshed = await api(config, "GET", `/sessions/${session.id}`);
|
|
569
|
+
if (!ACTIVE_STATUSES.has(refreshed.session.status)) {
|
|
570
|
+
await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
|
|
571
|
+
}
|
|
572
|
+
outcome = await attachTty(config, session.id, session.harness, 0n);
|
|
573
|
+
}
|
|
574
|
+
finishAttach(config, session.id, outcome);
|
|
575
|
+
exitAfterSessionEnd(config, session.id);
|
|
412
576
|
};
|
|
413
|
-
// ───
|
|
414
|
-
const
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
577
|
+
// ─── projects · sessions: the workbench at a glance ─────────────────────────
|
|
578
|
+
const projectsCommand = async (config) => {
|
|
579
|
+
const [projects, active] = await Promise.all([
|
|
580
|
+
api(config, "GET", "/projects"),
|
|
581
|
+
api(config, "GET", "/sessions"),
|
|
582
|
+
]);
|
|
583
|
+
if (projects.length === 0) {
|
|
584
|
+
say(dim("no adopted projects — mend adopt brings one in"));
|
|
418
585
|
return;
|
|
419
586
|
}
|
|
420
|
-
|
|
421
|
-
|
|
587
|
+
const liveByProject = new Map();
|
|
588
|
+
for (const session of active) {
|
|
589
|
+
liveByProject.set(session.projectId, (liveByProject.get(session.projectId) ?? 0) + 1);
|
|
590
|
+
}
|
|
591
|
+
const nameWidth = Math.max(...projects.map((p) => p.name.length));
|
|
592
|
+
const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length));
|
|
593
|
+
for (const project of projects) {
|
|
594
|
+
const live = liveByProject.get(project.id) ?? 0;
|
|
595
|
+
const liveLabel = live > 0 ? green(`${live} live`) : dim("—");
|
|
596
|
+
say(`${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
const printSessionRow = (row) => {
|
|
600
|
+
const { session, annotation } = row;
|
|
601
|
+
const live = ACTIVE_STATUSES.has(session.status);
|
|
602
|
+
const status = session.status.padEnd(9);
|
|
603
|
+
const facts = [];
|
|
604
|
+
if (session.label !== null)
|
|
605
|
+
facts.push(session.label);
|
|
606
|
+
if (annotation !== undefined && annotation.openComments > 0) {
|
|
607
|
+
facts.push(amber(`${annotation.openComments} open`));
|
|
422
608
|
}
|
|
609
|
+
if (annotation !== undefined && annotation.pendingFollowUp)
|
|
610
|
+
facts.push(amber("follow-up pending"));
|
|
611
|
+
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(" · "))}` : ""}`);
|
|
612
|
+
};
|
|
613
|
+
/**
|
|
614
|
+
* Active sessions by default; --all sweeps every project's detail so settled
|
|
615
|
+
* sessions arrive with their review facts (open comments, pending follow-up).
|
|
616
|
+
*/
|
|
617
|
+
const sessionsCommand = async (config, args) => {
|
|
618
|
+
const all = args.includes("--all");
|
|
619
|
+
const json = args.includes("--json");
|
|
620
|
+
const projectFlag = args.indexOf("--project");
|
|
621
|
+
const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
|
|
622
|
+
? String(args[projectFlag + 1])
|
|
623
|
+
: null;
|
|
624
|
+
const projects = await api(config, "GET", "/projects");
|
|
625
|
+
const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
|
|
626
|
+
if (projectName !== null && scope.length === 0) {
|
|
627
|
+
return fail(`no adopted project named "${projectName}"`);
|
|
628
|
+
}
|
|
629
|
+
let rows;
|
|
630
|
+
if (all || projectName !== null) {
|
|
631
|
+
const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
|
|
632
|
+
rows = details.flatMap((detail) => detail.sessions.map((session) => ({
|
|
633
|
+
session,
|
|
634
|
+
projectName: detail.project.name,
|
|
635
|
+
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
636
|
+
})));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
const active = await api(config, "GET", "/sessions");
|
|
640
|
+
const nameById = new Map(projects.map((p) => [p.id, p.name]));
|
|
641
|
+
rows = active.map((session) => ({
|
|
642
|
+
session,
|
|
643
|
+
projectName: nameById.get(session.projectId) ?? session.projectId.slice(0, 8),
|
|
644
|
+
annotation: undefined,
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
if (rows.length === 0) {
|
|
648
|
+
if (json) {
|
|
649
|
+
say(JSON.stringify({ version: 1, sessions: [] }, null, 2));
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
say(dim(all ? "no sessions" : "no active sessions — mend sessions --all includes settled ones"));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
rows.sort((a, b) => {
|
|
656
|
+
const aLive = ACTIVE_STATUSES.has(a.session.status) ? 1 : 0;
|
|
657
|
+
const bLive = ACTIVE_STATUSES.has(b.session.status) ? 1 : 0;
|
|
658
|
+
if (aLive !== bLive)
|
|
659
|
+
return bLive - aLive;
|
|
660
|
+
return b.session.createdAt.localeCompare(a.session.createdAt);
|
|
661
|
+
});
|
|
662
|
+
if (json) {
|
|
663
|
+
const payload = {
|
|
664
|
+
version: 1,
|
|
665
|
+
sessions: rows.map(({ session, projectName: rowProjectName, annotation }) => ({
|
|
666
|
+
id: session.id,
|
|
667
|
+
projectId: session.projectId,
|
|
668
|
+
projectName: rowProjectName,
|
|
669
|
+
harness: session.harness,
|
|
670
|
+
label: session.label,
|
|
671
|
+
worktree: session.worktree,
|
|
672
|
+
branch: session.branch,
|
|
673
|
+
baseSha: session.baseSha,
|
|
674
|
+
status: session.status,
|
|
675
|
+
summary: session.summary,
|
|
676
|
+
createdAt: session.createdAt,
|
|
677
|
+
reviewUrl: `${config.url.replace(/\/$/, "")}/sessions/${session.id}`,
|
|
678
|
+
review: annotation === undefined
|
|
679
|
+
? null
|
|
680
|
+
: {
|
|
681
|
+
openComments: annotation.openComments,
|
|
682
|
+
totalComments: annotation.totalComments,
|
|
683
|
+
pendingFollowUp: annotation.pendingFollowUp,
|
|
684
|
+
},
|
|
685
|
+
})),
|
|
686
|
+
};
|
|
687
|
+
say(JSON.stringify(payload, null, 2));
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
for (const row of rows)
|
|
691
|
+
printSessionRow(row);
|
|
692
|
+
};
|
|
693
|
+
// ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
|
|
694
|
+
const hasNodeFfi = () => {
|
|
695
|
+
try {
|
|
696
|
+
createRequire(import.meta.url)("node:ffi");
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
catch {
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
/**
|
|
704
|
+
* The dashboard needs @opentui/core, whose Node backend binds the native
|
|
705
|
+
* renderer over node:ffi — present from Node 26, and only behind
|
|
706
|
+
* --experimental-ffi. Gate here and re-exec the same argv with the flag so
|
|
707
|
+
* the user never types it; every other command stays on plain Node >= 22.
|
|
708
|
+
*/
|
|
709
|
+
const dashboard = async (config) => {
|
|
710
|
+
if (process.stdout.isTTY !== true) {
|
|
711
|
+
say(HELP);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (!hasNodeFfi()) {
|
|
715
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
716
|
+
if (Number.isNaN(major) || major < 26) {
|
|
717
|
+
return fail(`the dashboard needs Node >= 26 (node:ffi) — this is ${process.version}; every other command still works`);
|
|
718
|
+
}
|
|
719
|
+
const rerun = spawnSync(process.execPath, ["--experimental-ffi", "--disable-warning=ExperimentalWarning", ...process.argv.slice(1)], { stdio: "inherit" });
|
|
720
|
+
process.exit(rerun.status ?? 0);
|
|
721
|
+
}
|
|
722
|
+
const { runDashboard } = await import("./dashboard.js");
|
|
723
|
+
await runDashboard({
|
|
724
|
+
config,
|
|
725
|
+
cwd: process.cwd(),
|
|
726
|
+
api: (method, route, body) => request(config, method, route, body),
|
|
727
|
+
attachTty: (sessionId, harness) => attachTty(config, sessionId, harness, 0n),
|
|
728
|
+
});
|
|
423
729
|
};
|
|
424
730
|
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
425
731
|
const HELP = `mend — the agent workbench
|
|
426
732
|
|
|
733
|
+
mend the dashboard: every project and session, live
|
|
427
734
|
mend adopt [source] [--name <name>] adopt a repository into the store (default: cwd)
|
|
428
735
|
mend codex|claude|opencode new session worktree + launch the harness in it
|
|
429
736
|
mend run -- <command...> same, with an arbitrary command
|
|
430
737
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
431
738
|
mend continue [session-id] resume a session with its pending review follow-up
|
|
432
739
|
mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
|
|
433
|
-
mend
|
|
740
|
+
mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
|
|
741
|
+
mend projects adopted projects and their live sessions
|
|
742
|
+
mend sessions [--all] [--project p] [--json]
|
|
743
|
+
sessions with review facts; JSON is stable for integrations
|
|
744
|
+
mend status active sessions (alias of mend sessions)
|
|
434
745
|
|
|
435
746
|
server: MEND_URL (default http://localhost:3105) · auth: MEND_TOKEN
|
|
747
|
+
detach key: Ctrl+] (set MEND_DETACH_KEY=none when an outer multiplexer owns detaching)
|
|
436
748
|
config file: ~/.mend/cli.json { "url": ..., "token": ... }
|
|
437
749
|
`;
|
|
438
750
|
const main = async () => {
|
|
@@ -452,9 +764,16 @@ const main = async () => {
|
|
|
452
764
|
return continueSession(config, rest);
|
|
453
765
|
case "resume":
|
|
454
766
|
return resumeCommand(config, rest);
|
|
767
|
+
case "rejoin":
|
|
768
|
+
return rejoinCommand(config, rest);
|
|
769
|
+
case "projects":
|
|
770
|
+
return projectsCommand(config);
|
|
771
|
+
case "sessions":
|
|
455
772
|
case "status":
|
|
456
|
-
return
|
|
773
|
+
return sessionsCommand(config, rest);
|
|
457
774
|
case undefined:
|
|
775
|
+
case "ui":
|
|
776
|
+
return dashboard(config);
|
|
458
777
|
case "help":
|
|
459
778
|
case "--help":
|
|
460
779
|
say(HELP);
|