@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 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
- return (_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: session.harness.padEnd(9) }), _jsx("span", { fg: FAINT, children: session.id.slice(0, 8) }), _jsx("span", { fg: color, children: ` ${session.status.padEnd(10)}` }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: MUTED, children: (session.label ?? "").slice(0, 40) })] }) }));
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 keepRowVisible = (scroll, index) => {
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 (index < scroll.scrollTop)
191
- scroll.scrollTo(index);
192
- else if (index + 1 > scroll.scrollTop + viewH)
193
- scroll.scrollTo(index + 1 - viewH);
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
- keepRowVisible(sessionScrollRef.current, sessionIndex);
250
- }, [sessionIndex]);
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
- : `session settled · ${short}`);
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
- openPicker(null);
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
- : `session — ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
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
- : picker !== null
583
- ? " ↑↓ move · enter start · esc cancel"
584
- : focus === "projects"
585
- ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
586
- : " ↑↓ move · enter attach/resume · n new · v review · e rename · o web · h/⇥ projects · q quit";
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)))) })) : 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) => {
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
- await attachOrExit(config, session.id, session.harness);
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.includes(0x1d)) {
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
- /** How every one-shot command handles an attach outcome: detach says so, settle returns. */
402
- const finishAttach = (config, sessionId, outcome) => {
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 ${sessionId.slice(0, 8)}`);
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.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
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' 'shell:open a shell in a live session workspace'
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
- await withSpinner("resuming — a fresh workspace restores the saved session state…", api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
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.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
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
- if (!alreadyLive) {
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"] [--model <id>] [--effort low|medium|high|xhigh|max]
2054
- [--base <ref>] [--ask] [--fast]
2055
- new session worktree + launch the harness in it; a quoted
2056
- prompt becomes its first message (and names the session),
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 `mend run … -- <command...>`.
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 === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
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
- return { project, prompt, model, effort, base, ask, fast, custom, error: null };
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.12.2",
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",