muonroi-cli 1.7.1 → 1.7.2

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.
@@ -1,2 +1,2 @@
1
- export declare const PACKAGE_VERSION = "1.7.1";
1
+ export declare const PACKAGE_VERSION = "1.7.2";
2
2
  export declare const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/sync-version.cjs. DO NOT EDIT BY HAND.
2
2
  // Sourced from package.json at build time so it survives bun --compile bundling.
3
- export const PACKAGE_VERSION = "1.7.1";
3
+ export const PACKAGE_VERSION = "1.7.2";
4
4
  export const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
5
5
  //# sourceMappingURL=version.js.map
package/dist/src/index.js CHANGED
@@ -391,6 +391,7 @@ async function startInteractive(apiKey, baseURL, model, maxToolRounds, batchApi,
391
391
  const { createRoot } = await import("@opentui/react");
392
392
  const { createElement } = await import("react");
393
393
  const { App } = await import("./ui/app.js");
394
+ const { relaunchWithSession } = await import("./ui/utils/relaunch.js");
394
395
  const renderer = await createCliRenderer({
395
396
  exitOnCtrlC: false,
396
397
  // We manage SIGINT ourselves (orchestrator abort → agent cleanup → renderer destroy).
@@ -441,64 +442,106 @@ async function startInteractive(apiKey, baseURL, model, maxToolRounds, batchApi,
441
442
  process.env.TERM_PROGRAM === "WezTerm" ||
442
443
  process.env.TERM_PROGRAM === "wezterm" ||
443
444
  process.env.MUONROI_FORCE_SHELL_HOLD === "1";
444
- const onExit = () => {
445
- void agent.cleanup().finally(() => {
446
- // Restore terminal state from JS before the native destroyRenderer runs
447
- restoreTerminalSync();
448
- renderer.destroy();
449
- // Restore terminal modes BEFORE spawning child shell. Use synchronous
450
- // writes to stdout's underlying fd so escape codes flush before the
451
- // child inherits stdin — async process.stdout.write() can buffer past
452
- // the spawn() boundary, leaving the terminal in mouse-tracking mode.
453
- const writeSync = (seq) => {
454
- try {
455
- const fs = require("node:fs");
456
- fs.writeSync(1, seq);
457
- }
458
- catch {
459
- /* fall back to async */
460
- try {
461
- process.stdout.write(seq);
462
- }
463
- catch {
464
- /* noop */
465
- }
466
- }
467
- };
445
+ // Restore the terminal to a clean main-screen state: undo Kitty keyboard,
446
+ // raw mode, mouse tracking, bracketed paste, and alt-screen. Shared by the
447
+ // /exit path AND the session-resume relaunch a relaunch that skips this
448
+ // leaves the child (and, once the parent exits, the shell) with a terminal
449
+ // still in mouse-tracking/alt-screen mode, so stray escape bytes get parsed
450
+ // by the shell as a bogus command ("'…' is not recognized … operable
451
+ // program or batch file").
452
+ function restoreTerminalForHandoff() {
453
+ // Restore terminal state from JS before the native destroyRenderer runs
454
+ restoreTerminalSync();
455
+ renderer.destroy();
456
+ // Use synchronous writes to stdout's underlying fd so escape codes flush
457
+ // before the child inherits stdin — async process.stdout.write() can buffer
458
+ // past the spawn() boundary, leaving the terminal in mouse-tracking mode.
459
+ const writeSync = (seq) => {
468
460
  try {
469
- // 1. Take stdin out of raw mode + pause it so no buffered keystrokes
470
- // (or in-flight mouse-event bytes) hit the child shell.
471
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
472
- try {
473
- process.stdin.setRawMode(false);
474
- }
475
- catch {
476
- /* noop */
477
- }
478
- }
461
+ const fs = require("node:fs");
462
+ fs.writeSync(1, seq);
463
+ }
464
+ catch {
465
+ /* fall back to async */
479
466
  try {
480
- process.stdin.pause();
467
+ process.stdout.write(seq);
481
468
  }
482
469
  catch {
483
470
  /* noop */
484
471
  }
472
+ }
473
+ };
474
+ try {
475
+ // 1. Take stdin out of raw mode + pause it so no buffered keystrokes
476
+ // (or in-flight mouse-event bytes) hit the child.
477
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
485
478
  try {
486
- process.stdin.unref?.();
479
+ process.stdin.setRawMode(false);
487
480
  }
488
481
  catch {
489
482
  /* noop */
490
483
  }
491
- // 2. Disable extended-coords first (1006/1015/1005), then the basic
492
- // tracking modes (1003→1002→1000). Wrong order leaves the terminal
493
- // emitting SGR-formatted events after the base modes are off.
494
- writeSync("\x1B[?1006l\x1B[?1015l\x1B[?1005l");
495
- writeSync("\x1B[?1003l\x1B[?1002l\x1B[?1000l");
496
- writeSync("\x1B[?2004l\x1B[?25h"); // bracketed paste off, cursor on
497
- writeSync("\x1B[?1049l\x1B[0m\x1B[!p"); // exit alt-screen, reset SGR, soft reset
484
+ }
485
+ try {
486
+ process.stdin.pause();
487
+ }
488
+ catch {
489
+ /* noop */
490
+ }
491
+ try {
492
+ process.stdin.unref?.();
498
493
  }
499
494
  catch {
500
- // best-effort
495
+ /* noop */
501
496
  }
497
+ // 2. Disable extended-coords first (1006/1015/1005), then the basic
498
+ // tracking modes (1003→1002→1000). Wrong order leaves the terminal
499
+ // emitting SGR-formatted events after the base modes are off.
500
+ writeSync("\x1B[?1006l\x1B[?1015l\x1B[?1005l");
501
+ writeSync("\x1B[?1003l\x1B[?1002l\x1B[?1000l");
502
+ writeSync("\x1B[?2004l\x1B[?25h"); // bracketed paste off, cursor on
503
+ writeSync("\x1B[?1049l\x1B[0m\x1B[!p"); // exit alt-screen, reset SGR, soft reset
504
+ }
505
+ catch {
506
+ // best-effort
507
+ }
508
+ }
509
+ // Resume a different session by restarting the CLI bound to --session <id>.
510
+ // Routes through the SAME teardown as /exit, then supervises the child so the
511
+ // shell never reclaims the foreground mid-restart (which corrupted the
512
+ // terminal and dropped the user back to a broken prompt).
513
+ const onRelaunch = (sessionId) => {
514
+ void agent.cleanup().finally(() => {
515
+ restoreTerminalForHandoff();
516
+ // Let the terminal process the restore sequences before the child takes
517
+ // over stdin (mirrors the /exit shell-hold timing).
518
+ setTimeout(() => {
519
+ try {
520
+ relaunchWithSession(sessionId, { supervise: true });
521
+ }
522
+ catch (err) {
523
+ console.error(`[relaunch] failed: ${err?.message ?? err}`);
524
+ process.exit(1);
525
+ }
526
+ }, 80);
527
+ });
528
+ };
529
+ const onExit = () => {
530
+ void agent.cleanup().finally(() => {
531
+ restoreTerminalForHandoff();
532
+ const writeSync = (seq) => {
533
+ try {
534
+ require("node:fs").writeSync(1, seq);
535
+ }
536
+ catch {
537
+ try {
538
+ process.stdout.write(seq);
539
+ }
540
+ catch {
541
+ /* noop */
542
+ }
543
+ }
544
+ };
502
545
  // WezTerm (and similar single-pane terminals) closes the window when
503
546
  // the foreground process exits. To keep the pane usable after `/exit`,
504
547
  // we hand control over to an interactive shell instead of exiting.
@@ -547,6 +590,7 @@ async function startInteractive(apiKey, baseURL, model, maxToolRounds, batchApi,
547
590
  },
548
591
  initialMessage,
549
592
  onExit,
593
+ onRelaunch,
550
594
  }));
551
595
  }
552
596
  async function runHeadless(prompt, apiKey, baseURL, model, maxToolRounds, batchApi, sandboxMode, sandboxSettings, format, session, permissionMode = "safe", councilAutoAnswer) {
@@ -8,6 +8,12 @@ export declare class SessionStore {
8
8
  /**
9
9
  * List recent sessions in this workspace (most recently updated first).
10
10
  * Used by the `/sessions` slash command for resume picking.
11
+ *
12
+ * Only sessions that actually have a persisted message are returned. Every
13
+ * CLI launch WITHOUT `--session` opens a fresh empty session, and test /
14
+ * harness runs leave behind title-only stubs — without this filter the picker
15
+ * fills with empty rows that resume to a blank screen, so a user picking one
16
+ * thinks resume is broken.
11
17
  */
12
18
  listRecentSessions(limit?: number): SessionInfo[];
13
19
  getLatestSession(): SessionInfo | null;
@@ -64,14 +64,21 @@ export class SessionStore {
64
64
  /**
65
65
  * List recent sessions in this workspace (most recently updated first).
66
66
  * Used by the `/sessions` slash command for resume picking.
67
+ *
68
+ * Only sessions that actually have a persisted message are returned. Every
69
+ * CLI launch WITHOUT `--session` opens a fresh empty session, and test /
70
+ * harness runs leave behind title-only stubs — without this filter the picker
71
+ * fills with empty rows that resume to a blank screen, so a user picking one
72
+ * thinks resume is broken.
67
73
  */
68
74
  listRecentSessions(limit = 20) {
69
75
  const rows = getDatabase()
70
76
  .prepare(`
71
- SELECT id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
72
- FROM sessions
73
- WHERE workspace_id = ?
74
- ORDER BY updated_at DESC
77
+ SELECT s.id, s.workspace_id, s.title, s.model, s.mode, s.cwd_at_start, s.cwd_last, s.status, s.created_at, s.updated_at
78
+ FROM sessions s
79
+ WHERE s.workspace_id = ?
80
+ AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id)
81
+ ORDER BY s.updated_at DESC
75
82
  LIMIT ?
76
83
  `)
77
84
  .all(this.workspace.id, limit);
@@ -16,6 +16,6 @@ import "./slash/export.js";
16
16
  import "./slash/status.js";
17
17
  import type { AppProps } from "./types.js";
18
18
  export type { AppStartupConfig } from "./types.js";
19
- export declare function App({ agent, startupConfig, initialMessage, onExit }: AppProps): import("react").ReactNode;
19
+ export declare function App({ agent, startupConfig, initialMessage, onExit, onRelaunch }: AppProps): import("react").ReactNode;
20
20
  export type { McpRunInfo } from "./components/message-view.js";
21
21
  export { computeMcpRunInfo } from "./components/message-view.js";
@@ -408,7 +408,7 @@ ${prompt}`;
408
408
  // user cannot enable a provider we are not actively maintaining UX for.
409
409
  // Hoisted to module-level so React useEffect deps stay stable across renders.
410
410
  const SPLASH_PROVIDERS = ["deepseek", "siliconflow"];
411
- export function App({ agent, startupConfig, initialMessage, onExit }) {
411
+ export function App({ agent, startupConfig, initialMessage, onExit, onRelaunch }) {
412
412
  const t = dark;
413
413
  const renderer = useRenderer();
414
414
  // Set initial status bar values synchronously before first render
@@ -3929,9 +3929,15 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
3929
3929
  case "update":
3930
3930
  setIsUpdating(true);
3931
3931
  setUpdateOutput(null);
3932
+ // Always echo into the message log — the home-screen `updateOutput`
3933
+ // banner only renders on the splash screen, so an /update run inside an
3934
+ // active chat would otherwise produce no visible output at all.
3935
+ setMessages((prev) => [...prev, buildAssistantEntry("⟳ Checking for updates…")]);
3932
3936
  runUpdate(startupConfig.version).then((result) => {
3933
3937
  setIsUpdating(false);
3934
- setUpdateOutput(result.success ? result.output : `Update failed: ${result.output}`);
3938
+ const text = result.success ? result.output : `Update failed: ${result.output}`;
3939
+ setUpdateOutput(text);
3940
+ setMessages((prev) => [...prev, buildAssistantEntry(text)]);
3935
3941
  });
3936
3942
  break;
3937
3943
  case "clear":
@@ -4830,9 +4836,14 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
4830
4836
  if (key.name === "return") {
4831
4837
  setIsUpdating(true);
4832
4838
  setShowUpdateModal(false);
4839
+ // Echo into the message log too — same reason as the /update command:
4840
+ // the updateOutput banner only renders on the home/splash screen.
4841
+ setMessages((prev) => [...prev, buildAssistantEntry("⟳ Checking for updates…")]);
4833
4842
  runUpdate(startupConfig.version).then((result) => {
4834
4843
  setIsUpdating(false);
4835
- setUpdateOutput(result.output);
4844
+ const text = result.success ? result.output : `Update failed: ${result.output}`;
4845
+ setUpdateOutput(text);
4846
+ setMessages((prev) => [...prev, buildAssistantEntry(text)]);
4836
4847
  });
4837
4848
  return;
4838
4849
  }
@@ -5228,12 +5239,21 @@ export function App({ agent, startupConfig, initialMessage, onExit }) {
5228
5239
  }
5229
5240
  // Close the modal first so the toast renders before the spawn.
5230
5241
  setShowSessionPicker(false);
5231
- pushToast("info", `Resuming session ${picked.id.slice(-8)}… restarting CLI`);
5232
- // Defer to the next tick so OpenTUI flushes the toast frame; then
5233
- // spawn the child (which inherits the TTY) and exit this process.
5242
+ pushToast("info", `Resuming session ${picked.id}… restarting CLI`);
5243
+ // Defer to the next tick so OpenTUI flushes the toast frame, then hand
5244
+ // off to onRelaunch which tears down the renderer + restores the
5245
+ // terminal (raw mode / mouse tracking / alt-screen) and supervises the
5246
+ // child. Doing the spawn here directly (the old relaunchWithSession
5247
+ // call) skipped that teardown and dropped the user to a corrupted
5248
+ // shell prompt. Fall back to the legacy path only if no handler wired.
5234
5249
  setTimeout(() => {
5235
5250
  try {
5236
- relaunchWithSession(picked.id);
5251
+ if (onRelaunch) {
5252
+ onRelaunch(picked.id);
5253
+ }
5254
+ else {
5255
+ relaunchWithSession(picked.id);
5256
+ }
5237
5257
  }
5238
5258
  catch (err) {
5239
5259
  console.error(`[session-picker] relaunch failed: ${err?.message ?? err}`);
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { Semantic } from "@muonroi/agent-harness-opentui";
2
3
  import { bottomAlignedModalTop } from "../utils/modal.js";
3
4
  /**
4
5
  * Recent-sessions picker. Opened by `/sessions` or `/session`. Selecting a
@@ -14,15 +15,19 @@ export function SessionPickerModal({ t, sessions, focusIndex, width, height, })
14
15
  const panelHeight = Math.min(contentHeight, maxH);
15
16
  const top = bottomAlignedModalTop(height, panelHeight);
16
17
  const overlayBg = "#000000cc";
17
- return (_jsx("box", { position: "absolute", left: 0, top: 0, width: width, height: height, alignItems: "center", paddingTop: top, backgroundColor: overlayBg, children: _jsxs("box", { width: panelWidth, height: panelHeight, backgroundColor: t.backgroundPanel, paddingTop: 1, paddingBottom: 1, flexDirection: "column", children: [_jsxs("box", { flexShrink: 0, flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: t.primary, children: _jsx("b", { children: "Resume session" }) }), _jsx("text", { fg: t.textMuted, children: "esc" })] }), _jsx("scrollbox", { flexGrow: 1, minHeight: 0, children: sessions.length === 0 ? (_jsx("box", { paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "No prior sessions in this workspace." }) })) : (sessions.map((s, idx) => {
18
- const focused = idx === focusIndex;
19
- const ts = formatTimestamp(s.updatedAt);
20
- const titleRaw = s.title?.trim() || "(untitled)";
21
- const titleMax = Math.max(8, panelWidth - 38);
22
- const title = titleRaw.length > titleMax ? `${titleRaw.slice(0, titleMax - 1)}…` : titleRaw;
23
- const idShort = s.id.slice(-8);
24
- return (_jsxs("box", { backgroundColor: focused ? t.selectedBg : undefined, paddingLeft: 2, paddingRight: 2, width: "100%", flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: focused ? t.selected : t.text, children: `${ts} ${title}` }), _jsx("text", { fg: focused ? t.primary : t.textMuted, children: `${s.model} ${idShort}` })] }, s.id));
25
- })) }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "↑↓ navigate · enter resume (restarts CLI) · esc cancel" }) })] }) }));
18
+ return (_jsx(Semantic, { id: "session-picker", role: "dialog", name: "Resume session", isModal: true, children: _jsx("box", { position: "absolute", left: 0, top: 0, width: width, height: height, alignItems: "center", paddingTop: top, backgroundColor: overlayBg, children: _jsxs("box", { width: panelWidth, height: panelHeight, backgroundColor: t.backgroundPanel, paddingTop: 1, paddingBottom: 1, flexDirection: "column", children: [_jsxs("box", { flexShrink: 0, flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: t.primary, children: _jsx("b", { children: "Resume session" }) }), _jsx("text", { fg: t.textMuted, children: "esc" })] }), _jsx("scrollbox", { flexGrow: 1, minHeight: 0, children: sessions.length === 0 ? (_jsx("box", { paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "No prior sessions in this workspace." }) })) : (sessions.map((s, idx) => {
19
+ const focused = idx === focusIndex;
20
+ const ts = formatTimestamp(s.updatedAt);
21
+ const titleRaw = s.title?.trim() || "(untitled)";
22
+ const titleMax = Math.max(8, panelWidth - 38);
23
+ const title = titleRaw.length > titleMax ? `${titleRaw.slice(0, titleMax - 1)}…` : titleRaw;
24
+ // Show the FULL session id (not slice(-8)). The resumed-session
25
+ // header displays the full id, so a truncated id here made users
26
+ // think they had resumed a "different" session when it was the
27
+ // same one shown in two formats.
28
+ const idLabel = s.id;
29
+ return (_jsx(Semantic, { id: `session-item-${idx}`, role: "listitem", name: `${title} ${idLabel}`, value: s.id, selected: focused || undefined, children: _jsxs("box", { backgroundColor: focused ? t.selectedBg : undefined, paddingLeft: 2, paddingRight: 2, width: "100%", flexDirection: "row", justifyContent: "space-between", children: [_jsx("text", { fg: focused ? t.selected : t.text, children: `${ts} ${title}` }), _jsx("text", { fg: focused ? t.primary : t.textMuted, children: `${s.model} ${idLabel}` })] }) }, s.id));
30
+ })) }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "↑↓ navigate · enter resume (restarts CLI) · esc cancel" }) })] }) }) }));
26
31
  }
27
32
  /**
28
33
  * Compact MM-DD HH:MM timestamp for the picker rows. Trades the year for
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
2
+ import { Semantic } from "@muonroi/agent-harness-opentui";
2
3
  import { bottomAlignedModalTop } from "../utils/modal.js";
3
4
  export function UpdateModal({ t, width, height, currentVersion, latestVersion, }) {
4
5
  const overlayBg = "#000000cc";
5
6
  const panelWidth = Math.min(60, width - 6);
6
7
  const panelHeight = 9;
7
8
  const top = bottomAlignedModalTop(height, panelHeight);
8
- return (_jsx("box", { position: "absolute", left: 0, top: 0, width: width, height: height, alignItems: "center", paddingTop: top, backgroundColor: overlayBg, children: _jsxs("box", { width: panelWidth, height: panelHeight, backgroundColor: t.backgroundPanel, paddingTop: 1, paddingBottom: 1, flexDirection: "column", children: [_jsxs("box", { flexShrink: 0, flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: "#f59e0b", children: _jsx("b", { children: "Update Available" }) }), _jsx("text", { fg: t.textMuted, children: "esc to dismiss" })] }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsxs("text", { fg: t.text, children: ["A new version of muonroi-cli is available: ", _jsxs("span", { style: { fg: t.textMuted }, children: ["v", currentVersion] }), " → ", _jsxs("span", { style: { fg: "#22c55e" }, children: ["v", latestVersion] })] }) }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "Press enter to update now, or esc to dismiss" }) })] }) }));
9
+ return (_jsx(Semantic, { id: "update-modal", role: "dialog", name: "Update Available", isModal: true, children: _jsx("box", { position: "absolute", left: 0, top: 0, width: width, height: height, alignItems: "center", paddingTop: top, backgroundColor: overlayBg, children: _jsxs("box", { width: panelWidth, height: panelHeight, backgroundColor: t.backgroundPanel, paddingTop: 1, paddingBottom: 1, flexDirection: "column", children: [_jsxs("box", { flexShrink: 0, flexDirection: "row", justifyContent: "space-between", paddingLeft: 2, paddingRight: 2, children: [_jsx("text", { fg: "#f59e0b", children: _jsx("b", { children: "Update Available" }) }), _jsx("text", { fg: t.textMuted, children: "esc to dismiss" })] }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsxs("text", { fg: t.text, children: ["A new version of muonroi-cli is available: ", _jsxs("span", { style: { fg: t.textMuted }, children: ["v", currentVersion] }), " → ", _jsxs("span", { style: { fg: "#22c55e" }, children: ["v", latestVersion] })] }) }), _jsx("box", { flexShrink: 0, paddingLeft: 2, paddingRight: 2, paddingTop: 1, children: _jsx("text", { fg: t.textMuted, children: "Press enter to update now, or esc to dismiss" }) })] }) }) }));
9
10
  }
10
11
  //# sourceMappingURL=update-modal.js.map
@@ -69,6 +69,13 @@ export interface AppProps {
69
69
  startupConfig: AppStartupConfig;
70
70
  initialMessage?: string;
71
71
  onExit?: () => void;
72
+ /**
73
+ * Restart the CLI bound to a different session id (used by the /sessions
74
+ * picker). Routes through the same terminal teardown as onExit and supervises
75
+ * the child process, so resuming never strands the user at a corrupted shell
76
+ * prompt.
77
+ */
78
+ onRelaunch?: (sessionId: string) => void;
72
79
  }
73
80
  export interface ActiveTurnState {
74
81
  kind: "local" | "telegram";
@@ -27,6 +27,23 @@ export interface RelaunchOptions {
27
27
  onExit?: (code: number) => void;
28
28
  /** Injected spawn for tests. Defaults to the real node:child_process spawn. */
29
29
  spawnFn?: typeof spawn;
30
+ /**
31
+ * Run immediately before the spawn — used to tear down the current TUI
32
+ * (renderer.destroy, restore raw mode / mouse tracking / alt-screen) so the
33
+ * child inherits a CLEAN terminal. Without this the child (and the shell, if
34
+ * the parent exits) inherit a terminal still in mouse-tracking/alt-screen
35
+ * mode, and stray escape bytes get parsed by the shell as a command
36
+ * ("'…' is not recognized as an internal or external command").
37
+ */
38
+ beforeSpawn?: () => void;
39
+ /**
40
+ * When true, the parent stays alive and supervises the child: it exits with
41
+ * the child's exit code instead of exiting the instant the child spawns.
42
+ * This keeps any intermediate launcher (a `bun run` wrapper, the bun bin
43
+ * shim) alive so the shell does NOT regain the terminal foreground while the
44
+ * child is running. Required for a correct in-terminal restart on Windows.
45
+ */
46
+ supervise?: boolean;
30
47
  }
31
48
  /**
32
49
  * Spawn a fresh CLI process bound to {sessionId} and exit the current one.
@@ -26,16 +26,22 @@ export function sanitizeArgvForResume(args, sessionId) {
26
26
  const out = [];
27
27
  for (let i = 0; i < args.length; i++) {
28
28
  const a = args[i];
29
- if (a === "-s" || a === "--session") {
29
+ if (a === "-s" || a === "--session" || a === "--mock-llm") {
30
30
  // skip the flag AND its value (if present and not another flag)
31
31
  const next = args[i + 1];
32
32
  if (next !== undefined && !next.startsWith("-"))
33
33
  i++;
34
34
  continue;
35
35
  }
36
- if (a.startsWith("--session=")) {
36
+ if (a.startsWith("--session=") || a.startsWith("--mock-llm=")) {
37
37
  continue; // skip the combined form
38
38
  }
39
+ // Transient launch-mode flags must NOT survive a resume — re-entering
40
+ // agent-mode (named-pipe transport) on a user-driven resume strands the
41
+ // child with no harness server. Drop them.
42
+ if (a === "--agent-mode") {
43
+ continue;
44
+ }
39
45
  out.push(a);
40
46
  }
41
47
  out.push("--session", sessionId);
@@ -60,12 +66,30 @@ export function relaunchWithSession(sessionId, opts = {}) {
60
66
  const exit = opts.onExit ?? ((code) => process.exit(code));
61
67
  const spawnImpl = opts.spawnFn ?? spawn;
62
68
  const args = sanitizeArgvForResume(argv.slice(1), sessionId);
69
+ // Tear down the current TUI and restore the terminal BEFORE spawning so the
70
+ // child inherits a clean TTY (no raw mode / mouse tracking / alt-screen).
71
+ if (opts.beforeSpawn) {
72
+ try {
73
+ opts.beforeSpawn();
74
+ }
75
+ catch (err) {
76
+ console.error(`[relaunch] beforeSpawn teardown failed: ${err?.message ?? err}`);
77
+ }
78
+ }
63
79
  const child = spawnImpl(exec, args, { stdio: "inherit", detached: false });
64
80
  child.once("error", (err) => {
65
81
  console.error(`[relaunch] spawn failed: ${err?.message ?? err}`);
66
82
  exit(1);
67
83
  });
68
- // Hand the TTY to the child and exit cleanly. The child takes over rendering.
84
+ if (opts.supervise) {
85
+ // Stay alive until the child exits so any intermediate launcher (bun run
86
+ // wrapper / bin shim) keeps the terminal foreground for the child instead
87
+ // of letting the shell reclaim it mid-restart.
88
+ child.once("exit", (code) => exit(code ?? 0));
89
+ return;
90
+ }
91
+ // Legacy fast-handoff: exit the instant the child spawns. Correct only when
92
+ // there is no intervening launcher waiting on this process.
69
93
  child.once("spawn", () => exit(0));
70
94
  }
71
95
  //# sourceMappingURL=relaunch.js.map
@@ -40,6 +40,24 @@ describe("sanitizeArgvForResume", () => {
40
40
  expect(() => sanitizeArgvForResume([], "")).toThrow(/sessionId is required/);
41
41
  expect(() => sanitizeArgvForResume([], " ")).toThrow(/sessionId is required/);
42
42
  });
43
+ it("strips transient launch-mode flags (--agent-mode) so resume does not re-enter agent-mode", () => {
44
+ expect(sanitizeArgvForResume(["--agent-mode", "-m", "grok-build-0.1"], "new-id")).toEqual([
45
+ "-m",
46
+ "grok-build-0.1",
47
+ "--session",
48
+ "new-id",
49
+ ]);
50
+ });
51
+ it("strips `--mock-llm <dir>` and the combined form", () => {
52
+ expect(sanitizeArgvForResume(["--mock-llm", "/fix/dir", "-y"], "new-id")).toEqual(["-y", "--session", "new-id"]);
53
+ expect(sanitizeArgvForResume(["--mock-llm=/fix/dir", "-y"], "new-id")).toEqual(["-y", "--session", "new-id"]);
54
+ });
55
+ it("strips agent-mode + mock-llm together (harness-launched resume → clean user CLI)", () => {
56
+ expect(sanitizeArgvForResume(["--agent-mode", "--mock-llm", "/fx", "--session", "old"], "new")).toEqual([
57
+ "--session",
58
+ "new",
59
+ ]);
60
+ });
43
61
  });
44
62
  describe("relaunchWithSession", () => {
45
63
  it("spawns the same executable with the sanitized argv + session id, then exits 0", () => {
@@ -72,6 +90,38 @@ describe("relaunchWithSession", () => {
72
90
  expect(errMock).toHaveBeenCalled();
73
91
  errMock.mockRestore();
74
92
  });
93
+ it("runs beforeSpawn teardown before spawning", () => {
94
+ const calls = [];
95
+ const child = new EventEmitter();
96
+ const spawnMock = vi.fn(() => {
97
+ calls.push("spawn");
98
+ return child;
99
+ });
100
+ relaunchWithSession("sess", {
101
+ argv: ["/bin/bun", "src/index.ts"],
102
+ onExit: () => { },
103
+ spawnFn: spawnMock,
104
+ beforeSpawn: () => calls.push("teardown"),
105
+ });
106
+ expect(calls).toEqual(["teardown", "spawn"]);
107
+ });
108
+ it("supervise: stays alive until the child exits, then propagates the exit code", () => {
109
+ const exitMock = vi.fn();
110
+ const child = new EventEmitter();
111
+ const spawnMock = vi.fn(() => child);
112
+ relaunchWithSession("sess", {
113
+ argv: ["/bin/bun", "src/index.ts"],
114
+ onExit: exitMock,
115
+ spawnFn: spawnMock,
116
+ supervise: true,
117
+ });
118
+ // Does NOT exit on spawn when supervising.
119
+ child.emit("spawn");
120
+ expect(exitMock).not.toHaveBeenCalled();
121
+ // Exits with the child's code when the child finally exits.
122
+ child.emit("exit", 3);
123
+ expect(exitMock).toHaveBeenCalledWith(3);
124
+ });
75
125
  it("throws when argv[0] is missing (cannot relaunch without an executable)", () => {
76
126
  expect(() => relaunchWithSession("sess", {
77
127
  argv: [],
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.7.1",
6
+ "version": "1.7.2",
7
7
  "description": "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.",
8
8
  "repository": {
9
9
  "type": "git",