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