@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/cli.js CHANGED
@@ -17,7 +17,10 @@ import { Command } from "commander";
17
17
  import chalk from "chalk";
18
18
  import { reportInstallFailure } from "./installReport.js";
19
19
  import { connect } from "./connection.js";
20
- import { detectModels } from "./detect.js";
20
+ import { scanProviders } from "./detect.js";
21
+ import { isHosted, hostStatus, onHostMessage, sendToHost, waitForHostToken, exitHostedSoon } from "./hostChannel.js";
22
+ import { markStage, onStage, startupComplete, startupTimings, formatStartupTimings } from "./startupTimings.js";
23
+ import { readStartupCache, writeStartupCache } from "./startupCache.js";
21
24
  // Logo colors: cyan → blue → indigo gradient (matching melaya.org)
22
25
  const c1 = chalk.hex("#22D3EE").bold;
23
26
  const c2 = chalk.hex("#10B0F0").bold;
@@ -43,15 +46,55 @@ const BANNER = `
43
46
  ${br("▸")} ${dm("Local AI Pipeline Runner")} ${chalk.hex("#333")(`v${version}`)}
44
47
  ${br("─────────────────────────────────────────────────────")}
45
48
  `;
49
+ // Inside the desktop app: give the last host message time to leave before exit.
50
+ const flushHost = () => (isHosted() ? new Promise((r) => setTimeout(r, 300)) : Promise.resolve());
51
+ // Desktop app "stop" (pause, disconnect, quit): run the graceful shutdown the
52
+ // CLI already has for Ctrl+C. Newest SIGTERM handler first, so connection.ts
53
+ // stops runs and assistant hosts before singleInstance.ts exits; the lock is
54
+ // released on "exit" either way. Hard exit if nothing is listening yet.
55
+ function stopHosted() {
56
+ const handlers = process.listeners("SIGTERM").slice().reverse();
57
+ exitHostedSoon(0, 5000);
58
+ for (const h of handlers) {
59
+ try {
60
+ h("SIGTERM");
61
+ }
62
+ catch { /* keep going */ }
63
+ }
64
+ if (handlers.length === 0)
65
+ process.exit(0);
66
+ }
46
67
  async function main() {
68
+ const hosted = isHosted();
47
69
  const program = new Command()
48
70
  .name("melaya-runner")
49
- .description("Run Melaya AI pipelines locally with your own models")
50
- .requiredOption("--token <token>", "Runner token from the Melaya platform")
71
+ .description("Run Melaya AI pipelines locally with your own models");
72
+ // In the desktop app the token arrives over the host channel, never argv.
73
+ if (hosted)
74
+ program.option("--token <token>", "Runner token from the Melaya platform");
75
+ else
76
+ program.requiredOption("--token <token>", "Runner token from the Melaya platform");
77
+ program
51
78
  .option("--server <url>", "Server URL", "wss://api.melaya.org")
52
79
  .option("--verbose", "Show detailed logs", false)
80
+ .option("--computer", "Let Melaya agents operate allowed apps on this machine (headless Linux / VM)", false)
81
+ .option("--computer-helper <path>", "Path to the melaya-computer helper binary (default: MELAYA_COMPUTER_HELPER)")
82
+ .option("--diagnostics", "Print startup stage timings (config, socket, online, Python, model discovery)", false)
53
83
  .parse(process.argv);
54
84
  const opts = program.opts();
85
+ // --computer: this runner hosts the computer executor itself (computerHostCli.ts); the desktop app hosts its own.
86
+ if (opts.computer && !hosted) {
87
+ const { enableCliComputerHost } = await import("./computerHostCli.js");
88
+ enableCliComputerHost({ helperPath: opts.computerHelper, verbose: opts.verbose });
89
+ }
90
+ if (hosted) {
91
+ hostStatus("starting");
92
+ sendToHost({ type: "version", runnerVersion: version });
93
+ onHostMessage((msg) => { if (msg.type === "stop")
94
+ stopHosted(); });
95
+ opts.token = await waitForHostToken();
96
+ }
97
+ markStage("config");
55
98
  console.log(BANNER);
56
99
  // Single-instance guard: refuse to start if another runner is already running
57
100
  // on this machine. Two runners for one user fight over a single server-side
@@ -61,63 +104,128 @@ async function main() {
61
104
  const { acquireSingleInstanceLock } = await import("./singleInstance.js");
62
105
  const lock = acquireSingleInstanceLock();
63
106
  if (!lock.ok) {
107
+ hostStatus("blocked", `Another Melaya runner is already running on this computer (process ${lock.ownerPid}). Close it, then resume the runner.`);
64
108
  console.log(chalk.red(` ✗ A Melaya runner is already running on this machine (pid ${lock.ownerPid}).`));
65
109
  console.log(chalk.gray(" Only one runner can run per machine. Stop the other one first"));
66
110
  console.log(chalk.gray(` (e.g. \`kill ${lock.ownerPid}\`, or close its terminal), then start again.`));
67
111
  await reportInstallFailure("single_instance_locked", { token: opts.token, server: opts.server });
112
+ await flushHost();
68
113
  process.exit(1);
69
114
  }
70
- // Detect Python. Fast path: a supported host interpreter (3.10–3.12). If the
71
- // host only has an unsupported/newer Python (Homebrew now ships 3.13/3.14 as
72
- // `python3`) — or none at all — we PROVISION a managed CPython 3.12 via uv, so
73
- // the runner needs zero Python setup on the box. `venv` can't do this (it only
74
- // wraps an existing interpreter); uv downloads a standalone one.
75
- let python;
76
- const py = await findPython();
77
- if (py.ok) {
78
- python = py.cmd;
79
- console.log(chalk.green(` ✓ Python: ${python} (3.${py.minor})`));
80
- }
81
- else {
82
- console.log(chalk.yellow(` ⚠ No supported host Python (need 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}` +
83
- `${py.foundVersion ? `; host has ${py.foundVersion}` : ""}) — provisioning one automatically`));
84
- const { provisionPython } = await import("./uvBootstrap.js");
85
- const provisioned = await provisionPython(`3.${PY_MAX_MINOR}`, (m) => console.log(chalk.gray(` ${m}`)));
86
- if (!provisioned) {
87
- console.log(chalk.red(` ✗ Could not auto-provision Python 3.${PY_MAX_MINOR}`));
88
- console.log(chalk.gray(" Install a supported Python manually, then restart the runner:"));
89
- console.log(chalk.gray(" macOS: brew install python@3.12"));
90
- console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
91
- console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
92
- await reportInstallFailure("python_missing", {
93
- token: opts.token, server: opts.server,
94
- detail: py.foundVersion ? `host=${py.foundVersion}` : "none",
95
- });
96
- process.exit(1);
115
+ // Readiness (audit P1.2): connect FIRST with the last good model list from
116
+ // disk (or none), then resolve Python and scan model providers in parallel
117
+ // and publish the fresh list through `runner:models`. Before, Python probing,
118
+ // provisioning and a serial provider scan (up to ~24 s of timeouts) all ran
119
+ // before the socket even opened. Requests that need Python wait for it.
120
+ const cache = readStartupCache();
121
+ const pythonReady = resolvePython(cache.python);
122
+ const discovery = scanProviders();
123
+ const handle = await connect({
124
+ token: opts.token,
125
+ serverUrl: opts.server,
126
+ pythonPath: pythonReady,
127
+ models: cache.models ?? [],
128
+ verbose: opts.verbose,
129
+ });
130
+ say = (l) => handle.print(l);
131
+ // Fresh discovery: print it, publish it, remember it for the next start.
132
+ void discovery.then((scan) => {
133
+ reportModels(scan.models, (l) => handle.print(l));
134
+ if (opts.verbose || opts.diagnostics) {
135
+ const slow = scan.timedOut.length ? `; timed out: ${scan.timedOut.join(", ")}` : "";
136
+ handle.print(chalk.gray(` model discovery ${scan.ms}ms (${Object.entries(scan.probeMs).map(([k, v]) => `${k} ${v}ms`).join(", ")}${slow})`));
137
+ }
138
+ handle.publishModels(scan.models);
139
+ writeStartupCache({ models: scan.models, modelsAt: Date.now() });
140
+ markStage("discovery_done");
141
+ }).catch(() => { markStage("discovery_done"); });
142
+ void pythonReady.then(async (python) => {
143
+ if (python) {
144
+ markStage("python_ready");
145
+ return;
146
+ }
147
+ // Same terminal failure as before the reorder: no usable Python, no runner.
148
+ console.log(chalk.red(` ✗ Could not auto-provision Python 3.${PY_MAX_MINOR}`));
149
+ console.log(chalk.gray(" Install a supported Python manually, then restart the runner:"));
150
+ console.log(chalk.gray(" macOS: brew install python@3.12"));
151
+ console.log(chalk.gray(" Linux: sudo apt install python3.12 python3.12-venv (or: pyenv install 3.12)"));
152
+ console.log(chalk.gray(" Windows: winget install Python.Python.3.12"));
153
+ await reportInstallFailure("python_missing", {
154
+ token: opts.token, server: opts.server,
155
+ detail: lastPythonFound ? `host=${lastPythonFound}` : "none",
156
+ });
157
+ hostStatus("error", `Could not set up Python 3.${PY_MAX_MINOR} for local AI runs. Check your internet connection, then resume the runner.`);
158
+ await flushHost();
159
+ process.exit(1);
160
+ });
161
+ // --diagnostics (and --verbose): the stage timeline once every stage is in, or after 2 min.
162
+ if (opts.diagnostics || opts.verbose) {
163
+ let printed = false;
164
+ const print = () => {
165
+ if (printed)
166
+ return;
167
+ printed = true;
168
+ const lines = formatStartupTimings().split("\n").map((l) => ` ${l}`).join("\n");
169
+ handle.print(chalk.gray(`\n Startup timings (from process start):\n${lines}`));
170
+ if (opts.diagnostics)
171
+ handle.print(`startup-timings ${JSON.stringify(startupTimings().sinceStartMs)}`);
172
+ };
173
+ if (startupComplete())
174
+ print();
175
+ else {
176
+ const off = onStage(() => { if (startupComplete()) {
177
+ off();
178
+ print();
179
+ } });
180
+ setTimeout(() => { off(); print(); }, 120_000).unref?.();
97
181
  }
98
- python = provisioned;
99
- console.log(chalk.green(` ✓ Python: ${python} (auto-provisioned 3.${PY_MAX_MINOR})`));
100
182
  }
101
- // Detect local models
102
- const models = await detectModels();
183
+ }
184
+ function reportModels(models, print) {
103
185
  if (models.length === 0) {
104
- console.log(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running; Claude Code / Codex / GitHub Copilot not logged in)"));
105
- console.log(chalk.gray(" Start LM Studio or Ollama, log in to Claude Code / Codex, or run `melaya-runner copilot login`, then restart the runner"));
186
+ print(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running; Claude Code / Codex / GitHub Copilot not logged in)"));
187
+ print(chalk.gray(" Start LM Studio or Ollama, log in to Claude Code / Codex, or run `melaya-runner copilot login`; the runner picks them up automatically"));
188
+ return;
106
189
  }
107
- else {
108
- for (const m of models) {
109
- console.log(chalk.green(` ✓ ${m.provider}: ${m.name}`));
190
+ for (const m of models)
191
+ print(chalk.green(` ✓ ${m.provider}: ${m.name}`));
192
+ }
193
+ // The unsupported interpreter found during the last scan (for the failure report).
194
+ let lastPythonFound;
195
+ // Lines printed while the connect spinner may be spinning go through the
196
+ // connection's printer once it exists (keeps the spinner line intact).
197
+ let say = (l) => console.log(l);
198
+ // Detect Python. Fast path: the interpreter that worked last time (one probe).
199
+ // Then every candidate is probed IN PARALLEL (async, so the socket keeps
200
+ // running) and the best one by preference order wins. If the host only has an
201
+ // unsupported/newer Python (Homebrew now ships 3.13/3.14 as `python3`), or
202
+ // none at all, we PROVISION a managed CPython 3.12 via uv, so the runner needs
203
+ // zero Python setup on the box. `venv` can't do this (it only wraps an
204
+ // existing interpreter); uv downloads a standalone one.
205
+ async function resolvePython(cached) {
206
+ if (cached?.cmd) {
207
+ const v = await probePyVersion(cached.cmd);
208
+ if (v && v.major === 3 && v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR) {
209
+ say(chalk.green(` ✓ Python: ${cached.cmd} (3.${v.minor}${cached.provisioned ? ", auto-provisioned" : ""})`));
210
+ return cached.cmd;
110
211
  }
111
212
  }
112
- console.log();
113
- // Connect to the server
114
- await connect({
115
- token: opts.token,
116
- serverUrl: opts.server,
117
- pythonPath: python,
118
- models,
119
- verbose: opts.verbose,
120
- });
213
+ const py = await findPython();
214
+ if (py.ok) {
215
+ say(chalk.green(` ✓ Python: ${py.cmd} (3.${py.minor})`));
216
+ writeStartupCache({ python: { cmd: py.cmd, minor: py.minor } });
217
+ return py.cmd;
218
+ }
219
+ lastPythonFound = py.foundVersion;
220
+ say(chalk.yellow(` ⚠ No supported host Python (need 3.${PY_MIN_MINOR}–3.${PY_MAX_MINOR}` +
221
+ `${py.foundVersion ? `; host has ${py.foundVersion}` : ""}) — provisioning one automatically`));
222
+ const { provisionPython } = await import("./uvBootstrap.js");
223
+ const provisioned = await provisionPython(`3.${PY_MAX_MINOR}`, (m) => say(chalk.gray(` ${m}`)));
224
+ if (!provisioned)
225
+ return null;
226
+ say(chalk.green(` ✓ Python: ${provisioned} (auto-provisioned 3.${PY_MAX_MINOR})`));
227
+ writeStartupCache({ python: { cmd: provisioned, minor: PY_MAX_MINOR, provisioned: true } });
228
+ return provisioned;
121
229
  }
122
230
  // Supported CPython range for the runner's venv. agentscope and its native
123
231
  // deps (tiktoken, numpy, pydantic-core, curl_cffi via scrapling, sentence-
@@ -132,17 +240,21 @@ async function main() {
132
240
  const PY_MIN_MINOR = 10;
133
241
  const PY_MAX_MINOR = 12;
134
242
  async function probePyVersion(cmd) {
135
- const { execSync } = await import("child_process");
136
- try {
137
- const out = execSync(`"${cmd}" --version`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
138
- const m = out.match(/Python (\d+)\.(\d+)/);
139
- if (!m)
140
- return null;
141
- return { major: Number(m[1]), minor: Number(m[2]) };
142
- }
143
- catch {
144
- return null;
145
- }
243
+ const { execFile } = await import("child_process");
244
+ // Async (never blocks the socket) and no shell: the command is a fixed name or a cached path.
245
+ return new Promise((resolve) => {
246
+ try {
247
+ execFile(cmd, ["--version"], { encoding: "utf-8", timeout: 5000, windowsHide: true }, (err, stdout, stderr) => {
248
+ if (err)
249
+ return resolve(null);
250
+ const m = `${stdout || ""}${stderr || ""}`.trim().match(/Python (\d+)\.(\d+)/);
251
+ resolve(m ? { major: Number(m[1]), minor: Number(m[2]) } : null);
252
+ });
253
+ }
254
+ catch {
255
+ resolve(null);
256
+ }
257
+ });
146
258
  }
147
259
  async function findPython() {
148
260
  const candidates = [];
@@ -165,17 +277,16 @@ async function findPython() {
165
277
  }
166
278
  // 3. Generic names last — accepted only if they resolve into the supported range.
167
279
  candidates.push("python3", "python");
280
+ // Probe all candidates at once; the preference order above still decides.
281
+ const unique = [...new Set(candidates)];
282
+ const versions = await Promise.all(unique.map((cmd) => probePyVersion(cmd)));
168
283
  let fallback; // a real 3.x that exists but is out of range
169
- const seen = new Set();
170
- for (const cmd of candidates) {
171
- if (seen.has(cmd))
172
- continue;
173
- seen.add(cmd);
174
- const v = await probePyVersion(cmd);
284
+ for (let i = 0; i < unique.length; i++) {
285
+ const v = versions[i];
175
286
  if (!v || v.major !== 3)
176
287
  continue;
177
288
  if (v.minor >= PY_MIN_MINOR && v.minor <= PY_MAX_MINOR)
178
- return { ok: true, cmd, minor: v.minor };
289
+ return { ok: true, cmd: unique[i], minor: v.minor };
179
290
  if (!fallback)
180
291
  fallback = `Python ${v.major}.${v.minor}`;
181
292
  }
@@ -200,6 +311,8 @@ async function bootstrap() {
200
311
  }
201
312
  bootstrap().catch(async (e) => {
202
313
  console.error(chalk.red(`\n Fatal: ${e.message}\n`));
314
+ // The full error goes to the app's log (stderr above); the status line stays generic.
315
+ hostStatus("error", "The runner stopped unexpectedly. Copy diagnostics for details.");
203
316
  // Token is parsed inside main(); recover it from argv so a crash BEFORE the
204
317
  // socket still reports. Only the STAGE is sent — never the error text, which
205
318
  // routinely carries file paths and usernames.
@@ -209,5 +322,6 @@ bootstrap().catch(async (e) => {
209
322
  token: tokArg ? tokArg.replace(/^--token[= ]?/, "") : undefined,
210
323
  server: srvArg ? srvArg.replace(/^--server[= ]?/, "") : undefined,
211
324
  });
325
+ await flushHost();
212
326
  process.exit(1);
213
327
  });
@@ -0,0 +1,5 @@
1
+ export declare const COMPUTER_HELPER_VERSION = "1.0.0";
2
+ export declare const COMPUTER_HELPER_MANIFEST: Record<string, {
3
+ url: string;
4
+ sha256: string;
5
+ } | undefined>;
@@ -0,0 +1,9 @@
1
+ // GENERATED by .github/scripts/write-computer-helper-manifest.mjs in desktop-release.yml.
2
+ // Do not edit by hand: commit the file the release job uploads (artifact
3
+ // "computer-helper-manifest") once the helpers are published at these URLs.
4
+ //
5
+ // Pinned computer helper releases for the runner CLI host (computerHostCli.ts),
6
+ // keyed by `${process.platform}-${process.arch}`. A download is only accepted
7
+ // when its sha256 matches.
8
+ export const COMPUTER_HELPER_VERSION = "1.0.0";
9
+ export const COMPUTER_HELPER_MANIFEST = {};
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Computer control hosted by the runner CLI: `npx @melaya/runner --computer`.
3
+ *
4
+ * On a headless Linux machine or VM (and on any computer without the Melaya
5
+ * desktop app) the runner itself hosts the computer executor: it starts the
6
+ * native helper for this platform (desktop/native/computer-linux on Linux; the
7
+ * Windows / macOS helpers speak the same protocol) and drives it over stdio
8
+ * exactly as the desktop app's helperProcess.ts does. The protocol is
9
+ * desktop/native/PROTOCOL.md (v1):
10
+ *
11
+ * helper -> {"event":"ready",...} first line
12
+ * host -> {"id","op":"init","denyPatterns","selfPids"} <- {"id","ok":true}
13
+ * host -> {"id","op":"capabilities"} <- {"id","capabilities"}
14
+ * host -> {"id","op":"ping"} <- {"id","pong":true,"busy","current"}
15
+ * host -> {"id","op":"command","action","allowedAppIds","allowedApps","deadlineMs"} <- {"id","result"}
16
+ * host -> {"op":"cancel","target?"} / {"op":"shutdown"}
17
+ * helper -> events: target, user_input, log
18
+ *
19
+ * Like the desktop app, this host runs `batch` (step by step, per-step
20
+ * `expect`), `keep_awake` (systemd-inhibit / caffeinate) and `handoff` (no one
21
+ * is at a headless screen: unsupported) itself, and never sends them to the
22
+ * helper.
23
+ *
24
+ * computerRelay.ts (server socket <-> executor) loads this module lazily and
25
+ * calls createCliComputerHost({ send, verbose }) when the runner is not inside
26
+ * the desktop app. It returns null unless the CLI was started with --computer
27
+ * (enableCliComputerHost) or MELAYA_COMPUTER=1.
28
+ *
29
+ * Before anything reaches the helper this file enforces what the desktop app's
30
+ * bridge does (the runner cannot import desktop/): known kinds, batch limits,
31
+ * the allowlist, per-app tiers (the server's requiredTierOf,
32
+ * server/src/services/computer/computerEffects.ts) and a hard-deny list of apps
33
+ * no agent may touch (Melaya itself, authentication prompts, lock screens). The
34
+ * helper enforces the allowlist and tiers again per window.
35
+ *
36
+ * Helper binary: --computer-helper <path> or MELAYA_COMPUTER_HELPER, else
37
+ * <package>/native/<platform>-<arch>/, else the per-user cache
38
+ * ~/.melaya/computer/<version>/<platform>-<arch>/ where a pinned release (url +
39
+ * sha256 in HELPER_MANIFEST) is downloaded and verified. Types MIRROR
40
+ * packages/desktop-contract/src/computer.ts (this package cannot import it).
41
+ */
42
+ import { type ChildProcess } from "child_process";
43
+ export type ComputerAppTier = "view" | "click" | "full";
44
+ export interface AllowedApp {
45
+ appId: string;
46
+ tier: ComputerAppTier;
47
+ }
48
+ export type ComputerRunnerToHost = {
49
+ type: "computer.command";
50
+ id: string;
51
+ action: unknown;
52
+ deadlineMs: number;
53
+ allowedAppIds: string[];
54
+ allowedApps?: AllowedApp[];
55
+ } | {
56
+ type: "computer.stop";
57
+ reason: string;
58
+ } | {
59
+ type: "computer.capabilities.request";
60
+ };
61
+ export type ComputerHostToRunner = {
62
+ type: "computer.result";
63
+ id: string;
64
+ result: ComputerResultLike;
65
+ } | {
66
+ type: "computer.capabilities";
67
+ capabilities: ComputerCapabilitiesLike | null;
68
+ };
69
+ export type ComputerResultLike = Record<string, unknown> & {
70
+ ok: boolean;
71
+ kind: string;
72
+ elapsedMs: number;
73
+ };
74
+ export type ComputerCapabilitiesLike = Record<string, unknown> & {
75
+ platform: string;
76
+ backend: string;
77
+ kinds: string[];
78
+ host?: "desktop" | "cli";
79
+ };
80
+ export declare const COMPUTER_ACTION_KINDS: readonly ["screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps", "list_displays", "clipboard_read", "ocr", "wait", "hover", "move", "scroll", "focus_window", "open_app", "open_url", "open_file", "click", "type", "press", "drag", "select", "set_value", "toggle", "window", "clipboard_write", "file_dialog", "batch", "zoom", "cursor_position", "read_table", "expect", "mouse", "hold_key", "select_text", "quit_app", "save_dialog", "handoff", "keep_awake"];
81
+ /** Kinds that do not act on an app window: an empty allowlist does not block them. */
82
+ export declare const APP_INDEPENDENT_KINDS: Set<string>;
83
+ export declare const COMPUTER_MAX_BATCH_STEPS = 100;
84
+ /** The lowest app tier that allows this action (same rule as the server and the helpers). */
85
+ export declare function requiredTierOf(action: Record<string, unknown>): ComputerAppTier;
86
+ /**
87
+ * Apps no agent may read or drive, whatever the allowlist says. Sent to the
88
+ * helper at init (it adds them to its own built-in list) and checked here for
89
+ * actions that name an app and for app / window lists coming back. Plain
90
+ * regexes valid in JS, .NET and Rust.
91
+ */
92
+ export declare const HARD_DENY_SOURCES: string[];
93
+ export declare function isHardDenied(appId: string): boolean;
94
+ export type PolicyCheck = {
95
+ ok: true;
96
+ action: Record<string, unknown>;
97
+ allowedApps: AllowedApp[];
98
+ allowedAppIds: string[];
99
+ deadlineMs: number;
100
+ } | {
101
+ ok: false;
102
+ result: ComputerResultLike;
103
+ };
104
+ /**
105
+ * Everything the CLI host checks before a command reaches the helper: shape,
106
+ * kinds, batch limits, allowlist (minus hard-denied apps), the tier ceiling and
107
+ * the app an action names. Returns the sanitized allowlist to forward.
108
+ */
109
+ export declare function checkCommand(msg: {
110
+ action?: unknown;
111
+ allowedAppIds?: unknown;
112
+ allowedApps?: unknown;
113
+ deadlineMs?: unknown;
114
+ }): PolicyCheck;
115
+ /**
116
+ * Pinned helper releases, one per platform-arch, from computerHelperManifest.ts:
117
+ * generated by the release job (desktop-release.yml,
118
+ * .github/scripts/write-computer-helper-manifest.mjs) with the published URL
119
+ * and its sha256, and committed with the release. A download is only ever
120
+ * accepted when its sha256 matches. Empty until the first release: use
121
+ * MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
122
+ */
123
+ export declare const HELPER_VERSION = "1.0.0";
124
+ export declare const HELPER_MANIFEST: Record<string, {
125
+ url: string;
126
+ sha256: string;
127
+ } | undefined>;
128
+ export declare function platformKey(platform?: string, arch?: string): string;
129
+ export declare function helperCacheDir(env?: NodeJS.ProcessEnv): string;
130
+ /** Finds the helper binary on disk (no download). */
131
+ export declare function resolveHelperPath(opts?: {
132
+ helperPath?: string;
133
+ env?: NodeJS.ProcessEnv;
134
+ exists?: (p: string) => boolean;
135
+ }): string | null;
136
+ /** Downloads the pinned helper for this platform into the per-user cache and verifies its sha256. */
137
+ export declare function downloadHelper(opts?: {
138
+ env?: NodeJS.ProcessEnv;
139
+ fetchFn?: typeof fetch;
140
+ log?: (l: string) => void;
141
+ manifest?: typeof HELPER_MANIFEST;
142
+ }): Promise<string | null>;
143
+ export declare const HELPER_MISSING_MESSAGE = "The computer helper is not installed on this machine. Build it (desktop/native/computer-linux: ./build.sh) and start the runner with MELAYA_COMPUTER_HELPER=/path/to/melaya-computer, or pass --computer-helper.";
144
+ export type SpawnFn = (command: string, args: string[], options: {
145
+ stdio: ["pipe", "pipe", "pipe"];
146
+ env?: NodeJS.ProcessEnv;
147
+ }) => ChildProcess;
148
+ /** Splits a byte stream into JSON objects, one per line; tolerant of partial chunks, CRLF and noise. */
149
+ export declare class LineDecoder {
150
+ private buf;
151
+ push(chunk: string, onObject: (o: Record<string, unknown>) => void, onNoise?: (line: string) => void): void;
152
+ }
153
+ export interface HelperOptions {
154
+ spawn?: SpawnFn;
155
+ env?: NodeJS.ProcessEnv;
156
+ log?: (l: string) => void;
157
+ onEvent?: (e: Record<string, unknown>) => void;
158
+ /** How long a cancelled command may take to answer before the helper is killed (PROTOCOL.md §5). */
159
+ abortGraceMs?: number;
160
+ }
161
+ export declare class HelperProcess {
162
+ private readonly path;
163
+ private readonly opts;
164
+ private child;
165
+ private waiters;
166
+ private aborted;
167
+ private seq;
168
+ private restarts;
169
+ private disposed;
170
+ private starting;
171
+ private readyResolve;
172
+ ready: Record<string, unknown> | null;
173
+ constructor(path: string, opts?: HelperOptions);
174
+ private log;
175
+ start(): Promise<void>;
176
+ private onMessage;
177
+ private onExit;
178
+ get crashLooping(): boolean;
179
+ /** Writes one request and waits for the line with the same id. */
180
+ request(msg: Record<string, unknown>, timeoutMs: number): Promise<Record<string, unknown>>;
181
+ /** Fire-and-forget line. */
182
+ send(msg: Record<string, unknown>): void;
183
+ /**
184
+ * Aborts one command (or every one). A command that has not answered `stopped`
185
+ * within the grace period gets the helper killed; it restarts on the next command.
186
+ */
187
+ cancel(target?: string): void;
188
+ get running(): boolean;
189
+ dispose(): void;
190
+ }
191
+ /** Holds a sleep inhibitor child process: systemd-inhibit (Linux) or caffeinate (macOS). */
192
+ export declare class KeepAwake {
193
+ private readonly opts;
194
+ private child;
195
+ constructor(opts?: {
196
+ spawn?: SpawnFn;
197
+ env?: NodeJS.ProcessEnv;
198
+ platform?: string;
199
+ });
200
+ available(): string | null;
201
+ get active(): boolean;
202
+ acquire(): string | null;
203
+ release(): void;
204
+ }
205
+ export interface CliComputerHostOptions {
206
+ /** Results / capabilities pushed outside a handle() call (the relay's executor callback). */
207
+ send?: (msg: ComputerHostToRunner) => void;
208
+ verbose?: boolean;
209
+ /** Path to the helper binary (else MELAYA_COMPUTER_HELPER, the package's native/ folder, the per-user cache). */
210
+ helperPath?: string;
211
+ /** Force on / off (else enableCliComputerHost() or MELAYA_COMPUTER=1). */
212
+ enabled?: boolean;
213
+ /** Download the pinned helper when none is installed (default: true). */
214
+ download?: boolean;
215
+ env?: NodeJS.ProcessEnv;
216
+ spawn?: SpawnFn;
217
+ log?: (line: string) => void;
218
+ /** Test seam for keep_awake. */
219
+ keepAwake?: KeepAwake;
220
+ abortGraceMs?: number;
221
+ }
222
+ export interface CliComputerHostApi {
223
+ handle(msg: ComputerRunnerToHost): Promise<ComputerHostToRunner | void>;
224
+ capabilities(): Promise<ComputerCapabilitiesLike | null>;
225
+ stopAll(reason: string): void;
226
+ dispose(): void;
227
+ }
228
+ /** Called by cli.ts for `--computer`: later createCliComputerHost() calls (from the relay) are enabled. */
229
+ export declare function enableCliComputerHost(opts?: Partial<CliComputerHostOptions>): void;
230
+ export declare function isCliComputerEnabled(env?: NodeJS.ProcessEnv): boolean;
231
+ /** Test seam: forget enableCliComputerHost(). */
232
+ export declare function _resetCliComputerHost(): void;
233
+ export declare function createCliComputerHost(options?: CliComputerHostOptions): CliComputerHostApi | null;