@sealant/mend 0.12.2 → 0.13.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
@@ -275,9 +275,11 @@ const App = ({ ctx, onQuit }) => {
275
275
  }
276
276
  say(outcome === "unavailable"
277
277
  ? "attach unavailable — could not connect"
278
- : outcome === "detached"
278
+ : outcome === "detached" || outcome === "interrupted"
279
279
  ? `detached — ${short} keeps running`
280
- : `session settled · ${short}`);
280
+ : outcome === "dropped"
281
+ ? `disconnected — ${short} keeps running`
282
+ : `session settled · ${short}`);
281
283
  refetch();
282
284
  };
283
285
  /** Converge on the server's truth once, after the LAST in-flight mutation. */
package/dist/main.js CHANGED
@@ -227,6 +227,24 @@ 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
+ };
230
248
  const launch = async (config, harness, args) => {
231
249
  const parsed = parseLaunchArgs(args);
232
250
  if (parsed.error !== null)
@@ -239,6 +257,9 @@ const launch = async (config, harness, args) => {
239
257
  if (harness === "run" && structured) {
240
258
  return fail("mend run takes no prompt or harness flags — usage: mend run [--project p] -- <command...>");
241
259
  }
260
+ if (harness === "run" && (parsed.detach || parsed.foreground)) {
261
+ return fail("mend run takes no lifecycle flags — it tails the record; Ctrl+C stops watching, not the command");
262
+ }
242
263
  const argv = harness === "run" ? parsed.custom : (HARNESS_COMMANDS[harness] ?? []);
243
264
  if (argv.length === 0) {
244
265
  return fail(harness === "run" ? "usage: mend run -- <command...>" : `unknown harness ${harness}`);
@@ -247,11 +268,38 @@ const launch = async (config, harness, args) => {
247
268
  // Say which project the cwd resolved to before anything is created — a
248
269
  // wrong guess should be visible here, not discovered in the tree later.
249
270
  say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
271
+ const lifecycle = harness === "run"
272
+ ? "background"
273
+ : parsed.detach
274
+ ? "detach"
275
+ : parsed.foreground
276
+ ? "foreground"
277
+ : (await resolvedBackgroundSessions(config, project))
278
+ ? "background"
279
+ : "foreground";
280
+ // Foreground holds from the moment the session exists: a signal between
281
+ // create and attach stops it too. attachTty owns signals while attached.
282
+ let createdSessionId = null;
283
+ let attachOwnsSignals = false;
284
+ const onLaunchSignal = () => {
285
+ if (attachOwnsSignals)
286
+ return;
287
+ const id = createdSessionId;
288
+ if (id === null)
289
+ process.exit(1);
290
+ void stopSessionQuickly(config, id).then((stopped) => process.exit(stopped ? 0 : 1));
291
+ };
292
+ if (lifecycle === "foreground") {
293
+ for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
294
+ process.on(signal, onLaunchSignal);
295
+ }
296
+ }
250
297
  const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
251
298
  harness,
252
299
  label: null,
253
300
  base: parsed.base,
254
301
  });
302
+ createdSessionId = session.id;
255
303
  say(`${green("✓")} worktree ${session.worktree} ${dim(`· branch ${session.branch}`)}`);
256
304
  const baseWord = session.baseRef === null ? "" : `${session.baseRef} `;
257
305
  say(`${green("✓")} base ${baseWord}${dim(session.baseSha.slice(0, 12))} · session ${dim(session.id.slice(0, 8))}`);
@@ -274,12 +322,17 @@ const launch = async (config, harness, args) => {
274
322
  }
275
323
  : { argv };
276
324
  await withSpinner("provisioning workspace — a first launch builds the harness image (can take minutes)…", api(config, "POST", `/sessions/${session.id}/launch`, launchBody));
325
+ if (lifecycle === "detach") {
326
+ say(`${green("✓ recording")} · running detached`);
327
+ say(`${cobalt(" attach")} · mend attach ${session.id.slice(0, 8)}`);
328
+ return;
329
+ }
277
330
  say(`${green("✓ recording")} · workspace mounts the worktree${detachHint()}`);
278
331
  say("");
279
- await attachOrExit(config, session.id, session.harness);
332
+ attachOwnsSignals = true;
333
+ await attachOrExit(config, session.id, session.harness, lifecycle);
280
334
  exitAfterSessionEnd(config, session.id);
281
335
  };
282
- // ─── the terminal bridge: raw stdin/stdout against the platform PTY ─────────
283
336
  /**
284
337
  * Attach this terminal to the session's PTY through the Mend server over ONE
285
338
  * WebSocket: binary frames are PTY bytes both ways, text frames carry control
@@ -288,7 +341,9 @@ const launch = async (config, harness, args) => {
288
341
  * Ctrl+] detaches — the session keeps running and can be reattached from
289
342
  * anywhere. Resolves when the session settles or the user detaches; the
290
343
  * caller decides what each outcome means (commands exit, the dashboard
291
- * resumes).
344
+ * resumes). With `handleSignals`, a terminal-window close (SIGHUP) or kill
345
+ * resolves `interrupted` through the same restore path instead of leaving
346
+ * raw mode pushed — the handler never exits the process itself.
292
347
  */
293
348
  const attachTty = async (config, sessionId, harness, from, processId, options) => {
294
349
  const url = parseMendUrl(`${config.url}/api/tty`);
@@ -336,6 +391,16 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
336
391
  const rawTty = process.stdin.isTTY === true;
337
392
  let rawModeEnabled = false;
338
393
  let detached = false;
394
+ let sawEnd = false;
395
+ let interrupted = false;
396
+ const onSignal = () => {
397
+ // Resolve the attach instead of exiting: the shared `finally` restores raw
398
+ // mode and closes the socket, then the caller decides what the signal means.
399
+ interrupted = true;
400
+ ws.close();
401
+ finishAttachment?.();
402
+ };
403
+ const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
339
404
  const onTtyFrame = (event) => {
340
405
  if (typeof event.data !== "string") {
341
406
  process.stdout.write(Buffer.from(event.data));
@@ -349,6 +414,7 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
349
414
  // Session lifecycle is authoritative. Start detaching immediately instead
350
415
  // of holding the user's terminal for the later close handshake and Mend's
351
416
  // settle/checkpoint/harvest work.
417
+ sawEnd = true;
352
418
  ws.close();
353
419
  finishAttachment?.();
354
420
  }
@@ -371,6 +437,9 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
371
437
  try {
372
438
  sendResize();
373
439
  process.stdout.on("resize", onWinch);
440
+ if (options?.handleSignals === true)
441
+ for (const signal of signals)
442
+ process.on(signal, onSignal);
374
443
  // Read-only (logs): never forward stdin — Ctrl+C exits the CLI, the
375
444
  // socket drops, and the process inside keeps running untouched.
376
445
  if (options?.readOnly !== true) {
@@ -383,9 +452,11 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
383
452
  }
384
453
  ws.addEventListener("message", onTtyFrame);
385
454
  await finished;
386
- return detached ? "detached" : "ended";
455
+ return detached ? "detached" : interrupted ? "interrupted" : sawEnd ? "ended" : "dropped";
387
456
  }
388
457
  finally {
458
+ for (const signal of signals)
459
+ process.off(signal, onSignal);
389
460
  process.stdin.off("data", onKeys);
390
461
  process.stdout.off("resize", onWinch);
391
462
  ws.removeEventListener("message", onTtyFrame);
@@ -398,18 +469,81 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
398
469
  stopHerdrHint();
399
470
  }
400
471
  };
401
- /** How every one-shot command handles an attach outcome: detach says so, settle returns. */
402
- const finishAttach = (config, sessionId, outcome) => {
472
+ /**
473
+ * Best-effort stop under a signal's short grace window — a SIGHUP handler
474
+ * cannot afford the ordinary retry path. True when the server accepted.
475
+ */
476
+ const stopSessionQuickly = async (config, sessionId) => {
477
+ const headers = { "content-type": "application/json" };
478
+ if (config.token !== null)
479
+ headers["authorization"] = `Bearer ${config.token}`;
480
+ try {
481
+ const response = await fetch(`${config.url}/api/sessions/${sessionId}/stop`, {
482
+ method: "POST",
483
+ headers,
484
+ signal: AbortSignal.timeout(2500),
485
+ });
486
+ return response.ok;
487
+ }
488
+ catch {
489
+ return false;
490
+ }
491
+ };
492
+ /** Foreground exit: stop the session, or say honestly that it may still run. */
493
+ const stopAndExit = async (config, sessionId) => {
494
+ const id8 = sessionId.slice(0, 8);
495
+ if (await stopSessionQuickly(config, sessionId)) {
496
+ say(`${green("✓")} stopped · ${id8}`);
497
+ say(`${cobalt(" review")} · ${config.url}/sessions/${sessionId}`);
498
+ process.exit(0);
499
+ }
500
+ say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
501
+ process.exit(1);
502
+ };
503
+ /**
504
+ * How every one-shot command handles an attach outcome: detach says so and
505
+ * always leaves the session running; a signal or a dropped socket answers to
506
+ * the lifecycle mode; a settled session returns so the caller prints its facts.
507
+ */
508
+ const finishAttach = async (config, sessionId, outcome, mode = "background") => {
509
+ const id8 = sessionId.slice(0, 8);
403
510
  if (outcome === "unavailable") {
404
511
  return fail(`tty attach unavailable: could not connect to ${config.url}`);
405
512
  }
406
513
  if (outcome === "detached") {
407
514
  say("");
408
- say(`${amber("detached")} — the session keeps running; reattach: mend attach ${sessionId.slice(0, 8)}`);
515
+ say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
516
+ process.exit(0);
517
+ }
518
+ if (outcome === "interrupted") {
519
+ say("");
520
+ if (mode === "foreground")
521
+ return stopAndExit(config, sessionId);
522
+ say(`${amber("detached")} — the session keeps running; reattach: mend attach ${id8}`);
523
+ process.exit(0);
524
+ }
525
+ if (outcome === "dropped") {
526
+ say("");
527
+ if (mode === "foreground") {
528
+ // The drop may be the server settling the session — verify before stopping.
529
+ let live;
530
+ try {
531
+ const detail = await request(config, "GET", `/sessions/${sessionId}`);
532
+ live = agentIsLive(detail.session, detail.currentAgent);
533
+ }
534
+ catch {
535
+ say(`${amber("could not stop")} — the session may still run · mend stop ${id8}`);
536
+ process.exit(1);
537
+ }
538
+ if (!live)
539
+ return; // settled as the socket closed — the caller prints the end facts
540
+ return stopAndExit(config, sessionId);
541
+ }
542
+ say(`${amber("disconnected")} — the session keeps running; reattach: mend attach ${id8}`);
409
543
  process.exit(0);
410
544
  }
411
545
  };
412
- const attachOrExit = async (config, sessionId, harness) => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n));
546
+ const attachOrExit = async (config, sessionId, harness, mode = "background") => finishAttach(config, sessionId, await attachTty(config, sessionId, harness, 0n, undefined, { handleSignals: true }), mode);
413
547
  /** Return terminal control as soon as the terminal reports the observed end. */
414
548
  const exitAfterSessionEnd = (config, sessionId) => {
415
549
  say("");
@@ -431,6 +565,56 @@ const attach = async (config, args) => {
431
565
  await attachOrExit(config, match.id, match.harness);
432
566
  exitAfterSessionEnd(config, match.id);
433
567
  };
568
+ /**
569
+ * Explicit stop — the one intent detaching never carries. Ends the agent; the
570
+ * workspace harvests and closes; the record and review remain.
571
+ */
572
+ const stopCommand = async (config, args) => {
573
+ const all = args.includes("--all");
574
+ const projectFlag = args.indexOf("--project");
575
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
576
+ ? String(args[projectFlag + 1])
577
+ : null;
578
+ const prefix = args.find((arg, index) => !arg.startsWith("--") && index !== projectFlag + 1);
579
+ if (!all && prefix === undefined) {
580
+ return fail("usage: mend stop <session-id-prefix> | --all [--project <p>]");
581
+ }
582
+ const [sessions, projects] = await Promise.all([
583
+ api(config, "GET", "/sessions"),
584
+ api(config, "GET", "/projects"),
585
+ ]);
586
+ let scoped = sessions;
587
+ if (projectName !== null) {
588
+ const project = projects.find((p) => p.name === projectName);
589
+ if (project === undefined)
590
+ return fail(`no project named "${projectName}"`);
591
+ scoped = sessions.filter((s) => s.projectId === project.id);
592
+ }
593
+ let targets;
594
+ if (all) {
595
+ targets = scoped;
596
+ if (targets.length === 0) {
597
+ say("no active sessions");
598
+ return;
599
+ }
600
+ }
601
+ else {
602
+ const matches = scoped.filter((s) => s.id.startsWith(prefix ?? ""));
603
+ if (matches.length === 0)
604
+ return fail(`no active session matches "${prefix}"`);
605
+ if (matches.length > 1) {
606
+ return fail(`session prefix "${prefix}" is ambiguous — use more of the id`);
607
+ }
608
+ targets = matches;
609
+ }
610
+ for (const session of targets) {
611
+ await api(config, "POST", `/sessions/${session.id}/stop`);
612
+ say(`${green("✓")} stopped · ${session.harness} · ${dim(session.id.slice(0, 8))} · ${session.branch}`);
613
+ say(`${cobalt(" review")} · ${config.url}/sessions/${session.id}`);
614
+ }
615
+ if (all)
616
+ say(`${green("✓")} stopped ${targets.length} session${targets.length === 1 ? "" : "s"}`);
617
+ };
434
618
  /** Compact picker: numbered live sessions, one keystroke of typing. */
435
619
  const pickSessionInteractively = async (rows) => {
436
620
  say(dim("more than one live session — pick one:"));
@@ -493,12 +677,14 @@ const shellCommand = async (config, args) => {
493
677
  say(`${green("✓")} shell in ${session.harness} session ${dim(session.id.slice(0, 8))} · ${dim(session.branch)}${detachHint()}`);
494
678
  const shellProcess = await withSpinner("opening a shell in the session workspace…", api(config, "POST", `/sessions/${session.id}/shell`));
495
679
  say("");
496
- const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id);
680
+ const outcome = await attachTty(config, session.id, "shell", 0n, shellProcess.id, {
681
+ handleSignals: true,
682
+ });
497
683
  if (outcome === "unavailable") {
498
684
  return fail(`tty attach unavailable: could not connect to ${config.url}`);
499
685
  }
500
686
  say("");
501
- if (outcome === "detached") {
687
+ if (outcome === "detached" || outcome === "interrupted" || outcome === "dropped") {
502
688
  say(`${amber("detached")} — the shell keeps running and holds the workspace open`);
503
689
  process.exit(0);
504
690
  }
@@ -1571,7 +1757,8 @@ _mend() {
1571
1757
  'adopt:adopt a repository into the store'
1572
1758
  'codex:new session + codex' 'claude:new session + claude' 'opencode:new session + opencode'
1573
1759
  'run:new session + arbitrary command'
1574
- 'attach:reattach to a running session' 'shell:open a shell in a live session workspace'
1760
+ 'attach:reattach to a running session' 'stop:stop the agent — record and review remain'
1761
+ 'shell:open a shell in a live session workspace'
1575
1762
  'service:reachable ports — add, list, stop'
1576
1763
  'keys:the machine Mend deploy key — init, show, share'
1577
1764
  'accounts:your connected accounts on the platform'
@@ -1587,7 +1774,7 @@ _mend() {
1587
1774
  return
1588
1775
  fi
1589
1776
  case $words[2] in
1590
- shell|attach|continue|resume|rejoin)
1777
+ shell|attach|stop|continue|resume|rejoin)
1591
1778
  local -a sessions
1592
1779
  sessions=(\${(f)"$(command mend __complete session 2>/dev/null | tr '\\t' ':')"})
1593
1780
  (( \${#sessions} )) && _describe 'session' sessions
@@ -1599,11 +1786,11 @@ _mend "$@"
1599
1786
  const BASH_COMPLETIONS = `_mend() {
1600
1787
  local cur=\${COMP_WORDS[COMP_CWORD]}
1601
1788
  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") )
1789
+ 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
1790
  return
1604
1791
  fi
1605
1792
  case \${COMP_WORDS[1]} in
1606
- shell|attach|continue|resume|rejoin)
1793
+ shell|attach|stop|continue|resume|rejoin)
1607
1794
  COMPREPLY=( $(compgen -W "$(command mend __complete session 2>/dev/null | cut -f1)" -- "$cur") )
1608
1795
  ;;
1609
1796
  esac
@@ -1782,7 +1969,17 @@ const resumeCommand = async (config, args) => {
1782
1969
  }
1783
1970
  say(`${green("✓")} resuming ${match.harness} · ${dim(match.id.slice(0, 8))}${withHarness === null ? "" : ` ${dim("as")} ${withHarness}`}`);
1784
1971
  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 }));
1972
+ // A formerly-protocol session (a phone pickup) resumed from a terminal must
1973
+ // come back as a TUI — the handoff verb routes it there; a plain resume
1974
+ // would re-enter protocol mode with nothing to attach.
1975
+ const priorAgent = detail.annotations.find((annotation) => annotation.sessionId === match.id)?.currentAgent ??
1976
+ null;
1977
+ const protocolPrior = priorAgent?.kind === "agent-protocol" && withHarness === null;
1978
+ await withSpinner(protocolPrior
1979
+ ? "reopening as a terminal — same conversation…"
1980
+ : "resuming — a fresh workspace restores the saved session state…", protocolPrior
1981
+ ? api(config, "POST", `/sessions/${match.id}/handoff`, { to: "pty" })
1982
+ : api(config, "POST", `/sessions/${match.id}/resume`, { harness: withHarness }));
1786
1983
  say(`${green("✓ recording")} · same worktree, conversation restored${detachHint()}`);
1787
1984
  say("");
1788
1985
  await attachOrExit(config, match.id, withHarness ?? match.harness);
@@ -1840,22 +2037,37 @@ const rejoinCommand = async (config, args) => {
1840
2037
  say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
1841
2038
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
1842
2039
  let restored = false;
1843
- if (!alreadyLive) {
2040
+ const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
2041
+ null;
2042
+ if (currentAgent?.kind === "agent-protocol") {
2043
+ // A protocol agent (a phone pickup) has no PTY to attach. Hand the session
2044
+ // off to a terminal: the TUI resumes the same provider conversation, with
2045
+ // the phone-authored turns in its scrollback.
2046
+ await withSpinner(alreadyLive
2047
+ ? "taking over from the protocol session — same conversation…"
2048
+ : "reopening as a terminal — same conversation…", api(config, "POST", `/sessions/${session.id}/handoff`, { to: "pty" }));
2049
+ restored = true;
2050
+ }
2051
+ else if (!alreadyLive) {
1844
2052
  restored = await resumeForRejoin(config, session.id, "resuming — a fresh workspace restores the saved session state…");
1845
2053
  }
1846
2054
  say(restored
1847
2055
  ? `${green("✓ recording")} · same worktree, conversation restored${detachHint()}`
1848
2056
  : `${green("✓ recording")} · attached to the live session${detachHint()}`);
1849
2057
  say("");
1850
- let outcome = await attachTty(config, session.id, session.harness, 0n);
2058
+ let outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
2059
+ handleSignals: true,
2060
+ });
1851
2061
  if (outcome === "unavailable") {
1852
2062
  const refreshed = await api(config, "GET", `/sessions/${session.id}`);
1853
2063
  if (!agentIsLive(refreshed.session, refreshed.currentAgent)) {
1854
2064
  await resumeForRejoin(config, session.id, "session settled while attaching — restoring it once…");
1855
2065
  }
1856
- outcome = await attachTty(config, session.id, session.harness, 0n);
2066
+ outcome = await attachTty(config, session.id, session.harness, 0n, undefined, {
2067
+ handleSignals: true,
2068
+ });
1857
2069
  }
1858
- finishAttach(config, session.id, outcome);
2070
+ await finishAttach(config, session.id, outcome);
1859
2071
  exitAfterSessionEnd(config, session.id);
1860
2072
  };
1861
2073
  // ─── projects · sessions: the workbench at a glance ─────────────────────────
@@ -2051,11 +2263,13 @@ start
2051
2263
  adopt a repository into the store (default: cwd; any git
2052
2264
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
2053
2265
  mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
2054
- [--base <ref>] [--ask] [--fast]
2266
+ [--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
2055
2267
  new session worktree + launch the harness in it; a quoted
2056
2268
  prompt becomes its first message (and names the session),
2057
2269
  --ask restores the harness's permission prompts, --fast
2058
- requests priority processing (codex service tier)
2270
+ requests priority processing (codex service tier),
2271
+ --detach launches without attaching (reattach anywhere),
2272
+ --foreground stops the session when this CLI exits
2059
2273
  mend pair [--url <base url>] pair a phone or a second machine: prints a QR, the code, and
2060
2274
  the URL to reach this server (one device, once, 10 minutes)
2061
2275
  mend doctor read-only checklist of this machine's setup — one line per
@@ -2077,6 +2291,9 @@ everything else
2077
2291
  hardware keys sign here; Ctrl-C stops sharing)
2078
2292
  mend run -- <command...> same, with an arbitrary command
2079
2293
  mend attach <session-id-prefix> reattach this terminal to a running session
2294
+ mend stop <session-id-prefix> | --all [--project <p>]
2295
+ stop the agent — the workspace harvests and closes; the
2296
+ record and review remain
2080
2297
  mend shell [session-id-prefix] open a shell in a live session's workspace
2081
2298
  mend service run [session] --port <p> [--name <n>] [--udp] -- <command...>
2082
2299
  start + supervise a server in the session workspace
@@ -2123,6 +2340,8 @@ const main = async () => {
2123
2340
  return launch(config, command, rest);
2124
2341
  case "attach":
2125
2342
  return attach(config, rest);
2343
+ case "stop":
2344
+ return stopCommand(config, rest);
2126
2345
  case "shell":
2127
2346
  return shellCommand(config, rest);
2128
2347
  case "service":
package/dist/shared.js CHANGED
@@ -36,11 +36,14 @@ const LAUNCH_ERROR = {
36
36
  base: null,
37
37
  ask: false,
38
38
  fast: false,
39
+ detach: false,
40
+ foreground: false,
39
41
  custom: [],
40
42
  };
41
43
  /**
42
44
  * `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
43
- * [--base <ref>] [--ask] [--project <p>]`, plus `mend run … -- <command...>`.
45
+ * [--base <ref>] [--ask] [--detach|-d] [--foreground] [--project <p>]`, plus
46
+ * `mend run … -- <command...>`.
44
47
  * The first non-flag positional is the prompt; a second one is an error so a
45
48
  * forgotten quote fails loudly instead of launching with half a sentence.
46
49
  */
@@ -55,6 +58,8 @@ export const parseLaunchArgs = (args) => {
55
58
  let base = null;
56
59
  let ask = false;
57
60
  let fast = false;
61
+ let detach = false;
62
+ let foreground = false;
58
63
  for (let index = 0; index < flagArgs.length; index += 1) {
59
64
  const arg = flagArgs[index] ?? "";
60
65
  if (arg === "--ask") {
@@ -65,6 +70,14 @@ export const parseLaunchArgs = (args) => {
65
70
  fast = true;
66
71
  continue;
67
72
  }
73
+ if (arg === "--detach" || arg === "-d") {
74
+ detach = true;
75
+ continue;
76
+ }
77
+ if (arg === "--foreground") {
78
+ foreground = true;
79
+ continue;
80
+ }
68
81
  if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
69
82
  const value = flagArgs[index + 1];
70
83
  if (value === undefined)
@@ -99,7 +112,22 @@ export const parseLaunchArgs = (args) => {
99
112
  if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
100
113
  return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
101
114
  }
102
- return { project, prompt, model, effort, base, ask, fast, custom, error: null };
115
+ if (detach && foreground) {
116
+ return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
117
+ }
118
+ return {
119
+ project,
120
+ prompt,
121
+ model,
122
+ effort,
123
+ base,
124
+ ask,
125
+ fast,
126
+ detach,
127
+ foreground,
128
+ custom,
129
+ error: null,
130
+ };
103
131
  };
104
132
  export const LIVE_STATUSES = new Set([
105
133
  "starting",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.12.2",
3
+ "version": "0.13.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",