@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/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 api = async (config, method, route, body) => {
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
- return fail(`cannot reach the Mend server at ${config.url} — is it running?`);
82
+ throw new Error(`cannot reach the Mend server at ${config.url} — is it running?`);
46
83
  }
47
84
  if (response.status === 401) {
48
- return fail(`unauthorized — set MEND_TOKEN (or "token" in ${CONFIG_PATH}) to a bearer token for ${config.url}`);
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
- return fail(parsed.message ?? `${method} ${route} → ${response.status}`);
92
+ message = parsed.message ?? message;
55
93
  }
56
94
  catch {
57
- return fail(`${method} ${route} → ${response.status}`);
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
- const rawSource = positional[0] ?? ".";
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
- const projectName = name ?? path.basename(source, ".git");
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 byOrigin = projects.find((p) => p.originUrl !== null && cwd.startsWith(p.originUrl));
110
- const byName = projects.find((p) => p.name === path.basename(cwd));
111
- const project = byOrigin ?? byName;
112
- if (project === undefined) {
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
- return project;
116
- };
117
- const HARNESS_COMMANDS = {
118
- codex: ["codex"],
119
- claude: ["claude"],
120
- opencode: ["opencode"],
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 · detach: ${dim("Ctrl+]")}`);
205
+ say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
150
206
  say("");
151
- await attachTty(config, session.id, 0n);
152
- const settled = await api(config, "GET", `/sessions/${session.id}`);
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 fail(`tty attach unavailable: could not connect to ${url.host}`);
184
- }
185
- const sendResize = () => ws.send(JSON.stringify({
186
- t: "resize",
187
- cols: process.stdout.columns ?? 80,
188
- rows: process.stdout.rows ?? 24,
189
- }));
190
- sendResize();
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
- if (rawTty)
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
- process.stdin.on("data", onKeys);
209
- ws.addEventListener("message", (event) => {
210
- if (typeof event.data === "string")
211
- return; // control frames ({"t":"end"}) — close follows
212
- process.stdout.write(Buffer.from(event.data));
213
- });
214
- await new Promise((resolve) => {
215
- ws.addEventListener("close", () => resolve(), { once: true });
216
- });
217
- process.stdin.off("data", onKeys);
218
- process.stdout.off("resize", onWinch);
219
- if (rawTty)
220
- process.stdin.setRawMode(false);
221
- process.stdin.pause();
222
- if (detached) {
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))} · detach: ${dim("Ctrl+]")}`);
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
- say(`${green("✓")} session settled`);
242
- process.exit(0);
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 · detach: ${dim("Ctrl+]")}`);
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
- say(`${green("✓")} session ${settled.session.status} · recorded`);
373
- say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
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 = new Set(["starting", "running", "waiting", "idle"]);
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 · detach: ${dim("Ctrl+]")}`);
503
+ say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
405
504
  say("");
406
- await attachTty(config, match.id, 0n);
407
- const settled = await api(config, "GET", `/sessions/${match.id}`);
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
- say(`${green("✓")} session ${settled.session.status} · recorded`);
410
- say(`${cobalt(" review")} · ${config.url}/sessions/${match.id}`);
411
- process.exit(0);
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
- // ─── status ─────────────────────────────────────────────────────────────────
414
- const status = async (config) => {
415
- const sessions = await api(config, "GET", "/sessions");
416
- if (sessions.length === 0) {
417
- say(dim("no active sessions"));
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
- for (const session of sessions) {
421
- say(`${session.harness} ${dim(session.id.slice(0, 8))} ${session.status} ${dim(session.branch)}`);
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 status active sessions
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 status(config);
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);