@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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Files this runner produced on the computer, reported to the Melaya desktop
3
+ * app (contract ComputerRunnerFileMessage, { type: "computer.file" }) so
4
+ * computer control can open them or pick them in a file dialog by handle.
5
+ *
6
+ * Only inside the desktop app (hostChannel.isHosted()); under `npx` every
7
+ * function here is a no-op. The path goes to the app's main process over the
8
+ * parentPort and never to the server: the app turns it into an opaque handle.
9
+ *
10
+ * Sources: Assistant turn output files (after assistantFiles.ts accepted them)
11
+ * and pipeline outputs written to MELAYA_OUTPUT_DIR during a run. Browser
12
+ * downloads are denied by the browser bridge (acceptDownloads: false), so there
13
+ * is nothing to report from there.
14
+ */
15
+ import { lstatSync, readdirSync } from "node:fs";
16
+ import { basename, extname, join } from "node:path";
17
+ import { isHosted, sendToHost } from "./hostChannel.js";
18
+ /** Never offered to computer actions as a file handle. */
19
+ const BLOCKED_EXT = new Set([
20
+ "exe", "msi", "bat", "cmd", "com", "scr", "ps1", "vbs", "js", "jse", "wsf", "hta", "lnk", "reg", "jar",
21
+ "dll", "app", "pkg", "dmg", "sh", "command", "py", "pyc",
22
+ ]);
23
+ /** At most this many files per run, so a tool that writes thousands of files cannot flood the host. */
24
+ export const MAX_REPORTED_PER_RUN = 25;
25
+ function reportable(path) {
26
+ try {
27
+ const st = lstatSync(path);
28
+ if (!st.isFile() || st.isSymbolicLink() || st.size <= 0)
29
+ return false;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ const ext = extname(path).slice(1).toLowerCase();
35
+ return !!ext && !BLOCKED_EXT.has(ext);
36
+ }
37
+ /** Report one file. Returns true when a message was sent. */
38
+ export function reportHostFile(path, origin) {
39
+ if (!isHosted() || typeof path !== "string" || !path)
40
+ return false;
41
+ if (!reportable(path))
42
+ return false;
43
+ sendToHost({ type: "computer.file", path, name: basename(path), origin });
44
+ return true;
45
+ }
46
+ /**
47
+ * Files in `dir` (and one level of sub-folders) modified at or after `sinceMs`,
48
+ * newest first, capped. Pure apart from reading the folder.
49
+ */
50
+ export function filesWrittenSince(dir, sinceMs, max = MAX_REPORTED_PER_RUN) {
51
+ const out = [];
52
+ const walk = (d, depth) => {
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(d, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const e of entries) {
61
+ const p = join(d, e.name);
62
+ if (e.isDirectory()) {
63
+ if (depth < 1)
64
+ walk(p, depth + 1);
65
+ continue;
66
+ }
67
+ if (!e.isFile())
68
+ continue;
69
+ try {
70
+ const m = lstatSync(p).mtimeMs;
71
+ // 2 s of slack for filesystems with coarse timestamps.
72
+ if (m >= sinceMs - 2000)
73
+ out.push({ p, m });
74
+ }
75
+ catch { /* gone */ }
76
+ }
77
+ };
78
+ walk(dir, 0);
79
+ return out.sort((a, b) => b.m - a.m).slice(0, max).map((x) => x.p);
80
+ }
81
+ /** After a run: report the outputs it wrote. No-op outside the desktop app. */
82
+ export function reportOutputsSince(dir, sinceMs) {
83
+ if (!isHosted() || !dir)
84
+ return 0;
85
+ let n = 0;
86
+ for (const p of filesWrittenSince(dir, sinceMs))
87
+ if (reportHostFile(p, "output"))
88
+ n++;
89
+ return n;
90
+ }
@@ -40,6 +40,7 @@
40
40
  */
41
41
  import { spawn } from "child_process";
42
42
  import chalk from "chalk";
43
+ import { launchInstalledBrowser, ProvisionError } from "./browserProvisioner.js";
43
44
  const LOGIN_TIMEOUT_MS = 5 * 60_000; // 5 min for the user to finish typing
44
45
  const POLL_INTERVAL_MS = 750;
45
46
  // URL fragments that indicate "the user is logged in" — LinkedIn redirects
@@ -163,26 +164,21 @@ export async function runLinkedInLoginFlow(progress) {
163
164
  let browser = null;
164
165
  try {
165
166
  progress("Opening LinkedIn login window…");
167
+ // One provisioner for every headed flow (browserProvisioner.ts): installed
168
+ // Chrome, then Edge, then Brave. Downloading Chromium through npx is a
169
+ // CLI-only last resort; the desktop app gets a clear "install Chrome or
170
+ // Edge" instead (audit P1.4).
166
171
  try {
167
- browser = await playwright.chromium.launch({
168
- headless: false,
169
- channel: "chrome",
172
+ ({ browser } = await launchInstalledBrowser((o) => playwright.chromium.launch(o), {
170
173
  args: ["--disable-blink-features=AutomationControlled"],
171
- });
174
+ progress,
175
+ downloadChromium: () => _installChromium(progress),
176
+ }));
172
177
  }
173
- catch (chromeErr) {
174
- progress(`System Chrome not available (${(chromeErr?.message || chromeErr).toString().split("\n")[0]}); ` +
175
- `falling back to the bundled browser.`);
176
- try {
177
- await _installChromium(progress);
178
- }
179
- catch (e) {
180
- return { ok: false, error: "chromium_install_failed", detail: e?.message };
181
- }
182
- browser = await playwright.chromium.launch({
183
- headless: false,
184
- args: ["--disable-blink-features=AutomationControlled"],
185
- });
178
+ catch (e) {
179
+ if (e instanceof ProvisionError)
180
+ return { ok: false, error: "browser_not_installed", detail: e.instructions || e.message };
181
+ return { ok: false, error: "chromium_install_failed", detail: e?.message };
186
182
  }
187
183
  _activeLoginBrowser = browser;
188
184
  const context = await browser.newContext({
package/dist/lumaLogin.js CHANGED
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { spawn } from "child_process";
25
25
  import chalk from "chalk";
26
+ import { launchInstalledBrowser, ProvisionError } from "./browserProvisioner.js";
26
27
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
27
28
  const POLL_INTERVAL_MS = 750;
28
29
  // Pages that mean "logged in to Luma". Luma's web app routes the user to
@@ -128,26 +129,19 @@ export async function runLumaLoginFlow(progress) {
128
129
  let browser = null;
129
130
  try {
130
131
  progress("Opening Luma login window…");
132
+ // Same provisioner as LinkedIn (browserProvisioner.ts): installed Chrome,
133
+ // Edge, Brave; npx Chromium download only from the CLI (audit P1.4).
131
134
  try {
132
- browser = await playwright.chromium.launch({
133
- headless: false,
134
- channel: "chrome",
135
+ ({ browser } = await launchInstalledBrowser((o) => playwright.chromium.launch(o), {
135
136
  args: ["--disable-blink-features=AutomationControlled"],
136
- });
137
+ progress,
138
+ downloadChromium: () => _installChromium(progress),
139
+ }));
137
140
  }
138
- catch (chromeErr) {
139
- progress(`System Chrome not available (${(chromeErr?.message || chromeErr).toString().split("\n")[0]}); ` +
140
- `falling back to the bundled browser.`);
141
- try {
142
- await _installChromium(progress);
143
- }
144
- catch (e) {
145
- return { ok: false, error: "chromium_install_failed", detail: e?.message };
146
- }
147
- browser = await playwright.chromium.launch({
148
- headless: false,
149
- args: ["--disable-blink-features=AutomationControlled"],
150
- });
141
+ catch (e) {
142
+ if (e instanceof ProvisionError)
143
+ return { ok: false, error: "browser_not_installed", detail: e.instructions || e.message };
144
+ return { ok: false, error: "chromium_install_failed", detail: e?.message };
151
145
  }
152
146
  _activeLoginBrowser = browser;
153
147
  const context = await browser.newContext({
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Child process trees: spawn, track, and stop them for sure.
3
+ *
4
+ * A runner child (pipeline Python, Assistant host, RAG ingest, venv build) can
5
+ * start its own children (a CLI model provider, a browser, pip). Killing only
6
+ * the direct child leaves those grandchildren running after the runner exits.
7
+ *
8
+ * - POSIX: every tracked child is spawned as the leader of its own process
9
+ * group (`detached: true`), so one signal to `-pid` reaches the whole tree.
10
+ * - Windows: `taskkill /PID <pid> /T` first (asks windowed processes such as
11
+ * browsers to close), then `taskkill /PID <pid> /T /F` after the grace period.
12
+ *
13
+ * Every tracked child is also killed synchronously on process exit, so a crash
14
+ * or a `process.exit()` elsewhere does not leave orphans.
15
+ */
16
+ import { spawn as rawSpawn, type ChildProcess } from "child_process";
17
+ /** Track a child so shutdown (and process exit) stops its whole tree. Returns the child. */
18
+ export declare function trackChild<T extends ChildProcess>(child: T): T;
19
+ /** Live tracked children (for diagnostics and tests). */
20
+ export declare function trackedChildren(): ChildProcess[];
21
+ /**
22
+ * Drop-in replacement for child_process.spawn: the child leads its own process
23
+ * group on POSIX (unless the caller set `detached` itself) and is tracked.
24
+ * Windows keeps the default (detached there means a new console window).
25
+ */
26
+ export declare const spawnTracked: typeof rawSpawn;
27
+ /**
28
+ * Stop a child and every descendant: a graceful request first, then a forced
29
+ * kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
30
+ * kill was issued). Never throws.
31
+ */
32
+ export declare function terminateTree(child: ChildProcess, opts?: {
33
+ graceMs?: number;
34
+ }): Promise<void>;
35
+ /** Stop every tracked child tree, in parallel, within the grace period. */
36
+ export declare function terminateAllTracked(opts?: {
37
+ graceMs?: number;
38
+ }): Promise<number>;
39
+ /** Last resort on process exit: synchronous forced kill of every tracked tree. */
40
+ export declare function killAllTrackedSync(): void;
41
+ export interface ShutdownStep {
42
+ name: string;
43
+ /** Upper bound for this step; the shutdown moves on when it passes. */
44
+ deadlineMs: number;
45
+ run: () => unknown | Promise<unknown>;
46
+ }
47
+ export interface ShutdownStepResult {
48
+ name: string;
49
+ outcome: "ok" | "timeout" | "error";
50
+ ms: number;
51
+ error?: string;
52
+ }
53
+ /** Run independent shutdown steps in parallel, each bounded by its own deadline. */
54
+ export declare function runShutdownSteps(steps: ShutdownStep[]): Promise<ShutdownStepResult[]>;
55
+ /** One line for logs: "runs ok 12ms, browsers timeout 3000ms". */
56
+ export declare function formatShutdownResults(results: ShutdownStepResult[]): string;
57
+ export declare function claimGracefulShutdown(): void;
58
+ export declare function gracefulShutdownClaimed(): boolean;
59
+ /** Run this child below normal priority for its whole life (fire-and-forget warm-ups). */
60
+ export declare function lowerPriority(child: ChildProcess): ChildProcess;
61
+ /** Mark a setup child; it runs below normal priority while setup is in background mode. */
62
+ export declare function registerSetupChild(child: ChildProcess): ChildProcess;
63
+ /** Background mode on: new setup children start below normal priority. */
64
+ export declare function setSetupBackground(on: boolean): void;
65
+ export declare function setupIsBackground(): boolean;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Child process trees: spawn, track, and stop them for sure.
3
+ *
4
+ * A runner child (pipeline Python, Assistant host, RAG ingest, venv build) can
5
+ * start its own children (a CLI model provider, a browser, pip). Killing only
6
+ * the direct child leaves those grandchildren running after the runner exits.
7
+ *
8
+ * - POSIX: every tracked child is spawned as the leader of its own process
9
+ * group (`detached: true`), so one signal to `-pid` reaches the whole tree.
10
+ * - Windows: `taskkill /PID <pid> /T` first (asks windowed processes such as
11
+ * browsers to close), then `taskkill /PID <pid> /T /F` after the grace period.
12
+ *
13
+ * Every tracked child is also killed synchronously on process exit, so a crash
14
+ * or a `process.exit()` elsewhere does not leave orphans.
15
+ */
16
+ import { spawn as rawSpawn, spawnSync, execFile } from "child_process";
17
+ import os from "os";
18
+ const isWin = process.platform === "win32";
19
+ const tracked = new Set();
20
+ let exitHookInstalled = false;
21
+ function alive(child) {
22
+ return child.exitCode === null && child.signalCode === null && typeof child.pid === "number";
23
+ }
24
+ /** Track a child so shutdown (and process exit) stops its whole tree. Returns the child. */
25
+ export function trackChild(child) {
26
+ if (!child || typeof child.pid !== "number")
27
+ return child;
28
+ tracked.add(child);
29
+ const forget = () => { tracked.delete(child); };
30
+ child.once("exit", forget);
31
+ child.once("error", forget);
32
+ if (!exitHookInstalled) {
33
+ exitHookInstalled = true;
34
+ process.on("exit", killAllTrackedSync);
35
+ }
36
+ return child;
37
+ }
38
+ /** Live tracked children (for diagnostics and tests). */
39
+ export function trackedChildren() {
40
+ return [...tracked].filter(alive);
41
+ }
42
+ /**
43
+ * Drop-in replacement for child_process.spawn: the child leads its own process
44
+ * group on POSIX (unless the caller set `detached` itself) and is tracked.
45
+ * Windows keeps the default (detached there means a new console window).
46
+ */
47
+ export const spawnTracked = ((command, argsOrOptions, maybeOptions) => {
48
+ const args = Array.isArray(argsOrOptions) ? argsOrOptions : [];
49
+ const given = ((Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions) ?? {});
50
+ const opts = { ...given };
51
+ if (!isWin && opts.detached === undefined)
52
+ opts.detached = true;
53
+ if (isWin && opts.windowsHide === undefined)
54
+ opts.windowsHide = true;
55
+ return trackChild(rawSpawn(command, args, opts));
56
+ });
57
+ function taskkill(pid, force) {
58
+ return new Promise((resolve) => {
59
+ const args = ["/PID", String(pid), "/T"];
60
+ if (force)
61
+ args.push("/F");
62
+ try {
63
+ execFile("taskkill", args, { windowsHide: true, timeout: 5000 }, () => resolve());
64
+ }
65
+ catch {
66
+ resolve();
67
+ }
68
+ });
69
+ }
70
+ function signalTree(child, signal) {
71
+ const pid = child.pid;
72
+ if (typeof pid !== "number")
73
+ return;
74
+ // Group first (covers grandchildren); the plain pid when it does not lead a group.
75
+ try {
76
+ process.kill(-pid, signal);
77
+ return;
78
+ }
79
+ catch { /* not a group leader, or gone */ }
80
+ try {
81
+ process.kill(pid, signal);
82
+ }
83
+ catch { /* gone */ }
84
+ }
85
+ function waitExit(child, ms) {
86
+ if (!alive(child))
87
+ return Promise.resolve(true);
88
+ return new Promise((resolve) => {
89
+ let done = false;
90
+ const finish = (v) => { if (done)
91
+ return; done = true; clearTimeout(t); resolve(v); };
92
+ const t = setTimeout(() => finish(!alive(child)), ms);
93
+ child.once("exit", () => finish(true));
94
+ });
95
+ }
96
+ /**
97
+ * Stop a child and every descendant: a graceful request first, then a forced
98
+ * kill once `graceMs` has passed. Resolves when the tree is gone (or the forced
99
+ * kill was issued). Never throws.
100
+ */
101
+ export async function terminateTree(child, opts = {}) {
102
+ const graceMs = Math.max(0, opts.graceMs ?? 2000);
103
+ const pid = child?.pid;
104
+ if (typeof pid !== "number")
105
+ return;
106
+ if (isWin) {
107
+ if (alive(child)) {
108
+ if (graceMs > 0) {
109
+ await taskkill(pid, false);
110
+ if (await waitExit(child, graceMs))
111
+ return;
112
+ }
113
+ await taskkill(pid, true);
114
+ await waitExit(child, 1000);
115
+ }
116
+ return;
117
+ }
118
+ if (graceMs > 0 && alive(child)) {
119
+ signalTree(child, "SIGTERM");
120
+ await waitExit(child, graceMs);
121
+ }
122
+ // Always sweep the group: the leader may be gone while a grandchild ignored SIGTERM.
123
+ try {
124
+ process.kill(-pid, "SIGKILL");
125
+ }
126
+ catch { /* group already empty */ }
127
+ if (alive(child)) {
128
+ try {
129
+ process.kill(pid, "SIGKILL");
130
+ }
131
+ catch { /* gone */ }
132
+ await waitExit(child, 1000);
133
+ }
134
+ }
135
+ /** Stop every tracked child tree, in parallel, within the grace period. */
136
+ export async function terminateAllTracked(opts = {}) {
137
+ const live = [...tracked];
138
+ await Promise.all(live.map((c) => terminateTree(c, opts)));
139
+ return live.length;
140
+ }
141
+ /** Last resort on process exit: synchronous forced kill of every tracked tree. */
142
+ export function killAllTrackedSync() {
143
+ for (const child of tracked) {
144
+ const pid = child.pid;
145
+ if (typeof pid !== "number")
146
+ continue;
147
+ if (isWin) {
148
+ if (!alive(child))
149
+ continue;
150
+ try {
151
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: 3000 });
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ else {
156
+ try {
157
+ process.kill(-pid, "SIGKILL");
158
+ }
159
+ catch { /* not a leader / gone */ }
160
+ if (alive(child)) {
161
+ try {
162
+ process.kill(pid, "SIGKILL");
163
+ }
164
+ catch { /* gone */ }
165
+ }
166
+ }
167
+ }
168
+ tracked.clear();
169
+ }
170
+ /** Run independent shutdown steps in parallel, each bounded by its own deadline. */
171
+ export async function runShutdownSteps(steps) {
172
+ return Promise.all(steps.map(async (s) => {
173
+ const t0 = Date.now();
174
+ let timer;
175
+ const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), Math.max(0, s.deadlineMs)); });
176
+ try {
177
+ const r = await Promise.race([Promise.resolve().then(s.run).then(() => "ok"), deadline]);
178
+ return { name: s.name, outcome: r, ms: Date.now() - t0 };
179
+ }
180
+ catch (e) {
181
+ return { name: s.name, outcome: "error", ms: Date.now() - t0, error: String(e?.message || e).slice(0, 200) };
182
+ }
183
+ finally {
184
+ if (timer)
185
+ clearTimeout(timer);
186
+ }
187
+ }));
188
+ }
189
+ /** One line for logs: "runs ok 12ms, browsers timeout 3000ms". */
190
+ export function formatShutdownResults(results) {
191
+ return results.map((r) => `${r.name} ${r.outcome} ${r.ms}ms`).join(", ");
192
+ }
193
+ // ── Graceful shutdown ownership ───────────────────────────────────────────
194
+ // connection.ts owns the graceful shutdown once the socket exists; other
195
+ // signal handlers (singleInstance.ts) must then not exit the process under it.
196
+ let gracefulClaimed = false;
197
+ export function claimGracefulShutdown() { gracefulClaimed = true; }
198
+ export function gracefulShutdownClaimed() { return gracefulClaimed; }
199
+ // ── Background priority ───────────────────────────────────────────────────
200
+ // Setup work (dependency installs, warm-ups) runs below normal priority while
201
+ // nobody waits for it; a request that must wait promotes it back to normal.
202
+ let backgroundMode = false;
203
+ const setupChildren = new Set();
204
+ function applyPriority(pid, low) {
205
+ try {
206
+ os.setPriority(pid, low ? os.constants.priority.PRIORITY_BELOW_NORMAL : os.constants.priority.PRIORITY_NORMAL);
207
+ }
208
+ catch { /* not permitted (raising priority back needs privileges on POSIX) */ }
209
+ }
210
+ /** Run this child below normal priority for its whole life (fire-and-forget warm-ups). */
211
+ export function lowerPriority(child) {
212
+ if (typeof child?.pid === "number")
213
+ applyPriority(child.pid, true);
214
+ return child;
215
+ }
216
+ /** Mark a setup child; it runs below normal priority while setup is in background mode. */
217
+ export function registerSetupChild(child) {
218
+ const pid = child?.pid;
219
+ if (typeof pid !== "number")
220
+ return child;
221
+ setupChildren.add(child);
222
+ child.once("exit", () => setupChildren.delete(child));
223
+ child.once("error", () => setupChildren.delete(child));
224
+ if (backgroundMode)
225
+ applyPriority(pid, true);
226
+ return child;
227
+ }
228
+ /** Background mode on: new setup children start below normal priority. */
229
+ export function setSetupBackground(on) {
230
+ if (backgroundMode === on)
231
+ return;
232
+ backgroundMode = on;
233
+ for (const c of setupChildren)
234
+ if (typeof c.pid === "number" && alive(c))
235
+ applyPriority(c.pid, on);
236
+ }
237
+ export function setupIsBackground() { return backgroundMode; }
@@ -125,20 +125,30 @@ export function classifyControlEffect(name) {
125
125
  const n = String(name || "").toLowerCase().trim();
126
126
  if (!n)
127
127
  return null;
128
+ // The verb lists are accent-free ("reinitialiser", "creer"), so a real
129
+ // label ("Réinitialiser", "Créer", full-width "DELETE") is also tried
130
+ // folded. Escalation only: the raw name is still tried as before.
131
+ const f = foldName(n);
132
+ const has = (p) => containsPhrase(n, p) || (f !== n && containsPhrase(f, p));
128
133
  for (const p of PURCHASE_VERBS)
129
- if (containsPhrase(n, p))
134
+ if (has(p))
130
135
  return "purchase";
131
136
  for (const p of DESTRUCTIVE_VERBS)
132
- if (containsPhrase(n, p))
137
+ if (has(p))
133
138
  return "destructive";
134
139
  for (const p of ACCOUNT_VERBS)
135
- if (containsPhrase(n, p))
140
+ if (has(p))
136
141
  return "account_change";
137
142
  for (const p of PUBLISH_VERBS)
138
- if (containsPhrase(n, p))
143
+ if (has(p))
139
144
  return "publish";
140
145
  return null;
141
146
  }
147
+ /** Case, diacritics, full/half-width forms and curly apostrophes folded. */
148
+ function foldName(s) {
149
+ return s.normalize("NFKD").replace(/[̀-ͯ]/g, "").normalize("NFKC").toLowerCase()
150
+ .replace(/[‘’ʼ]/g, "'").replace(/[\s  ]+/g, " ").trim();
151
+ }
142
152
  /** Does this effect need approval in the given autonomy mode?
143
153
  * safe -> gate every consequential write
144
154
  * payments_only -> gate purchases AND the two irreversible families
package/dist/pythonEnv.js CHANGED
@@ -9,8 +9,8 @@
9
9
  * Idempotent: re-uses the venv if a marker file matches the current
10
10
  * shared-bundle version. Re-bootstraps when the bundle version changes.
11
11
  */
12
- import { spawn } from "child_process";
13
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
13
+ import { spawnTracked, registerSetupChild, terminateTree } from "./processTree.js";
14
14
  import { createHash } from "crypto";
15
15
  import { join } from "path";
16
16
  import { homedir, platform } from "os";
@@ -387,10 +387,12 @@ function runProc(cmd, args, onLine, envExtra = {},
387
387
  // (which every run, RAG ingest and assistant boot sits behind).
388
388
  inactivityMs = 10 * 60_000) {
389
389
  return new Promise((resolve) => {
390
- const child = spawn(cmd, args, {
390
+ // Tracked (shutdown stops pip/uv and their children) and, while the setup
391
+ // runs in the background, below normal priority (processTree.ts).
392
+ const child = registerSetupChild(spawnTracked(cmd, args, {
391
393
  stdio: ["ignore", "pipe", "pipe"],
392
394
  env: { ...process.env, ...envExtra },
393
- });
395
+ }));
394
396
  let done = false;
395
397
  let timer;
396
398
  const finish = (code) => {
@@ -405,10 +407,7 @@ inactivityMs = 10 * 60_000) {
405
407
  if (timer)
406
408
  clearTimeout(timer);
407
409
  timer = setTimeout(() => {
408
- try {
409
- child.kill("SIGKILL");
410
- }
411
- catch { /* already gone */ }
410
+ void terminateTree(child, { graceMs: 0 });
412
411
  onLine(`[runProc] no output for ${Math.round(inactivityMs / 60000)} min — killing '${cmd}'`);
413
412
  finish(1);
414
413
  }, inactivityMs);
@@ -13,6 +13,7 @@ import { homedir, setPriority } from "os";
13
13
  import { spawn } from "child_process";
14
14
  import { createHash, createHmac, timingSafeEqual } from "crypto";
15
15
  import { venvPython } from "./pythonEnv.js";
16
+ import { trackChild } from "./processTree.js";
16
17
  const CACHE_DIR = join(homedir(), ".melaya-runner");
17
18
  const SHARED_DIR = join(CACHE_DIR, "shared");
18
19
  const VERSION_FILE = join(CACHE_DIR, "shared-version.txt");
@@ -162,7 +163,8 @@ function precompileInBackground() {
162
163
  const dirs = ["shared", "agentscope", "crews"].map((d) => join(CACHE_DIR, d)).filter((d) => existsSync(d));
163
164
  if (!dirs.length)
164
165
  return;
165
- const child = spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false });
166
+ // Tracked: runner shutdown stops it instead of leaving it compiling.
167
+ const child = trackChild(spawn(py, ["-m", "compileall", "-q", "-j", "2", ...dirs], { stdio: "ignore", windowsHide: true, detached: false }));
166
168
  child.on("error", () => { });
167
169
  if (child.pid) {
168
170
  try {
@@ -20,6 +20,7 @@
20
20
  import { mkdirSync, readFileSync, unlinkSync, openSync, writeSync, closeSync } from "fs";
21
21
  import { join } from "path";
22
22
  import { homedir } from "os";
23
+ import { gracefulShutdownClaimed } from "./processTree.js";
23
24
  const CACHE_DIR = join(homedir(), ".melaya-runner");
24
25
  const LOCK_PATH = join(CACHE_DIR, "runner.lock");
25
26
  function pidAlive(pid) {
@@ -110,7 +111,15 @@ export function acquireSingleInstanceLock() {
110
111
  };
111
112
  process.on("exit", release);
112
113
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
113
- process.on(sig, () => { release(); process.exit(0); });
114
+ // Once connection.ts owns a graceful shutdown (SIGINT/SIGTERM), let it
115
+ // finish: it exits on its own and "exit" above releases the lock then, so
116
+ // a successor cannot start while this runner's children are still stopping.
117
+ process.on(sig, () => {
118
+ if (sig !== "SIGHUP" && gracefulShutdownClaimed())
119
+ return;
120
+ release();
121
+ process.exit(0);
122
+ });
114
123
  }
115
124
  return { ok: true };
116
125
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Last good startup facts, so the next start can connect at once:
3
+ * ~/.melaya-runner/startup-cache.json holds the last published model list and
4
+ * the Python interpreter that worked. The runner connects with these, then
5
+ * publishes fresh discovery through `runner:models` when it lands.
6
+ *
7
+ * Model names only (no credentials, no tokens). A missing or malformed file
8
+ * reads as empty; writes are best-effort and atomic (temp file + rename).
9
+ */
10
+ import type { DetectedModel } from "./detect.js";
11
+ export interface StartupCache {
12
+ models?: DetectedModel[];
13
+ modelsAt?: number;
14
+ python?: {
15
+ cmd: string;
16
+ minor: number;
17
+ provisioned?: boolean;
18
+ };
19
+ }
20
+ export declare function startupCachePath(dir?: string): string;
21
+ export declare function readStartupCache(path?: string): StartupCache;
22
+ export declare function writeStartupCache(patch: StartupCache, path?: string): void;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Last good startup facts, so the next start can connect at once:
3
+ * ~/.melaya-runner/startup-cache.json holds the last published model list and
4
+ * the Python interpreter that worked. The runner connects with these, then
5
+ * publishes fresh discovery through `runner:models` when it lands.
6
+ *
7
+ * Model names only (no credentials, no tokens). A missing or malformed file
8
+ * reads as empty; writes are best-effort and atomic (temp file + rename).
9
+ */
10
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { join } from "path";
13
+ const PROVIDERS = new Set(["lmstudio", "ollama", "claude_code", "codex", "github_copilot"]);
14
+ export function startupCachePath(dir = join(homedir(), ".melaya-runner")) {
15
+ return join(dir, "startup-cache.json");
16
+ }
17
+ export function readStartupCache(path = startupCachePath()) {
18
+ try {
19
+ const j = JSON.parse(readFileSync(path, "utf-8"));
20
+ const out = {};
21
+ if (Array.isArray(j.models)) {
22
+ out.models = j.models
23
+ .filter((m) => m && PROVIDERS.has(String(m.provider)) && typeof m.name === "string" && m.name.length > 0 && m.name.length < 200)
24
+ .slice(0, 500);
25
+ out.modelsAt = Number(j.modelsAt) || 0;
26
+ }
27
+ if (j.python && typeof j.python.cmd === "string" && Number.isInteger(j.python.minor)) {
28
+ out.python = { cmd: j.python.cmd, minor: j.python.minor, ...(j.python.provisioned ? { provisioned: true } : {}) };
29
+ }
30
+ return out;
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ export function writeStartupCache(patch, path = startupCachePath()) {
37
+ try {
38
+ const next = { ...readStartupCache(path), ...patch };
39
+ mkdirSync(join(path, ".."), { recursive: true });
40
+ const tmp = `${path}.${process.pid}.tmp`;
41
+ writeFileSync(tmp, JSON.stringify(next), { encoding: "utf-8", mode: 0o600 });
42
+ renameSync(tmp, path);
43
+ }
44
+ catch { /* best-effort: the next start just discovers again */ }
45
+ }