@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/README.md +54 -0
- package/dist/dashboard.js +2 -2
- package/dist/doctor.js +233 -0
- package/dist/main.js +386 -142
- package/dist/pair.js +83 -0
- package/dist/review-workflow.js +2 -9
- package/dist/review.js +89 -43
- package/dist/shared.js +151 -5
- package/package.json +3 -1
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 {
|
|
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}[0m` : 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
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
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
|
|
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…`,
|
|
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
|
-
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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(
|
|
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)}…`,
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1322
|
-
|
|
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
|
|
1336
|
-
*
|
|
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
|
|
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 (
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
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
|
|
1412
|
-
? detail.sessions.find((s) => s
|
|
1413
|
-
: detail.sessions.find((s) =>
|
|
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
|
|
1416
|
-
?
|
|
1417
|
-
:
|
|
1625
|
+
return fail(prefix === undefined
|
|
1626
|
+
? "no settled session to resume — mend status lists sessions"
|
|
1627
|
+
: `no session matches "${prefix}"`);
|
|
1418
1628
|
}
|
|
1419
|
-
if (
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":
|