@melaya/runner 1.1.67 → 1.1.69

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,7 @@ 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";
29
32
  import { startLumaBrowserBridge } from "./lumaBrowserBridge.js";
30
33
  // Melaya Browser (plan Sections 3/4/7): the governed browser control
31
34
  // bridge + its runner-side grant verifier and engine discovery.
@@ -43,6 +46,33 @@ import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, a
43
46
  // Obsidian app bridge: App ops through the user's running Obsidian (CLI /
44
47
  // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
45
48
  import { ObsidianAppBridge } from "./obsidianApp.js";
49
+ // Desktop app host channel: state changes for the app's tray and web UI (no-op under npx).
50
+ import { hostStatus, isHosted, hostProgress, sendToHost } from "./hostChannel.js";
51
+ // Desktop app: files this runner wrote are offered to computer control as handles (no-op under npx).
52
+ import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
53
+ // Computer control: computer:command / computer:stop relayed to the desktop app
54
+ // executor (or the CLI computer host); capabilities reported on connect.
55
+ import { attachComputerRelay } from "./computerRelay.js";
56
+ // Model rediscovery while connected (audit P2.3): every 15 min, and skipped
57
+ // when the provider fingerprint (credential/cache file mtimes + local server
58
+ // ports) is unchanged since the last scan, unless that scan is older than
59
+ // MODEL_REFRESH_MAX_SKIP_MS (LM Studio / Ollama model lists can change with
60
+ // the port still open).
61
+ const MODEL_REFRESH_MS = 15 * 60_000;
62
+ const MODEL_REFRESH_MAX_SKIP_MS = 60 * 60_000;
63
+ // Background setup (audit P1.5): module download, Python dependencies, Claude
64
+ // version probe, Python import warm-up. Starts only once the runner has been
65
+ // online and idle (no runs, no Assistant boot) for BG_SETUP_IDLE_MS, at below-
66
+ // normal priority. A request that needs it first does the work itself (same
67
+ // singleflight), and is told when it waits on setup already in flight.
68
+ const BG_SETUP_IDLE_MS = 15_000;
69
+ const BG_SETUP_CHECK_MS = 5_000;
70
+ // Shutdown budget (audit P0.5): every subsystem gets its own deadline and they
71
+ // run in parallel; the slowest bound (SHUTDOWN_STEP_MS) plus the final sweep
72
+ // stays inside the desktop app's 5 s hard exit (cli.ts stopHosted).
73
+ const SHUTDOWN_STEP_MS = 3_000;
74
+ const SHUTDOWN_CHILD_GRACE_MS = 2_000;
75
+ const SHUTDOWN_SWEEP_MS = 800;
46
76
  // Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
47
77
  // compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
48
78
  const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
@@ -117,6 +147,58 @@ const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
117
147
  const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
118
148
  export async function connect(opts) {
119
149
  const spinner = ora("Connecting to Melaya...").start();
150
+ const print = (line) => {
151
+ if (spinner.isSpinning) {
152
+ spinner.clear();
153
+ console.log(line);
154
+ spinner.render();
155
+ }
156
+ else
157
+ console.log(line);
158
+ };
159
+ // ── Python, resolved after the socket connects (audit P1.2) ─────────────
160
+ // Requests that need Python await it here; most of the time it is already
161
+ // resolved by the time the first request arrives.
162
+ let resolvedPython = typeof opts.pythonPath === "string" ? opts.pythonPath : null;
163
+ const pythonPromise = Promise.resolve(opts.pythonPath).then((p) => { resolvedPython = p; return p; });
164
+ const getPython = async () => {
165
+ if (resolvedPython)
166
+ return resolvedPython;
167
+ if (opts.verbose)
168
+ print(chalk.gray(" [python] waiting for the interpreter to be resolved"));
169
+ const p = await pythonPromise;
170
+ if (!p)
171
+ throw new Error("No supported Python (3.10-3.12) is available on this computer yet. Install Python 3.12, then restart the runner.");
172
+ return p;
173
+ };
174
+ // ── Background setup state (audit P1.5) ──────────────────────────────────
175
+ let bgSetup = null;
176
+ let bgSetupStep = "";
177
+ let foregroundDemand = false;
178
+ let lastActivityAt = Date.now();
179
+ let bgReadyResolve = () => { };
180
+ const bgReady = new Promise((r) => { bgReadyResolve = r; });
181
+ /** A request needs Python / the runtime now: setup work stops being background. */
182
+ const demandRuntime = (what) => {
183
+ lastActivityAt = Date.now();
184
+ if (!foregroundDemand) {
185
+ foregroundDemand = true;
186
+ bgReadyResolve();
187
+ // Assistant hosts read MEL_CLAUDE_CODE_VERSION; resolve it now if the
188
+ // background setup has not (defined below; only called from handlers).
189
+ void _detectClaudeCodeVersion();
190
+ }
191
+ setSetupBackground(false);
192
+ if (bgSetup && bgSetupStep) {
193
+ print(chalk.hex("#7C6FF0")(` ⏳ ${what} waits for the one-time setup to finish (${bgSetupStep})…`));
194
+ hostProgress("running", bgSetupStep, true);
195
+ }
196
+ };
197
+ /** The interpreter for a request the user is waiting on. */
198
+ const foregroundPython = async (what) => {
199
+ demandRuntime(what);
200
+ return getPython();
201
+ };
120
202
  // Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
121
203
  // The `path` must match the server's Socket.IO HTTP path (/api/v1/events).
122
204
  // The namespace is specified in the URL suffix, not the path option.
@@ -129,6 +211,10 @@ export async function connect(opts) {
129
211
  reconnectionDelay: 2000,
130
212
  reconnectionAttempts: Infinity,
131
213
  });
214
+ const computerRelay = attachComputerRelay(socket, { verbose: opts.verbose, deps: { log: (l) => console.log(chalk.gray(l)) } });
215
+ // Browser engine discovery (cached promise, loads Playwright) overlaps the
216
+ // handshake instead of delaying runner:hello after it.
217
+ void discoverEngines().catch(() => { });
132
218
  let relay = null;
133
219
  // ── Disconnect diagnostics (transport-close root-cause) ──────────────
134
220
  // A "transport close" disconnect can be a NETWORK blip OR the Node event loop
@@ -185,7 +271,7 @@ export async function connect(opts) {
185
271
  }
186
272
  if (!localVersion)
187
273
  throw new Error("runner python runtime still setting up");
188
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => vaultLog(`venv: ${m}`));
274
+ const env = await ensurePythonEnv(await getPython(), localVersion, (m) => vaultLog(`venv: ${m}`));
189
275
  if (!env.ok)
190
276
  throw new Error(`venv bootstrap failed: ${env.reason}`);
191
277
  const script = findVaultHostScript(__dirname);
@@ -264,13 +350,17 @@ export async function connect(opts) {
264
350
  // it as MEL_CLAUDE_CODE_VERSION, which every assistant host inherits. Without
265
351
  // it each host ran a blocking `claude --version` (slow on Windows, through a
266
352
  // .cmd shim) on its first model call. Fixed argv, no user input.
353
+ let _claudeVersionStarted = false;
267
354
  const _detectClaudeCodeVersion = async () => {
268
- if (process.env.MEL_CLAUDE_CODE_VERSION)
355
+ if (process.env.MEL_CLAUDE_CODE_VERSION || _claudeVersionStarted)
269
356
  return;
357
+ _claudeVersionStarted = true;
270
358
  const out = await new Promise((resolve) => {
271
359
  let buf = "";
272
360
  try {
273
361
  const p = spawn("claude", ["--version"], { shell: process.platform === "win32", stdio: ["ignore", "pipe", "ignore"] });
362
+ if (!foregroundDemand)
363
+ lowerPriority(p);
274
364
  const timer = setTimeout(() => { try {
275
365
  p.kill();
276
366
  }
@@ -291,51 +381,59 @@ export async function connect(opts) {
291
381
  if (_assistantPrewarmed)
292
382
  return;
293
383
  _assistantPrewarmed = true;
384
+ const step = (s) => { bgSetupStep = s; hostProgress("running", s); };
385
+ let outcome = "done";
294
386
  void _detectClaudeCodeVersion();
295
387
  try {
296
388
  const { getLocalSharedVersion } = await import("./sharedVendor.js");
297
389
  let localVersion = getLocalSharedVersion();
298
390
  const freshSetup = !localVersion;
299
391
  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…"));
392
+ // FIRST LAUNCH: no shared bundle on disk yet. Download it now (the
393
+ // runner has been idle since it came online) so the venv is built
394
+ // before the first assistant/pipeline run instead of making the user
395
+ // wait 1-2 min mid-chat. Shown to the user (not verbose-gated): a
396
+ // silent multi-minute pause reads as "stuck" and users leave.
397
+ print(chalk.hex("#7C6FF0")("\n ⚙ First-time setup: preparing your local AI runtime (one-time, in the background)…"));
398
+ print(chalk.gray(" downloading runtime modules…"));
399
+ step("downloading runtime modules");
307
400
  try {
308
401
  await ensureSharedModules(opts.serverUrl, "latest", opts.token);
309
402
  localVersion = getLocalSharedVersion();
310
403
  }
311
404
  catch (e) {
312
- console.log(chalk.gray(` setup deferred (${e?.message || e}) — it will finish on your first run`));
405
+ print(chalk.gray(` setup deferred (${e?.message || e}) — it will finish on your first run`));
406
+ outcome = "failed";
313
407
  return;
314
408
  }
315
- if (!localVersion)
409
+ if (!localVersion) {
410
+ outcome = "failed";
316
411
  return;
412
+ }
317
413
  }
318
414
  const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
319
415
  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}`)); });
416
+ print(chalk.gray(" installing Python dependencies (~1-2 min)…"));
417
+ step(freshSetup ? "installing Python dependencies" : "checking Python dependencies");
418
+ const envResult = await ensurePythonEnv(await getPython(), localVersion, (m) => { if (opts.verbose)
419
+ print(chalk.gray(` [assistant prewarm] ${m}`)); });
420
+ if (!envResult.ok)
421
+ outcome = "failed";
323
422
  if (freshSetup) {
324
- console.log(envResult.ok
423
+ print(envResult.ok
325
424
  ? chalk.green(" ✓ Local AI runtime ready — your first chat will start instantly\n")
326
425
  : chalk.yellow(` ⚠ runtime setup did not finish (${envResult.reason || "unknown"}); it will retry on your first run\n`));
327
426
  }
328
427
  else if (opts.verbose) {
329
- console.log(chalk.gray(" [assistant prewarm] ready"));
428
+ print(chalk.gray(" [assistant prewarm] ready"));
330
429
  }
331
430
  // 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) {
431
+ // doesn't pay the ~6s cold `import shared.runtime.registry`. Spawned
432
+ // fire-and-forget at below-normal priority with the SAME PYTHONPATH/env a
433
+ // real pipeline spawn uses (see the runner:run handler below) so
434
+ // `shared.*` resolves; this heats the OS file cache + bytecode. Skipped
435
+ // once a request has already started using the runtime.
436
+ if (envResult.ok && !foregroundDemand) {
339
437
  try {
340
438
  const sharedDir = getSharedDir();
341
439
  if (existsSync(sharedDir)) {
@@ -348,10 +446,11 @@ export async function connect(opts) {
348
446
  ...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
349
447
  };
350
448
  const warmChild = spawn(envResult.pythonPath, ["-c", "import shared.runtime.registry"], { env: warmEnv, stdio: "ignore" });
449
+ lowerPriority(warmChild);
351
450
  warmChild.on("error", () => { });
352
451
  warmChild.on("exit", (code) => {
353
452
  if (code === 0 && opts.verbose) {
354
- console.log(chalk.gray(" [assistant prewarm] import-warmed"));
453
+ print(chalk.gray(" [assistant prewarm] import-warmed"));
355
454
  }
356
455
  });
357
456
  warmChild.unref();
@@ -361,36 +460,92 @@ export async function connect(opts) {
361
460
  }
362
461
  }
363
462
  catch (e) {
463
+ outcome = "failed";
364
464
  if (opts.verbose)
365
- console.log(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
465
+ print(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
466
+ }
467
+ finally {
468
+ bgSetupStep = "";
469
+ hostProgress(outcome);
366
470
  }
367
471
  };
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.
472
+ // Start the background setup once the runner is online and has been idle
473
+ // (no runs, no Assistant host booting or live) for BG_SETUP_IDLE_MS. When a
474
+ // request needs the runtime first, it does the work itself and this never
475
+ // runs (the request path already ensured the same things).
476
+ const _runtimeIdle = () => activeProcesses.size === 0 && activeAssistants.size === 0 && bootingAssistants.size === 0;
477
+ let bgTimer = null;
478
+ const scheduleBackgroundSetup = () => {
479
+ if (bgSetup || bgTimer || foregroundDemand)
480
+ return;
481
+ lastActivityAt = Date.now();
482
+ bgTimer = setInterval(() => {
483
+ if (bgSetup || foregroundDemand) {
484
+ if (bgTimer)
485
+ clearInterval(bgTimer);
486
+ bgTimer = null;
487
+ return;
488
+ }
489
+ if (!_runtimeIdle()) {
490
+ lastActivityAt = Date.now();
491
+ return;
492
+ }
493
+ if (Date.now() - lastActivityAt < BG_SETUP_IDLE_MS)
494
+ return;
495
+ if (bgTimer)
496
+ clearInterval(bgTimer);
497
+ bgTimer = null;
498
+ setSetupBackground(true);
499
+ bgSetup = _prewarmAssistantEnv().finally(() => {
500
+ setSetupBackground(false);
501
+ bgReadyResolve();
502
+ });
503
+ }, BG_SETUP_CHECK_MS);
504
+ bgTimer.unref?.();
505
+ };
506
+ // ── Model list: initial (cached) list on connect, fresh discovery later ──
507
+ // cli.ts connects with the last good list from disk and publishes the fresh
508
+ // scan through publishModels(). While connected, rescan every 15 min, and
509
+ // only when the provider fingerprint changed (credential/cache file mtimes,
510
+ // local server ports) or the last scan is over an hour old. Authenticated
511
+ // network calls inside a scan are cached per credential (detect.ts).
512
+ const publishModels = (models) => {
513
+ if (JSON.stringify(models) === JSON.stringify(opts.models))
514
+ return;
515
+ opts.models = models;
516
+ if (socket.connected)
517
+ socket.emit("runner:models", models);
518
+ if (opts.verbose)
519
+ print(chalk.gray(` [models] published: ${models.length} model(s)`));
520
+ };
373
521
  let modelRefresh = null;
522
+ let lastFingerprint = "";
523
+ let lastScanAt = Date.now();
524
+ void providerFingerprint().then((f) => { lastFingerprint = f; }).catch(() => { });
374
525
  const startModelRefresh = () => {
375
526
  if (modelRefresh)
376
527
  return;
377
528
  modelRefresh = setInterval(async () => {
378
529
  try {
379
- const fresh = await detectModels();
380
- if (JSON.stringify(fresh) === JSON.stringify(opts.models))
530
+ const fp = await providerFingerprint();
531
+ if (fp === lastFingerprint && Date.now() - lastScanAt < MODEL_REFRESH_MAX_SKIP_MS) {
532
+ if (opts.verbose)
533
+ print(chalk.gray(" [models] unchanged, rescan skipped"));
381
534
  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)`));
535
+ }
536
+ const scan = await scanProviders();
537
+ lastFingerprint = fp;
538
+ lastScanAt = Date.now();
539
+ publishModels(scan.models);
387
540
  }
388
541
  catch { /* keep the last good list */ }
389
- }, 5 * 60_000);
542
+ }, MODEL_REFRESH_MS);
390
543
  modelRefresh.unref?.();
391
544
  };
392
545
  socket.on("connect", async () => {
393
546
  spinner.succeed(chalk.green("Connected to Melaya"));
547
+ markStage("socket_connected");
548
+ hostStatus("online");
394
549
  connectedAt = Date.now();
395
550
  peakLagMs = 0; // fresh window each connection so the disconnect line reflects THIS session
396
551
  // New socket lifetime → new generation. Grants minted for an earlier
@@ -451,16 +606,17 @@ export async function connect(opts) {
451
606
  console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
452
607
  }
453
608
  }
609
+ markStage("online");
454
610
  // Start the Luma browser bridge if a saved session exists. Best-
455
611
  // effort — failure here means Luma POSTs fall back to aiohttp
456
612
  // (which 403s under the bot rule) but reads still work.
457
613
  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();
614
+ // Pre-warm the runtime (modules, Python deps, Claude version, Python
615
+ // imports) so the first chat message doesn't pay it inline, but only once
616
+ // the runner has been idle for a while and at below-normal priority, so it
617
+ // does not compete with the first screen (audit P1.5). A request that
618
+ // arrives first does the work itself.
619
+ scheduleBackgroundSetup();
464
620
  console.log(chalk.gray(" Waiting for pipeline runs...\n"));
465
621
  });
466
622
  // A runner that never connects is invisible to the funnel: last_seen stays
@@ -475,9 +631,18 @@ export async function connect(opts) {
475
631
  void import("./installReport.js").then((m) => m.reportInstallFailure(authRejected ? "socket_auth_rejected" : "socket_unreachable", { token: opts.token, server: opts.serverUrl })).catch(() => { });
476
632
  }
477
633
  if (authRejected) {
634
+ hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
478
635
  console.log(chalk.yellow(" Token may be expired or revoked. Generate a new one in Settings → Connectors."));
479
636
  setTimeout(() => process.exit(1), 300); // let the beacon leave the box
480
637
  }
638
+ else if (isHosted() && /invalid|expired|revoked|token required/i.test(err.message)) {
639
+ // Desktop app: the handshake's token errors are final too; the app asks for a new token.
640
+ hostStatus("error", "This computer's runner token was revoked or is invalid. Connect this computer again.", "token_rejected");
641
+ setTimeout(() => process.exit(1), 300);
642
+ }
643
+ else {
644
+ hostStatus(connectedAt ? "reconnecting" : "starting", "Can't reach Melaya right now. Retrying.");
645
+ }
481
646
  });
482
647
  socket.on("disconnect", (reason) => {
483
648
  const upS = connectedAt ? Math.round((Date.now() - connectedAt) / 1000) : 0;
@@ -498,9 +663,11 @@ export async function connect(opts) {
498
663
  browserBridge.teardownAll(`socket_disconnect:${reason}`).catch(() => { });
499
664
  }
500
665
  if (reason === "io server disconnect") {
666
+ hostStatus("error", "Melaya closed the connection: this computer was disconnected from your account, or stopped answering.", "server_disconnect");
501
667
  console.log(chalk.red(" Server closed the connection. Check your token."));
502
668
  process.exit(1);
503
669
  }
670
+ hostStatus("reconnecting");
504
671
  });
505
672
  // ── Melaya Browser: grant public key delivery (plan Section 0.6) ──────
506
673
  // The server pushes its current EdDSA grant-verification public keys
@@ -744,7 +911,7 @@ export async function connect(opts) {
744
911
  // ModuleNotFoundError. The venv is created lazily on first run
745
912
  // and reused until sharedVersion changes.
746
913
  const { ensurePythonEnv } = await import("./pythonEnv.js");
747
- const envResult = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (msg) => {
914
+ const envResult = await ensurePythonEnv(await foregroundPython("This run"), payload.sharedVersion, (msg) => {
748
915
  console.log(chalk.gray(` [venv] ${msg}`));
749
916
  // Surface bootstrap progress to the FE so the launching
750
917
  // animation reflects the real state instead of staying on a
@@ -1121,6 +1288,7 @@ export async function connect(opts) {
1121
1288
  });
1122
1289
  }
1123
1290
  // Spawn Python subprocess (uses the bootstrapped venv python).
1291
+ const runStartedAt = Date.now();
1124
1292
  const proc = spawn(pipelinePython, ["-u", join(runDir, "main.py")], {
1125
1293
  cwd: runDir,
1126
1294
  env,
@@ -1162,6 +1330,7 @@ export async function connect(opts) {
1162
1330
  });
1163
1331
  proc.on("exit", (code) => {
1164
1332
  activeProcesses.delete(payload.runId);
1333
+ reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
1165
1334
  // Melaya Browser terminal path (plan Section 7): the pipeline
1166
1335
  // process exited, so revoke the grant, cancel in-flight browser
1167
1336
  // ops, release leases, and close ONLY owned browsers. Idempotent.
@@ -1305,7 +1474,7 @@ export async function connect(opts) {
1305
1474
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1306
1475
  // Dedicated venv with agentscope + anthropic + openai installed.
1307
1476
  const { ensurePythonEnv } = await import("./pythonEnv.js");
1308
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (msg) => {
1477
+ const envResult = await ensurePythonEnv(await foregroundPython("This strategy"), sharedVersion, (msg) => {
1309
1478
  console.log(chalk.gray(` [venv] ${msg}`));
1310
1479
  socket.emit("runner:event", {
1311
1480
  run_id: runId,
@@ -1491,6 +1660,7 @@ export async function connect(opts) {
1491
1660
  // and ships it inline on the crew dispatch payload.
1492
1661
  ...(cfg.credentials ?? {}),
1493
1662
  };
1663
+ const runStartedAt = Date.now();
1494
1664
  const proc = spawn(envResult.pythonPath, ["-u", join(runDir, "main.py")], {
1495
1665
  cwd: runDir,
1496
1666
  env,
@@ -1518,6 +1688,7 @@ export async function connect(opts) {
1518
1688
  });
1519
1689
  proc.on("exit", (code) => {
1520
1690
  activeProcesses.delete(runId);
1691
+ reportOutputsSince(env.MELAYA_OUTPUT_DIR, runStartedAt);
1521
1692
  const status = code === 0 ? "done" : "failed";
1522
1693
  if (status === "failed" && stderrTail.length > 0) {
1523
1694
  const tail = stderrTail.slice(-15).join("\n");
@@ -1621,6 +1792,9 @@ export async function connect(opts) {
1621
1792
  const r = readTurnFile(st, parsed.path);
1622
1793
  if (r.ok)
1623
1794
  socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
1795
+ // Accepted (inside this turn's dir): also offer it to computer control in the desktop app.
1796
+ if (r.ok)
1797
+ reportHostFile(isAbsolute(String(parsed.path)) ? String(parsed.path) : join(st.dir, String(parsed.path)), "output");
1624
1798
  else if (opts.verbose)
1625
1799
  console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
1626
1800
  }
@@ -1924,12 +2098,17 @@ export async function connect(opts) {
1924
2098
  return;
1925
2099
  if (!payload?.userId || !payload?.configHash)
1926
2100
  return;
2101
+ // Booting a spare host is heavy: wait until the background setup ran (idle)
2102
+ // or a request already brought the runtime up (audit P1.5).
2103
+ await bgReady;
2104
+ if (warmSpare || _prewarmInFlight)
2105
+ return;
1927
2106
  try {
1928
2107
  const sharedVersion = String(payload.sharedVersion ?? "latest");
1929
2108
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
1930
2109
  _dropSpareOnOlderBundle();
1931
2110
  const { ensurePythonEnv } = await import("./pythonEnv.js");
1932
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
2111
+ const envResult = await ensurePythonEnv(await getPython(), sharedVersion, (m) => { if (opts.verbose)
1933
2112
  console.log(chalk.gray(` [assistant prewarm] ${m}`)); });
1934
2113
  if (!envResult.ok)
1935
2114
  return;
@@ -2033,7 +2212,7 @@ export async function connect(opts) {
2033
2212
  await ensureSharedModules(opts.serverUrl, sharedVersion, opts.token);
2034
2213
  _dropSpareOnOlderBundle();
2035
2214
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2036
- const envResult = await ensurePythonEnv(opts.pythonPath, sharedVersion, (m) => { if (opts.verbose)
2215
+ const envResult = await ensurePythonEnv(await foregroundPython("This chat"), sharedVersion, (m) => { if (opts.verbose)
2037
2216
  console.log(chalk.gray(` [assistant venv] ${m}`)); });
2038
2217
  if (!envResult.ok) {
2039
2218
  emitEv({ kind: "error", message: `python env failed: ${envResult.reason}` });
@@ -2479,7 +2658,7 @@ export async function connect(opts) {
2479
2658
  // Reuse the same venv as runner:run — agentscope.rag + ollama +
2480
2659
  // qdrant-client land here.
2481
2660
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2482
- const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
2661
+ const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion, (m) => emitProgress("venv", { message: m }));
2483
2662
  if (!env.ok) {
2484
2663
  emitProgress("error", { error: `venv bootstrap failed: ${env.reason}` });
2485
2664
  socket.emit("rag:ingest-result", {
@@ -2685,7 +2864,7 @@ export async function connect(opts) {
2685
2864
  return;
2686
2865
  }
2687
2866
  const { ensurePythonEnv } = await import("./pythonEnv.js");
2688
- const env = await ensurePythonEnv(opts.pythonPath, payload.sharedVersion);
2867
+ const env = await ensurePythonEnv(await foregroundPython("This request"), payload.sharedVersion);
2689
2868
  if (!env.ok) {
2690
2869
  socket.emit("rag:retrieve-result", {
2691
2870
  session_id: sid,
@@ -2802,7 +2981,7 @@ export async function connect(opts) {
2802
2981
  reply(false, undefined, "runner is still setting up its Python runtime — try again in a moment");
2803
2982
  return;
2804
2983
  }
2805
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
2984
+ const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
2806
2985
  console.log(chalk.gray(` [db-test venv] ${m}`)); });
2807
2986
  if (!env.ok) {
2808
2987
  reply(false, undefined, `venv bootstrap failed: ${env.reason}`);
@@ -2923,7 +3102,7 @@ export async function connect(opts) {
2923
3102
  reply({ error: "runner python runtime still setting up" });
2924
3103
  return;
2925
3104
  }
2926
- const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
3105
+ const env = await ensurePythonEnv(await foregroundPython("This request"), localVersion, (m) => { if (opts.verbose)
2927
3106
  console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
2928
3107
  if (!env.ok) {
2929
3108
  reply({ error: `venv bootstrap failed: ${env.reason}` });
@@ -3014,6 +3193,9 @@ export async function connect(opts) {
3014
3193
  const outcome = await runLinkedInLoginFlow(emitProgress);
3015
3194
  console.log(describeOutcome(outcome));
3016
3195
  socket.emit("linkedin:login-result", { session_id: sid, ...outcome });
3196
+ // Desktop app: no Chrome / Edge to sign in with is something only the user can fix.
3197
+ if (outcome.error === "browser_not_installed")
3198
+ sendToHost({ type: "notice", code: "browser_not_installed" });
3017
3199
  }
3018
3200
  catch (e) {
3019
3201
  const msg = e?.message || "linkedin_login_unhandled_error";
@@ -3042,6 +3224,8 @@ export async function connect(opts) {
3042
3224
  const outcome = await runLumaLoginFlow(emitProgress);
3043
3225
  console.log(describeOutcome(outcome));
3044
3226
  socket.emit("luma:login-result", { session_id: sid, ...outcome });
3227
+ if (outcome.error === "browser_not_installed")
3228
+ sendToHost({ type: "notice", code: "browser_not_installed" });
3045
3229
  // Successful sign-in just wrote a fresh storage-state.json. If the
3046
3230
  // bridge is already up, force the next forward to re-read state
3047
3231
  // from disk; if it isn't up (first sign-in on this runner), boot
@@ -3092,7 +3276,8 @@ export async function connect(opts) {
3092
3276
  socket.on("runner:kill", (data) => {
3093
3277
  const proc = activeProcesses.get(data.runId);
3094
3278
  if (proc) {
3095
- proc.kill("SIGTERM");
3279
+ // The whole tree: a run's own children (CLI providers, browsers) too.
3280
+ void terminateTree(proc, { graceMs: SHUTDOWN_CHILD_GRACE_MS });
3096
3281
  activeProcesses.delete(data.runId);
3097
3282
  console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
3098
3283
  }
@@ -3141,38 +3326,70 @@ export async function connect(opts) {
3141
3326
  // script that fetches `localhost:1234`/`11434` and POSTs each SSE
3142
3327
  // line back as an HTTP callback to the cloud server. Same pattern
3143
3328
  // 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");
3329
+ // ── Graceful shutdown (audit P0.5) ─────────────────────────────────
3330
+ // Every subsystem is awaited under its own deadline, in parallel: runs and
3331
+ // Assistant hosts (whole process trees), browsers, the Luma bridge, the
3332
+ // vault host, the computer relay, the local relay. A final sweep stops any
3333
+ // tracked child still alive (venv builds, warm-ups), then the socket closes.
3334
+ // Bounded well inside the desktop app's 5 s hard exit. A second Ctrl+C (or
3335
+ // SIGTERM) during shutdown forces the exit at once; the process "exit" hook
3336
+ // in processTree.ts still kills every tracked tree on the way out.
3337
+ let shuttingDown = false;
3338
+ const shutdown = async () => {
3339
+ const t0 = Date.now();
3340
+ if (modelRefresh) {
3341
+ clearInterval(modelRefresh);
3342
+ modelRefresh = null;
3343
+ }
3344
+ if (bgTimer) {
3345
+ clearInterval(bgTimer);
3346
+ bgTimer = null;
3347
+ }
3348
+ const runs = [...activeProcesses.entries()];
3349
+ for (const [id] of runs)
3149
3350
  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
- }
3351
+ activeProcesses.clear();
3352
+ const hosts = [...activeAssistants.values()].map((s) => s.proc);
3353
+ if (warmSpare)
3354
+ hosts.push(warmSpare.proc);
3157
3355
  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
- }
3356
+ warmSpare = null;
3357
+ const results = await runShutdownSteps([
3358
+ { name: "runs", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(runs.map(([, p]) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
3359
+ { name: "assistant_hosts", deadlineMs: SHUTDOWN_STEP_MS, run: () => Promise.all(hosts.map((p) => terminateTree(p, { graceMs: SHUTDOWN_CHILD_GRACE_MS }))) },
3360
+ // Melaya Browser terminal path (plan Section 7): cancels in-flight ops,
3361
+ // releases every lease, closes ONLY owned browsers (attached user
3362
+ // browsers are merely disconnected).
3363
+ { name: "browsers", deadlineMs: SHUTDOWN_STEP_MS, run: () => browserBridge?.shutdown() },
3364
+ { name: "luma_browser", deadlineMs: SHUTDOWN_STEP_MS, run: () => lumaBridge?.shutdown() },
3365
+ { name: "vaults", deadlineMs: SHUTDOWN_STEP_MS, run: () => vaultService.stop() },
3366
+ { name: "computer", deadlineMs: SHUTDOWN_STEP_MS, run: () => computerRelay.dispose() },
3367
+ { name: "relay", deadlineMs: SHUTDOWN_STEP_MS, run: () => relay?.close() },
3368
+ ]);
3369
+ // Anything still tracked (venv build, warm-up, vault host, helper): stop the trees now.
3370
+ const swept = await runShutdownSteps([
3371
+ { name: "children", deadlineMs: SHUTDOWN_SWEEP_MS + 500, run: () => terminateAllTracked({ graceMs: SHUTDOWN_SWEEP_MS }) },
3372
+ ]);
3373
+ const line = `shutdown ${Date.now() - t0}ms: ${formatShutdownResults([...results, ...swept])}`;
3374
+ if (opts.verbose)
3375
+ console.log(chalk.gray(` ${line}`));
3376
+ sendToHost({ type: "log", level: "info", line });
3171
3377
  socket.disconnect();
3172
- process.exit(0);
3173
3378
  };
3379
+ const cleanup = () => {
3380
+ if (shuttingDown) {
3381
+ // Second signal: stop waiting.
3382
+ killAllTrackedSync();
3383
+ process.exit(0);
3384
+ }
3385
+ shuttingDown = true;
3386
+ console.log(chalk.gray("\n Shutting down..."));
3387
+ void shutdown().catch(() => { }).finally(() => process.exit(0));
3388
+ };
3389
+ claimGracefulShutdown();
3174
3390
  process.on("SIGINT", cleanup);
3175
3391
  process.on("SIGTERM", cleanup);
3392
+ return { publishModels, print };
3176
3393
  }
3177
3394
  export function verifySignature(content, signature, runnerToken) {
3178
3395
  if (!content || !signature || !runnerToken)