@sealant/mend 0.3.0 → 0.5.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/dist/dashboard.js CHANGED
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tan
5
5
  import { useEffect, useRef, useState } from "react";
6
6
  import { reviewTargetForSession } from "./review-workflow.js";
7
7
  import { ReviewScreen } from "./review.js";
8
- import { HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
8
+ import { cwdFacts, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
9
9
  import { openUrl } from "./terminal.js";
10
10
  import { AMBER, BG, COBALT, FAINT, GREEN, INK, MUTED, RED, RULE, WASH } from "./tui-theme.js";
11
11
  const STATUS_GLYPH = {
@@ -66,7 +66,7 @@ const itemKey = (item) => item.kind === "session"
66
66
  : String(item.harness);
67
67
  const itemHeight = (view) => (view.kind === "sessions" ? 2 : 1);
68
68
  const homeView = (cwd, data) => {
69
- const home = matchProjectByCwd(data.projects, cwd);
69
+ const home = matchProjectByCwd(data.projects, cwdFacts(cwd));
70
70
  return home === undefined ? { kind: "projects" } : { kind: "sessions", projectId: home.id };
71
71
  };
72
72
  const deriveItems = (view, data) => {
package/dist/main.js CHANGED
@@ -8,7 +8,7 @@ import * as path from "node:path";
8
8
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
9
  import { formatLoadReport } from "./env.js";
10
10
  import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
11
- import { CONTINUE_COMMANDS, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, } from "./shared.js";
11
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
12
12
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
13
13
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
14
14
  const mendCliHome = () => {
@@ -37,6 +37,14 @@ const fail = (message) => {
37
37
  process.stderr.write(`mend: ${message}\n`);
38
38
  process.exit(1);
39
39
  };
40
+ const parseMendUrl = (value) => {
41
+ try {
42
+ return new URL(value);
43
+ }
44
+ catch {
45
+ return fail(`"${value}" is not a valid Mend URL`);
46
+ }
47
+ };
40
48
  const paint = (code) => (text) => process.stdout.isTTY ? `[${code}m${text}` : text;
41
49
  const dim = paint("2");
42
50
  const green = paint("32");
@@ -110,7 +118,12 @@ const request = async (config, method, route, body) => {
110
118
  }
111
119
  throw new Error(message);
112
120
  }
113
- return JSON.parse(text);
121
+ try {
122
+ return JSON.parse(text);
123
+ }
124
+ catch {
125
+ throw new Error(`${method} ${route} returned invalid JSON from ${config.url}`);
126
+ }
114
127
  };
115
128
  /** The same call for one-shot commands: any failure prints and exits. */
116
129
  const api = async (config, method, route, body) => {
@@ -121,7 +134,6 @@ const api = async (config, method, route, body) => {
121
134
  return fail(error instanceof Error ? error.message : String(error));
122
135
  }
123
136
  };
124
- const worktreePathOf = (storePath, worktree) => path.join(path.dirname(storePath), "worktrees", worktree);
125
137
  /** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
126
138
  const withSpinner = async (label, work) => {
127
139
  if (process.stdout.isTTY !== true)
@@ -187,7 +199,7 @@ const findProject = async (config, explicit, adoptCwd = false) => {
187
199
  return named;
188
200
  }
189
201
  const cwd = process.cwd();
190
- const project = matchProjectByCwd(projects, cwd);
202
+ const project = matchProjectByCwd(projects, cwdFacts(cwd));
191
203
  if (project !== undefined)
192
204
  return project;
193
205
  if (!adoptCwd) {
@@ -207,21 +219,29 @@ const findProject = async (config, explicit, adoptCwd = false) => {
207
219
  return adopted;
208
220
  };
209
221
  const launch = async (config, harness, args) => {
210
- const projectFlag = args.indexOf("--project");
211
- const explicit = projectFlag !== -1 && args[projectFlag + 1] !== undefined
212
- ? String(args[projectFlag + 1])
213
- : null;
214
- const dashdash = args.indexOf("--");
215
- const custom = dashdash !== -1 ? args.slice(dashdash + 1) : [];
216
- const argv = harness === "run" ? custom : (HARNESS_COMMANDS[harness] ?? []);
222
+ const parsed = parseLaunchArgs(args);
223
+ if (parsed.error !== null)
224
+ return fail(parsed.error);
225
+ const structured = parsed.prompt !== null ||
226
+ parsed.model !== null ||
227
+ parsed.effort !== null ||
228
+ parsed.ask ||
229
+ parsed.fast;
230
+ if (harness === "run" && structured) {
231
+ return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
232
+ }
233
+ const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
217
234
  if (argv.length === 0) {
218
235
  return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
219
236
  }
220
- const project = await findProject(config, explicit, true);
237
+ const project = await findProject(config, parsed.project, true);
238
+ // Say which project the cwd resolved to before anything is created — a
239
+ // wrong guess should be visible here, not discovered in the tree later.
240
+ say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
221
241
  const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
222
242
  harness,
223
243
  label: null,
224
- base: null,
244
+ base: parsed.base,
225
245
  });
226
246
  say(`${green("✓")} worktree ${session.worktree} ${dim(`· branch ${session.branch}`)}`);
227
247
  say(`${green("✓")} base ${dim(session.baseSha.slice(0, 12))} · session ${dim(session.id.slice(0, 8))}`);
@@ -232,7 +252,18 @@ const launch = async (config, harness, args) => {
232
252
  if (harness === "run") {
233
253
  return supervisedRun(config, session, argv);
234
254
  }
235
- await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
255
+ // A structured start sends no argv: the server composes the harness flags
256
+ // (one shared mapping) and names the session from the prompt immediately.
257
+ const launchBody = structured
258
+ ? {
259
+ ...(parsed.prompt === null ? {} : { prompt: parsed.prompt }),
260
+ ...(parsed.model === null ? {} : { model: parsed.model }),
261
+ ...(parsed.effort === null ? {} : { effort: parsed.effort }),
262
+ ...(parsed.ask ? { permissionMode: "ask" } : {}),
263
+ ...(parsed.fast ? { speed: "fast" } : {}),
264
+ }
265
+ : { argv };
266
+ await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, launchBody));
236
267
  say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
237
268
  say("");
238
269
  await attachOrExit(config, session.id, session.harness);
@@ -250,14 +281,14 @@ const launch = async (config, harness, args) => {
250
281
  * resumes).
251
282
  */
252
283
  const attachTty = async (config, sessionId, harness, from, processId, options) => {
253
- const url = new URL(`${config.url}/api/tty`);
284
+ const url = parseMendUrl(`${config.url}/api/tty`);
254
285
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
255
286
  // Process addressing reaches any PTY in the workspace (a shell); the
256
287
  // session form remains the agent's PTY.
257
- if (processId !== undefined)
258
- url.searchParams.set("process", processId);
259
- else
288
+ if (processId === undefined)
260
289
  url.searchParams.set("session", sessionId);
290
+ else
291
+ url.searchParams.set("process", processId);
261
292
  url.searchParams.set("from", from.toString());
262
293
  if (config.token !== null)
263
294
  url.searchParams.set("token", config.token);
@@ -411,11 +442,11 @@ const pickSessionInteractively = async (rows) => {
411
442
  * picker, and a non-interactive caller never gets a silent guess.
412
443
  */
413
444
  const resolveLiveSession = async (config, prefix) => {
414
- const sessions = await api(config, "GET", "/sessions");
445
+ const sessions = await api(config, "GET", "/sessions?retained=1");
415
446
  if (prefix !== undefined) {
416
447
  const matches = sessions.filter((s) => s.id.startsWith(prefix));
417
448
  if (matches.length === 0)
418
- return fail(`no live session matches "${prefix}"`);
449
+ return fail(`no live or retained session matches "${prefix}"`);
419
450
  const exact = matches[0];
420
451
  if (matches.length > 1 || exact === undefined) {
421
452
  return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
@@ -423,11 +454,11 @@ const resolveLiveSession = async (config, prefix) => {
423
454
  return exact;
424
455
  }
425
456
  const projects = await api(config, "GET", "/projects");
426
- const project = matchProjectByCwd(projects, process.cwd());
457
+ const project = matchProjectByCwd(projects, cwdFacts(process.cwd()));
427
458
  const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
428
459
  const only = candidates[0];
429
460
  if (only === undefined) {
430
- return fail("no live session — start one with mend codex|claude|opencode");
461
+ return fail("no live or retained session — start one with mend codex|claude|opencode");
431
462
  }
432
463
  if (candidates.length === 1)
433
464
  return only;
@@ -464,16 +495,68 @@ const shellCommand = async (config, args) => {
464
495
  say(`${green("✓")} shell ended`);
465
496
  process.exit(0);
466
497
  };
467
- const serviceUrl = (config, service) => {
468
- const host = new URL(config.url).hostname || "localhost";
469
- // A UDP Service has no page to open — the endpoint is the whole fact.
470
- return service.protocol === "udp"
471
- ? `${host}:${service.hostPort ?? "?"} (udp)`
472
- : `http://${host}:${service.hostPort ?? "?"}`;
498
+ const flattenService = (view) => {
499
+ const attempt = view.service.currentAttemptId === null
500
+ ? null
501
+ : (view.attempts.find((candidate) => candidate.id === view.service.currentAttemptId) ?? null);
502
+ const observation = view.currentForward !== null && view.latestObservation?.forwardId === view.currentForward.id
503
+ ? view.latestObservation
504
+ : null;
505
+ const endpoint = view.endpoints.find((candidate) => candidate.scope === "private") ?? view.endpoints[0] ?? null;
506
+ const browserUrl = view.endpoints.find((candidate) => candidate.browserUrl !== null)?.browserUrl ?? null;
507
+ return {
508
+ id: view.service.id,
509
+ processId: attempt?.id ?? null,
510
+ sessionId: view.service.sessionId,
511
+ label: view.service.name,
512
+ status: observation?.state ?? view.currentForward?.state ?? attempt?.status ?? "stopped",
513
+ workspacePort: view.service.workspacePort,
514
+ hostPort: endpoint?.hostPort ?? null,
515
+ authority: endpoint?.authority ?? null,
516
+ browserUrl,
517
+ exposureScope: endpoint?.scope ?? null,
518
+ mendAuthentication: endpoint?.mendAuthentication ?? null,
519
+ protocol: view.service.transport,
520
+ sealantSessionId: attempt?.sealantSessionId ?? null,
521
+ attemptExitedAt: attempt?.exitedAt ?? null,
522
+ argv: attempt?.argv ?? [],
523
+ workspaceExpiresAt: view.workspaceExpiresAt,
524
+ workspaceTtlRenewedAt: view.workspaceTtlRenewedAt,
525
+ workspaceTtlRenewalFailedAt: view.workspaceTtlRenewalFailedAt,
526
+ workspaceTtlRenewalError: view.workspaceTtlRenewalError,
527
+ };
528
+ };
529
+ const fetchServiceViews = (config, all = false) => api(config, "GET", `/services${all ? "?all=1" : ""}`);
530
+ const fetchServices = async (config, all = false) => (await fetchServiceViews(config, all)).map(flattenService);
531
+ const mutateService = async (config, method, endpointPath, body) => flattenService(await api(config, method, endpointPath, body));
532
+ const serviceUrl = (service) => service.browserUrl ??
533
+ (service.authority === null
534
+ ? "unbound"
535
+ : `${service.authority}${service.protocol === "udp" ? " (udp)" : ""}`);
536
+ const serviceExposure = (service) => {
537
+ if (service.exposureScope === null)
538
+ return "";
539
+ if (service.exposureScope === "private") {
540
+ return "No Mend sign-in protects this port. Anyone who can reach this private address can connect.";
541
+ }
542
+ return `Loopback only · Mend auth: ${service.mendAuthentication ?? "unknown"}`;
543
+ };
544
+ const printWorkspaceTtlFailure = (service) => {
545
+ if (service.workspaceTtlRenewalError === null)
546
+ return;
547
+ say(amber(` workspace TTL renewal failed · ${service.workspaceTtlRenewalError}`));
548
+ say(dim(` last renewed ${service.workspaceTtlRenewedAt ?? "unknown"} · known expiry ${service.workspaceExpiresAt ?? "unknown"} · failed ${service.workspaceTtlRenewalFailedAt ?? "unknown"}`));
549
+ };
550
+ const printServiceEndpoint = (service) => {
551
+ const exposure = serviceExposure(service);
552
+ say(` ${cobalt(serviceUrl(service))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
553
+ printWorkspaceTtlFailure(service);
473
554
  };
474
- const printService = (config, service) => {
555
+ const printService = (service) => {
475
556
  const status = service.status === "reachable" ? green(service.status) : amber(service.status);
476
- 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))}`);
557
+ const exposure = serviceExposure(service);
558
+ say(`${(service.label ?? service.id.slice(0, 8)).padEnd(12)} ${dim(`:${service.workspacePort ?? "?"}${service.protocol === "udp" ? "/udp" : ""} →`)} ${serviceUrl(service)} ${status} ${dim(service.id.slice(0, 8))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
559
+ printWorkspaceTtlFailure(service);
477
560
  };
478
561
  /**
479
562
  * Adopt an already-listening workspace port as a Service: Mend binds a host
@@ -484,10 +567,18 @@ const serviceAdd = async (config, args) => {
484
567
  const nameFlag = args.indexOf("--name");
485
568
  const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
486
569
  const protocol = args.includes("--udp") ? "udp" : "tcp";
570
+ const http = args.includes("--http");
571
+ const https = args.includes("--https");
572
+ if (http && https)
573
+ return fail("Choose either --http or --https, not both.");
574
+ const browserScheme = https ? "https" : http ? "http" : null;
575
+ if (protocol === "udp" && browserScheme !== null) {
576
+ return fail("UDP Services cannot use --http or --https");
577
+ }
487
578
  const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
488
579
  const portRaw = positional.find((a) => /^\d+$/.test(a));
489
580
  if (portRaw === undefined) {
490
- return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
581
+ return fail("usage: mend service add [session] <port> [--name <n>] [--udp] [--http|--https]");
491
582
  }
492
583
  const port = Number(portRaw);
493
584
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -495,13 +586,14 @@ const serviceAdd = async (config, args) => {
495
586
  }
496
587
  const prefix = positional.find((a) => a !== portRaw);
497
588
  const session = await resolveLiveSession(config, prefix);
498
- const service = await api(config, "POST", `/sessions/${session.id}/services`, {
589
+ const service = await mutateService(config, "POST", `/sessions/${session.id}/services`, {
499
590
  port,
500
591
  name,
501
592
  protocol,
593
+ browserScheme,
502
594
  });
503
595
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
504
- say(` ${cobalt(serviceUrl(config, service))}`);
596
+ printServiceEndpoint(service);
505
597
  if (protocol === "udp") {
506
598
  say(dim(` udp — a reply is the only reachability signal; silence just relays`));
507
599
  }
@@ -510,19 +602,19 @@ const serviceAdd = async (config, args) => {
510
602
  }
511
603
  };
512
604
  const serviceList = async (config) => {
513
- const services = await api(config, "GET", "/services");
605
+ const services = await fetchServices(config);
514
606
  if (services.length === 0) {
515
607
  say(dim("no live services — mend service add <port> adopts a listening one"));
516
608
  return;
517
609
  }
518
610
  for (const service of services)
519
- printService(config, service);
611
+ printService(service);
520
612
  };
521
613
  const serviceStop = async (config, args) => {
522
614
  const needle = args.find((a) => !a.startsWith("--"));
523
615
  if (needle === undefined)
524
616
  return fail("usage: mend service stop <name-or-id-prefix>");
525
- const services = await api(config, "GET", "/services");
617
+ const services = await fetchServices(config);
526
618
  const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
527
619
  if (matches.length === 0)
528
620
  return fail(`no live service matches "${needle}"`);
@@ -530,11 +622,11 @@ const serviceStop = async (config, args) => {
530
622
  if (matches.length > 1 || match === undefined) {
531
623
  return fail(`"${needle}" is ambiguous — use more of the id`);
532
624
  }
533
- const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
625
+ const stoppedService = await mutateService(config, "POST", `/services/${match.id}/stop`);
534
626
  say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
535
627
  };
536
628
  const findLiveService = async (config, needle) => {
537
- const services = await api(config, "GET", "/services");
629
+ const services = await fetchServices(config);
538
630
  const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
539
631
  if (matches.length === 0)
540
632
  return fail(`no live service matches "${needle}"`);
@@ -552,7 +644,7 @@ const findLiveService = async (config, needle) => {
552
644
  */
553
645
  const serviceRun = async (config, args) => {
554
646
  const dashdash = args.indexOf("--");
555
- const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
647
+ const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] [--http|--https] -- <command...>\n" +
556
648
  " mend service run [session] <name> (a declared recipe)";
557
649
  // No explicit command = a DECLARED Service: resolve the name against the
558
650
  // session worktree's mend.toml and start (or adopt) its recipe.
@@ -571,27 +663,15 @@ const serviceRun = async (config, args) => {
571
663
  ? `no mend.toml recipes in this worktree — declare [service.${name}] first`
572
664
  : `no recipe named "${name}" — declared: ${known}`);
573
665
  }
574
- if (recipe.command === null) {
575
- // A port-only recipe is an adopt: something else starts the listener.
576
- const service = await api(config, "POST", `/sessions/${session.id}/services`, {
577
- port: recipe.port,
578
- name: recipe.name,
579
- protocol: recipe.protocol,
580
- });
581
- say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
582
- say(` ${cobalt(serviceUrl(config, service))}`);
583
- return;
584
- }
585
- const service = await withSpinner(recipe.protocol === "udp"
586
- ? `starting ${recipe.name} (udp :${recipe.port})…`
587
- : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
588
- argv: ["sh", "-c", recipe.command],
589
- port: recipe.port,
666
+ const service = await withSpinner(recipe.command === null
667
+ ? `adopting ${recipe.name} on :${recipe.port}…`
668
+ : recipe.protocol === "udp"
669
+ ? `starting ${recipe.name} (udp :${recipe.port})…`
670
+ : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/recipe`, {
590
671
  name: recipe.name,
591
- protocol: recipe.protocol,
592
672
  }));
593
673
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
594
- say(` ${cobalt(serviceUrl(config, service))}`);
674
+ printServiceEndpoint(service);
595
675
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
596
676
  return;
597
677
  }
@@ -600,74 +680,101 @@ const serviceRun = async (config, args) => {
600
680
  return fail(usage);
601
681
  const head = args.slice(0, dashdash);
602
682
  const portFlag = head.indexOf("--port");
603
- const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
683
+ const port = portFlag === -1 ? Number.NaN : Number(head[portFlag + 1]);
604
684
  if (!Number.isInteger(port) || port < 1 || port > 65535)
605
685
  return fail(usage);
606
686
  const nameFlag = head.indexOf("--name");
607
687
  const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
608
688
  const protocol = head.includes("--udp") ? "udp" : "tcp";
689
+ const http = head.includes("--http");
690
+ const https = head.includes("--https");
691
+ if (http && https)
692
+ return fail(usage);
693
+ const browserScheme = https ? "https" : http ? "http" : null;
694
+ if (protocol === "udp" && browserScheme !== null)
695
+ return fail(usage);
609
696
  const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
610
697
  const session = await resolveLiveSession(config, prefix);
611
698
  const service = await withSpinner(protocol === "udp"
612
699
  ? `starting ${name ?? argv[0]} (udp :${port})…`
613
- : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
700
+ : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
614
701
  argv,
615
702
  port,
616
703
  name,
617
704
  protocol,
705
+ browserScheme,
618
706
  }));
619
707
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
620
- say(` ${cobalt(serviceUrl(config, service))}`);
708
+ printServiceEndpoint(service);
621
709
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
622
710
  };
623
- /**
624
- * Watch a supervised Service's output: record replay, then live tail. A DEAD
625
- * Service still answers — its record outlives the process — printed once
626
- * instead of followed.
627
- */
711
+ /** Read sequence-addressed PTY output without attaching an input-capable terminal. */
628
712
  const serviceLogs = async (config, args) => {
629
- const needle = args.find((a) => !a.startsWith("--"));
630
- if (needle === undefined)
631
- return fail("usage: mend service logs <name-or-id-prefix>");
632
- const everything = await api(config, "GET", "/services?all=1");
633
- const matches = everything.filter((service) => service.label === needle || service.id.startsWith(needle));
713
+ const fromFlag = args.indexOf("--from");
714
+ const from = fromFlag === -1 ? "0" : args[fromFlag + 1];
715
+ const needle = args.find((argument, index) => !argument.startsWith("--") && index !== fromFlag + 1);
716
+ if (needle === undefined || from === undefined || !/^(0|[1-9]\d*)$/.test(from)) {
717
+ return fail("usage: mend service logs <name-or-id-prefix> [--from <decimal-sequence>]");
718
+ }
719
+ const everything = await fetchServiceViews(config, true);
720
+ const matches = everything.filter((view) => view.service.name === needle ||
721
+ view.service.id.startsWith(needle) ||
722
+ view.attempts.some((attempt) => attempt.id.startsWith(needle)));
634
723
  if (matches.length === 0)
635
724
  return fail(`no service matches "${needle}"`);
636
- // Prefer the live one; otherwise the newest ended one (list is newest-first).
637
- const service = matches.find((m) => m.status !== "exited" && m.status !== "stopped") ?? matches[0];
638
- if (service === undefined)
725
+ const view = matches.find((candidate) => {
726
+ const current = candidate.service.currentAttemptId === null
727
+ ? null
728
+ : candidate.attempts.find((attempt) => attempt.id === candidate.service.currentAttemptId);
729
+ return current !== null && current !== undefined && current.exitedAt === null;
730
+ }) ?? matches[0];
731
+ if (view === undefined)
639
732
  return fail(`no service matches "${needle}"`);
640
- if (service.sealantSessionId === null) {
733
+ const currentAttempt = view.service.currentAttemptId === null
734
+ ? null
735
+ : (view.attempts.find((candidate) => candidate.id === view.service.currentAttemptId) ?? null);
736
+ const attempt = view.attempts.find((candidate) => candidate.id.startsWith(needle)) ??
737
+ currentAttempt ??
738
+ view.attempts.findLast((candidate) => candidate.sealantSessionId !== null) ??
739
+ null;
740
+ if (attempt?.sealantSessionId === null || attempt === null) {
641
741
  return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
642
742
  }
643
- if (service.status === "exited" || service.status === "stopped") {
644
- // Post-mortem: print the record, don't attach.
645
- const output = await api(config, "GET", `/processes/${service.id}/output`);
646
- say(dim(`${service.label ?? service.id.slice(0, 8)} · ${service.status} — recorded output:`));
647
- say("");
648
- process.stdout.write(output.text.endsWith("\n") ? output.text : `${output.text}\n`);
649
- process.exit(0);
650
- }
651
- say(dim(`following ${service.label ?? service.id.slice(0, 8)} — Ctrl+C detaches, the service keeps running`));
743
+ const label = view.service.name;
744
+ say(dim(attempt.exitedAt === null
745
+ ? `following ${label} from sequence ${from} — Ctrl+C detaches, the Service keeps running`
746
+ : `${label} · ${attempt.status} — recorded output from sequence ${from}`));
652
747
  say("");
653
- const outcome = await attachTty(config, service.sessionId, "service", 0n, service.id, {
654
- readOnly: true,
655
- });
656
- if (outcome === "unavailable") {
657
- return fail(`tty attach unavailable: could not connect to ${config.url}`);
748
+ let cursor = from;
749
+ let telemetryReported = false;
750
+ for (;;) {
751
+ const page = await api(config, "GET", `/processes/${attempt.id}/logs?from=${encodeURIComponent(cursor)}&limit=256`);
752
+ if (!telemetryReported) {
753
+ say(dim(`telemetry loss: ${page.telemetryLoss} · ${page.telemetryNote}`));
754
+ telemetryReported = true;
755
+ }
756
+ for (const chunk of page.chunks) {
757
+ process.stdout.write(Buffer.from(chunk.dataBase64, "base64"));
758
+ }
759
+ const advanced = page.nextFrom !== cursor;
760
+ cursor = page.nextFrom;
761
+ const ended = page.status === "exited" || page.status === "failed";
762
+ if (ended && !advanced)
763
+ break;
764
+ if (!advanced)
765
+ await new Promise((resolve) => setTimeout(resolve, 250));
658
766
  }
659
767
  say("");
660
- say(dim("stream ended"));
661
- process.exit(0);
768
+ say(dim(`stream ended · next sequence ${cursor}`));
662
769
  };
663
770
  const serviceRestart = async (config, args) => {
664
771
  const needle = args.find((a) => !a.startsWith("--"));
665
772
  if (needle === undefined)
666
773
  return fail("usage: mend service restart <name-or-id-prefix>");
667
774
  const service = await findLiveService(config, needle);
668
- const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, api(config, "POST", `/services/${service.id}/restart`));
775
+ const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, mutateService(config, "POST", `/services/${service.id}/restart`));
669
776
  say(`${green("✓")} restarted · ${restarted.status}`);
670
- say(` ${cobalt(serviceUrl(config, restarted))}`);
777
+ printServiceEndpoint(restarted);
671
778
  };
672
779
  /**
673
780
  * Scaffold mend.toml from the project's own manifests. Static suggestion,
@@ -910,7 +1017,7 @@ const keysShare = async (config) => {
910
1017
  if (agentSock === undefined || agentSock === "") {
911
1018
  return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
912
1019
  }
913
- const url = new URL(`${config.url}/api/keys/bridge/ws`);
1020
+ const url = parseMendUrl(`${config.url}/api/keys/bridge/ws`);
914
1021
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
915
1022
  url.searchParams.set("host", os.hostname());
916
1023
  if (config.token !== null)
@@ -1040,8 +1147,9 @@ const dotfilesShow = async (config) => {
1040
1147
  }
1041
1148
  else {
1042
1149
  const branch = dotfiles.repository.ref ?? "default branch";
1150
+ const subdir = dotfiles.repository.subdirectory === null ? [] : [`${dotfiles.repository.subdirectory}/`];
1043
1151
  const bootstrap = dotfiles.repository.bootstrap ? "install.sh on" : "install.sh off";
1044
- say(`repo ${dotfiles.repository.url} ${dim(`(${branch} · ${bootstrap})`)}`);
1152
+ say(`repo ${dotfiles.repository.url} ${dim(`(${[branch, ...subdir, bootstrap].join(" · ")})`)}`);
1045
1153
  }
1046
1154
  if (dotfiles.snapshot === null) {
1047
1155
  say(`snapshot ${dim("none — sync from this machine: mend dotfiles sync --all")}`);
@@ -1101,7 +1209,7 @@ const envLoad = async (config, args) => {
1101
1209
  // `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
1102
1210
  // ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
1103
1211
  const secretFlag = args.indexOf("--secret");
1104
- const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
1212
+ const secretArg = secretFlag === -1 ? undefined : args[secretFlag + 1];
1105
1213
  const secretNames = secretArg !== undefined &&
1106
1214
  !secretArg.startsWith("--") &&
1107
1215
  !secretArg.includes("/") &&
@@ -1317,9 +1425,14 @@ const supervisedRun = async (config, session, argv) => {
1317
1425
  }
1318
1426
  if (event.type === "session") {
1319
1427
  const detail = await api(config, "GET", `/sessions/${session.id}`);
1320
- if (["completed", "failed", "stopped"].includes(detail.session.status)) {
1321
- settled = detail.session.status;
1322
- }
1428
+ // The agent's own end settles the wait: the session fold may read `idle` while a
1429
+ // shell still holds the workspace.
1430
+ const ended = agentOutcome(detail.currentAgent) ??
1431
+ (["completed", "failed", "stopped"].includes(detail.session.status)
1432
+ ? detail.session.status
1433
+ : null);
1434
+ if (ended !== null)
1435
+ settled = ended;
1323
1436
  }
1324
1437
  }
1325
1438
  }
@@ -1331,19 +1444,14 @@ const supervisedRun = async (config, session, argv) => {
1331
1444
  };
1332
1445
  /**
1333
1446
  * The second half of the review loop (plan §7.3): find the session with a
1334
- * pending follow-up, deliver it (which reopens the session), and relaunch the
1335
- * harness in the same worktree with the instruction as its prompt.
1447
+ * pending follow-up and retry the one server-owned delivery operation. The
1448
+ * server persists intent, launches, correlates membership, and finalizes.
1336
1449
  */
1337
1450
  const continueSession = async (config, args) => {
1338
1451
  const explicitSession = args.find((a) => !a.startsWith("--")) ?? null;
1339
1452
  let sessionId = explicitSession;
1340
1453
  let followUp = null;
1341
- if (sessionId !== null) {
1342
- followUp = await api(config, "GET", `/sessions/${sessionId}/follow-up`);
1343
- if (followUp === null)
1344
- return fail(`session ${sessionId} has no pending follow-up`);
1345
- }
1346
- else {
1454
+ if (sessionId === null) {
1347
1455
  // Search the cwd's project, newest sessions first.
1348
1456
  const project = await findProject(config, null);
1349
1457
  const detail = await api(config, "GET", `/projects/${project.id}`);
@@ -1359,42 +1467,52 @@ const continueSession = async (config, args) => {
1359
1467
  return fail("no session with a pending follow-up — send one from the review first");
1360
1468
  }
1361
1469
  }
1470
+ else {
1471
+ followUp = await api(config, "GET", `/sessions/${sessionId}/follow-up`);
1472
+ if (followUp === null)
1473
+ return fail(`session ${sessionId} has no pending follow-up`);
1474
+ }
1362
1475
  const detail = await api(config, "GET", `/sessions/${sessionId}`);
1363
1476
  const session = detail.session;
1364
- const projects = await api(config, "GET", "/projects");
1365
- const project = projects.find((p) => p.id === session.projectId);
1366
- if (project === undefined)
1367
- return fail(`project ${session.projectId} not found`);
1368
- const worktree = worktreePathOf(project.storePath, session.worktree);
1369
- const build = CONTINUE_COMMANDS[session.harness];
1370
1477
  say(`${green("✓")} follow-up for session ${dim(session.id.slice(0, 8))} · ${session.branch}`);
1371
1478
  say(dim(" instruction:"));
1372
1479
  for (const line of followUp.instruction.split("\n").slice(0, 6))
1373
1480
  say(dim(` │ ${line}`));
1374
1481
  if (followUp.instruction.split("\n").length > 6)
1375
1482
  say(dim(" │ …"));
1376
- if (build === undefined) {
1377
- say("");
1378
- say(`${amber(" cannot relaunch")} ${dim(`— harness "${session.harness}" has no known resume command.`)}`);
1379
- say(dim(` run it yourself in ${worktree}; the follow-up stays pending.`));
1380
- process.exit(1);
1483
+ if (followUp.reviewSliceId === null ||
1484
+ followUp.checkpointAId === null ||
1485
+ followUp.checkpointBId === null ||
1486
+ followUp.diffDigest === null ||
1487
+ followUp.idempotencyKey === null) {
1488
+ return fail("legacy follow-up — recreate it from a pinned Review before delivery");
1489
+ }
1490
+ const delivered = await withSpinner("delivering persisted Review bundle — retries reconcile one process…", api(config, "POST", `/sessions/${session.id}/follow-up/deliver`, {
1491
+ reviewSliceId: followUp.reviewSliceId,
1492
+ checkpointAId: followUp.checkpointAId,
1493
+ checkpointBId: followUp.checkpointBId,
1494
+ diffDigest: followUp.diffDigest,
1495
+ commentIds: followUp.commentIds,
1496
+ instruction: followUp.instruction,
1497
+ idempotencyKey: followUp.idempotencyKey,
1498
+ }));
1499
+ if (delivered.status === "pending") {
1500
+ return fail("the session is active — the follow-up remains pending");
1501
+ }
1502
+ if (delivered.status === "delivery_failed") {
1503
+ return fail(delivered.deliveryError ?? "delivery failed before process membership finalized");
1381
1504
  }
1382
- await api(config, "POST", `/sessions/${session.id}/follow-up/deliver`);
1383
- say(`${green("✓")} delivered · session reopened`);
1505
+ say(`${green("✓ recording")} · delivered to run ${delivered.deliverySealantRunId ?? "unknown"}${detachHint()}`);
1384
1506
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1385
- // Relaunch SUPERVISED, exactly like a fresh `mend <harness>`: a new
1386
- // workspace mounts the SAME worktree, the platform PTY runs the harness
1387
- // with the instruction as its opening prompt, and the record continues —
1388
- // the web session page shows the live terminal throughout.
1389
- const argv = build(followUp.instruction);
1390
- await withSpinner("provisioning workspace — the resumed run records like any other…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
1391
- say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
1392
1507
  say("");
1393
1508
  await attachOrExit(config, session.id, session.harness);
1394
1509
  exitAfterSessionEnd(config, session.id);
1395
1510
  };
1396
1511
  // ─── resume: rejoin a session — same worktree, restored harness state ───────
1397
1512
  const ACTIVE_STATUSES = LIVE_STATUSES;
1513
+ /** Whether a listed session's AGENT is live — the annotation carries its current agent process. */
1514
+ const agentLiveIn = (detail, session) => agentIsLive(session, detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
1515
+ null);
1398
1516
  /**
1399
1517
  * Sessions are continuous work, not runs: `mend resume` rejoins one on a
1400
1518
  * fresh workspace — saved harness state restored, a claude resume is native
@@ -1407,15 +1525,15 @@ const resumeCommand = async (config, args) => {
1407
1525
  const prefix = args.find((a, i) => !a.startsWith("--") && i !== withFlag + 1);
1408
1526
  const project = await findProject(config, null);
1409
1527
  const detail = await api(config, "GET", `/projects/${project.id}`);
1410
- const match = prefix !== undefined
1411
- ? detail.sessions.find((s) => s.id.startsWith(prefix))
1412
- : detail.sessions.find((s) => !ACTIVE_STATUSES.has(s.status));
1528
+ const match = prefix === undefined
1529
+ ? detail.sessions.find((s) => !agentLiveIn(detail, s))
1530
+ : detail.sessions.find((s) => s.id.startsWith(prefix));
1413
1531
  if (match === undefined) {
1414
- return fail(prefix !== undefined
1415
- ? `no session matches "${prefix}"`
1416
- : "no settled session to resume — mend status lists sessions");
1532
+ return fail(prefix === undefined
1533
+ ? "no settled session to resume — mend status lists sessions"
1534
+ : `no session matches "${prefix}"`);
1417
1535
  }
1418
- if (ACTIVE_STATUSES.has(match.status)) {
1536
+ if (agentLiveIn(detail, match)) {
1419
1537
  return fail(`session ${match.id.slice(0, 8)} is live — attach: mend attach ${match.id.slice(0, 8)}`);
1420
1538
  }
1421
1539
  say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
@@ -1440,7 +1558,7 @@ const resumeForRejoin = async (config, sessionId, label) => {
1440
1558
  }
1441
1559
  catch (error) {
1442
1560
  const refreshed = await api(config, "GET", `/sessions/${sessionId}`);
1443
- if (ACTIVE_STATUSES.has(refreshed.session.status))
1561
+ if (agentIsLive(refreshed.session, refreshed.currentAgent))
1444
1562
  return false;
1445
1563
  return fail(error instanceof Error ? error.message : String(error));
1446
1564
  }
@@ -1456,7 +1574,7 @@ const rejoinCommand = async (config, args) => {
1456
1574
  const eligible = detail.sessions
1457
1575
  .filter((session) => harness === null || session.harness === harness)
1458
1576
  .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt));
1459
- const newest = eligible.find((session) => ACTIVE_STATUSES.has(session.status)) ?? eligible[0];
1577
+ const newest = eligible.find((session) => agentLiveIn(detail, session)) ?? eligible[0];
1460
1578
  const matches = prefix === undefined
1461
1579
  ? newest === undefined
1462
1580
  ? []
@@ -1474,10 +1592,11 @@ const rejoinCommand = async (config, args) => {
1474
1592
  const session = matches[0];
1475
1593
  if (session === undefined)
1476
1594
  return fail("session selection failed");
1477
- say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${ACTIVE_STATUSES.has(session.status) ? "already live" : "restoring"}`);
1595
+ const alreadyLive = agentLiveIn(detail, session);
1596
+ say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
1478
1597
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1479
1598
  let restored = false;
1480
- if (!ACTIVE_STATUSES.has(session.status)) {
1599
+ if (!alreadyLive) {
1481
1600
  restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
1482
1601
  }
1483
1602
  say(restored
@@ -1487,7 +1606,7 @@ const rejoinCommand = async (config, args) => {
1487
1606
  let outcome = await attachTty(config, session.id, session.harness, 0n);
1488
1607
  if (outcome === "unavailable") {
1489
1608
  const refreshed = await api(config, "GET", `/sessions/${session.id}`);
1490
- if (!ACTIVE_STATUSES.has(refreshed.session.status)) {
1609
+ if (!agentIsLive(refreshed.session, refreshed.currentAgent)) {
1491
1610
  await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
1492
1611
  }
1493
1612
  outcome = await attachTty(config, session.id, session.harness, 0n);
@@ -1511,11 +1630,16 @@ const projectsCommand = async (config) => {
1511
1630
  }
1512
1631
  const nameWidth = Math.max(...projects.map((p) => p.name.length));
1513
1632
  const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length));
1633
+ // The cwd's project is marked — the same resolution mend claude|shell use.
1634
+ const here = matchProjectByCwd(projects, cwdFacts(process.cwd()));
1514
1635
  for (const project of projects) {
1515
1636
  const live = liveByProject.get(project.id) ?? 0;
1516
1637
  const liveLabel = live > 0 ? green(`${live} live`) : dim("—");
1517
- say(`${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
1638
+ const marker = project.id === here?.id ? cobalt("▸ ") : " ";
1639
+ say(`${marker}${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
1518
1640
  }
1641
+ if (here !== undefined)
1642
+ say(dim(` ▸ ${here.name} is the cwd's project`));
1519
1643
  };
1520
1644
  const printSessionRow = (row) => {
1521
1645
  const { session, annotation } = row;
@@ -1667,7 +1791,12 @@ const HELP = `mend — the agent workbench
1667
1791
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1668
1792
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
1669
1793
  hardware keys sign here; Ctrl-C stops sharing)
1670
- mend codex|claude|opencode new session worktree + launch the harness in it
1794
+ mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
1795
+ [--base <ref>] [--ask] [--fast]
1796
+ new session worktree + launch the harness in it; a quoted
1797
+ prompt becomes its first message (and names the session),
1798
+ --ask restores the harness's permission prompts, --fast
1799
+ requests priority processing (codex service tier)
1671
1800
  mend run -- <command...> same, with an arbitrary command
1672
1801
  mend attach <session-id-prefix> reattach this terminal to a running session
1673
1802
  mend shell [session-id-prefix] open a shell in a live session's workspace
@@ -1,15 +1,8 @@
1
- import { CONTINUE_COMMANDS } from "./shared.js";
2
1
  export const reviewTargetForSession = (session, annotation, projectName) => annotation?.changeId === null || annotation?.changeId === undefined
3
2
  ? null
4
3
  : { session, changeId: annotation.changeId, projectName };
5
4
  export const commentRange = (start, current) => start === null
6
5
  ? { line: current, endLine: null }
7
6
  : { line: Math.min(start, current), endLine: Math.max(start, current) };
8
- /** Close the review loop without changing session identity or worktree. */
9
- export const deliverReview = async (api, session, instruction) => {
10
- const build = CONTINUE_COMMANDS[session.harness];
11
- if (build === undefined)
12
- throw new Error(`Harness “${session.harness}” has no known relaunch command`);
13
- await api("POST", `/sessions/${session.id}/follow-up/deliver`, {});
14
- await api("POST", `/sessions/${session.id}/launch`, { argv: build(instruction) });
15
- };
7
+ /** One server-owned operation: persist intent, launch, correlate, finalize. */
8
+ export const deliverReview = (api, sessionId, input) => api("POST", `/sessions/${sessionId}/follow-up/deliver`, input);
package/dist/review.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { RGBA, SyntaxStyle, } from "@opentui/core";
4
4
  import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
5
  import { useQuery, useQueryClient } from "@tanstack/react-query";
6
6
  import { useEffect, useMemo, useRef, useState } from "react";
7
7
  import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
8
8
  import { commentRange, deliverReview } from "./review-workflow.js";
9
- import { LIVE_STATUSES } from "./shared.js";
10
9
  import { openUrl } from "./terminal.js";
11
10
  import { ADD_WASH, AMBER, BG, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, PANEL, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
12
11
  const REVIEW_KEY = (changeId) => ["review", changeId];
@@ -27,7 +26,19 @@ const truncate = (text, width) => {
27
26
  return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}…`;
28
27
  };
29
28
  const fetchReview = async (ctx, changeId) => {
30
- const wire = await ctx.api("GET", `/changes/${changeId}/diff`);
29
+ const opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
30
+ idempotencyKey: `cli-review:${changeId}`,
31
+ });
32
+ const pinned = await ctx.api("GET", `/changes/${changeId}/reviews/${opened.slice.id}/diff`);
33
+ const wire = {
34
+ change: pinned.change,
35
+ diff: pinned.patch,
36
+ files: pinned.files.map((file) => ({
37
+ path: file.newPath ?? file.oldPath ?? "unknown path",
38
+ additions: file.additions,
39
+ deletions: file.deletions,
40
+ })),
41
+ };
31
42
  const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
32
43
  ctx.api("GET", `/changes/${changeId}/comments`),
33
44
  ctx.api("GET", `/changes/${changeId}/tour`),
@@ -37,6 +48,11 @@ const fetchReview = async (ctx, changeId) => {
37
48
  ]);
38
49
  return {
39
50
  wire,
51
+ sliceId: pinned.slice.id,
52
+ checkpointAId: pinned.checkpointA.id,
53
+ checkpointBId: pinned.checkpointB.id,
54
+ diffDigest: pinned.slice.diffDigest,
55
+ anchorFiles: pinned.anchorFiles,
40
56
  files: buildReviewFiles(wire.diff, wire.files),
41
57
  comments,
42
58
  tour,
@@ -113,14 +129,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
113
129
  const [editor, setEditor] = useState(null);
114
130
  const [busy, setBusy] = useState(false);
115
131
  const [status, setStatus] = useState("");
116
- const [checkpointState, setCheckpointState] = useState("recording");
132
+ const checkpointState = data === undefined ? (isFetching ? "recording" : "failed") : "recorded";
117
133
  const lockRef = useRef(false);
118
134
  const editorRef = useRef(null);
119
135
  const diffRef = useRef(null);
120
136
  const diffScrollRef = useRef(null);
121
137
  const fileScrollRef = useRef(null);
122
138
  const commentScrollRef = useRef(null);
123
- const checkpointedRef = useRef(false);
124
139
  const files = useMemo(() => {
125
140
  const order = {
126
141
  added: 0,
@@ -152,21 +167,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
152
167
  const refresh = () => {
153
168
  void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
154
169
  };
155
- const recordReviewCheckpoint = () => {
156
- if (checkpointedRef.current)
157
- return;
158
- checkpointedRef.current = true;
159
- setCheckpointState("recording");
160
- void ctx
161
- .api("POST", `/sessions/${session.id}/checkpoints`, { trigger: "review-open" })
162
- .then(() => setCheckpointState("recorded"))
163
- .catch((error) => {
164
- checkpointedRef.current = false;
165
- setCheckpointState("failed");
166
- setStatus(`Checkpoint not recorded · ${errorMessage(error)} · K retries`);
167
- });
168
- };
169
- useEffect(recordReviewCheckpoint, [ctx, session.id]);
170
170
  useEffect(() => {
171
171
  fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
172
172
  }, [fileIndex]);
@@ -325,6 +325,8 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
325
325
  setEditor({
326
326
  kind: "send",
327
327
  instruction: assembleReviewInstruction(data.wire.change, sendable),
328
+ commentIds: sendable.map((comment) => comment.id),
329
+ idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`,
328
330
  });
329
331
  };
330
332
  const submitEditor = async () => {
@@ -338,17 +340,54 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
338
340
  setWorking(true);
339
341
  try {
340
342
  if (editor.kind === "comment") {
341
- await ctx.api("POST", `/changes/${changeId}/comments`, {
342
- file: editor.file,
343
- line: editor.line,
344
- endLine: editor.endLine,
343
+ if (data === undefined)
344
+ throw new Error("The pinned Review is not available.");
345
+ const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
346
+ const endLine = editor.endLine ?? editor.line;
347
+ const hunk = editor.line === null || endLine === null
348
+ ? null
349
+ : (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
350
+ editor.line !== null &&
351
+ editor.line >= candidate.newStart &&
352
+ endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
353
+ if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
354
+ throw new Error("The selected line is outside the pinned Review anchor.");
355
+ }
356
+ await ctx.api("POST", `/changes/${changeId}/reviews/${data.sliceId}/comments`, {
357
+ target: editor.file === null
358
+ ? {
359
+ oldPath: null,
360
+ newPath: null,
361
+ side: null,
362
+ startLine: null,
363
+ endLine: null,
364
+ hunkContextHash: null,
365
+ }
366
+ : {
367
+ oldPath: anchorFile?.oldPath ?? null,
368
+ newPath: anchorFile?.newPath ?? null,
369
+ side: "new",
370
+ startLine: editor.line,
371
+ endLine,
372
+ hunkContextHash: hunk?.contextHash ?? null,
373
+ },
345
374
  body: value,
346
375
  });
347
376
  setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
348
377
  }
349
378
  else {
350
- await ctx.api("POST", `/sessions/${session.id}/follow-up`, { instruction: value });
351
- setStatus("Follow-up saved · y delivers it and relaunches the same session");
379
+ if (data === undefined)
380
+ throw new Error("The pinned Review is not available.");
381
+ await deliverReview(ctx.api, session.id, {
382
+ reviewSliceId: data.sliceId,
383
+ checkpointAId: data.checkpointAId,
384
+ checkpointBId: data.checkpointBId,
385
+ diffDigest: data.diffDigest,
386
+ commentIds: editor.commentIds,
387
+ instruction: value,
388
+ idempotencyKey: editor.idempotencyKey,
389
+ });
390
+ setStatus("Follow-up delivery requested · retry keeps the same run");
352
391
  }
353
392
  setEditor(null);
354
393
  refresh();
@@ -366,16 +405,29 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
366
405
  void runAction(`${state} ${anchorOf(selectedComment)}`, () => ctx.api("POST", `/changes/${changeId}/comments/${selectedComment.id}/state`, { state }));
367
406
  };
368
407
  const deliverAndRelaunch = () => {
369
- if (data?.followUp?.status !== "pending") {
370
- setStatus("No pending follow-up — s assembles the open review comments first");
408
+ const followUp = data?.followUp ?? null;
409
+ if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
410
+ setStatus("No retryable follow-up — s assembles the open Review comments first");
371
411
  return;
372
412
  }
373
- if (LIVE_STATUSES.has(data.sessionStatus)) {
374
- setStatus("The session is live — deliver the pending follow-up once it settles");
413
+ if (followUp.reviewSliceId === null ||
414
+ followUp.checkpointAId === null ||
415
+ followUp.checkpointBId === null ||
416
+ followUp.diffDigest === null ||
417
+ followUp.idempotencyKey === null) {
418
+ setStatus("Legacy follow-up · recreate it from a pinned Review before delivery");
375
419
  return;
376
420
  }
377
- const instruction = data.followUp.instruction;
378
- void runAction("Delivering review and relaunching the same session", () => deliverReview(ctx.api, session, instruction), "Review delivered · the same session is running · diff updates live");
421
+ const input = {
422
+ reviewSliceId: followUp.reviewSliceId,
423
+ checkpointAId: followUp.checkpointAId,
424
+ checkpointBId: followUp.checkpointBId,
425
+ diffDigest: followUp.diffDigest,
426
+ commentIds: followUp.commentIds,
427
+ instruction: followUp.instruction,
428
+ idempotencyKey: followUp.idempotencyKey,
429
+ };
430
+ void runAction("Delivering the persisted Review bundle", () => deliverReview(ctx.api, session.id, input), "Delivery reconciled · the same idempotency key names one run");
379
431
  };
380
432
  const focusOrder = wide
381
433
  ? ["files", "diff", "comments"]
@@ -410,12 +462,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
410
462
  setStatus("Refreshing live change…");
411
463
  return refresh();
412
464
  }
413
- if (key.name === "k" && key.shift) {
414
- if (checkpointState !== "failed")
415
- return;
416
- recordReviewCheckpoint();
417
- return;
418
- }
419
465
  if (key.name === "o") {
420
466
  openUrl(`${ctx.config.url}/changes/${changeId}`);
421
467
  setStatus("Opened web review");
@@ -518,13 +564,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
518
564
  const displayPatch = selectedFile === null || !showWhitespace
519
565
  ? (selectedFile?.patch ?? "")
520
566
  : visibleWhitespace(selectedFile.patch);
521
- const footer = editor !== null
522
- ? " ctrl+enter save · esc cancel"
523
- : focus === "files"
567
+ const footer = editor === null
568
+ ? focus === "files"
524
569
  ? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
525
570
  : focus === "comments"
526
571
  ? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
527
- : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`;
572
+ : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
573
+ : " ctrl+enter save · esc cancel";
528
574
  const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
529
575
  rootOptions: { backgroundColor: PANEL, border: false },
530
576
  wrapperOptions: { backgroundColor: PANEL },
@@ -553,7 +599,7 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
553
599
  contentOptions: { backgroundColor: PANEL },
554
600
  }, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
555
601
  ? {}
556
- : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : null] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
602
+ : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment === null ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
557
603
  ? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
558
604
  : ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
559
605
  }
package/dist/shared.js CHANGED
@@ -18,17 +18,163 @@ export const CONTINUE_COMMANDS = {
18
18
  claude: (instruction) => ["claude", instruction],
19
19
  opencode: (instruction) => ["opencode", "run", instruction],
20
20
  };
21
+ /** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
22
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
23
+ const LAUNCH_ERROR = {
24
+ project: null,
25
+ prompt: null,
26
+ model: null,
27
+ effort: null,
28
+ base: null,
29
+ ask: false,
30
+ fast: false,
31
+ custom: [],
32
+ };
33
+ /**
34
+ * `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
35
+ * [--base <ref>] [--ask] [--project <p>]`, plus `mend run … -- <command...>`.
36
+ * The first non-flag positional is the prompt; a second one is an error so a
37
+ * forgotten quote fails loudly instead of launching with half a sentence.
38
+ */
39
+ export const parseLaunchArgs = (args) => {
40
+ const dashdash = args.indexOf("--");
41
+ const custom = dashdash === -1 ? [] : args.slice(dashdash + 1);
42
+ const flagArgs = dashdash === -1 ? args : args.slice(0, dashdash);
43
+ let project = null;
44
+ let prompt = null;
45
+ let model = null;
46
+ let effort = null;
47
+ let base = null;
48
+ let ask = false;
49
+ let fast = false;
50
+ for (let index = 0; index < flagArgs.length; index += 1) {
51
+ const arg = flagArgs[index] ?? "";
52
+ if (arg === "--ask") {
53
+ ask = true;
54
+ continue;
55
+ }
56
+ if (arg === "--fast") {
57
+ fast = true;
58
+ continue;
59
+ }
60
+ if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
61
+ const value = flagArgs[index + 1];
62
+ if (value === undefined)
63
+ return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
64
+ if (arg === "--project")
65
+ project = value;
66
+ else if (arg === "--model")
67
+ model = value;
68
+ else if (arg === "--effort")
69
+ effort = value;
70
+ else
71
+ base = value;
72
+ index += 1;
73
+ continue;
74
+ }
75
+ if (arg.startsWith("-")) {
76
+ // Also catches a prompt starting with "-", which the harness would
77
+ // otherwise read as a flag of its own.
78
+ return {
79
+ ...LAUNCH_ERROR,
80
+ error: `unknown flag ${arg} — quote the prompt if it starts with "-"`,
81
+ };
82
+ }
83
+ if (prompt !== null) {
84
+ return {
85
+ ...LAUNCH_ERROR,
86
+ error: 'one prompt only — quote it: mend claude "fix the auth test"',
87
+ };
88
+ }
89
+ prompt = arg;
90
+ }
91
+ if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
92
+ return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
93
+ }
94
+ return { project, prompt, model, effort, base, ask, fast, custom, error: null };
95
+ };
21
96
  export const LIVE_STATUSES = new Set([
22
97
  "starting",
23
98
  "running",
24
99
  "waiting",
25
100
  "idle",
26
101
  ]);
27
- /** The cwd's adopted project: adopted-from-here first, then a basename match. */
28
- export const matchProjectByCwd = (projects, cwd) => {
29
- const byOrigin = projects.find((p) => p.originUrl !== null && cwd.startsWith(p.originUrl));
30
- const byName = projects.find((p) => p.name === path.basename(cwd));
31
- return byOrigin ?? byName;
102
+ /**
103
+ * Whether the session's AGENT is live. Session status is a fold over every process — a session
104
+ * reads `idle` while a shell holds the workspace after its agent ended — so the agent's own row
105
+ * answers when one exists; `starting` is a launch with no row yet.
106
+ */
107
+ export const agentIsLive = (session, currentAgent) => currentAgent === null
108
+ ? LIVE_STATUSES.has(session.status)
109
+ : session.status === "starting" ||
110
+ (currentAgent.exitedAt === null &&
111
+ (currentAgent.status === "starting" || currentAgent.status === "running"));
112
+ /** What an ended agent process's exit says about the work; null while it runs. */
113
+ export const agentOutcome = (currentAgent) => {
114
+ if (currentAgent === null || currentAgent.exitedAt === null)
115
+ return null;
116
+ if (currentAgent.status === "stopped")
117
+ return "stopped";
118
+ if (currentAgent.harness === "shell")
119
+ return "completed";
120
+ return currentAgent.exitCode === null || currentAgent.exitCode === 0 ? "completed" : "failed";
121
+ };
122
+ /**
123
+ * A git remote URL reduced to `host/owner/name` so https, ssh, scp-style, and
124
+ * `.git` spellings of the same repository compare equal. Null for anything
125
+ * that is not a URL-ish remote (a local path stays a path).
126
+ */
127
+ export const normalizeRemoteUrl = (raw) => {
128
+ if (raw === null)
129
+ return null;
130
+ const trimmed = raw.trim();
131
+ if (trimmed === "")
132
+ return null;
133
+ const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(trimmed);
134
+ const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/\s:]+)(?::\d+)?\/(.+)$/i.exec(trimmed);
135
+ const parts = url ?? scp;
136
+ if (parts === null)
137
+ return null;
138
+ const [, host, rest] = parts;
139
+ if (host === undefined || rest === undefined)
140
+ return null;
141
+ const repoPath = rest.replace(/\/+$/, "").replace(/\.git$/i, "");
142
+ return `${host.toLowerCase()}/${repoPath.toLowerCase()}`;
143
+ };
144
+ /**
145
+ * The cwd's adopted project, in order: a project adopted from this very
146
+ * path; a project whose origin is the same remote as the cwd repo's origin
147
+ * (normalized, so a GitHub-adopted project matches a clone of it anywhere);
148
+ * a project named like the repository root (through the same normalization
149
+ * adopt uses, so a checkout called "Mend" matches the project "mend").
150
+ */
151
+ export const matchProjectByCwd = (projects, facts) => {
152
+ const root = facts.repoRoot ?? facts.cwd;
153
+ const byPath = projects.find((p) => p.originUrl !== null &&
154
+ !p.originUrl.includes("://") &&
155
+ (root === p.originUrl ||
156
+ facts.cwd === p.originUrl ||
157
+ facts.cwd.startsWith(`${p.originUrl}/`)));
158
+ if (byPath !== undefined)
159
+ return byPath;
160
+ const remote = normalizeRemoteUrl(facts.originUrl);
161
+ const byRemote = remote === null ? undefined : projects.find((p) => normalizeRemoteUrl(p.originUrl) === remote);
162
+ if (byRemote !== undefined)
163
+ return byRemote;
164
+ const name = normalizeProjectName(path.basename(root));
165
+ return projects.find((p) => p.name === name);
166
+ };
167
+ /** Read the cwd's facts from git once; callers pass them to the matcher. */
168
+ export const cwdFacts = (cwd) => ({
169
+ cwd,
170
+ repoRoot: gitTopLevel(cwd),
171
+ originUrl: gitOriginUrl(cwd),
172
+ });
173
+ /** The cwd repo's origin remote, or null when there is no repo or no origin. */
174
+ export const gitOriginUrl = (cwd) => {
175
+ const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
176
+ const out = result.status === 0 ? result.stdout.trim() : "";
177
+ return out === "" ? null : out;
32
178
  };
33
179
  /** The repository root of `cwd`, or null when it is not inside a git repo. */
34
180
  export const gitTopLevel = (cwd) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",