@sealant/mend 0.4.0 → 0.6.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/main.js CHANGED
@@ -5,10 +5,12 @@ import { createRequire } from "node:module";
5
5
  import * as net from "node:net";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
+ import { doctorCommand } from "./doctor.js";
8
9
  import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
9
10
  import { formatLoadReport } from "./env.js";
11
+ import { pairCommand, qrCommand } from "./pair.js";
10
12
  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";
13
+ import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
12
14
  // `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
13
15
  // when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
14
16
  const mendCliHome = () => {
@@ -37,6 +39,14 @@ const fail = (message) => {
37
39
  process.stderr.write(`mend: ${message}\n`);
38
40
  process.exit(1);
39
41
  };
42
+ const parseMendUrl = (value) => {
43
+ try {
44
+ return new URL(value);
45
+ }
46
+ catch {
47
+ return fail(`"${value}" is not a valid Mend URL`);
48
+ }
49
+ };
40
50
  const paint = (code) => (text) => process.stdout.isTTY ? `[${code}m${text}` : text;
41
51
  const dim = paint("2");
42
52
  const green = paint("32");
@@ -110,7 +120,12 @@ const request = async (config, method, route, body) => {
110
120
  }
111
121
  throw new Error(message);
112
122
  }
113
- return JSON.parse(text);
123
+ try {
124
+ return JSON.parse(text);
125
+ }
126
+ catch {
127
+ throw new Error(`${method} ${route} returned invalid JSON from ${config.url}`);
128
+ }
114
129
  };
115
130
  /** The same call for one-shot commands: any failure prints and exits. */
116
131
  const api = async (config, method, route, body) => {
@@ -121,7 +136,8 @@ const api = async (config, method, route, body) => {
121
136
  return fail(error instanceof Error ? error.message : String(error));
122
137
  }
123
138
  };
124
- const worktreePathOf = (storePath, worktree) => path.join(path.dirname(storePath), "worktrees", worktree);
139
+ /** The same call bound to one config — the dependency ./pair.ts takes. */
140
+ const boundApi = (config) => (method, route, body) => api(config, method, route, body);
125
141
  /** A live elapsed-time spinner around a slow await — provisioning is not a hang. */
126
142
  const withSpinner = async (label, work) => {
127
143
  if (process.stdout.isTTY !== true)
@@ -187,7 +203,7 @@ const findProject = async (config, explicit, adoptCwd = false) => {
187
203
  return named;
188
204
  }
189
205
  const cwd = process.cwd();
190
- const project = matchProjectByCwd(projects, cwd);
206
+ const project = matchProjectByCwd(projects, cwdFacts(cwd));
191
207
  if (project !== undefined)
192
208
  return project;
193
209
  if (!adoptCwd) {
@@ -207,21 +223,29 @@ const findProject = async (config, explicit, adoptCwd = false) => {
207
223
  return adopted;
208
224
  };
209
225
  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] ?? []);
226
+ const parsed = parseLaunchArgs(args);
227
+ if (parsed.error !== null)
228
+ return fail(parsed.error);
229
+ const structured = parsed.prompt !== null ||
230
+ parsed.model !== null ||
231
+ parsed.effort !== null ||
232
+ parsed.ask ||
233
+ parsed.fast;
234
+ if (harness === "run" && structured) {
235
+ return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
236
+ }
237
+ const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
217
238
  if (argv.length === 0) {
218
239
  return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
219
240
  }
220
- const project = await findProject(config, explicit, true);
241
+ const project = await findProject(config, parsed.project, true);
242
+ // Say which project the cwd resolved to before anything is created — a
243
+ // wrong guess should be visible here, not discovered in the tree later.
244
+ say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
221
245
  const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
222
246
  harness,
223
247
  label: null,
224
- base: null,
248
+ base: parsed.base,
225
249
  });
226
250
  say(`${green("✓")} worktree ${session.worktree} ${dim(`· branch ${session.branch}`)}`);
227
251
  say(`${green("✓")} base ${dim(session.baseSha.slice(0, 12))} · session ${dim(session.id.slice(0, 8))}`);
@@ -232,7 +256,18 @@ const launch = async (config, harness, args) => {
232
256
  if (harness === "run") {
233
257
  return supervisedRun(config, session, argv);
234
258
  }
235
- await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
259
+ // A structured start sends no argv: the server composes the harness flags
260
+ // (one shared mapping) and names the session from the prompt immediately.
261
+ const launchBody = structured
262
+ ? {
263
+ ...(parsed.prompt === null ? {} : { prompt: parsed.prompt }),
264
+ ...(parsed.model === null ? {} : { model: parsed.model }),
265
+ ...(parsed.effort === null ? {} : { effort: parsed.effort }),
266
+ ...(parsed.ask ? { permissionMode: "ask" } : {}),
267
+ ...(parsed.fast ? { speed: "fast" } : {}),
268
+ }
269
+ : { argv };
270
+ await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, launchBody));
236
271
  say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
237
272
  say("");
238
273
  await attachOrExit(config, session.id, session.harness);
@@ -250,14 +285,14 @@ const launch = async (config, harness, args) => {
250
285
  * resumes).
251
286
  */
252
287
  const attachTty = async (config, sessionId, harness, from, processId, options) => {
253
- const url = new URL(`${config.url}/api/tty`);
288
+ const url = parseMendUrl(`${config.url}/api/tty`);
254
289
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
255
290
  // Process addressing reaches any PTY in the workspace (a shell); the
256
291
  // session form remains the agent's PTY.
257
- if (processId !== undefined)
258
- url.searchParams.set("process", processId);
259
- else
292
+ if (processId === undefined)
260
293
  url.searchParams.set("session", sessionId);
294
+ else
295
+ url.searchParams.set("process", processId);
261
296
  url.searchParams.set("from", from.toString());
262
297
  if (config.token !== null)
263
298
  url.searchParams.set("token", config.token);
@@ -411,11 +446,11 @@ const pickSessionInteractively = async (rows) => {
411
446
  * picker, and a non-interactive caller never gets a silent guess.
412
447
  */
413
448
  const resolveLiveSession = async (config, prefix) => {
414
- const sessions = await api(config, "GET", "/sessions");
449
+ const sessions = await api(config, "GET", "/sessions?retained=1");
415
450
  if (prefix !== undefined) {
416
451
  const matches = sessions.filter((s) => s.id.startsWith(prefix));
417
452
  if (matches.length === 0)
418
- return fail(`no live session matches "${prefix}"`);
453
+ return fail(`no live or retained session matches "${prefix}"`);
419
454
  const exact = matches[0];
420
455
  if (matches.length > 1 || exact === undefined) {
421
456
  return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
@@ -423,11 +458,11 @@ const resolveLiveSession = async (config, prefix) => {
423
458
  return exact;
424
459
  }
425
460
  const projects = await api(config, "GET", "/projects");
426
- const project = matchProjectByCwd(projects, process.cwd());
461
+ const project = matchProjectByCwd(projects, cwdFacts(process.cwd()));
427
462
  const candidates = project === undefined ? sessions : sessions.filter((s) => s.projectId === project.id);
428
463
  const only = candidates[0];
429
464
  if (only === undefined) {
430
- return fail("no live session — start one with mend codex|claude|opencode");
465
+ return fail("no live or retained session — start one with mend codex|claude|opencode");
431
466
  }
432
467
  if (candidates.length === 1)
433
468
  return only;
@@ -464,16 +499,68 @@ const shellCommand = async (config, args) => {
464
499
  say(`${green("✓")} shell ended`);
465
500
  process.exit(0);
466
501
  };
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 ?? "?"}`;
502
+ const flattenService = (view) => {
503
+ const attempt = view.service.currentAttemptId === null
504
+ ? null
505
+ : (view.attempts.find((candidate) => candidate.id === view.service.currentAttemptId) ?? null);
506
+ const observation = view.currentForward !== null && view.latestObservation?.forwardId === view.currentForward.id
507
+ ? view.latestObservation
508
+ : null;
509
+ const endpoint = view.endpoints.find((candidate) => candidate.scope === "private") ?? view.endpoints[0] ?? null;
510
+ const browserUrl = view.endpoints.find((candidate) => candidate.browserUrl !== null)?.browserUrl ?? null;
511
+ return {
512
+ id: view.service.id,
513
+ processId: attempt?.id ?? null,
514
+ sessionId: view.service.sessionId,
515
+ label: view.service.name,
516
+ status: observation?.state ?? view.currentForward?.state ?? attempt?.status ?? "stopped",
517
+ workspacePort: view.service.workspacePort,
518
+ hostPort: endpoint?.hostPort ?? null,
519
+ authority: endpoint?.authority ?? null,
520
+ browserUrl,
521
+ exposureScope: endpoint?.scope ?? null,
522
+ mendAuthentication: endpoint?.mendAuthentication ?? null,
523
+ protocol: view.service.transport,
524
+ sealantSessionId: attempt?.sealantSessionId ?? null,
525
+ attemptExitedAt: attempt?.exitedAt ?? null,
526
+ argv: attempt?.argv ?? [],
527
+ workspaceExpiresAt: view.workspaceExpiresAt,
528
+ workspaceTtlRenewedAt: view.workspaceTtlRenewedAt,
529
+ workspaceTtlRenewalFailedAt: view.workspaceTtlRenewalFailedAt,
530
+ workspaceTtlRenewalError: view.workspaceTtlRenewalError,
531
+ };
532
+ };
533
+ const fetchServiceViews = (config, all = false) => api(config, "GET", `/services${all ? "?all=1" : ""}`);
534
+ const fetchServices = async (config, all = false) => (await fetchServiceViews(config, all)).map(flattenService);
535
+ const mutateService = async (config, method, endpointPath, body) => flattenService(await api(config, method, endpointPath, body));
536
+ const serviceUrl = (service) => service.browserUrl ??
537
+ (service.authority === null
538
+ ? "unbound"
539
+ : `${service.authority}${service.protocol === "udp" ? " (udp)" : ""}`);
540
+ const serviceExposure = (service) => {
541
+ if (service.exposureScope === null)
542
+ return "";
543
+ if (service.exposureScope === "private") {
544
+ return "No Mend sign-in protects this port. Anyone who can reach this private address can connect.";
545
+ }
546
+ return `Loopback only · Mend auth: ${service.mendAuthentication ?? "unknown"}`;
473
547
  };
474
- const printService = (config, service) => {
548
+ const printWorkspaceTtlFailure = (service) => {
549
+ if (service.workspaceTtlRenewalError === null)
550
+ return;
551
+ say(amber(` workspace TTL renewal failed · ${service.workspaceTtlRenewalError}`));
552
+ say(dim(` last renewed ${service.workspaceTtlRenewedAt ?? "unknown"} · known expiry ${service.workspaceExpiresAt ?? "unknown"} · failed ${service.workspaceTtlRenewalFailedAt ?? "unknown"}`));
553
+ };
554
+ const printServiceEndpoint = (service) => {
555
+ const exposure = serviceExposure(service);
556
+ say(` ${cobalt(serviceUrl(service))}${exposure === "" ? "" : ` ${dim(exposure)}`}`);
557
+ printWorkspaceTtlFailure(service);
558
+ };
559
+ const printService = (service) => {
475
560
  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))}`);
561
+ const exposure = serviceExposure(service);
562
+ 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)}`}`);
563
+ printWorkspaceTtlFailure(service);
477
564
  };
478
565
  /**
479
566
  * Adopt an already-listening workspace port as a Service: Mend binds a host
@@ -484,10 +571,18 @@ const serviceAdd = async (config, args) => {
484
571
  const nameFlag = args.indexOf("--name");
485
572
  const name = nameFlag !== -1 && args[nameFlag + 1] !== undefined ? String(args[nameFlag + 1]) : null;
486
573
  const protocol = args.includes("--udp") ? "udp" : "tcp";
574
+ const http = args.includes("--http");
575
+ const https = args.includes("--https");
576
+ if (http && https)
577
+ return fail("Choose either --http or --https, not both.");
578
+ const browserScheme = https ? "https" : http ? "http" : null;
579
+ if (protocol === "udp" && browserScheme !== null) {
580
+ return fail("UDP Services cannot use --http or --https");
581
+ }
487
582
  const positional = args.filter((a, i) => !a.startsWith("--") && (nameFlag === -1 || i !== nameFlag + 1));
488
583
  const portRaw = positional.find((a) => /^\d+$/.test(a));
489
584
  if (portRaw === undefined) {
490
- return fail("usage: mend service add [session] <port> [--name <n>] [--udp]");
585
+ return fail("usage: mend service add [session] <port> [--name <n>] [--udp] [--http|--https]");
491
586
  }
492
587
  const port = Number(portRaw);
493
588
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -495,13 +590,14 @@ const serviceAdd = async (config, args) => {
495
590
  }
496
591
  const prefix = positional.find((a) => a !== portRaw);
497
592
  const session = await resolveLiveSession(config, prefix);
498
- const service = await api(config, "POST", `/sessions/${session.id}/services`, {
593
+ const service = await mutateService(config, "POST", `/sessions/${session.id}/services`, {
499
594
  port,
500
595
  name,
501
596
  protocol,
597
+ browserScheme,
502
598
  });
503
599
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
504
- say(` ${cobalt(serviceUrl(config, service))}`);
600
+ printServiceEndpoint(service);
505
601
  if (protocol === "udp") {
506
602
  say(dim(` udp — a reply is the only reachability signal; silence just relays`));
507
603
  }
@@ -510,19 +606,19 @@ const serviceAdd = async (config, args) => {
510
606
  }
511
607
  };
512
608
  const serviceList = async (config) => {
513
- const services = await api(config, "GET", "/services");
609
+ const services = await fetchServices(config);
514
610
  if (services.length === 0) {
515
611
  say(dim("no live services — mend service add <port> adopts a listening one"));
516
612
  return;
517
613
  }
518
614
  for (const service of services)
519
- printService(config, service);
615
+ printService(service);
520
616
  };
521
617
  const serviceStop = async (config, args) => {
522
618
  const needle = args.find((a) => !a.startsWith("--"));
523
619
  if (needle === undefined)
524
620
  return fail("usage: mend service stop <name-or-id-prefix>");
525
- const services = await api(config, "GET", "/services");
621
+ const services = await fetchServices(config);
526
622
  const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
527
623
  if (matches.length === 0)
528
624
  return fail(`no live service matches "${needle}"`);
@@ -530,11 +626,11 @@ const serviceStop = async (config, args) => {
530
626
  if (matches.length > 1 || match === undefined) {
531
627
  return fail(`"${needle}" is ambiguous — use more of the id`);
532
628
  }
533
- const stoppedService = await api(config, "POST", `/services/${match.id}/stop`);
629
+ const stoppedService = await mutateService(config, "POST", `/services/${match.id}/stop`);
534
630
  say(`${green("✓")} stopped · ${stoppedService.label ?? stoppedService.id.slice(0, 8)}`);
535
631
  };
536
632
  const findLiveService = async (config, needle) => {
537
- const services = await api(config, "GET", "/services");
633
+ const services = await fetchServices(config);
538
634
  const matches = services.filter((service) => service.label === needle || service.id.startsWith(needle));
539
635
  if (matches.length === 0)
540
636
  return fail(`no live service matches "${needle}"`);
@@ -552,7 +648,7 @@ const findLiveService = async (config, needle) => {
552
648
  */
553
649
  const serviceRun = async (config, args) => {
554
650
  const dashdash = args.indexOf("--");
555
- const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] -- <command...>\n" +
651
+ const usage = "usage: mend service run [session] --port <port> [--name <n>] [--udp] [--http|--https] -- <command...>\n" +
556
652
  " mend service run [session] <name> (a declared recipe)";
557
653
  // No explicit command = a DECLARED Service: resolve the name against the
558
654
  // session worktree's mend.toml and start (or adopt) its recipe.
@@ -571,27 +667,15 @@ const serviceRun = async (config, args) => {
571
667
  ? `no mend.toml recipes in this worktree — declare [service.${name}] first`
572
668
  : `no recipe named "${name}" — declared: ${known}`);
573
669
  }
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,
670
+ const service = await withSpinner(recipe.command === null
671
+ ? `adopting ${recipe.name} on :${recipe.port}…`
672
+ : recipe.protocol === "udp"
673
+ ? `starting ${recipe.name} (udp :${recipe.port})…`
674
+ : `starting ${recipe.name} — waiting for :${recipe.port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/recipe`, {
590
675
  name: recipe.name,
591
- protocol: recipe.protocol,
592
676
  }));
593
677
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
594
- say(` ${cobalt(serviceUrl(config, service))}`);
678
+ printServiceEndpoint(service);
595
679
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
596
680
  return;
597
681
  }
@@ -600,74 +684,101 @@ const serviceRun = async (config, args) => {
600
684
  return fail(usage);
601
685
  const head = args.slice(0, dashdash);
602
686
  const portFlag = head.indexOf("--port");
603
- const port = portFlag !== -1 ? Number(head[portFlag + 1]) : Number.NaN;
687
+ const port = portFlag === -1 ? Number.NaN : Number(head[portFlag + 1]);
604
688
  if (!Number.isInteger(port) || port < 1 || port > 65535)
605
689
  return fail(usage);
606
690
  const nameFlag = head.indexOf("--name");
607
691
  const name = nameFlag !== -1 && head[nameFlag + 1] !== undefined ? String(head[nameFlag + 1]) : null;
608
692
  const protocol = head.includes("--udp") ? "udp" : "tcp";
693
+ const http = head.includes("--http");
694
+ const https = head.includes("--https");
695
+ if (http && https)
696
+ return fail(usage);
697
+ const browserScheme = https ? "https" : http ? "http" : null;
698
+ if (protocol === "udp" && browserScheme !== null)
699
+ return fail(usage);
609
700
  const prefix = head.find((a, i) => !a.startsWith("--") && i !== portFlag + 1 && i !== nameFlag + 1);
610
701
  const session = await resolveLiveSession(config, prefix);
611
702
  const service = await withSpinner(protocol === "udp"
612
703
  ? `starting ${name ?? argv[0]} (udp :${port})…`
613
- : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, api(config, "POST", `/sessions/${session.id}/services/run`, {
704
+ : `starting ${name ?? argv[0]} — waiting for :${port} to answer…`, mutateService(config, "POST", `/sessions/${session.id}/services/run`, {
614
705
  argv,
615
706
  port,
616
707
  name,
617
708
  protocol,
709
+ browserScheme,
618
710
  }));
619
711
  say(`${green("✓")} Service ${service.label ?? ""} · ${service.status}`);
620
- say(` ${cobalt(serviceUrl(config, service))}`);
712
+ printServiceEndpoint(service);
621
713
  say(dim(` logs: mend service logs ${service.label ?? service.id.slice(0, 8)}`));
622
714
  };
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
- */
715
+ /** Read sequence-addressed PTY output without attaching an input-capable terminal. */
628
716
  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));
717
+ const fromFlag = args.indexOf("--from");
718
+ const from = fromFlag === -1 ? "0" : args[fromFlag + 1];
719
+ const needle = args.find((argument, index) => !argument.startsWith("--") && index !== fromFlag + 1);
720
+ if (needle === undefined || from === undefined || !/^(0|[1-9]\d*)$/.test(from)) {
721
+ return fail("usage: mend service logs <name-or-id-prefix> [--from <decimal-sequence>]");
722
+ }
723
+ const everything = await fetchServiceViews(config, true);
724
+ const matches = everything.filter((view) => view.service.name === needle ||
725
+ view.service.id.startsWith(needle) ||
726
+ view.attempts.some((attempt) => attempt.id.startsWith(needle)));
634
727
  if (matches.length === 0)
635
728
  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)
729
+ const view = matches.find((candidate) => {
730
+ const current = candidate.service.currentAttemptId === null
731
+ ? null
732
+ : candidate.attempts.find((attempt) => attempt.id === candidate.service.currentAttemptId);
733
+ return current !== null && current !== undefined && current.exitedAt === null;
734
+ }) ?? matches[0];
735
+ if (view === undefined)
639
736
  return fail(`no service matches "${needle}"`);
640
- if (service.sealantSessionId === null) {
737
+ const currentAttempt = view.service.currentAttemptId === null
738
+ ? null
739
+ : (view.attempts.find((candidate) => candidate.id === view.service.currentAttemptId) ?? null);
740
+ const attempt = view.attempts.find((candidate) => candidate.id.startsWith(needle)) ??
741
+ currentAttempt ??
742
+ view.attempts.findLast((candidate) => candidate.sealantSessionId !== null) ??
743
+ null;
744
+ if (attempt?.sealantSessionId === null || attempt === null) {
641
745
  return fail(`"${needle}" is an adopted port — no process of Mend's, no logs. mend service run supervises.`);
642
746
  }
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`));
747
+ const label = view.service.name;
748
+ say(dim(attempt.exitedAt === null
749
+ ? `following ${label} from sequence ${from} — Ctrl+C detaches, the Service keeps running`
750
+ : `${label} · ${attempt.status} — recorded output from sequence ${from}`));
652
751
  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}`);
752
+ let cursor = from;
753
+ let telemetryReported = false;
754
+ for (;;) {
755
+ const page = await api(config, "GET", `/processes/${attempt.id}/logs?from=${encodeURIComponent(cursor)}&limit=256`);
756
+ if (!telemetryReported) {
757
+ say(dim(`telemetry loss: ${page.telemetryLoss} · ${page.telemetryNote}`));
758
+ telemetryReported = true;
759
+ }
760
+ for (const chunk of page.chunks) {
761
+ process.stdout.write(Buffer.from(chunk.dataBase64, "base64"));
762
+ }
763
+ const advanced = page.nextFrom !== cursor;
764
+ cursor = page.nextFrom;
765
+ const ended = page.status === "exited" || page.status === "failed";
766
+ if (ended && !advanced)
767
+ break;
768
+ if (!advanced)
769
+ await new Promise((resolve) => setTimeout(resolve, 250));
658
770
  }
659
771
  say("");
660
- say(dim("stream ended"));
661
- process.exit(0);
772
+ say(dim(`stream ended · next sequence ${cursor}`));
662
773
  };
663
774
  const serviceRestart = async (config, args) => {
664
775
  const needle = args.find((a) => !a.startsWith("--"));
665
776
  if (needle === undefined)
666
777
  return fail("usage: mend service restart <name-or-id-prefix>");
667
778
  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`));
779
+ const restarted = await withSpinner(`restarting ${service.label ?? service.id.slice(0, 8)}…`, mutateService(config, "POST", `/services/${service.id}/restart`));
669
780
  say(`${green("✓")} restarted · ${restarted.status}`);
670
- say(` ${cobalt(serviceUrl(config, restarted))}`);
781
+ printServiceEndpoint(restarted);
671
782
  };
672
783
  /**
673
784
  * Scaffold mend.toml from the project's own manifests. Static suggestion,
@@ -770,6 +881,92 @@ const serviceCommand = async (config, args) => {
770
881
  return serviceRun(config, [verb, ...rest]);
771
882
  }
772
883
  };
884
+ const isProvider = (value) => value === "claude" || value === "codex" || value === "github";
885
+ const readIfExists = (file) => (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null);
886
+ /** The credential as THIS machine holds it — the same files the agent CLIs wrote at login. */
887
+ const localCredential = (provider) => {
888
+ const home = os.homedir();
889
+ switch (provider) {
890
+ case "codex":
891
+ return readIfExists(path.join(process.env["CODEX_HOME"] ?? path.join(home, ".codex"), "auth.json"));
892
+ case "claude":
893
+ return readIfExists(path.join(process.env["CLAUDE_CONFIG_DIR"] ?? path.join(home, ".claude"), ".credentials.json"));
894
+ case "github": {
895
+ const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
896
+ const token = result.status === 0 ? result.stdout.trim() : "";
897
+ return token === "" ? null : token;
898
+ }
899
+ }
900
+ };
901
+ const accountLine = (account) => {
902
+ const meta = account.metadata;
903
+ const pick = (key) => (typeof meta[key] === "string" ? String(meta[key]) : null);
904
+ const identity = pick("login") ?? pick("email") ?? pick("accountEmail") ?? pick("accountId");
905
+ const suffix = pick("tokenSuffix");
906
+ const facts = [
907
+ account.status === "active" ? "connected" : account.status,
908
+ identity,
909
+ suffix === null ? null : `…${suffix}`,
910
+ `since ${account.connectedAt.slice(0, 10)}`,
911
+ ].filter((fact) => fact !== null);
912
+ return `${account.provider.padEnd(8)} ${facts.join(" · ")}`;
913
+ };
914
+ /**
915
+ * `mend accounts`: the signed-in user's own connected accounts on the platform — each person's
916
+ * subscriptions, under their own Sealant user (docs/SEALANT-IDENTITY.md).
917
+ */
918
+ const accountsCommand = async (config) => {
919
+ const identity = await api(config, "GET", "/me/sealant");
920
+ process.stdout.write(`platform user ${identity.sealantUserId}\n`);
921
+ const providers = ["claude", "codex", "github"];
922
+ for (const provider of providers) {
923
+ const account = identity.accounts.find((row) => row.provider === provider && row.name === "default") ??
924
+ identity.accounts.find((row) => row.provider === provider);
925
+ process.stdout.write(` ${account === undefined ? `${provider.padEnd(8)} not connected` : accountLine(account)}\n`);
926
+ }
927
+ };
928
+ /**
929
+ * `mend connect claude|codex|github [--from-stdin] [--remove]`: send THIS machine's credential
930
+ * for the provider to the platform under your own user. The file the provider's CLI wrote at
931
+ * login is read verbatim (codex: ~/.codex/auth.json; claude: ~/.claude/.credentials.json;
932
+ * github: `gh auth token`); `--from-stdin` takes a pasted token or file instead. Mend forwards
933
+ * it once and stores nothing.
934
+ */
935
+ const connectCommand = async (config, args) => {
936
+ const [providerArg, ...flags] = args;
937
+ if (!isProvider(providerArg)) {
938
+ return fail("usage: mend connect claude|codex|github [--from-stdin] [--remove]");
939
+ }
940
+ const provider = providerArg;
941
+ if (flags.includes("--remove")) {
942
+ const identity = await api(config, "GET", "/me/sealant");
943
+ const account = identity.accounts.find((row) => row.provider === provider);
944
+ if (account === undefined)
945
+ return fail(`${provider}: nothing connected`);
946
+ await api(config, "DELETE", `/me/sealant/accounts/${account.id}`);
947
+ process.stdout.write(`${provider}: disconnected\n`);
948
+ return;
949
+ }
950
+ let secret;
951
+ if (flags.includes("--from-stdin")) {
952
+ secret = fs.readFileSync(0, "utf8").trim();
953
+ if (secret === "")
954
+ return fail("nothing on stdin");
955
+ }
956
+ else {
957
+ secret = localCredential(provider);
958
+ if (secret === null) {
959
+ const where = provider === "github"
960
+ ? "`gh auth login` first, or pipe a token: gh auth token | mend connect github --from-stdin"
961
+ : provider === "codex"
962
+ ? "`codex login` first, or: mend connect codex --from-stdin < auth.json"
963
+ : "`claude setup-token` then: mend connect claude --from-stdin";
964
+ return fail(`${provider}: no credential on this machine — ${where}`);
965
+ }
966
+ }
967
+ const account = await withSpinner(`connecting ${provider}`, api(config, "POST", "/me/sealant/accounts", { provider, secret }));
968
+ process.stdout.write(`${accountLine(account)}\n`);
969
+ };
773
970
  // ─── login: obtain and save the bearer token ────────────────────────────────
774
971
  const takeFlagValue = (args, flag) => {
775
972
  const at = args.indexOf(flag);
@@ -910,7 +1107,7 @@ const keysShare = async (config) => {
910
1107
  if (agentSock === undefined || agentSock === "") {
911
1108
  return fail("SSH_AUTH_SOCK is not set — start (or plug in) your ssh-agent first");
912
1109
  }
913
- const url = new URL(`${config.url}/api/keys/bridge/ws`);
1110
+ const url = parseMendUrl(`${config.url}/api/keys/bridge/ws`);
914
1111
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
915
1112
  url.searchParams.set("host", os.hostname());
916
1113
  if (config.token !== null)
@@ -1102,7 +1299,7 @@ const envLoad = async (config, args) => {
1102
1299
  // `--secret` alone sends everything to Secrets; `--secret A,B` only those names (for the
1103
1300
  // ordinary-looking ones that embed credentials, like DATABASE_URL). Routing is by NAME.
1104
1301
  const secretFlag = args.indexOf("--secret");
1105
- const secretArg = secretFlag !== -1 ? args[secretFlag + 1] : undefined;
1302
+ const secretArg = secretFlag === -1 ? undefined : args[secretFlag + 1];
1106
1303
  const secretNames = secretArg !== undefined &&
1107
1304
  !secretArg.startsWith("--") &&
1108
1305
  !secretArg.includes("/") &&
@@ -1226,6 +1423,9 @@ _mend() {
1226
1423
  'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
1227
1424
  'service:reachable ports — add, list, stop'
1228
1425
  'keys:the machine Mend deploy key — init, show, share'
1426
+ 'accounts:your connected accounts on the platform'
1427
+ 'pair:pair a phone or a second machine' 'doctor:read-only checklist of this setup'
1428
+ 'connect:send this machine'"'"'s claude/codex/github credential to the platform'
1229
1429
  'continue:resume with the pending follow-up' 'resume:rejoin a settled session'
1230
1430
  'rejoin:attach if live, otherwise resume'
1231
1431
  'projects:adopted projects' 'sessions:sessions with review facts' 'status:active sessions'
@@ -1248,7 +1448,7 @@ _mend "$@"
1248
1448
  const BASH_COMPLETIONS = `_mend() {
1249
1449
  local cur=\${COMP_WORDS[COMP_CWORD]}
1250
1450
  if [ "$COMP_CWORD" -eq 1 ]; then
1251
- COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys continue resume rejoin projects sessions status ui help" -- "$cur") )
1451
+ COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys pair doctor continue resume rejoin projects sessions status ui help" -- "$cur") )
1252
1452
  return
1253
1453
  fi
1254
1454
  case \${COMP_WORDS[1]} in
@@ -1318,9 +1518,14 @@ const supervisedRun = async (config, session, argv) => {
1318
1518
  }
1319
1519
  if (event.type === "session") {
1320
1520
  const detail = await api(config, "GET", `/sessions/${session.id}`);
1321
- if (["completed", "failed", "stopped"].includes(detail.session.status)) {
1322
- settled = detail.session.status;
1323
- }
1521
+ // The agent's own end settles the wait: the session fold may read `idle` while a
1522
+ // shell still holds the workspace.
1523
+ const ended = agentOutcome(detail.currentAgent) ??
1524
+ (["completed", "failed", "stopped"].includes(detail.session.status)
1525
+ ? detail.session.status
1526
+ : null);
1527
+ if (ended !== null)
1528
+ settled = ended;
1324
1529
  }
1325
1530
  }
1326
1531
  }
@@ -1332,19 +1537,14 @@ const supervisedRun = async (config, session, argv) => {
1332
1537
  };
1333
1538
  /**
1334
1539
  * The second half of the review loop (plan §7.3): find the session with a
1335
- * pending follow-up, deliver it (which reopens the session), and relaunch the
1336
- * harness in the same worktree with the instruction as its prompt.
1540
+ * pending follow-up and retry the one server-owned delivery operation. The
1541
+ * server persists intent, launches, correlates membership, and finalizes.
1337
1542
  */
1338
1543
  const continueSession = async (config, args) => {
1339
1544
  const explicitSession = args.find((a) => !a.startsWith("--")) ?? null;
1340
1545
  let sessionId = explicitSession;
1341
1546
  let followUp = null;
1342
- if (sessionId !== null) {
1343
- followUp = await api(config, "GET", `/sessions/${sessionId}/follow-up`);
1344
- if (followUp === null)
1345
- return fail(`session ${sessionId} has no pending follow-up`);
1346
- }
1347
- else {
1547
+ if (sessionId === null) {
1348
1548
  // Search the cwd's project, newest sessions first.
1349
1549
  const project = await findProject(config, null);
1350
1550
  const detail = await api(config, "GET", `/projects/${project.id}`);
@@ -1360,42 +1560,52 @@ const continueSession = async (config, args) => {
1360
1560
  return fail("no session with a pending follow-up — send one from the review first");
1361
1561
  }
1362
1562
  }
1563
+ else {
1564
+ followUp = await api(config, "GET", `/sessions/${sessionId}/follow-up`);
1565
+ if (followUp === null)
1566
+ return fail(`session ${sessionId} has no pending follow-up`);
1567
+ }
1363
1568
  const detail = await api(config, "GET", `/sessions/${sessionId}`);
1364
1569
  const session = detail.session;
1365
- const projects = await api(config, "GET", "/projects");
1366
- const project = projects.find((p) => p.id === session.projectId);
1367
- if (project === undefined)
1368
- return fail(`project ${session.projectId} not found`);
1369
- const worktree = worktreePathOf(project.storePath, session.worktree);
1370
- const build = CONTINUE_COMMANDS[session.harness];
1371
1570
  say(`${green("✓")} follow-up for session ${dim(session.id.slice(0, 8))} · ${session.branch}`);
1372
1571
  say(dim(" instruction:"));
1373
1572
  for (const line of followUp.instruction.split("\n").slice(0, 6))
1374
1573
  say(dim(` │ ${line}`));
1375
1574
  if (followUp.instruction.split("\n").length > 6)
1376
1575
  say(dim(" │ …"));
1377
- if (build === undefined) {
1378
- say("");
1379
- say(`${amber(" cannot relaunch")} ${dim(`— harness "${session.harness}" has no known resume command.`)}`);
1380
- say(dim(` run it yourself in ${worktree}; the follow-up stays pending.`));
1381
- process.exit(1);
1576
+ if (followUp.reviewSliceId === null ||
1577
+ followUp.checkpointAId === null ||
1578
+ followUp.checkpointBId === null ||
1579
+ followUp.diffDigest === null ||
1580
+ followUp.idempotencyKey === null) {
1581
+ return fail("legacy follow-up — recreate it from a pinned Review before delivery");
1582
+ }
1583
+ const delivered = await withSpinner("delivering persisted Review bundle — retries reconcile one process…", api(config, "POST", `/sessions/${session.id}/follow-up/deliver`, {
1584
+ reviewSliceId: followUp.reviewSliceId,
1585
+ checkpointAId: followUp.checkpointAId,
1586
+ checkpointBId: followUp.checkpointBId,
1587
+ diffDigest: followUp.diffDigest,
1588
+ commentIds: followUp.commentIds,
1589
+ instruction: followUp.instruction,
1590
+ idempotencyKey: followUp.idempotencyKey,
1591
+ }));
1592
+ if (delivered.status === "pending") {
1593
+ return fail("the session is active — the follow-up remains pending");
1382
1594
  }
1383
- await api(config, "POST", `/sessions/${session.id}/follow-up/deliver`);
1384
- say(`${green("✓")} delivered · session reopened`);
1595
+ if (delivered.status === "delivery_failed") {
1596
+ return fail(delivered.deliveryError ?? "delivery failed before process membership finalized");
1597
+ }
1598
+ say(`${green("✓ recording")} · delivered to run ${delivered.deliverySealantRunId ?? "unknown"}${detachHint()}`);
1385
1599
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1386
- // Relaunch SUPERVISED, exactly like a fresh `mend <harness>`: a new
1387
- // workspace mounts the SAME worktree, the platform PTY runs the harness
1388
- // with the instruction as its opening prompt, and the record continues —
1389
- // the web session page shows the live terminal throughout.
1390
- const argv = build(followUp.instruction);
1391
- await withSpinner("provisioning workspace — the resumed run records like any other…", api(config, "POST", `/sessions/${session.id}/launch`, { argv }));
1392
- say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
1393
1600
  say("");
1394
1601
  await attachOrExit(config, session.id, session.harness);
1395
1602
  exitAfterSessionEnd(config, session.id);
1396
1603
  };
1397
1604
  // ─── resume: rejoin a session — same worktree, restored harness state ───────
1398
1605
  const ACTIVE_STATUSES = LIVE_STATUSES;
1606
+ /** Whether a listed session's AGENT is live — the annotation carries its current agent process. */
1607
+ const agentLiveIn = (detail, session) => agentIsLive(session, detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
1608
+ null);
1399
1609
  /**
1400
1610
  * Sessions are continuous work, not runs: `mend resume` rejoins one on a
1401
1611
  * fresh workspace — saved harness state restored, a claude resume is native
@@ -1408,15 +1618,15 @@ const resumeCommand = async (config, args) => {
1408
1618
  const prefix = args.find((a, i) => !a.startsWith("--") && i !== withFlag + 1);
1409
1619
  const project = await findProject(config, null);
1410
1620
  const detail = await api(config, "GET", `/projects/${project.id}`);
1411
- const match = prefix !== undefined
1412
- ? detail.sessions.find((s) => s.id.startsWith(prefix))
1413
- : detail.sessions.find((s) => !ACTIVE_STATUSES.has(s.status));
1621
+ const match = prefix === undefined
1622
+ ? detail.sessions.find((s) => !agentLiveIn(detail, s))
1623
+ : detail.sessions.find((s) => s.id.startsWith(prefix));
1414
1624
  if (match === undefined) {
1415
- return fail(prefix !== undefined
1416
- ? `no session matches "${prefix}"`
1417
- : "no settled session to resume — mend status lists sessions");
1625
+ return fail(prefix === undefined
1626
+ ? "no settled session to resume — mend status lists sessions"
1627
+ : `no session matches "${prefix}"`);
1418
1628
  }
1419
- if (ACTIVE_STATUSES.has(match.status)) {
1629
+ if (agentLiveIn(detail, match)) {
1420
1630
  return fail(`session ${match.id.slice(0, 8)} is live — attach: mend attach ${match.id.slice(0, 8)}`);
1421
1631
  }
1422
1632
  say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
@@ -1441,7 +1651,7 @@ const resumeForRejoin = async (config, sessionId, label) => {
1441
1651
  }
1442
1652
  catch (error) {
1443
1653
  const refreshed = await api(config, "GET", `/sessions/${sessionId}`);
1444
- if (ACTIVE_STATUSES.has(refreshed.session.status))
1654
+ if (agentIsLive(refreshed.session, refreshed.currentAgent))
1445
1655
  return false;
1446
1656
  return fail(error instanceof Error ? error.message : String(error));
1447
1657
  }
@@ -1457,7 +1667,7 @@ const rejoinCommand = async (config, args) => {
1457
1667
  const eligible = detail.sessions
1458
1668
  .filter((session) => harness === null || session.harness === harness)
1459
1669
  .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt));
1460
- const newest = eligible.find((session) => ACTIVE_STATUSES.has(session.status)) ?? eligible[0];
1670
+ const newest = eligible.find((session) => agentLiveIn(detail, session)) ?? eligible[0];
1461
1671
  const matches = prefix === undefined
1462
1672
  ? newest === undefined
1463
1673
  ? []
@@ -1475,10 +1685,11 @@ const rejoinCommand = async (config, args) => {
1475
1685
  const session = matches[0];
1476
1686
  if (session === undefined)
1477
1687
  return fail("session selection failed");
1478
- say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${ACTIVE_STATUSES.has(session.status) ? "already live" : "restoring"}`);
1688
+ const alreadyLive = agentLiveIn(detail, session);
1689
+ say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
1479
1690
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1480
1691
  let restored = false;
1481
- if (!ACTIVE_STATUSES.has(session.status)) {
1692
+ if (!alreadyLive) {
1482
1693
  restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
1483
1694
  }
1484
1695
  say(restored
@@ -1488,7 +1699,7 @@ const rejoinCommand = async (config, args) => {
1488
1699
  let outcome = await attachTty(config, session.id, session.harness, 0n);
1489
1700
  if (outcome === "unavailable") {
1490
1701
  const refreshed = await api(config, "GET", `/sessions/${session.id}`);
1491
- if (!ACTIVE_STATUSES.has(refreshed.session.status)) {
1702
+ if (!agentIsLive(refreshed.session, refreshed.currentAgent)) {
1492
1703
  await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
1493
1704
  }
1494
1705
  outcome = await attachTty(config, session.id, session.harness, 0n);
@@ -1512,11 +1723,16 @@ const projectsCommand = async (config) => {
1512
1723
  }
1513
1724
  const nameWidth = Math.max(...projects.map((p) => p.name.length));
1514
1725
  const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length));
1726
+ // The cwd's project is marked — the same resolution mend claude|shell use.
1727
+ const here = matchProjectByCwd(projects, cwdFacts(process.cwd()));
1515
1728
  for (const project of projects) {
1516
1729
  const live = liveByProject.get(project.id) ?? 0;
1517
1730
  const liveLabel = live > 0 ? green(`${live} live`) : dim("—");
1518
- say(`${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
1731
+ const marker = project.id === here?.id ? cobalt("▸ ") : " ";
1732
+ say(`${marker}${project.name.padEnd(nameWidth)} ${dim(project.defaultBranch.padEnd(branchWidth))} ${liveLabel} ${dim(project.storePath)}`);
1519
1733
  }
1734
+ if (here !== undefined)
1735
+ say(dim(` ▸ ${here.name} is the cwd's project`));
1520
1736
  };
1521
1737
  const printSessionRow = (row) => {
1522
1738
  const { session, annotation } = row;
@@ -1652,11 +1868,28 @@ const dashboard = async (config) => {
1652
1868
  // ─── entry ──────────────────────────────────────────────────────────────────
1653
1869
  const HELP = `mend — the agent workbench
1654
1870
 
1655
- mend the dashboard: every project and session, live
1871
+ start
1872
+ mend login [--url <server>] sign in with email + password; saves the token (0600)
1873
+ mend connect <provider> [--from-stdin] [--remove]
1874
+ send THIS machine's claude/codex/github credential to the
1875
+ platform under your own user (reads the file the provider's
1876
+ CLI wrote at login; --from-stdin pastes one instead)
1656
1877
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
1657
1878
  adopt a repository into the store (default: cwd; any git
1658
1879
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
1659
- mend login [--url <server>] sign in with email + password; saves the token (0600)
1880
+ mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
1881
+ [--base <ref>] [--ask] [--fast]
1882
+ new session worktree + launch the harness in it; a quoted
1883
+ prompt becomes its first message (and names the session),
1884
+ --ask restores the harness's permission prompts, --fast
1885
+ requests priority processing (codex service tier)
1886
+ mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
1887
+ the URL to reach this server (one device, once, 10 minutes)
1888
+ mend doctor read-only checklist of this machine's setup — one line per
1889
+ fact, each unfinished one ending in the command that fixes it
1890
+
1891
+ everything else
1892
+ mend the dashboard: every project and session, live
1660
1893
  mend logout forget the saved token
1661
1894
  mend keys init generate the machine's Mend deploy key (ed25519)
1662
1895
  mend keys show print the public key — add it as a deploy key on your git host
@@ -1664,11 +1897,11 @@ const HELP = `mend — the agent workbench
1664
1897
  secret-shaped names → secrets; --secret sends all (or the
1665
1898
  named ones, e.g. DATABASE_URL) to secrets
1666
1899
  mend env show what the project store holds — names only, never values
1900
+ mend accounts your connected accounts on the platform (claude, codex, github)
1667
1901
  mend dotfiles your dotfiles on the server: repo + synced home files
1668
1902
  mend dotfiles sync [--all | paths…] capture config files from THIS machine into your store
1669
1903
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
1670
1904
  hardware keys sign here; Ctrl-C stops sharing)
1671
- mend codex|claude|opencode new session worktree + launch the harness in it
1672
1905
  mend run -- <command...> same, with an arbitrary command
1673
1906
  mend attach <session-id-prefix> reattach this terminal to a running session
1674
1907
  mend shell [session-id-prefix] open a shell in a live session's workspace
@@ -1721,6 +1954,17 @@ const main = async () => {
1721
1954
  return keysCommand(config, rest);
1722
1955
  case "dotfiles":
1723
1956
  return dotfilesCommand(config, rest);
1957
+ case "accounts":
1958
+ return accountsCommand(config);
1959
+ case "connect":
1960
+ return connectCommand(config, rest);
1961
+ case "pair":
1962
+ return pairCommand(rest, boundApi(config));
1963
+ // Deliberately absent from HELP: the installer renders its own QR through this.
1964
+ case "qr":
1965
+ return qrCommand(rest);
1966
+ case "doctor":
1967
+ return doctorCommand(config, localCredential);
1724
1968
  case "env":
1725
1969
  return envCommand(config, rest);
1726
1970
  case "completions":