@sealant/mend 0.12.2 → 0.14.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 +108 -22
- package/dist/main.js +283 -29
- package/dist/shared.js +63 -3
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient
|
|
|
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 { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, pendingId, } from "./shared.js";
|
|
8
|
+
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
9
9
|
import { openUrl } from "./terminal.js";
|
|
10
10
|
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
11
|
import { createSseParser, eventFamilies } from "./workbench-events.js";
|
|
@@ -138,11 +138,23 @@ const paneScrollStyle = {
|
|
|
138
138
|
contentOptions: { backgroundColor: "transparent" },
|
|
139
139
|
};
|
|
140
140
|
const ProjectRow = ({ item, selected, nameWidth, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }), _jsx("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }), item.live > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.live} live` }) : null, item.open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.open}` }) : null] }) }));
|
|
141
|
+
/**
|
|
142
|
+
* The worktree IS the session's identity (one worktree per session): its
|
|
143
|
+
* branch, with the noisy default prefix receded. What you are working on
|
|
144
|
+
* leads every row; the harness is a fact about it, not its name.
|
|
145
|
+
*/
|
|
146
|
+
const worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
|
|
147
|
+
/**
|
|
148
|
+
* One worktree, grouped: the session line leads with the worktree name, and
|
|
149
|
+
* everything live inside it — its Services — hangs underneath. The child
|
|
150
|
+
* rows are facts, not targets: selection stays on the worktree line.
|
|
151
|
+
*/
|
|
141
152
|
const SessionRow = ({ item, selected, }) => {
|
|
142
|
-
const { session, annotation } = item;
|
|
153
|
+
const { session, annotation, services } = item;
|
|
143
154
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
144
155
|
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
145
|
-
|
|
156
|
+
const worktree = worktreeName(session.branch);
|
|
157
|
+
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: worktree.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: FAINT, children: (session.label ?? "").slice(0, 40) })] }) }), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: " └ " }), _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
|
|
146
158
|
};
|
|
147
159
|
const HarnessRow = ({ item, selected, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: item.hint })] }) }));
|
|
148
160
|
/**
|
|
@@ -183,15 +195,16 @@ const StatusLine = ({ busy, busyStarted, status, }) => {
|
|
|
183
195
|
};
|
|
184
196
|
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
185
197
|
/** Keep a 1-line-per-row selection inside its scrollbox's real viewport. */
|
|
186
|
-
const
|
|
198
|
+
const keepSpanVisible = (scroll, top, height) => {
|
|
187
199
|
if (scroll === null)
|
|
188
200
|
return;
|
|
189
201
|
const viewH = Math.max(1, scroll.viewport.height);
|
|
190
|
-
if (
|
|
191
|
-
scroll.scrollTo(
|
|
192
|
-
else if (
|
|
193
|
-
scroll.scrollTo(
|
|
202
|
+
if (top < scroll.scrollTop)
|
|
203
|
+
scroll.scrollTo(top);
|
|
204
|
+
else if (top + height > scroll.scrollTop + viewH)
|
|
205
|
+
scroll.scrollTo(top + height - viewH);
|
|
194
206
|
};
|
|
207
|
+
const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
|
|
195
208
|
const PROJECTS_PANE_WIDTH = 30;
|
|
196
209
|
const App = ({ ctx, onQuit }) => {
|
|
197
210
|
const renderer = useRenderer();
|
|
@@ -212,6 +225,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
212
225
|
const [busyStarted, setBusyStarted] = useState(0);
|
|
213
226
|
const [status, setStatus] = useState(null);
|
|
214
227
|
const [editing, setEditing] = useState(null);
|
|
228
|
+
/** Session id a stop is armed against; the second press fires it. */
|
|
229
|
+
const [stopArmed, setStopArmed] = useState(null);
|
|
230
|
+
/** The new-session flow asks the worktree's name FIRST, then the harness. */
|
|
231
|
+
const [naming, setNaming] = useState(null);
|
|
232
|
+
const [pendingName, setPendingName] = useState(null);
|
|
215
233
|
const [reviewing, setReviewing] = useState(null);
|
|
216
234
|
/** Set synchronously around attach/launch so keystrokes can't double-fire. */
|
|
217
235
|
const lockRef = useRef(false);
|
|
@@ -246,8 +264,17 @@ const App = ({ ctx, onQuit }) => {
|
|
|
246
264
|
keepRowVisible(projectScrollRef.current, projectIndex);
|
|
247
265
|
}, [projectIndex]);
|
|
248
266
|
useEffect(() => {
|
|
249
|
-
|
|
250
|
-
|
|
267
|
+
// Rows are worktree groups (a session line plus its service children), so
|
|
268
|
+
// the scroll target is the group's y offset, not its index.
|
|
269
|
+
let top = 0;
|
|
270
|
+
for (const [index, item] of sessionItems.entries()) {
|
|
271
|
+
if (index === sessionIndex)
|
|
272
|
+
break;
|
|
273
|
+
top += 1 + item.services.length;
|
|
274
|
+
}
|
|
275
|
+
const selectedHeight = 1 + (sessionItems[sessionIndex]?.services.length ?? 0);
|
|
276
|
+
keepSpanVisible(sessionScrollRef.current, top, selectedHeight);
|
|
277
|
+
}, [sessionIndex, sessionItems]);
|
|
251
278
|
const attachFlow = async (session) => {
|
|
252
279
|
const short = session.id.slice(0, 8);
|
|
253
280
|
lockRef.current = true;
|
|
@@ -275,9 +302,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
275
302
|
}
|
|
276
303
|
say(outcome === "unavailable"
|
|
277
304
|
? "attach unavailable — could not connect"
|
|
278
|
-
: outcome === "detached"
|
|
305
|
+
: outcome === "detached" || outcome === "interrupted"
|
|
279
306
|
? `detached — ${short} keeps running`
|
|
280
|
-
:
|
|
307
|
+
: outcome === "dropped"
|
|
308
|
+
? `disconnected — ${short} keeps running`
|
|
309
|
+
: `session settled · ${short}`);
|
|
281
310
|
refetch();
|
|
282
311
|
};
|
|
283
312
|
/** Converge on the server's truth once, after the LAST in-flight mutation. */
|
|
@@ -311,6 +340,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
311
340
|
const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
|
|
312
341
|
harness: vars.harness,
|
|
313
342
|
label: null,
|
|
343
|
+
name: vars.name,
|
|
314
344
|
base: null,
|
|
315
345
|
});
|
|
316
346
|
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
@@ -322,7 +352,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
322
352
|
id: vars.pendingKey,
|
|
323
353
|
harness: vars.harness,
|
|
324
354
|
label: null,
|
|
325
|
-
branch: "provisioning…"
|
|
355
|
+
branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
|
|
326
356
|
baseSha: "",
|
|
327
357
|
baseRef: null,
|
|
328
358
|
status: "starting",
|
|
@@ -387,9 +417,48 @@ const App = ({ ctx, onQuit }) => {
|
|
|
387
417
|
},
|
|
388
418
|
onSettled: settleRefetch,
|
|
389
419
|
});
|
|
420
|
+
// Stops are explicit — and armed: the first press names what a second press
|
|
421
|
+
// will stop; moving the selection re-arms against the newly selected row.
|
|
422
|
+
const stopMutation = useMutation({
|
|
423
|
+
mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
|
|
424
|
+
onMutate: async (session) => {
|
|
425
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
426
|
+
patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
|
|
427
|
+
say(`stopped · ${worktreeName(session.branch)} — the record and review remain`);
|
|
428
|
+
},
|
|
429
|
+
onError: (error) => {
|
|
430
|
+
say(errorText(error));
|
|
431
|
+
refetch();
|
|
432
|
+
},
|
|
433
|
+
onSettled: settleRefetch,
|
|
434
|
+
});
|
|
435
|
+
const armStop = () => {
|
|
436
|
+
const item = selectedSession;
|
|
437
|
+
if (item === null || isPendingId(item.session.id))
|
|
438
|
+
return;
|
|
439
|
+
if (!LIVE_STATUSES.has(item.session.status)) {
|
|
440
|
+
say("nothing to stop — the session is settled");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (stopArmed === item.session.id) {
|
|
444
|
+
setStopArmed(null);
|
|
445
|
+
stopMutation.mutate(item.session);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
setStopArmed(item.session.id);
|
|
449
|
+
say(`press again to stop · ${worktreeName(item.session.branch)}`);
|
|
450
|
+
};
|
|
390
451
|
const startSession = (projectId, harness) => {
|
|
391
452
|
setFocus("sessions");
|
|
392
|
-
launchMutation.mutate({ projectId, harness, pendingKey: pendingId() });
|
|
453
|
+
launchMutation.mutate({ projectId, harness, name: pendingName, pendingKey: pendingId() });
|
|
454
|
+
setPendingName(null);
|
|
455
|
+
};
|
|
456
|
+
/** Name first, then the harness: the worktree is the identity being created. */
|
|
457
|
+
const submitWorktreeName = (value) => {
|
|
458
|
+
setNaming(null);
|
|
459
|
+
const slug = normalizeProjectName(value.trim());
|
|
460
|
+
setPendingName(value.trim() === "" ? null : slug);
|
|
461
|
+
openPicker(null);
|
|
393
462
|
};
|
|
394
463
|
const resumeSession = (projectId, session, harness) => {
|
|
395
464
|
setFocus("sessions");
|
|
@@ -473,6 +542,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
473
542
|
setEditing(null);
|
|
474
543
|
return;
|
|
475
544
|
}
|
|
545
|
+
if (naming !== null) {
|
|
546
|
+
if (key.name === "escape")
|
|
547
|
+
setNaming(null);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
476
550
|
if (picker !== null) {
|
|
477
551
|
switch (key.name) {
|
|
478
552
|
case "down":
|
|
@@ -493,6 +567,10 @@ const App = ({ ctx, onQuit }) => {
|
|
|
493
567
|
return;
|
|
494
568
|
}
|
|
495
569
|
}
|
|
570
|
+
// Shift+K (and x below): stop the selected session. Lowercase k stays
|
|
571
|
+
// vim-up; the shift is the deliberateness the arm-confirm then doubles.
|
|
572
|
+
if (key.shift === true && key.name === "k")
|
|
573
|
+
return armStop();
|
|
496
574
|
switch (key.name) {
|
|
497
575
|
case "q":
|
|
498
576
|
return onQuit();
|
|
@@ -502,6 +580,8 @@ const App = ({ ctx, onQuit }) => {
|
|
|
502
580
|
case "up":
|
|
503
581
|
case "k":
|
|
504
582
|
return moveSelection(-1);
|
|
583
|
+
case "x":
|
|
584
|
+
return armStop();
|
|
505
585
|
case "return":
|
|
506
586
|
case "linefeed":
|
|
507
587
|
return activate();
|
|
@@ -522,8 +602,9 @@ const App = ({ ctx, onQuit }) => {
|
|
|
522
602
|
setFocus(focus === "projects" ? "sessions" : "projects");
|
|
523
603
|
return;
|
|
524
604
|
case "n":
|
|
605
|
+
// The worktree's name comes first; the harness picker follows.
|
|
525
606
|
if (selectedProject !== null)
|
|
526
|
-
|
|
607
|
+
setNaming({ projectId: selectedProject.project.id });
|
|
527
608
|
return;
|
|
528
609
|
case "e": {
|
|
529
610
|
const item = selectedSession;
|
|
@@ -571,7 +652,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
571
652
|
: `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
|
|
572
653
|
const detailTitle = selectedSession === null
|
|
573
654
|
? "session"
|
|
574
|
-
: `
|
|
655
|
+
: `worktree — ${worktreeName(selectedSession.session.branch)} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
575
656
|
const pickerTitle = picker === null
|
|
576
657
|
? ""
|
|
577
658
|
: picker.session === null
|
|
@@ -579,11 +660,13 @@ const App = ({ ctx, onQuit }) => {
|
|
|
579
660
|
: `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
|
|
580
661
|
const footerText = editing !== null
|
|
581
662
|
? " enter save · esc cancel"
|
|
582
|
-
:
|
|
583
|
-
? "
|
|
584
|
-
:
|
|
585
|
-
? " ↑↓ move · enter
|
|
586
|
-
:
|
|
663
|
+
: naming !== null
|
|
664
|
+
? " enter continue — the harness picker follows · esc cancel"
|
|
665
|
+
: picker !== null
|
|
666
|
+
? " ↑↓ move · enter start · esc cancel"
|
|
667
|
+
: focus === "projects"
|
|
668
|
+
? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
|
|
669
|
+
: " ↑↓ move · enter attach/resume · n new · x stop · v review · e rename · o web · h/⇥ projects · q quit";
|
|
587
670
|
const loadFailure = data === undefined && failureReason !== null
|
|
588
671
|
? failureReason instanceof Error
|
|
589
672
|
? failureReason.message
|
|
@@ -595,7 +678,10 @@ const App = ({ ctx, onQuit }) => {
|
|
|
595
678
|
refetch();
|
|
596
679
|
}, onQuit: onQuit }));
|
|
597
680
|
}
|
|
598
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [sessionItems.map((item, index) => (_jsx(SessionRow, { item: item, selected: index === sessionIndex }, item.session.id))), data !== undefined && sessionItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) :
|
|
681
|
+
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [sessionItems.map((item, index) => (_jsx(SessionRow, { item: item, selected: index === sessionIndex }, item.session.id))), data !== undefined && sessionItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : naming !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: " new worktree \u2014 name ", titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: "", placeholder: "name the worktree, e.g. fix-auth (empty = auto)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
682
|
+
if (typeof value === "string")
|
|
683
|
+
submitWorktreeName(value);
|
|
684
|
+
} }) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
599
685
|
if (typeof value === "string")
|
|
600
686
|
submitRename(editing, value);
|
|
601
687
|
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
|
package/dist/main.js
CHANGED
|
@@ -11,7 +11,7 @@ import { formatLoadReport } from "./env.js";
|
|
|
11
11
|
import { loginCommand } from "./login.js";
|
|
12
12
|
import { pairCommand, qrCommand } from "./pair.js";
|
|
13
13
|
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
14
|
-
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
14
|
+
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
15
15
|
import { sshCommand } from "./ssh-setup.js";
|
|
16
16
|
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
17
17
|
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
@@ -227,6 +227,38 @@ const findProject = async (config, explicit, adoptCwd = false) => {
|
|
|
227
227
|
say(`${green("✓")} adopted · ${adopted.name} · ${dim(adopted.storePath)}`);
|
|
228
228
|
return adopted;
|
|
229
229
|
};
|
|
230
|
+
/**
|
|
231
|
+
* Cascade for the launch lifecycle: per-launch flag → project stance → global
|
|
232
|
+
* setting. Any read failure (an older server, a blip) stays background — a
|
|
233
|
+
* network hiccup must never flip launch semantics to foreground.
|
|
234
|
+
*/
|
|
235
|
+
const resolvedBackgroundSessions = async (config, project) => {
|
|
236
|
+
if (project.backgroundSessions === "on")
|
|
237
|
+
return true;
|
|
238
|
+
if (project.backgroundSessions === "off")
|
|
239
|
+
return false;
|
|
240
|
+
try {
|
|
241
|
+
const settings = await request(config, "GET", "/settings");
|
|
242
|
+
return settings.backgroundSessions !== false;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/** One-line TTY ask: the worktree's name comes first — it is the identity being created. */
|
|
249
|
+
const askWorktreeName = async () => {
|
|
250
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
|
|
251
|
+
return null;
|
|
252
|
+
const readline = await import("node:readline/promises");
|
|
253
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
254
|
+
try {
|
|
255
|
+
const answer = (await rl.question(` worktree name ${dim("(enter for auto)")}: `)).trim();
|
|
256
|
+
return answer === "" ? null : normalizeProjectName(answer);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
rl.close();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
230
262
|
const launch = async (config, harness, args) => {
|
|
231
263
|
const parsed = parseLaunchArgs(args);
|
|
232
264
|
if (parsed.error !== null)
|
|
@@ -239,6 +271,9 @@ const launch = async (config, harness, args) => {
|
|
|
239
271
|
if (harness === "run" && structured) {
|
|
240
272
|
return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
|
|
241
273
|
}
|
|
274
|
+
if (harness === "run" && (parsed.detach || parsed.foreground)) {
|
|
275
|
+
return fail("mend run takes no lifecycle flags — it tails the record; Ctrl+C stops watching, not the command");
|
|
276
|
+
}
|
|
242
277
|
const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
|
|
243
278
|
if (argv.length === 0) {
|
|
244
279
|
return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
|
|
@@ -247,11 +282,42 @@ const launch = async (config, harness, args) => {
|
|
|
247
282
|
// Say which project the cwd resolved to before anything is created — a
|
|
248
283
|
// wrong guess should be visible here, not discovered in the tree later.
|
|
249
284
|
say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
|
|
285
|
+
// The worktree's name comes first, then the session details — it is the
|
|
286
|
+
// identity every list leads with. `mend run` stays scriptable: flag only.
|
|
287
|
+
const worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
|
|
288
|
+
const lifecycle = harness === "run"
|
|
289
|
+
? "background"
|
|
290
|
+
: parsed.detach
|
|
291
|
+
? "detach"
|
|
292
|
+
: parsed.foreground
|
|
293
|
+
? "foreground"
|
|
294
|
+
: (await resolvedBackgroundSessions(config, project))
|
|
295
|
+
? "background"
|
|
296
|
+
: "foreground";
|
|
297
|
+
// Foreground holds from the moment the session exists: a signal between
|
|
298
|
+
// create and attach stops it too. attachTty owns signals while attached.
|
|
299
|
+
let createdSessionId = null;
|
|
300
|
+
let attachOwnsSignals = false;
|
|
301
|
+
const onLaunchSignal = () => {
|
|
302
|
+
if (attachOwnsSignals)
|
|
303
|
+
return;
|
|
304
|
+
const id = createdSessionId;
|
|
305
|
+
if (id === null)
|
|
306
|
+
process.exit(1);
|
|
307
|
+
void stopSessionQuickly(config, id).then((stopped) => process.exit(stopped ? 0 : 1));
|
|
308
|
+
};
|
|
309
|
+
if (lifecycle === "foreground") {
|
|
310
|
+
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
|
|
311
|
+
process.on(signal, onLaunchSignal);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
250
314
|
const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
|
|
251
315
|
harness,
|
|
252
316
|
label: null,
|
|
317
|
+
name: worktreeName,
|
|
253
318
|
base: parsed.base,
|
|
254
319
|
});
|
|
320
|
+
createdSessionId = session.id;
|
|
255
321
|
say(`${green("✓")} worktree ${session.worktree} ${dim(`· branch ${session.branch}`)}`);
|
|
256
322
|
const baseWord = session.baseRef === null ? "" : `${session.baseRef} `;
|
|
257
323
|
say(`${green("✓")} base ${baseWord}${dim(session.baseSha.slice(0, 12))} · session ${dim(session.id.slice(0, 8))}`);
|
|
@@ -274,12 +340,17 @@ const launch = async (config, harness, args) => {
|
|
|
274
340
|
}
|
|
275
341
|
: { argv };
|
|
276
342
|
await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, launchBody));
|
|
343
|
+
if (lifecycle === "detach") {
|
|
344
|
+
say(`${green("✓ recording")} · running detached`);
|
|
345
|
+
say(`${cobalt(" attach")} · mend attach ${session.id.slice(0, 8)}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
277
348
|
say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
|
|
278
349
|
say("");
|
|
279
|
-
|
|
350
|
+
attachOwnsSignals = true;
|
|
351
|
+
await attachOrExit(config, session.id, session.harness, lifecycle);
|
|
280
352
|
exitAfterSessionEnd(config, session.id);
|
|
281
353
|
};
|
|
282
|
-
// ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
|
|
283
354
|
/**
|
|
284
355
|
* Attach this terminal to the session's PTY through the Mend server over ONE
|
|
285
356
|
* WebSocket: binary frames are PTY bytes both ways, text frames carry control
|
|
@@ -288,7 +359,9 @@ const launch = async (config, harness, args) => {
|
|
|
288
359
|
* Ctrl+] detaches — the session keeps running and can be reattached from
|
|
289
360
|
* anywhere. Resolves when the session settles or the user detaches; the
|
|
290
361
|
* caller decides what each outcome means (commands exit, the dashboard
|
|
291
|
-
* resumes).
|
|
362
|
+
* resumes). With `handleSignals`, a terminal-window close (SIGHUP) or kill
|
|
363
|
+
* resolves `interrupted` through the same restore path instead of leaving
|
|
364
|
+
* raw mode pushed — the handler never exits the process itself.
|
|
292
365
|
*/
|
|
293
366
|
const attachTty = async (config, sessionId, harness, from, processId, options) => {
|
|
294
367
|
const url = parseMendUrl(`${config.url}/api/tty`);
|
|
@@ -336,6 +409,16 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
336
409
|
const rawTty = process.stdin.isTTY === true;
|
|
337
410
|
let rawModeEnabled = false;
|
|
338
411
|
let detached = false;
|
|
412
|
+
let sawEnd = false;
|
|
413
|
+
let interrupted = false;
|
|
414
|
+
const onSignal = () => {
|
|
415
|
+
// Resolve the attach instead of exiting: the shared `finally` restores raw
|
|
416
|
+
// mode and closes the socket, then the caller decides what the signal means.
|
|
417
|
+
interrupted = true;
|
|
418
|
+
ws.close();
|
|
419
|
+
finishAttachment?.();
|
|
420
|
+
};
|
|
421
|
+
const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
339
422
|
const onTtyFrame = (event) => {
|
|
340
423
|
if (typeof event.data !== "string") {
|
|
341
424
|
process.stdout.write(Buffer.from(event.data));
|
|
@@ -349,6 +432,7 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
349
432
|
// Session lifecycle is authoritative. Start detaching immediately instead
|
|
350
433
|
// of holding the user's terminal for the later close handshake and Mend's
|
|
351
434
|
// settle/checkpoint/harvest work.
|
|
435
|
+
sawEnd = true;
|
|
352
436
|
ws.close();
|
|
353
437
|
finishAttachment?.();
|
|
354
438
|
}
|
|
@@ -357,8 +441,10 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
357
441
|
}
|
|
358
442
|
};
|
|
359
443
|
const onKeys = (data) => {
|
|
360
|
-
if (detachKeyEnabled && data
|
|
361
|
-
// Ctrl+] — detach, leave the session running.
|
|
444
|
+
if (detachKeyEnabled && isDetachChunk(data)) {
|
|
445
|
+
// Ctrl+] — detach, leave the session running. Matched in both its
|
|
446
|
+
// encodings: the inner TUI may have switched the user's terminal onto
|
|
447
|
+
// the kitty keyboard protocol, where the key arrives as CSI-u.
|
|
362
448
|
detached = true;
|
|
363
449
|
ws.close();
|
|
364
450
|
return;
|
|
@@ -371,6 +457,9 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
371
457
|
try {
|
|
372
458
|
sendResize();
|
|
373
459
|
process.stdout.on("resize", onWinch);
|
|
460
|
+
if (options?.handleSignals === true)
|
|
461
|
+
for (const signal of signals)
|
|
462
|
+
process.on(signal, onSignal);
|
|
374
463
|
// Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
|
|
375
464
|
// socket drops, and the process inside keeps running untouched.
|
|
376
465
|
if (options?.readOnly !== true) {
|
|
@@ -383,33 +472,111 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
383
472
|
}
|
|
384
473
|
ws.addEventListener("message", onTtyFrame);
|
|
385
474
|
await finished;
|
|
386
|
-
return detached ? "detached" : "ended";
|
|
475
|
+
return detached ? "detached" : interrupted ? "interrupted" : sawEnd ? "ended" : "dropped";
|
|
387
476
|
}
|
|
388
477
|
finally {
|
|
478
|
+
for (const signal of signals)
|
|
479
|
+
process.off(signal, onSignal);
|
|
389
480
|
process.stdin.off("data", onKeys);
|
|
390
481
|
process.stdout.off("resize", onWinch);
|
|
391
482
|
ws.removeEventListener("message", onTtyFrame);
|
|
392
483
|
ws.removeEventListener("close", onClose);
|
|
393
484
|
if (rawModeEnabled)
|
|
394
485
|
process.stdin.setRawMode(false);
|
|
486
|
+
if (options?.readOnly !== true && process.stdout.isTTY === true) {
|
|
487
|
+
// The inner TUI's terminal modes leaked onto OUR terminal through the
|
|
488
|
+
// byte bridge — kitty keyboard protocol, bracketed paste, mouse
|
|
489
|
+
// reporting, alternate screen, hidden cursor. The TUI keeps running
|
|
490
|
+
// remotely; the local terminal must come back to shell sanity, or every
|
|
491
|
+
// keystroke after a detach arrives as CSI-u junk. A reattach replays
|
|
492
|
+
// the session from 0, which re-establishes whatever the TUI had set.
|
|
493
|
+
process.stdout.write("\x1b[<u\x1b[=0;1u" + // pop the kitty keyboard stack, then force flags 0
|
|
494
|
+
"\x1b[?2004l" + // bracketed paste off
|
|
495
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + // mouse reporting off
|
|
496
|
+
"\x1b[?1049l" + // leave the alternate screen (no-op when already left)
|
|
497
|
+
"\x1b[?25h");
|
|
498
|
+
}
|
|
395
499
|
process.stdin.pause();
|
|
396
500
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
|
|
397
501
|
ws.close();
|
|
398
502
|
stopHerdrHint();
|
|
399
503
|
}
|
|
400
504
|
};
|
|
401
|
-
/**
|
|
402
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Best-effort stop under a signal's short grace window — a SIGHUP handler
|
|
507
|
+
* cannot afford the ordinary retry path. True when the server accepted.
|
|
508
|
+
*/
|
|
509
|
+
const stopSessionQuickly = async (config, sessionId) => {
|
|
510
|
+
const headers = { "content-type": "application/json" };
|
|
511
|
+
if (config.token !== null)
|
|
512
|
+
headers["authorization"] = `Bearer ${config.token}`;
|
|
513
|
+
try {
|
|
514
|
+
const response = await fetch(`${config.url}/api/sessions/${sessionId}/stop`, {
|
|
515
|
+
method: "POST",
|
|
516
|
+
headers,
|
|
517
|
+
signal: AbortSignal.timeout(2500),
|
|
518
|
+
});
|
|
519
|
+
return response.ok;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
/** Foreground exit: stop the session, or say honestly that it may still run. */
|
|
526
|
+
const stopAndExit = async (config, sessionId) => {
|
|
527
|
+
const id8 = sessionId.slice(0, 8);
|
|
528
|
+
if (await stopSessionQuickly(config, sessionId)) {
|
|
529
|
+
say(`${green("✓")} stopped · ${id8}`);
|
|
530
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
|
|
531
|
+
process.exit(0);
|
|
532
|
+
}
|
|
533
|
+
say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
|
|
534
|
+
process.exit(1);
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* How every one-shot command handles an attach outcome: detach says so and
|
|
538
|
+
* always leaves the session running; a signal or a dropped socket answers to
|
|
539
|
+
* the lifecycle mode; a settled session returns so the caller prints its facts.
|
|
540
|
+
*/
|
|
541
|
+
const finishAttach = async (config, sessionId, outcome, mode = "background") => {
|
|
542
|
+
const id8 = sessionId.slice(0, 8);
|
|
403
543
|
if (outcome === "unavailable") {
|
|
404
544
|
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
405
545
|
}
|
|
406
546
|
if (outcome === "detached") {
|
|
407
547
|
say("");
|
|
408
|
-
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${
|
|
548
|
+
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
549
|
+
process.exit(0);
|
|
550
|
+
}
|
|
551
|
+
if (outcome === "interrupted") {
|
|
552
|
+
say("");
|
|
553
|
+
if (mode === "foreground")
|
|
554
|
+
return stopAndExit(config, sessionId);
|
|
555
|
+
say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
556
|
+
process.exit(0);
|
|
557
|
+
}
|
|
558
|
+
if (outcome === "dropped") {
|
|
559
|
+
say("");
|
|
560
|
+
if (mode === "foreground") {
|
|
561
|
+
// The drop may be the server settling the session — verify before stopping.
|
|
562
|
+
let live;
|
|
563
|
+
try {
|
|
564
|
+
const detail = await request(config, "GET", `/sessions/${sessionId}`);
|
|
565
|
+
live = agentIsLive(detail.session, detail.currentAgent);
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
|
|
569
|
+
process.exit(1);
|
|
570
|
+
}
|
|
571
|
+
if (!live)
|
|
572
|
+
return; // settled as the socket closed — the caller prints the end facts
|
|
573
|
+
return stopAndExit(config, sessionId);
|
|
574
|
+
}
|
|
575
|
+
say(`${amber("disconnected")} — the session keeps running; reattach: mend attach ${id8}`);
|
|
409
576
|
process.exit(0);
|
|
410
577
|
}
|
|
411
578
|
};
|
|
412
|
-
const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
|
|
579
|
+
const attachOrExit = async (config, sessionId, harness, mode = "background") => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n, undefined, { handleSignals: true }), mode);
|
|
413
580
|
/** Return terminal control as soon as the terminal reports the observed end. */
|
|
414
581
|
const exitAfterSessionEnd = (config, sessionId) => {
|
|
415
582
|
say("");
|
|
@@ -426,11 +593,61 @@ const attach = async (config, args) => {
|
|
|
426
593
|
const match = sessions.find((s) => s.id.startsWith(prefix));
|
|
427
594
|
if (match === undefined)
|
|
428
595
|
return fail(`no active session matches "${prefix}"`);
|
|
429
|
-
say(`${green("✓")} attaching to ${match.
|
|
596
|
+
say(`${green("✓")} attaching to ${match.branch} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
|
|
430
597
|
say("");
|
|
431
598
|
await attachOrExit(config, match.id, match.harness);
|
|
432
599
|
exitAfterSessionEnd(config, match.id);
|
|
433
600
|
};
|
|
601
|
+
/**
|
|
602
|
+
* Explicit stop — the one intent detaching never carries. Ends the agent; the
|
|
603
|
+
* workspace harvests and closes; the record and review remain.
|
|
604
|
+
*/
|
|
605
|
+
const stopCommand = async (config, args) => {
|
|
606
|
+
const all = args.includes("--all");
|
|
607
|
+
const projectFlag = args.indexOf("--project");
|
|
608
|
+
const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
|
|
609
|
+
? String(args[projectFlag + 1])
|
|
610
|
+
: null;
|
|
611
|
+
const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
|
|
612
|
+
if (!all && prefix === undefined) {
|
|
613
|
+
return fail("usage: mend stop <session-id-prefix> | --all [--project <p>]");
|
|
614
|
+
}
|
|
615
|
+
const [sessions, projects] = await Promise.all([
|
|
616
|
+
api(config, "GET", "/sessions"),
|
|
617
|
+
api(config, "GET", "/projects"),
|
|
618
|
+
]);
|
|
619
|
+
let scoped = sessions;
|
|
620
|
+
if (projectName !== null) {
|
|
621
|
+
const project = projects.find((p) => p.name === projectName);
|
|
622
|
+
if (project === undefined)
|
|
623
|
+
return fail(`no project named "${projectName}"`);
|
|
624
|
+
scoped = sessions.filter((s) => s.projectId === project.id);
|
|
625
|
+
}
|
|
626
|
+
let targets;
|
|
627
|
+
if (all) {
|
|
628
|
+
targets = scoped;
|
|
629
|
+
if (targets.length === 0) {
|
|
630
|
+
say("no active sessions");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
else {
|
|
635
|
+
const matches = scoped.filter((s) => s.id.startsWith(prefix ?? ""));
|
|
636
|
+
if (matches.length === 0)
|
|
637
|
+
return fail(`no active session matches "${prefix}"`);
|
|
638
|
+
if (matches.length > 1) {
|
|
639
|
+
return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
|
|
640
|
+
}
|
|
641
|
+
targets = matches;
|
|
642
|
+
}
|
|
643
|
+
for (const session of targets) {
|
|
644
|
+
await api(config, "POST", `/sessions/${session.id}/stop`);
|
|
645
|
+
say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
|
|
646
|
+
say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
|
|
647
|
+
}
|
|
648
|
+
if (all)
|
|
649
|
+
say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
|
|
650
|
+
};
|
|
434
651
|
/** Compact picker: numbered live sessions, one keystroke of typing. */
|
|
435
652
|
const pickSessionInteractively = async (rows) => {
|
|
436
653
|
say(dim("more than one live session — pick one:"));
|
|
@@ -493,12 +710,14 @@ const shellCommand = async (config, args) => {
|
|
|
493
710
|
say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
|
|
494
711
|
const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
|
|
495
712
|
say("");
|
|
496
|
-
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id
|
|
713
|
+
const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id, {
|
|
714
|
+
handleSignals: true,
|
|
715
|
+
});
|
|
497
716
|
if (outcome === "unavailable") {
|
|
498
717
|
return fail(`tty attach unavailable: could not connect to ${config.url}`);
|
|
499
718
|
}
|
|
500
719
|
say("");
|
|
501
|
-
if (outcome === "detached") {
|
|
720
|
+
if (outcome === "detached" || outcome === "interrupted" || outcome === "dropped") {
|
|
502
721
|
say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
|
|
503
722
|
process.exit(0);
|
|
504
723
|
}
|
|
@@ -1571,7 +1790,8 @@ _mend() {
|
|
|
1571
1790
|
'adopt:adopt a repository into the store'
|
|
1572
1791
|
'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
|
|
1573
1792
|
'run:new session + arbitrary command'
|
|
1574
|
-
'attach:reattach to a running session' '
|
|
1793
|
+
'attach:reattach to a running session' 'stop:stop the agent — record and review remain'
|
|
1794
|
+
'shell:open a shell in a live session workspace'
|
|
1575
1795
|
'service:reachable ports — add, list, stop'
|
|
1576
1796
|
'keys:the machine Mend deploy key — init, show, share'
|
|
1577
1797
|
'accounts:your connected accounts on the platform'
|
|
@@ -1587,7 +1807,7 @@ _mend() {
|
|
|
1587
1807
|
return
|
|
1588
1808
|
fi
|
|
1589
1809
|
case $words[2] in
|
|
1590
|
-
shell|attach|continue|resume|rejoin)
|
|
1810
|
+
shell|attach|stop|continue|resume|rejoin)
|
|
1591
1811
|
local -a sessions
|
|
1592
1812
|
sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
|
|
1593
1813
|
(( \${#sessions} )) && _describe 'session' sessions
|
|
@@ -1599,11 +1819,11 @@ _mend "$@"
|
|
|
1599
1819
|
const BASH_COMPLETIONS = `_mend() {
|
|
1600
1820
|
local cur=\${COMP_WORDS[COMP_CWORD]}
|
|
1601
1821
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
1602
|
-
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1822
|
+
COMPREPLY=( $(compgen -W "adopt codex claude opencode run attach stop shell service keys pair doctor continue resume rejoin refresh projects sessions status ui help" -- "$cur") )
|
|
1603
1823
|
return
|
|
1604
1824
|
fi
|
|
1605
1825
|
case \${COMP_WORDS[1]} in
|
|
1606
|
-
shell|attach|continue|resume|rejoin)
|
|
1826
|
+
shell|attach|stop|continue|resume|rejoin)
|
|
1607
1827
|
COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
|
|
1608
1828
|
;;
|
|
1609
1829
|
esac
|
|
@@ -1782,7 +2002,17 @@ const resumeCommand = async (config, args) => {
|
|
|
1782
2002
|
}
|
|
1783
2003
|
say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
|
|
1784
2004
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${match.id}`);
|
|
1785
|
-
|
|
2005
|
+
// A formerly-protocol session (a phone pickup) resumed from a terminal must
|
|
2006
|
+
// come back as a TUI — the handoff verb routes it there; a plain resume
|
|
2007
|
+
// would re-enter protocol mode with nothing to attach.
|
|
2008
|
+
const priorAgent = detail.annotations.find((annotation) => annotation.sessionId === match.id)?.currentAgent ??
|
|
2009
|
+
null;
|
|
2010
|
+
const protocolPrior = priorAgent?.kind === "agent-protocol" && withHarness === null;
|
|
2011
|
+
await withSpinner(protocolPrior
|
|
2012
|
+
? "reopening as a terminal — same conversation…"
|
|
2013
|
+
: "resuming — a fresh workspace restores the saved session state…", protocolPrior
|
|
2014
|
+
? api(config, "POST", `/sessions/${match.id}/handoff`, { to: "pty" })
|
|
2015
|
+
: api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
|
|
1786
2016
|
say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
|
|
1787
2017
|
say("");
|
|
1788
2018
|
await attachOrExit(config, match.id, withHarness ?? match.harness);
|
|
@@ -1837,25 +2067,40 @@ const rejoinCommand = async (config, args) => {
|
|
|
1837
2067
|
if (session === undefined)
|
|
1838
2068
|
return fail("session selection failed");
|
|
1839
2069
|
const alreadyLive = agentLiveIn(detail, session);
|
|
1840
|
-
say(`${green("✓")} rejoining ${session.
|
|
2070
|
+
say(`${green("✓")} rejoining ${session.branch} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
|
|
1841
2071
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
|
|
1842
2072
|
let restored = false;
|
|
1843
|
-
|
|
2073
|
+
const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
|
|
2074
|
+
null;
|
|
2075
|
+
if (currentAgent?.kind === "agent-protocol") {
|
|
2076
|
+
// A protocol agent (a phone pickup) has no PTY to attach. Hand the session
|
|
2077
|
+
// off to a terminal: the TUI resumes the same provider conversation, with
|
|
2078
|
+
// the phone-authored turns in its scrollback.
|
|
2079
|
+
await withSpinner(alreadyLive
|
|
2080
|
+
? "taking over from the protocol session — same conversation…"
|
|
2081
|
+
: "reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
|
|
2082
|
+
restored = true;
|
|
2083
|
+
}
|
|
2084
|
+
else if (!alreadyLive) {
|
|
1844
2085
|
restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
|
|
1845
2086
|
}
|
|
1846
2087
|
say(restored
|
|
1847
2088
|
? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
|
|
1848
2089
|
: `${green("✓ recording")} · attached to the live session${detachHint()}`);
|
|
1849
2090
|
say("");
|
|
1850
|
-
let outcome = await attachTty(config, session.id, session.harness, 0n
|
|
2091
|
+
let outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
|
|
2092
|
+
handleSignals: true,
|
|
2093
|
+
});
|
|
1851
2094
|
if (outcome === "unavailable") {
|
|
1852
2095
|
const refreshed = await api(config, "GET", `/sessions/${session.id}`);
|
|
1853
2096
|
if (!agentIsLive(refreshed.session, refreshed.currentAgent)) {
|
|
1854
2097
|
await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
|
|
1855
2098
|
}
|
|
1856
|
-
outcome = await attachTty(config, session.id, session.harness, 0n
|
|
2099
|
+
outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
|
|
2100
|
+
handleSignals: true,
|
|
2101
|
+
});
|
|
1857
2102
|
}
|
|
1858
|
-
finishAttach(config, session.id, outcome);
|
|
2103
|
+
await finishAttach(config, session.id, outcome);
|
|
1859
2104
|
exitAfterSessionEnd(config, session.id);
|
|
1860
2105
|
};
|
|
1861
2106
|
// ─── projects · sessions: the workbench at a glance ─────────────────────────
|
|
@@ -2050,12 +2295,16 @@ start
|
|
|
2050
2295
|
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
2051
2296
|
adopt a repository into the store (default: cwd; any git
|
|
2052
2297
|
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
2053
|
-
mend codex|claude|opencode ["prompt"] [--
|
|
2054
|
-
[--
|
|
2055
|
-
|
|
2056
|
-
|
|
2298
|
+
mend codex|claude|opencode ["prompt"] [--name <worktree>] [--model <id>]
|
|
2299
|
+
[--effort low|medium|high|xhigh|max]
|
|
2300
|
+
[--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
|
|
2301
|
+
new session worktree + launch the harness in it; the
|
|
2302
|
+
worktree's name is asked first (--name skips the ask), a
|
|
2303
|
+
quoted prompt becomes its first message,
|
|
2057
2304
|
--ask restores the harness's permission prompts, --fast
|
|
2058
|
-
requests priority processing (codex service tier)
|
|
2305
|
+
requests priority processing (codex service tier),
|
|
2306
|
+
--detach launches without attaching (reattach anywhere),
|
|
2307
|
+
--foreground stops the session when this CLI exits
|
|
2059
2308
|
mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
|
|
2060
2309
|
the URL to reach this server (one device, once, 10 minutes)
|
|
2061
2310
|
mend doctor read-only checklist of this machine's setup — one line per
|
|
@@ -2077,6 +2326,9 @@ everything else
|
|
|
2077
2326
|
hardware keys sign here; Ctrl-C stops sharing)
|
|
2078
2327
|
mend run -- <command...> same, with an arbitrary command
|
|
2079
2328
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
2329
|
+
mend stop <session-id-prefix> | --all [--project <p>]
|
|
2330
|
+
stop the agent — the workspace harvests and closes; the
|
|
2331
|
+
record and review remain
|
|
2080
2332
|
mend shell [session-id-prefix] open a shell in a live session's workspace
|
|
2081
2333
|
mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
|
|
2082
2334
|
start + supervise a server in the session workspace
|
|
@@ -2123,6 +2375,8 @@ const main = async () => {
|
|
|
2123
2375
|
return launch(config, command, rest);
|
|
2124
2376
|
case "attach":
|
|
2125
2377
|
return attach(config, rest);
|
|
2378
|
+
case "stop":
|
|
2379
|
+
return stopCommand(config, rest);
|
|
2126
2380
|
case "shell":
|
|
2127
2381
|
return shellCommand(config, rest);
|
|
2128
2382
|
case "service":
|
package/dist/shared.js
CHANGED
|
@@ -34,13 +34,17 @@ const LAUNCH_ERROR = {
|
|
|
34
34
|
model: null,
|
|
35
35
|
effort: null,
|
|
36
36
|
base: null,
|
|
37
|
+
name: null,
|
|
37
38
|
ask: false,
|
|
38
39
|
fast: false,
|
|
40
|
+
detach: false,
|
|
41
|
+
foreground: false,
|
|
39
42
|
custom: [],
|
|
40
43
|
};
|
|
41
44
|
/**
|
|
42
45
|
* `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
|
|
43
|
-
* [--base <ref>] [--ask] [--project <p>]`, plus
|
|
46
|
+
* [--base <ref>] [--ask] [--detach|-d] [--foreground] [--project <p>]`, plus
|
|
47
|
+
* `mend run … -- <command...>`.
|
|
44
48
|
* The first non-flag positional is the prompt; a second one is an error so a
|
|
45
49
|
* forgotten quote fails loudly instead of launching with half a sentence.
|
|
46
50
|
*/
|
|
@@ -53,8 +57,11 @@ export const parseLaunchArgs = (args) => {
|
|
|
53
57
|
let model = null;
|
|
54
58
|
let effort = null;
|
|
55
59
|
let base = null;
|
|
60
|
+
let workName = null;
|
|
56
61
|
let ask = false;
|
|
57
62
|
let fast = false;
|
|
63
|
+
let detach = false;
|
|
64
|
+
let foreground = false;
|
|
58
65
|
for (let index = 0; index < flagArgs.length; index += 1) {
|
|
59
66
|
const arg = flagArgs[index] ?? "";
|
|
60
67
|
if (arg === "--ask") {
|
|
@@ -65,7 +72,19 @@ export const parseLaunchArgs = (args) => {
|
|
|
65
72
|
fast = true;
|
|
66
73
|
continue;
|
|
67
74
|
}
|
|
68
|
-
if (arg === "--
|
|
75
|
+
if (arg === "--detach" || arg === "-d") {
|
|
76
|
+
detach = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (arg === "--foreground") {
|
|
80
|
+
foreground = true;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (arg === "--project" ||
|
|
84
|
+
arg === "--model" ||
|
|
85
|
+
arg === "--effort" ||
|
|
86
|
+
arg === "--base" ||
|
|
87
|
+
arg === "--name") {
|
|
69
88
|
const value = flagArgs[index + 1];
|
|
70
89
|
if (value === undefined)
|
|
71
90
|
return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
|
|
@@ -75,6 +94,8 @@ export const parseLaunchArgs = (args) => {
|
|
|
75
94
|
model = value;
|
|
76
95
|
else if (arg === "--effort")
|
|
77
96
|
effort = value;
|
|
97
|
+
else if (arg === "--name")
|
|
98
|
+
workName = normalizeProjectName(value);
|
|
78
99
|
else
|
|
79
100
|
base = value;
|
|
80
101
|
index += 1;
|
|
@@ -99,7 +120,46 @@ export const parseLaunchArgs = (args) => {
|
|
|
99
120
|
if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
|
|
100
121
|
return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
|
|
101
122
|
}
|
|
102
|
-
|
|
123
|
+
if (detach && foreground) {
|
|
124
|
+
return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
project,
|
|
128
|
+
prompt,
|
|
129
|
+
model,
|
|
130
|
+
effort,
|
|
131
|
+
base,
|
|
132
|
+
name: workName,
|
|
133
|
+
ask,
|
|
134
|
+
fast,
|
|
135
|
+
detach,
|
|
136
|
+
foreground,
|
|
137
|
+
custom,
|
|
138
|
+
error: null,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Whether a raw stdin chunk carries the detach key (Ctrl+]). Two encodings
|
|
143
|
+
* reach us: the legacy control byte 0x1d — and, once an inner TUI (claude)
|
|
144
|
+
* pushes the kitty keyboard protocol through the PTY and the user's own
|
|
145
|
+
* terminal honors it, the CSI-u escape `ESC [ 93 ; 5 u` (`]` is code 93,
|
|
146
|
+
* ctrl is modifier 5), optionally with a kitty event-type suffix. Only press
|
|
147
|
+
* (`:1`) and repeat (`:2`) are intent; a release (`:3`) is not.
|
|
148
|
+
*/
|
|
149
|
+
export const isDetachChunk = (data) => {
|
|
150
|
+
if (data.includes(0x1d))
|
|
151
|
+
return true;
|
|
152
|
+
// latin1 maps bytes 1:1, so the scan sees exactly the wire bytes. A string
|
|
153
|
+
// scan rather than a regex: an escape byte inside a pattern trips lint, and
|
|
154
|
+
// anchoring on the real ESC[ prefix keeps pasted text from ever matching.
|
|
155
|
+
const text = data.toString("latin1");
|
|
156
|
+
const prefix = "\u001b[93;5";
|
|
157
|
+
for (let start = text.indexOf(prefix); start !== -1; start = text.indexOf(prefix, start + 1)) {
|
|
158
|
+
const rest = text.slice(start + prefix.length);
|
|
159
|
+
if (rest.startsWith("u") || rest.startsWith(":1u") || rest.startsWith(":2u"))
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
103
163
|
};
|
|
104
164
|
export const LIVE_STATUSES = new Set([
|
|
105
165
|
"starting",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|