@melaya/runner 1.1.68 → 1.1.70

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.
@@ -13,9 +13,12 @@
13
13
  import { io } from "socket.io-client";
14
14
  import chalk from "chalk";
15
15
  import ora from "ora";
16
- import { spawn } from "child_process";
16
+ // Every child spawned here leads its own process group (POSIX) and is tracked,
17
+ // so shutdown and runner:kill stop the whole tree (audit P0.5).
18
+ import { spawnTracked as spawn, terminateTree, terminateAllTracked, runShutdownSteps, formatShutdownResults, claimGracefulShutdown, killAllTrackedSync, setSetupBackground, lowerPriority, } from "./processTree.js";
19
+ import { markStage } from "./startupTimings.js";
17
20
  import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
18
- import { dirname, join } from "path";
21
+ import { dirname, isAbsolute, join } from "path";
19
22
  import { fileURLToPath } from "url";
20
23
  import { tmpdir, homedir, hostname } from "os";
21
24
  import { createHash, createHmac, timingSafeEqual } from "crypto";
@@ -25,7 +28,8 @@ import { startLocalRelay, setActiveProject } from "./localRelay.js";
25
28
  const __filename = fileURLToPath(import.meta.url);
26
29
  const __dirname = dirname(__filename);
27
30
  import { ensureSharedModules, getSharedDir } from "./sharedVendor.js";
28
- import { detectModels } from "./detect.js";
31
+ import { scanProviders, providerFingerprint } from "./detect.js";
32
+ import { createForcedRescan } from "./modelRescan.js";
29
33
  import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
30
34
  // Melaya Browser (plan Sections 3/4/7): the governed browser control
31
35
  // bridge + its runner-side grant verifier and engine discovery.
@@ -43,6 +47,33 @@ import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, a
43
47
  // Obsidian app bridge: App ops through the user's running Obsidian (CLI /
44
48
  // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
45
49
  import { ObsidianAppBridge } from "./obsidianApp.js";
50
+ // Desktop app host channel: state changes for the app's tray and web UI (no-op under npx).
51
+ import { hostStatus, isHosted, hostProgress, sendToHost } from "./hostChannel.js";
52
+ // Desktop app: files this runner wrote are offered to computer control as handles (no-op under npx).
53
+ import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
54
+ // Computer control: computer:command / computer:stop relayed to the desktop app
55
+ // executor (or the CLI computer host); capabilities reported on connect.
56
+ import { attachComputerRelay } from "./computerRelay.js";
57
+ // Model rediscovery while connected (audit P2.3): every 15 min, and skipped
58
+ // when the provider fingerprint (credential/cache file mtimes + local server
59
+ // ports) is unchanged since the last scan, unless that scan is older than
60
+ // MODEL_REFRESH_MAX_SKIP_MS (LM Studio / Ollama model lists can change with
61
+ // the port still open).
62
+ const MODEL_REFRESH_MS = 15 * 60_000;
63
+ const MODEL_REFRESH_MAX_SKIP_MS = 60 * 60_000;
64
+ // Background setup (audit P1.5): module download, Python dependencies, Claude
65
+ // version probe, Python import warm-up. Starts only once the runner has been
66
+ // online and idle (no runs, no Assistant boot) for BG_SETUP_IDLE_MS, at below-
67
+ // normal priority. A request that needs it first does the work itself (same
68
+ // singleflight), and is told when it waits on setup already in flight.
69
+ const BG_SETUP_IDLE_MS = 15_000;
70
+ const BG_SETUP_CHECK_MS = 5_000;
71
+ // Shutdown budget (audit P0.5): every subsystem gets its own deadline and they
72
+ // run in parallel; the slowest bound (SHUTDOWN_STEP_MS) plus the final sweep
73
+ // stays inside the desktop app's 5 s hard exit (cli.ts stopHosted).
74
+ const SHUTDOWN_STEP_MS = 3_000;
75
+ const SHUTDOWN_CHILD_GRACE_MS = 2_000;
76
+ const SHUTDOWN_SWEEP_MS = 800;
46
77
  // Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
47
78
  // compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
48
79
  const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
@@ -117,6 +148,58 @@ const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
117
148
  const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
118
149
  export async function connect(opts) {
119
150
  const spinner = ora("Connecting to Melaya...").start();
151
+ const print = (line) => {
152
+ if (spinner.isSpinning) {
153
+ spinner.clear();
154
+ console.log(line);
155
+ spinner.render();
156
+ }
157
+ else
158
+ console.log(line);
159
+ };
160
+ // ── Python, resolved after the socket connects (audit P1.2) ─────────────
161
+ // Requests that need Python await it here; most of the time it is already
162
+ // resolved by the time the first request arrives.
163
+ let resolvedPython = typeof opts.pythonPath === "string" ? opts.pythonPath : null;
164
+ const pythonPromise = Promise.resolve(opts.pythonPath).then((p) => { resolvedPython = p; return p; });
165
+ const getPython = async () => {
166
+ if (resolvedPython)
167
+ return resolvedPython;
168
+ if (opts.verbose)
169
+ print(chalk.gray(" [python] waiting for the interpreter to be resolved"));
170
+ const p = await pythonPromise;
171
+ if (!p)
172
+ throw new Error("No supported Python (3.10-3.12) is available on this computer yet. Install Python 3.12, then restart the runner.");
173
+ return p;
174
+ };
175
+ // ── Background setup state (audit P1.5) ──────────────────────────────────
176
+ let bgSetup = null;
177
+ let bgSetupStep = "";
178
+ let foregroundDemand = false;
179
+ let lastActivityAt = Date.now();
180
+ let bgReadyResolve = () => { };
181
+ const bgReady = new Promise((r) => { bgReadyResolve = r; });
182
+ /** A request needs Python / the runtime now: setup work stops being background. */
183
+ const demandRuntime = (what) => {
184
+ lastActivityAt = Date.now();
185
+ if (!foregroundDemand) {
186
+ foregroundDemand = true;
187
+ bgReadyResolve();
188
+ // Assistant hosts read MEL_CLAUDE_CODE_VERSION; resolve it now if the
189
+ // background setup has not (defined below; only called from handlers).
190
+ void _detectClaudeCodeVersion();
191
+ }
192
+ setSetupBackground(false);
193
+ if (bgSetup && bgSetupStep) {
194
+ print(chalk.hex("#7C6FF0")(` ⏳ ${what} waits for the one-time setup to finish (${bgSetupStep})…`));
195
+ hostProgress("running", bgSetupStep, true);
196
+ }
197
+ };
198
+ /** The interpreter for a request the user is waiting on. */
199
+ const foregroundPython = async (what) => {
200
+ demandRuntime(what);
201
+ return getPython();
202
+ };
120
203
  // Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
121
204
  // The `path` must match the server's Socket.IO HTTP path (/api/v1/events).
122
205
  // The namespace is specified in the URL suffix, not the path option.
@@ -129,6 +212,10 @@ export async function connect(opts) {
129
212
  reconnectionDelay: 2000,
130
213
  reconnectionAttempts: Infinity,
131
214
  });
215
+ const computerRelay = attachComputerRelay(socket, { verbose: opts.verbose, deps: { log: (l) => console.log(chalk.gray(l)) } });
216
+ // Browser engine discovery (cached promise, loads Playwright) overlaps the
217
+ // handshake instead of delaying runner:hello after it.
218
+ void discoverEngines().catch(() => { });
132
219
  let relay = null;
133
220
  // ── Disconnect diagnostics (transport-close root-cause) ──────────────
134
221
  // A "transport close" disconnect can be a NETWORK blip OR the Node event loop
@@ -185,7 +272,7 @@ export async function connect(opts) {
185
272
  }
186
273
  if (!localVersion)
187
274
  throw new Error("runner python runtime still setting up");
188
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => vaultLog(`venv: ${m}`));
275
+ const env = await ensurePythonEnv(await getPython(), localVersion, (m) => vaultLog(`venv: ${m}`));
189
276
  if (!env.ok)
190
277
  throw new Error(`venv bootstrap failed: ${env.reason}`);
191
278
  const script = findVaultHostScript(__dirname);
@@ -264,13 +351,17 @@ export async function connect(opts) {
264
351
  // it as MEL_CLAUDE_CODE_VERSION, which every assistant host inherits. Without
265
352
  // it each host ran a blocking `claude --version` (slow on Windows, through a
266
353
  // .cmd shim) on its first model call. Fixed argv, no user input.
354
+ let _claudeVersionStarted = false;
267
355
  const _detectClaudeCodeVersion = async () => {
268
- if (process.env.MEL_CLAUDE_CODE_VERSION)
356
+ if (process.env.MEL_CLAUDE_CODE_VERSION || _claudeVersionStarted)
269
357
  return;
358
+ _claudeVersionStarted = true;
270
359
  const out = await new Promise((resolve) => {
271
360
  let buf = "";
272
361
  try {
273
362
  const p = spawn("claude", ["--version"], { shell: process.platform === "win32", stdio: ["ignore", "pipe", "ignore"] });
363
+ if (!foregroundDemand)
364
+ lowerPriority(p);
274
365
  const timer = setTimeout(() => { try {
275
366
  p.kill();
276
367
  }
@@ -291,51 +382,59 @@ export async function connect(opts) {
291
382
  if (_assistantPrewarmed)
292
383
  return;
293
384
  _assistantPrewarmed = true;
385
+ const step = (s) => { bgSetupStep = s; hostProgress("running", s); };
386
+ let outcome = "done";
294
387
  void _detectClaudeCodeVersion();
295
388
  try {
296
389
  const { getLocalSharedVersion } = await import("./sharedVendor.js");
297
390
  let localVersion = getLocalSharedVersion();
298
391
  const freshSetup = !localVersion;
299
392
  if (!localVersion) {
300
- // FIRST LAUNCH: no shared bundle on disk yet. Download it now, on
301
- // connect, so the venv is built UP-FRONT instead of making the user wait
302
- // 1-2 min mid-chat on their first assistant/pipeline run. Shown to the
303
- // user (not verbose-gated) — a silent multi-minute pause reads as "stuck"
304
- // and users leave; a clear one-time-setup line keeps their trust.
305
- console.log(chalk.hex("#7C6FF0")("\n ⚙ First-time setup: preparing your local AI runtime (one-time)…"));
306
- console.log(chalk.gray(" downloading runtime modules…"));
393
+ // FIRST LAUNCH: no shared bundle on disk yet. Download it now (the
394
+ // runner has been idle since it came online) so the venv is built
395
+ // before the first assistant/pipeline run instead of making the user
396
+ // wait 1-2 min mid-chat. Shown to the user (not verbose-gated): a
397
+ // silent multi-minute pause reads as "stuck" and users leave.
398
+ print(chalk.hex("#7C6FF0")("\n ⚙ First-time setup: preparing your local AI runtime (one-time, in the background)…"));
399
+ print(chalk.gray(" downloading runtime modules…"));
400
+ step("downloading runtime modules");
307
401
  try {
308
402
  await ensureSharedModules(opts.serverUrl, "latest", opts.token);
309
403
  localVersion = getLocalSharedVersion();
310
404
  }
311
405
  catch (e) {
312
- console.log(chalk.gray(` setup deferred (${e?.message || e}) — it will finish on your first run`));
406
+ print(chalk.gray(` setup deferred (${e?.message || e}) — it will finish on your first run`));
407
+ outcome = "failed";
313
408
  return;
314
409
  }
315
- if (!localVersion)
410
+ if (!localVersion) {
411
+ outcome = "failed";
316
412
  return;
413
+ }
317
414
  }
318
415
  const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
319
416
  if (freshSetup)
320
- console.log(chalk.gray(" installing Python dependencies (~1-2 min)…"));
321
- const envResult = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
322
- console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
417
+ print(chalk.gray(" installing Python dependencies (~1-2 min)…"));
418
+ step(freshSetup ? "installing Python dependencies" : "checking Python dependencies");
419
+ const envResult = await ensurePythonEnv(await getPython(), localVersion, (m) => { if (opts.verbose)
420
+ print(chalk.gray(` [assistant prewarm] ${m}`)); });
421
+ if (!envResult.ok)
422
+ outcome = "failed";
323
423
  if (freshSetup) {
324
- console.log(envResult.ok
424
+ print(envResult.ok
325
425
  ? chalk.green(" ✓ Local AI runtime ready — your first chat will start instantly\n")
326
426
  : chalk.yellow(` ⚠ runtime setup did not finish (${envResult.reason || "unknown"}); it will retry on your first run\n`));
327
427
  }
328
428
  else if (opts.verbose) {
329
- console.log(chalk.gray(" [assistant prewarm] ready"));
429
+ print(chalk.gray(" [assistant prewarm] ready"));
330
430
  }
331
431
  // ALSO pre-import the heavy Python stack so the first real run/chat
332
- // doesn't pay the ~6s cold `import shared.runtime.registry`. Spawn it
333
- // detached + fire-and-forget with the SAME PYTHONPATH/env a real
334
- // pipeline spawn uses (see the runner:run handler below) so `shared.*`
335
- // resolves; this heats the OS file cache + bytecode. Never blocks
336
- // connect, never throws — swallow everything. Only when a shared
337
- // bundle exists locally (mirror the ensurePythonEnv guard above).
338
- if (envResult.ok) {
432
+ // doesn't pay the ~6s cold `import shared.runtime.registry`. Spawned
433
+ // fire-and-forget at below-normal priority with the SAME PYTHONPATH/env a
434
+ // real pipeline spawn uses (see the runner:run handler below) so
435
+ // `shared.*` resolves; this heats the OS file cache + bytecode. Skipped
436
+ // once a request has already started using the runtime.
437
+ if (envResult.ok && !foregroundDemand) {
339
438
  try {
340
439
  const sharedDir = getSharedDir();
341
440
  if (existsSync(sharedDir)) {
@@ -348,10 +447,11 @@ export async function connect(opts) {
348
447
  ...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
349
448
  };
350
449
  const warmChild = spawn(envResult.pythonPath, ["-c", "import shared.runtime.registry"], { env: warmEnv, stdio: "ignore" });
450
+ lowerPriority(warmChild);
351
451
  warmChild.on("error", () => { });
352
452
  warmChild.on("exit", (code) => {
353
453
  if (code === 0 && opts.verbose) {
354
- console.log(chalk.gray(" [assistant prewarm] import-warmed"));
454
+ print(chalk.gray(" [assistant prewarm] import-warmed"));
355
455
  }
356
456
  });
357
457
  warmChild.unref();
@@ -361,36 +461,113 @@ export async function connect(opts) {
361
461
  }
362
462
  }
363
463
  catch (e) {
464
+ outcome = "failed";
364
465
  if (opts.verbose)
365
- console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
466
+ print(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
467
+ }
468
+ finally {
469
+ bgSetupStep = "";
470
+ hostProgress(outcome);
366
471
  }
367
472
  };
368
- // Models were detected ONCE at startup, so a model a CLI added later (e.g.
369
- // gpt-6-astra landing in ~/.codex/models_cache.json after the runner started,
370
- // 2026-10-03) never reached the picker until a runner restart. Re-detect every
371
- // 5 minutes and push the list when it changed. File reads only (plus the
372
- // Claude model list call), no subprocess.
473
+ // Start the background setup once the runner is online and has been idle
474
+ // (no runs, no Assistant host booting or live) for BG_SETUP_IDLE_MS. When a
475
+ // request needs the runtime first, it does the work itself and this never
476
+ // runs (the request path already ensured the same things).
477
+ const _runtimeIdle = () => activeProcesses.size === 0 && activeAssistants.size === 0 && bootingAssistants.size === 0;
478
+ let bgTimer = null;
479
+ const scheduleBackgroundSetup = () => {
480
+ if (bgSetup || bgTimer || foregroundDemand)
481
+ return;
482
+ lastActivityAt = Date.now();
483
+ bgTimer = setInterval(() => {
484
+ if (bgSetup || foregroundDemand) {
485
+ if (bgTimer)
486
+ clearInterval(bgTimer);
487
+ bgTimer = null;
488
+ return;
489
+ }
490
+ if (!_runtimeIdle()) {
491
+ lastActivityAt = Date.now();
492
+ return;
493
+ }
494
+ if (Date.now() - lastActivityAt < BG_SETUP_IDLE_MS)
495
+ return;
496
+ if (bgTimer)
497
+ clearInterval(bgTimer);
498
+ bgTimer = null;
499
+ setSetupBackground(true);
500
+ bgSetup = _prewarmAssistantEnv().finally(() => {
501
+ setSetupBackground(false);
502
+ bgReadyResolve();
503
+ });
504
+ }, BG_SETUP_CHECK_MS);
505
+ bgTimer.unref?.();
506
+ };
507
+ // ── Model list: initial (cached) list on connect, fresh discovery later ──
508
+ // cli.ts connects with the last good list from disk and publishes the fresh
509
+ // scan through publishModels(). While connected, rescan every 15 min, and
510
+ // only when the provider fingerprint changed (credential/cache file mtimes,
511
+ // local server ports) or the last scan is over an hour old. Authenticated
512
+ // network calls inside a scan are cached per credential (detect.ts).
513
+ const publishModels = (models) => {
514
+ if (JSON.stringify(models) === JSON.stringify(opts.models))
515
+ return;
516
+ opts.models = models;
517
+ if (socket.connected)
518
+ socket.emit("runner:models", models);
519
+ if (opts.verbose)
520
+ print(chalk.gray(` [models] published: ${models.length} model(s)`));
521
+ };
373
522
  let modelRefresh = null;
523
+ let lastFingerprint = "";
524
+ let lastScanAt = Date.now();
525
+ void providerFingerprint().then((f) => { lastFingerprint = f; }).catch(() => { });
374
526
  const startModelRefresh = () => {
375
527
  if (modelRefresh)
376
528
  return;
377
529
  modelRefresh = setInterval(async () => {
378
530
  try {
379
- const fresh = await detectModels();
380
- if (JSON.stringify(fresh) === JSON.stringify(opts.models))
531
+ const fp = await providerFingerprint();
532
+ if (fp === lastFingerprint && Date.now() - lastScanAt < MODEL_REFRESH_MAX_SKIP_MS) {
533
+ if (opts.verbose)
534
+ print(chalk.gray(" [models] unchanged, rescan skipped"));
381
535
  return;
382
- opts.models = fresh;
383
- if (socket.connected)
384
- socket.emit("runner:models", fresh);
385
- if (opts.verbose)
386
- console.log(chalk.gray(` [models] refreshed: ${fresh.length} model(s)`));
536
+ }
537
+ const scan = await scanProviders();
538
+ lastFingerprint = fp;
539
+ lastScanAt = Date.now();
540
+ publishModels(scan.models);
387
541
  }
388
542
  catch { /* keep the last good list */ }
389
- }, 5 * 60_000);
543
+ }, MODEL_REFRESH_MS);
390
544
  modelRefresh.unref?.();
391
545
  };
546
+ // Forced rescan on request (desktop app after an engine launch / sign-in).
547
+ // Same publish path as the periodic rescan; also resets its change detection.
548
+ const forcedRescan = createForcedRescan({
549
+ scan: async () => {
550
+ const fp = await providerFingerprint().catch(() => "");
551
+ const scan = await scanProviders({ force: true });
552
+ if (fp)
553
+ lastFingerprint = fp;
554
+ lastScanAt = Date.now();
555
+ return scan.models;
556
+ },
557
+ publish: (models) => {
558
+ if (opts.verbose)
559
+ print(chalk.gray(` [models] forced rescan: ${models.length} model(s)`));
560
+ publishModels(models);
561
+ },
562
+ });
563
+ const rescanModels = async () => {
564
+ await forcedRescan.request();
565
+ return opts.models;
566
+ };
392
567
  socket.on("connect", async () => {
393
568
  spinner.succeed(chalk.green("Connected to Melaya"));
569
+ markStage("socket_connected");
570
+ hostStatus("online");
394
571
  connectedAt = Date.now();
395
572
  peakLagMs = 0; // fresh window each connection so the disconnect line reflects THIS session
396
573
  // New socket lifetime → new generation. Grants minted for an earlier
@@ -451,16 +628,17 @@ export async function connect(opts) {
451
628
  console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
452
629
  }
453
630
  }
631
+ markStage("online");
454
632
  // Start the Luma browser bridge if a saved session exists. Best-
455
633
  // effort — failure here means Luma POSTs fall back to aiohttp
456
634
  // (which 403s under the bot rule) but reads still work.
457
635
  await _ensureLumaBridge();
458
- // Pre-warm the assistant Python env in the BACKGROUND so the first chat
459
- // message doesn't pay the venv check + one-time NLTK/cert self-heal inline
460
- // (the biggest chunk of "◆ Assistant session … takes forever to spawn").
461
- // We warm with the version already on disk so it hits the cache instead of
462
- // reinstalling; skipped on a fresh runner (no bundle yet) to avoid churn.
463
- void _prewarmAssistantEnv();
636
+ // Pre-warm the runtime (modules, Python deps, Claude version, Python
637
+ // imports) so the first chat message doesn't pay it inline, but only once
638
+ // the runner has been idle for a while and at below-normal priority, so it
639
+ // does not compete with the first screen (audit P1.5). A request that
640
+ // arrives first does the work itself.
641
+ scheduleBackgroundSetup();
464
642
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
465
643
  });
466
644
  // A runner that never connects is invisible to the funnel: last_seen stays
@@ -475,9 +653,18 @@ export async function connect(opts) {
475
653
  void import("./installReport.js").then((m) => m.reportInstallFailure(authRejected ? "socket_auth_rejected" : "socket_unreachable", { token: opts.token, server: opts.serverUrl })).catch(() => { });
476
654
  }
477
655
  if (authRejected) {
656
+ hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
478
657
  console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
479
658
  setTimeout(() => process.exit(1), 300); // let the beacon leave the box
480
659
  }
660
+ else if (isHosted() && /invalid|expired|revoked|token required/i.test(err.message)) {
661
+ // Desktop app: the handshake's token errors are final too; the app asks for a new token.
662
+ hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
663
+ setTimeout(() => process.exit(1), 300);
664
+ }
665
+ else {
666
+ hostStatus(connectedAt ? "reconnecting" : "starting", "Can't reach Melaya right now. Retrying.");
667
+ }
481
668
  });
482
669
  socket.on("disconnect", (reason) => {
483
670
  const upS = connectedAt ? Math.round((Date.now() - connectedAt) / 1000) : 0;
@@ -498,9 +685,11 @@ export async function connect(opts) {
498
685
  browserBridge.teardownAll(`socket_disconnect:${reason}`).catch(() => { });
499
686
  }
500
687
  if (reason === "io server disconnect") {
688
+ hostStatus("error", "Melaya closed the connection: this computer was disconnected from your account, or stopped answering.", "server_disconnect");
501
689
  console.log(chalk.red(" Server closed the connection. Check your token."));
502
690
  process.exit(1);
503
691
  }
692
+ hostStatus("reconnecting");
504
693
  });
505
694
  // ── Melaya Browser: grant public key delivery (plan Section 0.6) ──────
506
695
  // The server pushes its current EdDSA grant-verification public keys
@@ -744,7 +933,7 @@ export async function connect(opts) {
744
933
  // ModuleNotFoundError. The venv is created lazily on first run
745
934
  // and reused until sharedVersion changes.
746
935
  const { ensurePythonEnv } = await import("./pythonEnv.js");
747
- const envResult = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (msg) => {
936
+ const envResult = await ensurePythonEnv(await foregroundPython("This run"), payload.sharedVersion, (msg) => {
748
937
  console.log(chalk.gray(` [venv] ${msg}`));
749
938
  // Surface bootstrap progress to the FE so the launching
750
939
  // animation reflects the real state instead of staying on a
@@ -1121,6 +1310,7 @@ export async function connect(opts) {
1121
1310
  });
1122
1311
  }
1123
1312
  // Spawn Python subprocess (uses the bootstrapped venv python).
1313
+ const runStartedAt = Date.now();
1124
1314
  const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
1125
1315
  cwd: runDir,
1126
1316
  env,
@@ -1162,6 +1352,7 @@ export async function connect(opts) {
1162
1352
  });
1163
1353
  proc.on("exit", (code) => {
1164
1354
  activeProcesses.delete(payload.runId);
1355
+ reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
1165
1356
  // Melaya Browser terminal path (plan Section 7): the pipeline
1166
1357
  // process exited, so revoke the grant, cancel in-flight browser
1167
1358
  // ops, release leases, and close ONLY owned browsers. Idempotent.
@@ -1305,7 +1496,7 @@ export async function connect(opts) {
1305
1496
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1306
1497
  // Dedicated venv with agentscope + anthropic + openai installed.
1307
1498
  const { ensurePythonEnv } = await import("./pythonEnv.js");
1308
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (msg) => {
1499
+ const envResult = await ensurePythonEnv(await foregroundPython("This strategy"), sharedVersion, (msg) => {
1309
1500
  console.log(chalk.gray(` [venv] ${msg}`));
1310
1501
  socket.emit("runner:event", {
1311
1502
  run_id: runId,
@@ -1491,6 +1682,7 @@ export async function connect(opts) {
1491
1682
  // and ships it inline on the crew dispatch payload.
1492
1683
  ...(cfg.credentials ?? {}),
1493
1684
  };
1685
+ const runStartedAt = Date.now();
1494
1686
  const proc = spawn(envResult.pythonPath, ["-u", join(runDir, "main.py")], {
1495
1687
  cwd: runDir,
1496
1688
  env,
@@ -1518,6 +1710,7 @@ export async function connect(opts) {
1518
1710
  });
1519
1711
  proc.on("exit", (code) => {
1520
1712
  activeProcesses.delete(runId);
1713
+ reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
1521
1714
  const status = code === 0 ? "done" : "failed";
1522
1715
  if (status === "failed" && stderrTail.length > 0) {
1523
1716
  const tail = stderrTail.slice(-15).join("\n");
@@ -1621,6 +1814,9 @@ export async function connect(opts) {
1621
1814
  const r = readTurnFile(st, parsed.path);
1622
1815
  if (r.ok)
1623
1816
  socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
1817
+ // Accepted (inside this turn's dir): also offer it to computer control in the desktop app.
1818
+ if (r.ok)
1819
+ reportHostFile(isAbsolute(String(parsed.path)) ? String(parsed.path) : join(st.dir, String(parsed.path)), "output");
1624
1820
  else if (opts.verbose)
1625
1821
  console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
1626
1822
  }
@@ -1924,12 +2120,17 @@ export async function connect(opts) {
1924
2120
  return;
1925
2121
  if (!payload?.userId || !payload?.configHash)
1926
2122
  return;
2123
+ // Booting a spare host is heavy: wait until the background setup ran (idle)
2124
+ // or a request already brought the runtime up (audit P1.5).
2125
+ await bgReady;
2126
+ if (warmSpare || _prewarmInFlight)
2127
+ return;
1927
2128
  try {
1928
2129
  const sharedVersion = String(payload.sharedVersion ?? "latest");
1929
2130
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1930
2131
  _dropSpareOnOlderBundle();
1931
2132
  const { ensurePythonEnv } = await import("./pythonEnv.js");
1932
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
2133
+ const envResult = await ensurePythonEnv(await getPython(), sharedVersion, (m) => { if (opts.verbose)
1933
2134
  console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
1934
2135
  if (!envResult.ok)
1935
2136
  return;
@@ -2033,7 +2234,7 @@ export async function connect(opts) {
2033
2234
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
2034
2235
  _dropSpareOnOlderBundle();
2035
2236
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2036
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
2237
+ const envResult = await ensurePythonEnv(await foregroundPython("This chat"), sharedVersion, (m) => { if (opts.verbose)
2037
2238
  console.log(chalk.gray(` [assistant venv] ${m}`)); });
2038
2239
  if (!envResult.ok) {
2039
2240
  emitEv({ kind: "error", message: `python env failed: ${envResult.reason}` });
@@ -2479,7 +2680,7 @@ export async function connect(opts) {
2479
2680
  // Reuse the same venv as runner:run — agentscope.rag + ollama +
2480
2681
  // qdrant-client land here.
2481
2682
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2482
- const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
2683
+ const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
2483
2684
  if (!env.ok) {
2484
2685
  emitProgress("error", { error: `venv bootstrap failed: ${env.reason}` });
2485
2686
  socket.emit("rag:ingest-result", {
@@ -2685,7 +2886,7 @@ export async function connect(opts) {
2685
2886
  return;
2686
2887
  }
2687
2888
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2688
- const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion);
2889
+ const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion);
2689
2890
  if (!env.ok) {
2690
2891
  socket.emit("rag:retrieve-result", {
2691
2892
  session_id: sid,
@@ -2802,7 +3003,7 @@ export async function connect(opts) {
2802
3003
  reply(false, undefined, "runner is still setting up its Python runtime — try again in a moment");
2803
3004
  return;
2804
3005
  }
2805
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
3006
+ const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
2806
3007
  console.log(chalk.gray(` [db-test venv] ${m}`)); });
2807
3008
  if (!env.ok) {
2808
3009
  reply(false, undefined, `venv bootstrap failed: ${env.reason}`);
@@ -2923,7 +3124,7 @@ export async function connect(opts) {
2923
3124
  reply({ error: "runner python runtime still setting up" });
2924
3125
  return;
2925
3126
  }
2926
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
3127
+ const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
2927
3128
  console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
2928
3129
  if (!env.ok) {
2929
3130
  reply({ error: `venv bootstrap failed: ${env.reason}` });
@@ -3014,6 +3215,9 @@ export async function connect(opts) {
3014
3215
  const outcome = await runLinkedInLoginFlow(emitProgress);
3015
3216
  console.log(describeOutcome(outcome));
3016
3217
  socket.emit("linkedin:login-result", { session_id: sid, ...outcome });
3218
+ // Desktop app: no Chrome / Edge to sign in with is something only the user can fix.
3219
+ if (outcome.error === "browser_not_installed")
3220
+ sendToHost({ type: "notice", code: "browser_not_installed" });
3017
3221
  }
3018
3222
  catch (e) {
3019
3223
  const msg = e?.message || "linkedin_login_unhandled_error";
@@ -3042,6 +3246,8 @@ export async function connect(opts) {
3042
3246
  const outcome = await runLumaLoginFlow(emitProgress);
3043
3247
  console.log(describeOutcome(outcome));
3044
3248
  socket.emit("luma:login-result", { session_id: sid, ...outcome });
3249
+ if (outcome.error === "browser_not_installed")
3250
+ sendToHost({ type: "notice", code: "browser_not_installed" });
3045
3251
  // Successful sign-in just wrote a fresh storage-state.json. If the
3046
3252
  // bridge is already up, force the next forward to re-read state
3047
3253
  // from disk; if it isn't up (first sign-in on this runner), boot
@@ -3092,7 +3298,8 @@ export async function connect(opts) {
3092
3298
  socket.on("runner:kill", (data) => {
3093
3299
  const proc = activeProcesses.get(data.runId);
3094
3300
  if (proc) {
3095
- proc.kill("SIGTERM");
3301
+ // The whole tree: a run's own children (CLI providers, browsers) too.
3302
+ void terminateTree(proc, { graceMs: SHUTDOWN_CHILD_GRACE_MS });
3096
3303
  activeProcesses.delete(data.runId);
3097
3304
  console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
3098
3305
  }
@@ -3141,38 +3348,71 @@ export async function connect(opts) {
3141
3348
  // script that fetches `localhost:1234`/`11434` and POSTs each SSE
3142
3349
  // line back as an HTTP callback to the cloud server. Same pattern
3143
3350
  // as the BacktestOptimizer dispatch.
3144
- // ── Graceful shutdown ──────────────────────────────────────────────
3145
- const cleanup = () => {
3146
- console.log(chalk.gray("\n Shutting down..."));
3147
- for (const [id, proc] of activeProcesses) {
3148
- proc.kill("SIGTERM");
3351
+ // ── Graceful shutdown (audit P0.5) ─────────────────────────────────
3352
+ // Every subsystem is awaited under its own deadline, in parallel: runs and
3353
+ // Assistant hosts (whole process trees), browsers, the Luma bridge, the
3354
+ // vault host, the computer relay, the local relay. A final sweep stops any
3355
+ // tracked child still alive (venv builds, warm-ups), then the socket closes.
3356
+ // Bounded well inside the desktop app's 5 s hard exit. A second Ctrl+C (or
3357
+ // SIGTERM) during shutdown forces the exit at once; the process "exit" hook
3358
+ // in processTree.ts still kills every tracked tree on the way out.
3359
+ let shuttingDown = false;
3360
+ const shutdown = async () => {
3361
+ const t0 = Date.now();
3362
+ if (modelRefresh) {
3363
+ clearInterval(modelRefresh);
3364
+ modelRefresh = null;
3365
+ }
3366
+ forcedRescan.dispose();
3367
+ if (bgTimer) {
3368
+ clearInterval(bgTimer);
3369
+ bgTimer = null;
3370
+ }
3371
+ const runs = [...activeProcesses.entries()];
3372
+ for (const [id] of runs)
3149
3373
  socket.emit("runner:runComplete", { runId: id, status: "failed" });
3150
- }
3151
- for (const [, s] of activeAssistants) {
3152
- try {
3153
- s.proc.kill("SIGTERM");
3154
- }
3155
- catch { /* noop */ }
3156
- }
3374
+ activeProcesses.clear();
3375
+ const hosts = [...activeAssistants.values()].map((s) => s.proc);
3376
+ if (warmSpare)
3377
+ hosts.push(warmSpare.proc);
3157
3378
  activeAssistants.clear();
3158
- // Melaya Browser terminal path (plan Section 7): shut the bridge down,
3159
- // which cancels in-flight ops, releases every lease, and closes ONLY
3160
- // owned browsers (attached user browsers are merely disconnected).
3161
- if (browserBridge) {
3162
- browserBridge.shutdown().catch(() => { });
3163
- }
3164
- relay?.close();
3165
- // Best-effort Chromium teardown. The bridge's shutdown is async but
3166
- // we can't await inside a SIGINT handler — fire and forget; the
3167
- // process.exit below kills any lingering child processes anyway.
3168
- if (lumaBridge) {
3169
- lumaBridge.shutdown().catch(() => { });
3170
- }
3379
+ warmSpare = null;
3380
+ const results = await runShutdownSteps([
3381
+ { name: "runs", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(runs.map(([, p]) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
3382
+ { name: "assistant_hosts", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(hosts.map((p) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
3383
+ // Melaya Browser terminal path (plan Section 7): cancels in-flight ops,
3384
+ // releases every lease, closes ONLY owned browsers (attached user
3385
+ // browsers are merely disconnected).
3386
+ { name: "browsers", deadlineMs: SHUTDOWN_STEP_MS, run: () => browserBridge?.shutdown() },
3387
+ { name: "luma_browser", deadlineMs: SHUTDOWN_STEP_MS, run: () => lumaBridge?.shutdown() },
3388
+ { name: "vaults", deadlineMs: SHUTDOWN_STEP_MS, run: () => vaultService.stop() },
3389
+ { name: "computer", deadlineMs: SHUTDOWN_STEP_MS, run: () => computerRelay.dispose() },
3390
+ { name: "relay", deadlineMs: SHUTDOWN_STEP_MS, run: () => relay?.close() },
3391
+ ]);
3392
+ // Anything still tracked (venv build, warm-up, vault host, helper): stop the trees now.
3393
+ const swept = await runShutdownSteps([
3394
+ { name: "children", deadlineMs: SHUTDOWN_SWEEP_MS + 500, run: () => terminateAllTracked({ graceMs: SHUTDOWN_SWEEP_MS }) },
3395
+ ]);
3396
+ const line = `shutdown ${Date.now() - t0}ms: ${formatShutdownResults([...results, ...swept])}`;
3397
+ if (opts.verbose)
3398
+ console.log(chalk.gray(` ${line}`));
3399
+ sendToHost({ type: "log", level: "info", line });
3171
3400
  socket.disconnect();
3172
- process.exit(0);
3173
3401
  };
3402
+ const cleanup = () => {
3403
+ if (shuttingDown) {
3404
+ // Second signal: stop waiting.
3405
+ killAllTrackedSync();
3406
+ process.exit(0);
3407
+ }
3408
+ shuttingDown = true;
3409
+ console.log(chalk.gray("\n Shutting down..."));
3410
+ void shutdown().catch(() => { }).finally(() => process.exit(0));
3411
+ };
3412
+ claimGracefulShutdown();
3174
3413
  process.on("SIGINT", cleanup);
3175
3414
  process.on("SIGTERM", cleanup);
3415
+ return { publishModels, rescanModels, print };
3176
3416
  }
3177
3417
  export function verifySignature(content, signature, runnerToken) {
3178
3418
  if (!content || !signature || !runnerToken)