@modelstatus/cli 0.1.35 → 0.1.36

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
package/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { resolveAuth, loadConfig, saveConfig, clearAuth, configFilePath } from "./config.js";
5
6
  import { createClient } from "./api.js";
@@ -15,6 +16,73 @@ import { maybeCheckForUpdate } from "./updater.js";
15
16
  import { track, maybeAnalyticsNotice } from "./telemetry.js";
16
17
  import { BUILD_VERSION } from "./version.js";
17
18
 
19
+ // TRUE BACKGROUND SCAN — hidden worker dispatch. MUST be the first executable
20
+ // statement, BEFORE main()/maybeAnalyticsNotice/track/maybeCheckForUpdate: those
21
+ // write to stdout (the analytics notice) and trigger the self-updater, but the
22
+ // worker's stdout MUST be PURE NDJSON. The dynamic import pulls in ONLY
23
+ // filesystem.js + detect/core + registry (no ink, upload, auth, telemetry).
24
+ //
25
+ // argv is env-agnostic: under node argv = [node, /abs/src/index.js, sentinel, …];
26
+ // under the bun-compiled binary argv = [/abs/binary, /$bunfs/…, sentinel, …]. In
27
+ // BOTH the sentinel is at index 2 and worker args at index 3+. (See
28
+ // src/sources/scan-process.js buildSpawnArgs for the matching parent spawn.)
29
+ if (process.argv[2] === "__mm_scan_worker") {
30
+ const { runWorker } = await import("./sources/scan-worker.js");
31
+ await runWorker(process.argv.slice(3)); // never returns — calls process.exit
32
+ }
33
+
34
+ // HIDDEN bench seam (NOT a public command; absent from help). Runs the REAL
35
+ // game loop with a per-frame timestamp tap → stdout NDJSON, AND starts the REAL
36
+ // background scan subprocess (the production self-re-exec) so the SHIPPED binary
37
+ // is measured doing BOTH jobs at once — the exact concurrent path `mm play`
38
+ // drives. Used by test/game-jitter-compiled.test.mjs to gate the bun-compiled
39
+ // artifact (where the runtime, and thus the self-re-exec branch, differs from
40
+ // node). Headless: drives the loop via its _inject IO seams (no TTY needed).
41
+ // mm __bench_frames --seconds=N --with-scan=DIR [--ndjson]
42
+ if (process.argv[2] === "__bench_frames") {
43
+ const args = process.argv.slice(3);
44
+ const getv = (k, d) => { const a = args.find((x) => x.startsWith(`--${k}=`)); return a ? a.slice(k.length + 3) : d; };
45
+ const seconds = Number(getv("seconds", "4")) || 4;
46
+ const scanDir = getv("with-scan", null);
47
+ const now = () => Number(process.hrtime.bigint() / 1000000n);
48
+ const emit = (o) => process.stdout.write(JSON.stringify(o) + "\n");
49
+
50
+ const { runGame } = await import("./tui/game/loop.js");
51
+ let scan = null;
52
+ if (scanDir) {
53
+ const { startScanProcess, isBunRuntime } = await import("./sources/scan-process.js");
54
+ scan = startScanProcess({ root: scanDir }, {});
55
+ // Echo the self-re-exec proof: the worker's runtime + spawn argv0, so the
56
+ // test KNOWS the subprocess path didn't silently fall back to in-process.
57
+ emit({ t: "reexec", bun: isBunRuntime(), parentExec: process.execPath, childPid: scan.child.pid, childSpawnFile: process.execPath });
58
+ }
59
+
60
+ // Fake IO so the loop runs without a TTY; wrap schedule to timestamp each tick.
61
+ const writes = [];
62
+ const out = { columns: 80, rows: 24, isTTY: false, write: (s) => { if (s) writes.push(s.length); return true; }, setRawMode() { return this; }, on() {}, removeListener() {}, resume() {}, pause() {} };
63
+ const input = { isTTY: false, isRaw: false, setRawMode(v) { this.isRaw = v; return this; }, on() {}, removeListener() {}, resume() {}, pause() {}, write() { return true; } };
64
+ const seqs = ["\x1b[C", "\x1b[D", " ", "\x1b[A"]; let ki = 0;
65
+ const drive = setInterval(() => { (input._d || []).forEach((f) => f(Buffer.from(seqs[ki++ % seqs.length]))); }, 45);
66
+ input.on = (ev, fn) => { if (ev === "data") (input._d ||= []).push(fn); };
67
+ input.removeListener = (ev, fn) => { if (ev === "data") input._d = (input._d || []).filter((f) => f !== fn); };
68
+
69
+ let prev = null;
70
+ const p = runGame({
71
+ width: 80, height: 24, scanStore: scan,
72
+ _inject: {
73
+ out, inp: input, proc: process, now,
74
+ schedule: (fn, ms) => setTimeout(() => { const t = now(); if (prev != null) emit({ t: "frame", dtMs: t - prev }); prev = t; fn(); }, ms),
75
+ cancel: (h) => clearTimeout(h),
76
+ },
77
+ });
78
+ setTimeout(() => { (input._d || []).forEach((f) => f(Buffer.from("q"))); }, seconds * 1000);
79
+ await p;
80
+ clearInterval(drive);
81
+ emit({ t: "done", filesScanned: scan ? scan.stats.filesScanned : 0, candidates: scan ? scan.stats.candidateCount : 0, phase: scan ? scan.stats.phase : "none" });
82
+ try { scan && scan.abort(); } catch {}
83
+ process.exit(0);
84
+ }
85
+
18
86
  function parseArgs(argv) {
19
87
  const flags = {};
20
88
  const positional = [];
@@ -134,6 +202,49 @@ async function cmdUpgrade(_positional, flags) {
134
202
  }
135
203
  }
136
204
 
205
+ /** `mm play [dir]` — the standalone game: NO Ink, ever. Runs the direct-ANSI
206
+ * 60Hz loop while a background scan subprocess walks `dir` (its progress feeds
207
+ * the in-game HUD). Pure terminal, restores cleanly on every exit path. */
208
+ async function cmdPlay(positional, flags) {
209
+ if (!process.stdout.isTTY) {
210
+ console.error("`mm play` needs an interactive terminal (a TTY).");
211
+ process.exit(1);
212
+ }
213
+ const dir = path.resolve(positional[1] || flags.dir || ".");
214
+ const { runGame } = await import("./tui/game/loop.js");
215
+
216
+ // Best-effort background scan of `dir` so the HUD shows live progress. We
217
+ // pre-fetch the signed registry once and hand the worker a cache file so it
218
+ // skips the network; on any failure the game still plays (scanStore stays in
219
+ // its error/empty state, which the HUD renders as "scan unavailable").
220
+ let scanHandle = null;
221
+ let cacheFile = null;
222
+ try {
223
+ const { startScanProcess } = await import("./sources/scan-process.js");
224
+ try {
225
+ const { getRegistry } = await import("./registry/fetch.js");
226
+ const snapshot = await getRegistry({ offline: !!flags.offline }).catch(() => null);
227
+ if (snapshot) {
228
+ cacheFile = path.join(os.tmpdir(), `mm-reg-${process.pid}.json`);
229
+ fs.writeFileSync(cacheFile, JSON.stringify(snapshot));
230
+ }
231
+ } catch { /* no cache — worker fetches itself */ }
232
+ scanHandle = startScanProcess({ root: dir, registryCachePath: cacheFile || undefined }, {});
233
+ } catch { scanHandle = null; }
234
+
235
+ try {
236
+ await runGame({
237
+ width: process.stdout.columns || 80,
238
+ height: process.stdout.rows || 24,
239
+ level: 1,
240
+ scanStore: scanHandle,
241
+ });
242
+ } finally {
243
+ try { scanHandle && scanHandle.abort(); } catch { /* ignore */ }
244
+ try { if (cacheFile) fs.unlinkSync(cacheFile); } catch { /* ignore */ }
245
+ }
246
+ }
247
+
137
248
  async function launchTui(initialView, flags) {
138
249
  // Pass apiKey straight through (may be null). The TUI's Bootstrap wrapper
139
250
  // renders an interactive SignIn screen when there's no key, then swaps to
@@ -584,6 +695,7 @@ Usage:
584
695
  mm integrations Manage live integrations (list | enable <id> | disable <id> | env <id> <tag>)
585
696
  mm clear Delete all tracked usages from your inventory (--all also wipes projects/rules; --yes to skip the prompt)
586
697
  mm upgrade Open Stripe checkout and poll until Pro is active
698
+ mm play [dir] Play Donkey Kong while a background scan walks the dir (just for fun)
587
699
  mm tui Force-launch the TUI (logs you in first if needed)
588
700
 
589
701
  Scan sources (--sources; default = filesystem + enabled integrations; "all" for everything):
@@ -648,6 +760,7 @@ async function main() {
648
760
  else if (cmd === "sources") await cmdSources(positional, flags);
649
761
  else if (cmd === "integrations") cmdIntegrations(positional, flags);
650
762
  else if (cmd === "clear") await cmdClear(positional, flags);
763
+ else if (cmd === "play") await cmdPlay(positional, flags);
651
764
  else if (cmd === "upgrade") await cmdUpgrade(positional, flags);
652
765
  else if (cmd === "tui" || !cmd) await launchTui(positional[1], flags);
653
766
  else if (cmd === "help" || flags.help) console.log(HELP);
@@ -0,0 +1,238 @@
1
+ /* TRUE BACKGROUND SCAN — PARENT side.
2
+ *
3
+ * Spawns the scan as a SEPARATE OS process (self-re-exec of the running runtime
4
+ * via the hidden `__mm_scan_worker` sentinel; see src/index.js top dispatch +
5
+ * src/sources/scan-worker.js), readline-parses its NDJSON stdout into a plain
6
+ * mutable `stats` store, and re-dispatches onto the SAME named-handler shape
7
+ * scan-runner.js uses ({onCandidate,onProgress,onSkip,onDone,onError}) — so this
8
+ * is the mechanical swap the scan-runner.js header comment promised, except the
9
+ * walk now runs in its OWN event loop (no cooperative-yield lag during a game /
10
+ * heavy IO), and survives an Ink unmount because it's a real subprocess.
11
+ *
12
+ * Returned handle mirrors scan-runner.js's {abort,pause,resume,paused} plus a
13
+ * read-only `stats` getter and the `child` reference. The game HUD reads `stats`
14
+ * every render and NEVER awaits the scan.
15
+ *
16
+ * Dual-runtime spawn (the ONE place runtime matters — both branches verified):
17
+ * - Compiled binary (bun --compile): process.execPath IS the binary; re-exec
18
+ * it with just the worker args. argv[1] is a virtual /$bunfs path and is NOT
19
+ * spawnable, so do NOT prepend it.
20
+ * - node / npm: process.execPath is node; prepend process.argv[1] (the real
21
+ * absolute src/index.js path) so node loads the entry, which dispatches.
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import readline from "node:readline";
25
+
26
+ /** True inside the bun-compiled binary, false under node. Defined-in-bun signal. */
27
+ export function isBunRuntime() {
28
+ return typeof globalThis.Bun !== "undefined" || process.versions.bun != null;
29
+ }
30
+
31
+ /**
32
+ * Build the argv passed to spawn() for `process.execPath`.
33
+ * @param {string[]} workerArgs ['--root', dir, ...] worker flags (NO sentinel)
34
+ * @returns {string[]} full spawn argv (sentinel + flags, with argv[1] prepended under node)
35
+ */
36
+ export function buildSpawnArgs(workerArgs) {
37
+ const wargs = ["__mm_scan_worker", ...workerArgs];
38
+ // Compiled binary: re-exec the binary itself (execPath) with just the worker
39
+ // args. node: execPath is node, so prepend the real entry script (argv[1]).
40
+ return isBunRuntime() ? wargs : [process.argv[1], ...wargs];
41
+ }
42
+
43
+ /**
44
+ * Start a background filesystem scan in a separate OS process.
45
+ *
46
+ * @param {object} opts
47
+ * @param {string} opts.root dir to walk (required)
48
+ * @param {string[]} [opts.exclude] extra ignore patterns (csv-joined for the worker)
49
+ * @param {object} [opts.env] extra env merged over process.env for the child
50
+ * @param {string} [opts.registryCachePath] pre-fetched snapshot JSON path (worker skips the network)
51
+ *
52
+ * @param {object} [handlers]
53
+ * @param {(candidate:object)=>void} [handlers.onCandidate]
54
+ * @param {(p:object)=>void} [handlers.onProgress] {filesScanned,dirsSeen,catalogsSkipped,currentDir}
55
+ * @param {(s:object)=>void} [handlers.onSkip] {path,distinct,catalogsSkipped}
56
+ * @param {(r:object)=>void} [handlers.onDone] {candidates,filesScanned,dirsSeen,catalogsSkipped,scannedAt} — suppressed after abort()
57
+ * @param {(err:Error)=>void} [handlers.onError]
58
+ *
59
+ * @returns {{abort:()=>void, pause:()=>void, resume:()=>void, paused:boolean, stats:object, child:import('node:child_process').ChildProcess}}
60
+ */
61
+ export function startScanProcess(opts, handlers = {}) {
62
+ const { root, exclude, env, registryCachePath } = opts || {};
63
+ const {
64
+ onCandidate = () => {},
65
+ onProgress = () => {},
66
+ onSkip = () => {},
67
+ onDone = () => {},
68
+ onError = () => {},
69
+ } = handlers;
70
+
71
+ // Worker flags. Sentinel + argv[1] handling lives in buildSpawnArgs.
72
+ const workerArgs = ["--root", root];
73
+ if (exclude && exclude.length) workerArgs.push("--exclude", exclude.join(","));
74
+ if (registryCachePath) workerArgs.push("--registry-cache", registryCachePath);
75
+
76
+ const child = spawn(process.execPath, buildSpawnArgs(workerArgs), {
77
+ // ignore stdin; PIPE stdout (NDJSON) + stderr (diagnostics). NEVER 'inherit'
78
+ // — the worker shares no terminal with the game, so its output can't touch
79
+ // the alt-screen frame.
80
+ stdio: ["ignore", "pipe", "pipe"],
81
+ env: { ...process.env, ...(env || {}) },
82
+ detached: false,
83
+ windowsHide: true,
84
+ });
85
+
86
+ // The HUD store — a plain mutable object the loop reads each render, read-only.
87
+ const stats = {
88
+ phase: "scanning", // scanning | done | error
89
+ filesScanned: 0,
90
+ candidateCount: 0,
91
+ dirsSeen: 0,
92
+ catalogsSkipped: 0,
93
+ currentDir: "",
94
+ scannedAt: null,
95
+ error: null,
96
+ };
97
+
98
+ let aborted = false;
99
+ let paused = false;
100
+ let finalized = false; // exactly-once phase finalization (done/error/exit)
101
+ let stderrBuf = "";
102
+
103
+ function handleLine(line) {
104
+ if (!line) return;
105
+ let msg;
106
+ try {
107
+ msg = JSON.parse(line);
108
+ } catch {
109
+ return; // drop a malformed / partial-leftover line, never throw
110
+ }
111
+ switch (msg.t) {
112
+ case "prog":
113
+ // dir-only prog lines carry just dirsSeen; full prog lines carry counts.
114
+ if (typeof msg.filesScanned === "number") stats.filesScanned = msg.filesScanned;
115
+ if (typeof msg.dirsSeen === "number") stats.dirsSeen = msg.dirsSeen;
116
+ if (typeof msg.catalogsSkipped === "number") stats.catalogsSkipped = msg.catalogsSkipped;
117
+ if (typeof msg.currentDir === "string") stats.currentDir = msg.currentDir;
118
+ if (!aborted) {
119
+ onProgress({
120
+ filesScanned: stats.filesScanned,
121
+ dirsSeen: stats.dirsSeen,
122
+ catalogsSkipped: stats.catalogsSkipped,
123
+ currentDir: stats.currentDir,
124
+ });
125
+ }
126
+ break;
127
+ case "cand":
128
+ // COUNTS only on the hot path: the loop never renders cand payloads, so
129
+ // parse pressure during a game is near-zero. The full Candidate[] is
130
+ // read once from the done line.
131
+ stats.candidateCount++;
132
+ if (!aborted) onCandidate(msg.candidate);
133
+ break;
134
+ case "skip":
135
+ if (typeof msg.catalogsSkipped === "number") stats.catalogsSkipped = msg.catalogsSkipped;
136
+ if (!aborted) onSkip({ path: msg.path, distinct: msg.distinct, catalogsSkipped: msg.catalogsSkipped });
137
+ break;
138
+ case "done":
139
+ if (finalized) return;
140
+ finalized = true;
141
+ stats.phase = "done";
142
+ stats.filesScanned = msg.filesScanned ?? stats.filesScanned;
143
+ stats.dirsSeen = msg.dirsSeen ?? stats.dirsSeen;
144
+ stats.catalogsSkipped = msg.catalogsSkipped ?? stats.catalogsSkipped;
145
+ stats.candidateCount = (msg.candidates || []).length;
146
+ stats.scannedAt = msg.scannedAt ?? Date.now();
147
+ if (!aborted) onDone({
148
+ candidates: msg.candidates || [],
149
+ filesScanned: stats.filesScanned,
150
+ dirsSeen: stats.dirsSeen,
151
+ catalogsSkipped: stats.catalogsSkipped,
152
+ scannedAt: stats.scannedAt,
153
+ });
154
+ break;
155
+ case "err":
156
+ if (finalized) return;
157
+ finalized = true;
158
+ stats.phase = "error";
159
+ stats.error = msg.message || "scan failed";
160
+ if (!aborted) onError(new Error(stats.error));
161
+ break;
162
+ default:
163
+ break;
164
+ }
165
+ }
166
+
167
+ const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
168
+ rl.on("line", handleLine);
169
+
170
+ child.stderr.on("data", (d) => {
171
+ stderrBuf += d.toString();
172
+ if (stderrBuf.length > 64_000) stderrBuf = stderrBuf.slice(-64_000); // bound
173
+ });
174
+
175
+ child.on("error", (err) => {
176
+ // Spawn failure (e.g. execPath missing) — surface unless we asked to die.
177
+ if (finalized || aborted) return;
178
+ finalized = true;
179
+ stats.phase = "error";
180
+ stats.error = err?.message || "failed to start scan worker";
181
+ onError(err instanceof Error ? err : new Error(stats.error));
182
+ });
183
+
184
+ child.on("exit", (code, signal) => {
185
+ // If the child exited without a done/err line and we didn't abort it, treat
186
+ // a non-zero/signal exit as an error so the HUD doesn't hang on "scanning".
187
+ if (finalized || aborted) return;
188
+ if (code === 0) return; // a 0 exit always followed a done line above
189
+ finalized = true;
190
+ stats.phase = "error";
191
+ stats.error = (stderrBuf.trim().split("\n").pop() || `scan worker exited (code ${code}, signal ${signal})`);
192
+ onError(new Error(stats.error));
193
+ });
194
+
195
+ return {
196
+ abort() {
197
+ // Idempotent. Suppresses onDone/onError and kills the child (cross-platform).
198
+ if (aborted) return;
199
+ aborted = true;
200
+ try {
201
+ child.kill("SIGTERM");
202
+ } catch {
203
+ /* already gone */
204
+ }
205
+ },
206
+ pause() {
207
+ if (paused) return;
208
+ paused = true;
209
+ // SIGSTOP genuinely freezes the child on posix; on win32 it's a no-op on
210
+ // the process but we still flip the flag so the UI reflects intent.
211
+ if (process.platform !== "win32") {
212
+ try {
213
+ child.kill("SIGSTOP");
214
+ } catch {
215
+ /* ignore */
216
+ }
217
+ }
218
+ },
219
+ resume() {
220
+ if (!paused) return;
221
+ paused = false;
222
+ if (process.platform !== "win32") {
223
+ try {
224
+ child.kill("SIGCONT");
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+ }
229
+ },
230
+ get paused() {
231
+ return paused;
232
+ },
233
+ get stats() {
234
+ return stats;
235
+ },
236
+ child,
237
+ };
238
+ }
@@ -0,0 +1,148 @@
1
+ /* TRUE BACKGROUND SCAN — CHILD (worker) side.
2
+ *
3
+ * Runs inside a self-re-exec'd OS process: the parent spawns `process.execPath`
4
+ * with the hidden `__mm_scan_worker` sentinel as argv[2] (see src/index.js top
5
+ * dispatch + src/sources/scan-process.js). This module is dynamic-imported by
6
+ * that dispatch, so it pulls in ONLY filesystem.js + detect/core + registry
7
+ * (NO ink, NO upload, NO auth, NO telemetry, NO updater) — the worker's stdout
8
+ * must be PURE NDJSON.
9
+ *
10
+ * Protocol: one JSON object per line, newline-terminated, FLUSHED. Tags:
11
+ * {t:"prog", filesScanned, dirsSeen, catalogsSkipped, currentDir}
12
+ * {t:"cand", candidate}
13
+ * {t:"skip", path, distinct, catalogsSkipped}
14
+ * {t:"done", candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt}
15
+ * {t:"err", message}
16
+ *
17
+ * Lifecycle: SIGTERM -> AbortController.abort() (clean stop). SIGSTOP/SIGCONT
18
+ * are handled by the OS (pause/resume) and need no listener. The terminal
19
+ * done/err line is flushed by deferring process.exit into the write callback,
20
+ * so the parent never loses the final line on exit.
21
+ */
22
+ import fs from "node:fs";
23
+ import { scanFilesystemStreaming } from "./filesystem.js";
24
+ import { compilePatterns } from "../detect/core.js";
25
+ import { getRegistry } from "../registry/fetch.js";
26
+
27
+ /** Parse the worker's own argv (process.argv.slice(3)) — light, no parseArgs dep. */
28
+ function parseWorkerArgs(args) {
29
+ const out = { root: process.cwd(), exclude: [], registryCache: null };
30
+ for (let i = 0; i < args.length; i++) {
31
+ const a = args[i];
32
+ if (a === "--root") out.root = args[++i];
33
+ else if (a === "--exclude") out.exclude = (args[++i] || "").split(",").filter(Boolean);
34
+ else if (a === "--registry-cache") out.registryCache = args[++i];
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** Flush-safe NDJSON writer. When `exitCode` is given, exit INSIDE the write
40
+ * callback so the final line is fully flushed to the pipe before we exit, and
41
+ * return a never-resolving Promise so the caller's `await` HANGS until that
42
+ * deferred process.exit fires — this prevents the top-level worker dispatch in
43
+ * index.js from falling through to main() (which would print HELP) between the
44
+ * promise resolving and the async exit landing. */
45
+ function makeEmitter() {
46
+ return function emit(obj, exitCode) {
47
+ const line = JSON.stringify(obj) + "\n";
48
+ if (typeof exitCode === "number") {
49
+ // Defer exit until this write drains — guarantees done/err is delivered.
50
+ process.stdout.write(line, () => process.exit(exitCode));
51
+ return new Promise(() => {}); // hang forever; the write cb exits the proc
52
+ }
53
+ process.stdout.write(line);
54
+ return undefined;
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Worker entrypoint. `args` is process.argv.slice(3). Never returns (process.exit).
60
+ */
61
+ export async function runWorker(args) {
62
+ const emit = makeEmitter();
63
+ const { root, exclude, registryCache } = parseWorkerArgs(args);
64
+
65
+ // (1) Registry snapshot: prefer the parent's pre-fetched cache file (skips the
66
+ // network); else fetch + verify ourselves. On total failure (offline, no
67
+ // cache) emit one err line and exit 1.
68
+ let snapshot;
69
+ try {
70
+ if (registryCache) {
71
+ snapshot = JSON.parse(fs.readFileSync(registryCache, "utf8"));
72
+ } else {
73
+ snapshot = await getRegistry();
74
+ }
75
+ } catch (e) {
76
+ return emit({ t: "err", message: `registry unavailable: ${e?.message ?? e}` }, 1);
77
+ }
78
+
79
+ // (2) Compile detection patterns once.
80
+ let compiled;
81
+ try {
82
+ compiled = compilePatterns(snapshot.detection);
83
+ } catch (e) {
84
+ return emit({ t: "err", message: `bad registry snapshot: ${e?.message ?? e}` }, 1);
85
+ }
86
+
87
+ // (7) Abort: parent SIGTERM -> abort the walk so the kill stops cleanly.
88
+ const ac = new AbortController();
89
+ process.on("SIGTERM", () => ac.abort());
90
+
91
+ // (4) Run the streaming scan, mapping the engine RAW onEvent protocol to NDJSON.
92
+ // yieldBudgetMs Infinity (count-only): the worker is ALONE in its event loop —
93
+ // no foreground frame loop to keep ticking — so the walk runs as fast as it
94
+ // can; the setImmediate-every-40-files yield still fires often enough to flush
95
+ // stdout + service the abort signal.
96
+ // Track the latest counters so the `done` line carries accurate finals even
97
+ // when the last event before completion was a candidate (which has no counts).
98
+ let filesScanned = 0;
99
+ let dirsSeen = 0;
100
+ let catalogsSkipped = 0;
101
+ try {
102
+ const candidates = await scanFilesystemStreaming(
103
+ { root, signal: ac.signal, exclude, env: process.env.MM_ENV, yieldBudgetMs: Infinity },
104
+ compiled,
105
+ (ev) => {
106
+ switch (ev.type) {
107
+ case "dir":
108
+ // Fold into a prog line carrying only the running dir count.
109
+ dirsSeen = ev.dirsSeen;
110
+ emit({ t: "prog", dirsSeen: ev.dirsSeen });
111
+ break;
112
+ case "candidate":
113
+ emit({ t: "cand", candidate: ev.candidate });
114
+ break;
115
+ case "skip":
116
+ catalogsSkipped = ev.catalogsSkipped;
117
+ emit({ t: "skip", path: ev.path, distinct: ev.distinct, catalogsSkipped: ev.catalogsSkipped });
118
+ break;
119
+ case "progress":
120
+ filesScanned = ev.filesScanned;
121
+ dirsSeen = ev.dirsSeen;
122
+ catalogsSkipped = ev.catalogsSkipped;
123
+ emit({
124
+ t: "prog",
125
+ filesScanned: ev.filesScanned,
126
+ dirsSeen: ev.dirsSeen,
127
+ catalogsSkipped: ev.catalogsSkipped,
128
+ currentDir: ev.currentDir,
129
+ });
130
+ break;
131
+ default:
132
+ break;
133
+ }
134
+ },
135
+ );
136
+
137
+ // (5) Final summary rides the done line — the full Candidate[] so the parent
138
+ // seeds the Scan view without buffering every cand. Exit 0 in the write cb;
139
+ // await the hang so the dispatch never falls through to main().
140
+ await emit(
141
+ { t: "done", candidates, filesScanned, dirsSeen, catalogsSkipped, scannedAt: Date.now() },
142
+ 0,
143
+ );
144
+ } catch (e) {
145
+ // Aborted walks resolve (return the partial set), so a throw here is real.
146
+ await emit({ t: "err", message: `${e?.message ?? e}` }, 1);
147
+ }
148
+ }
package/src/tui/app.js CHANGED
@@ -219,7 +219,46 @@ function Bootstrap(props) {
219
219
  return h(App, { ...props, apiKey, onSignedIn: (k) => { track("signed_in"); setApiKey(k); } });
220
220
  }
221
221
 
222
+ // Module-level controller so a view (the Scan tab) can UNMOUNT the whole Ink
223
+ // tree, run the direct-ANSI game loop (which owns its own raw mode + alt screen),
224
+ // then REMOUNT a fresh tree at a chosen tab. The game subprocess scan survives
225
+ // the unmount because it's a separate OS process, independent of Ink's lifecycle.
226
+ export const appController = {
227
+ _instance: null,
228
+ _opts: null,
229
+ /** Tear down the current Ink tree (releases raw mode + stdin listeners). */
230
+ unmount() {
231
+ try { this._instance && this._instance.unmount(); } catch { /* already gone */ }
232
+ this._instance = null;
233
+ },
234
+ /** Mount a fresh Ink tree, merging the base opts with `next` (e.g. a tab). */
235
+ remount(next = {}) {
236
+ const opts = { ...(this._opts || {}), ...next };
237
+ this._opts = opts;
238
+ this._instance = render(h(Bootstrap, opts));
239
+ return this._instance;
240
+ },
241
+ };
242
+
222
243
  export function runApp(opts) {
244
+ appController._opts = opts;
223
245
  const app = render(h(Bootstrap, opts));
224
- return app.waitUntilExit();
246
+ appController._instance = app;
247
+ // waitUntilExit resolves when the CURRENT instance unmounts. A Scan-tab game
248
+ // launch unmounts + remounts, which would resolve this early; so we re-arm on
249
+ // each remount and only resolve when an instance exits WITHOUT a pending
250
+ // remount (i.e. a real quit). The `_exiting` flag is set by a clean exit().
251
+ return new Promise((resolve) => {
252
+ const arm = (inst) => {
253
+ inst.waitUntilExit().then(() => {
254
+ // If a remount happened (a new instance is live and differs), keep waiting.
255
+ if (appController._instance && appController._instance !== inst) {
256
+ arm(appController._instance);
257
+ } else {
258
+ resolve();
259
+ }
260
+ });
261
+ };
262
+ arm(app);
263
+ });
225
264
  }