@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.
- package/dist/assistantHost.py +5 -0
- package/dist/browserBridge.js +178 -0
- package/dist/browserFast.d.ts +144 -0
- package/dist/browserFast.js +363 -0
- package/dist/browserProvisioner.d.ts +39 -0
- package/dist/browserProvisioner.js +96 -0
- package/dist/cli.js +180 -66
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +9 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +808 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +12 -2
- package/dist/connection.js +299 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +122 -0
- package/dist/hostChannel.js +117 -0
- package/dist/hostFiles.d.ts +25 -0
- package/dist/hostFiles.js +90 -0
- package/dist/linkedinLogin.js +13 -17
- package/dist/lumaLogin.js +11 -17
- package/dist/processTree.d.ts +65 -0
- package/dist/processTree.js +237 -0
- package/dist/publishGate.js +14 -4
- package/dist/pythonEnv.js +6 -7
- package/dist/sharedVendor.js +3 -1
- package/dist/singleInstance.js +10 -1
- package/dist/startupCache.d.ts +22 -0
- package/dist/startupCache.js +45 -0
- package/dist/startupTimings.d.ts +34 -0
- package/dist/startupTimings.js +81 -0
- package/dist/uvBootstrap.js +32 -24
- package/dist/vaults.js +3 -1
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
// user (not verbose-gated)
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
333
|
-
//
|
|
334
|
-
// pipeline spawn uses (see the runner:run handler below) so
|
|
335
|
-
// resolves; this heats the OS file cache + bytecode.
|
|
336
|
-
//
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
+
print(chalk.gray(` [assistant prewarm] skipped: ${e?.message || e}`));
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
bgSetupStep = "";
|
|
469
|
+
hostProgress(outcome);
|
|
366
470
|
}
|
|
367
471
|
};
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
|
|
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
|
|
380
|
-
if (
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
},
|
|
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
|
|
459
|
-
// message doesn't pay
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
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
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
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
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
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)
|