muonroi-cli 1.7.0 → 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.0";
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.0";
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: [],
@@ -51,6 +51,35 @@ export declare function saveScriptInstallMetadata(metadata: ScriptInstallMetadat
51
51
  export declare function getScriptInstallContext(homeDir?: string): ScriptInstallContext | null;
52
52
  export declare function fetchLatestReleaseVersion(): Promise<string | null>;
53
53
  export declare function parseChecksumsFile(contents: string): Map<string, string>;
54
+ /**
55
+ * How this muonroi-cli was installed. Drives which update path the built-in
56
+ * `/update` flow takes:
57
+ * - "script" → install.sh-managed; runScriptManagedUpdate replaces the binary.
58
+ * - "bun-global" → `bun add -g muonroi-cli`; update via bun.
59
+ * - "npm-global" → `npm install -g muonroi-cli`; update via npm.
60
+ * - "compiled" → standalone single-file binary; re-download / rebuild.
61
+ * - "dev-link" → linked/source build run from a git checkout (bun link,
62
+ * symlinked global bin, or `bun run src/index.ts`); rebuild dist.
63
+ * - "unknown" → can't tell; fall back to generic guidance.
64
+ */
65
+ export type InstallMethod = "script" | "bun-global" | "npm-global" | "compiled" | "dev-link" | "unknown";
66
+ /**
67
+ * Detect how the running muonroi-cli was installed by inspecting the location
68
+ * of this module on disk plus the runtime. Pure path inspection — no I/O beyond
69
+ * the install.json check, so it is safe to call on the hot path.
70
+ */
71
+ export declare function detectInstallMethod(homeDir?: string): InstallMethod;
72
+ /** Package-manager command that updates muonroi-cli for the given method, or null. */
73
+ export declare function getUpdateCommandForMethod(method: InstallMethod): string | null;
74
+ /**
75
+ * Top-level update entry point. Routes to the script-managed updater for
76
+ * install.sh installs, and returns the correct package-manager command for
77
+ * bun/npm global installs (rather than the misleading "reinstall via install.sh"
78
+ * dead-end). We do NOT auto-spawn the package manager: on Windows overwriting the
79
+ * files of the live process is unreliable, so we hand the user an exact command
80
+ * to run from a fresh terminal.
81
+ */
82
+ export declare function runManagedUpdate(currentVersion: string): Promise<ScriptUpdateRunResult>;
54
83
  export declare function runScriptManagedUpdate(currentVersion: string): Promise<ScriptUpdateRunResult>;
55
84
  export declare function buildScriptUninstallPlan(options?: ScriptUninstallOptions, homeDir?: string): ScriptUninstallPlan | null;
56
85
  export declare function runScriptManagedUninstall(options?: ScriptUninstallOptions): Promise<ScriptUpdateRunResult>;
@@ -6,6 +6,7 @@ import path from "path";
6
6
  import readline from "readline";
7
7
  import semverGt from "semver/functions/gt.js";
8
8
  import semverValid from "semver/functions/valid.js";
9
+ import { fileURLToPath } from "url";
9
10
  export const GITHUB_REPO = "muonroi/muonroi-cli";
10
11
  export const RELEASES_API = `https://api.github.com/repos/${GITHUB_REPO}/releases`;
11
12
  export const SCRIPT_INSTALL_METHOD = "script";
@@ -99,6 +100,111 @@ export function parseChecksumsFile(contents) {
99
100
  }
100
101
  return result;
101
102
  }
103
+ /** Absolute filesystem path of THIS module, normalized to forward slashes. */
104
+ function runningModulePath() {
105
+ try {
106
+ return fileURLToPath(import.meta.url).replace(/\\/g, "/");
107
+ }
108
+ catch {
109
+ return (process.argv[1] ?? "").replace(/\\/g, "/");
110
+ }
111
+ }
112
+ /** Walk up from `startDir` looking for a `.git` entry; return the repo root or null. */
113
+ function findGitRoot(startDir) {
114
+ let dir = startDir;
115
+ for (let i = 0; i < 30 && dir; i++) {
116
+ if (fs.existsSync(path.join(dir, ".git")))
117
+ return dir;
118
+ const parent = path.dirname(dir);
119
+ if (parent === dir)
120
+ break;
121
+ dir = parent;
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Detect how the running muonroi-cli was installed by inspecting the location
127
+ * of this module on disk plus the runtime. Pure path inspection — no I/O beyond
128
+ * the install.json check, so it is safe to call on the hot path.
129
+ */
130
+ export function detectInstallMethod(homeDir = os.homedir()) {
131
+ if (loadScriptInstallMetadata(homeDir))
132
+ return "script";
133
+ const modPath = runningModulePath();
134
+ const importUrl = (import.meta.url || "").replace(/\\/g, "/");
135
+ // Bun single-file compiled executable embeds modules under a virtual root
136
+ // (e.g. "/$bunfs/" or "/~BUN/"), so the module path is not a real fs path.
137
+ if (importUrl.includes("/$bunfs/") || importUrl.includes("/~BUN/") || modPath.includes("/$bunfs/")) {
138
+ return "compiled";
139
+ }
140
+ // Bun's global install root: ~/.bun/install/global/node_modules/muonroi-cli/...
141
+ if (modPath.includes("/.bun/install/global/"))
142
+ return "bun-global";
143
+ if (/\/node_modules\/muonroi-cli\//.test(modPath)) {
144
+ return modPath.includes("/.bun/") ? "bun-global" : "npm-global";
145
+ }
146
+ // Not under node_modules and not launched by node/bun → standalone binary.
147
+ const exeBase = ((process.execPath || "").replace(/\\/g, "/").split("/").pop() ?? "").toLowerCase();
148
+ if (!modPath.includes("/node_modules/") && !/^(node|bun)(\.exe)?$/.test(exeBase)) {
149
+ return "compiled";
150
+ }
151
+ // Linked/source build run from a git checkout (e.g. `bun link`, a symlinked
152
+ // global bin pointing at the repo, or `bun run src/index.ts`). The "update"
153
+ // here is a rebuild, not a package-manager swap.
154
+ if (modPath && !modPath.includes("/node_modules/") && findGitRoot(path.dirname(modPath))) {
155
+ return "dev-link";
156
+ }
157
+ return "unknown";
158
+ }
159
+ /** Package-manager command that updates muonroi-cli for the given method, or null. */
160
+ export function getUpdateCommandForMethod(method) {
161
+ switch (method) {
162
+ case "bun-global":
163
+ return "bun add -g muonroi-cli@latest";
164
+ case "npm-global":
165
+ return "npm install -g muonroi-cli@latest";
166
+ default:
167
+ return null;
168
+ }
169
+ }
170
+ /**
171
+ * Top-level update entry point. Routes to the script-managed updater for
172
+ * install.sh installs, and returns the correct package-manager command for
173
+ * bun/npm global installs (rather than the misleading "reinstall via install.sh"
174
+ * dead-end). We do NOT auto-spawn the package manager: on Windows overwriting the
175
+ * files of the live process is unreliable, so we hand the user an exact command
176
+ * to run from a fresh terminal.
177
+ */
178
+ export async function runManagedUpdate(currentVersion) {
179
+ const method = detectInstallMethod();
180
+ if (method === "script")
181
+ return runScriptManagedUpdate(currentVersion);
182
+ const cmd = getUpdateCommandForMethod(method);
183
+ if (cmd) {
184
+ const pm = method === "bun-global" ? "bun" : "npm";
185
+ return {
186
+ success: true,
187
+ output: `Installed via ${pm} (global package). To update, run this in a fresh terminal:\n\n ${cmd}\n\nThen restart muonroi-cli.`,
188
+ };
189
+ }
190
+ if (method === "compiled") {
191
+ const target = getReleaseTargetForPlatform();
192
+ const asset = target?.assetName ?? "the release asset for your platform";
193
+ return {
194
+ success: true,
195
+ output: `Standalone binary install. Download the latest ${asset} from https://github.com/${GITHUB_REPO}/releases/latest and replace the current binary, or rebuild from source.`,
196
+ };
197
+ }
198
+ if (method === "dev-link") {
199
+ const root = findGitRoot(path.dirname(runningModulePath()));
200
+ const target = root ?? "the muonroi-cli checkout";
201
+ return {
202
+ success: true,
203
+ output: `Running a linked/source build from ${target}. To update, rebuild it:\n\n git -C "${target}" pull && bun install && bun run build\n\nThen restart muonroi-cli. (If you also use the compiled muonroi-cli-dev binary, rebuild that separately.)`,
204
+ };
205
+ }
206
+ return notScriptManaged("update");
207
+ }
102
208
  export async function runScriptManagedUpdate(currentVersion) {
103
209
  const context = getScriptInstallContext();
104
210
  if (!context)
@@ -1,6 +1,6 @@
1
1
  import semverGt from "semver/functions/gt.js";
2
2
  import semverValid from "semver/functions/valid.js";
3
- import { fetchLatestReleaseVersion, runScriptManagedUpdate } from "./install-manager.js";
3
+ import { fetchLatestReleaseVersion, runManagedUpdate } from "./install-manager.js";
4
4
  export async function checkForUpdate(currentVersion) {
5
5
  try {
6
6
  const latestVersion = await fetchLatestReleaseVersion();
@@ -17,6 +17,6 @@ export async function checkForUpdate(currentVersion) {
17
17
  }
18
18
  }
19
19
  export function runUpdate(currentVersion) {
20
- return runScriptManagedUpdate(currentVersion);
20
+ return runManagedUpdate(currentVersion);
21
21
  }
22
22
  //# sourceMappingURL=update-checker.js.map
@@ -95,12 +95,12 @@ describe("checkForUpdate", () => {
95
95
  });
96
96
  });
97
97
  describe("runUpdate", () => {
98
- it("returns success when the script-managed updater succeeds", async () => {
98
+ it("returns success when the managed updater succeeds", async () => {
99
99
  vi.doMock("./install-manager", async () => {
100
100
  const actual = await vi.importActual("./install-manager");
101
101
  return {
102
102
  ...actual,
103
- runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: true, output: "Updated to Grok 2.0.0." }),
103
+ runManagedUpdate: vi.fn().mockResolvedValue({ success: true, output: "Updated to muonroi-cli 2.0.0." }),
104
104
  };
105
105
  });
106
106
  const { runUpdate } = await importModule();
@@ -108,12 +108,12 @@ describe("runUpdate", () => {
108
108
  expect(result.success).toBe(true);
109
109
  expect(result.output).toContain("Updated");
110
110
  });
111
- it("returns failure when the script-managed updater fails", async () => {
111
+ it("returns failure when the managed updater fails", async () => {
112
112
  vi.doMock("./install-manager", async () => {
113
113
  const actual = await vi.importActual("./install-manager");
114
114
  return {
115
115
  ...actual,
116
- runScriptManagedUpdate: vi.fn().mockResolvedValue({ success: false, output: "permission denied" }),
116
+ runManagedUpdate: vi.fn().mockResolvedValue({ success: false, output: "permission denied" }),
117
117
  };
118
118
  });
119
119
  const { runUpdate } = await importModule();
@@ -122,4 +122,17 @@ describe("runUpdate", () => {
122
122
  expect(result.output).toContain("permission denied");
123
123
  });
124
124
  });
125
+ describe("getUpdateCommandForMethod", () => {
126
+ it("maps bun/npm global installs to the right update command", async () => {
127
+ const { getUpdateCommandForMethod } = await import("./install-manager.js");
128
+ expect(getUpdateCommandForMethod("bun-global")).toBe("bun add -g muonroi-cli@latest");
129
+ expect(getUpdateCommandForMethod("npm-global")).toBe("npm install -g muonroi-cli@latest");
130
+ });
131
+ it("returns null for methods without a package-manager command", async () => {
132
+ const { getUpdateCommandForMethod } = await import("./install-manager.js");
133
+ expect(getUpdateCommandForMethod("script")).toBeNull();
134
+ expect(getUpdateCommandForMethod("compiled")).toBeNull();
135
+ expect(getUpdateCommandForMethod("unknown")).toBeNull();
136
+ });
137
+ });
125
138
  //# sourceMappingURL=update-checker.test.js.map
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.7.0",
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",