@melaya/runner 1.1.73 → 1.1.75

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.
@@ -36,6 +36,8 @@ export const COMPUTER_EVENTS = {
36
36
  export const UNSUPPORTED_MESSAGE = "Computer control needs the Melaya desktop app on this computer.";
37
37
  const MIN_DEADLINE_MS = 1_000;
38
38
  const MAX_DEADLINE_MS = 31 * 60_000;
39
+ /** Most pause time (user at the keyboard) one command may add to its deadline (contract COMPUTER_PAUSE.budgetMs, with room). */
40
+ const MAX_PAUSE_BUDGET_MS = 300_000;
39
41
  const MAX_PENDING = 64;
40
42
  // The CLI host module is optional (it may be absent from a build): load it by
41
43
  // a variable specifier so this file compiles and runs without it.
@@ -173,14 +175,19 @@ export function attachComputerRelay(socket, opts = {}) {
173
175
  const deadlineMs = Math.min(MAX_DEADLINE_MS, Math.max(MIN_DEADLINE_MS, Math.floor(Number(p.deadlineMs) || 15_000)));
174
176
  const allowedAppIds = Array.isArray(p.allowedAppIds) ? p.allowedAppIds.filter((x) => typeof x === "string") : [];
175
177
  const allowedApps = Array.isArray(p.allowedApps) ? p.allowedApps : undefined;
178
+ // Pause and resume (2026-10-23): time the executor may spend paused for the user on top of the deadline
179
+ // (sent only to executors that report pauseResume; the server waits that much longer too).
180
+ const budget = Math.floor(Number(p.pauseBudgetMs));
181
+ const pauseBudgetMs = Number.isFinite(budget) && budget > 0 ? Math.min(MAX_PAUSE_BUDGET_MS, budget) : 0;
176
182
  const msg = {
177
183
  type: "computer.command", id, action, deadlineMs, allowedAppIds,
178
184
  ...(allowedApps ? { allowedApps } : {}),
185
+ ...(pauseBudgetMs ? { pauseBudgetMs } : {}),
179
186
  };
180
187
  const startedAt = Date.now();
181
188
  const timer = setTimeout(() => {
182
189
  settle(id, errorResult(kind, "timeout", "The computer did not finish before the deadline. Read the screen before retrying: the action may or may not have happened.", Date.now() - startedAt));
183
- }, deadlineMs);
190
+ }, deadlineMs + pauseBudgetMs);
184
191
  pending.set(id, { ack, kind, startedAt, timer });
185
192
  if (hosted) {
186
193
  log(` [computer] ${kind} -> desktop app`);
@@ -44,6 +44,9 @@ import { newTurnFileState, readTurnFile, turnOutputDir } from "./assistantFiles.
44
44
  // registered on this computer, served after the runner verifies each
45
45
  // capability itself.
46
46
  import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, attachVaultSocketHandlers, findVaultHostScript, setVaultRelayPort, vaultToolEnv, trustRunVaultCapability, } from "./vaults.js";
47
+ // Stored plans: the server reads ~/.melaya/plans/<name>.json through this runner
48
+ // for the grouped-step tools' plan: {file} source (runner:plan-read).
49
+ import { attachPlanSocketHandlers } from "./stepPlans.js";
47
50
  // Obsidian app bridge: App ops through the user's running Obsidian (CLI /
48
51
  // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
49
52
  import { ObsidianAppBridge } from "./obsidianApp.js";
@@ -106,7 +109,11 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
106
109
  // to the server as `file` frames with their bytes, under the caps and path
107
110
  // confinement of assistantFiles.ts. A server that does not send `fileDelivery`
108
111
  // gets exactly the v3 behaviour (files stay on this computer only).
109
- const ASSISTANT_PROTOCOL_VERSION = 4;
112
+ // v5: Computer Control. A start / prewarm frame may carry `computerReady`; the
113
+ // host then registers the computer_* toolkit (MEL_ASSISTANT_COMPUTER_READY) and
114
+ // hashes "computer" after "browser". A server only sends it to a v5 runner, so
115
+ // an older host never boots config-stale over it.
116
+ const ASSISTANT_PROTOCOL_VERSION = 5;
110
117
  // Best-effort read of our own package version (mirrors cli.ts). Advertised in the
111
118
  // hello so the server can log / gate on a package floor too.
112
119
  let RUNNER_VERSION = "0.0.0";
@@ -305,6 +312,7 @@ export async function connect(opts) {
305
312
  app: new ObsidianAppBridge({ dir: vaultRegistry.dir, log: vaultLog }),
306
313
  });
307
314
  attachVaultSocketHandlers(socket, vaultService, () => pickNativeFolder("Select your Obsidian vault folder"));
315
+ attachPlanSocketHandlers(socket);
308
316
  if (vaultRegistry.list().length) {
309
317
  void vaultService.restore().catch((e) => vaultLog(`restore failed: ${e?.message || e}`));
310
318
  }
@@ -2024,6 +2032,12 @@ export async function connect(opts) {
2024
2032
  // always hash "browser" while a non-browser server hash omitted it, which
2025
2033
  // pinned every turn to a permanent runner_host_config_stale.
2026
2034
  MEL_ASSISTANT_BROWSER_CAPABLE: payload.browserCapable ? "1" : "",
2035
+ // Computer Control (v5): the computer_* toolkit joins the host when a
2036
+ // computer is on for the chat (Devices tile) or, with no selection, the
2037
+ // user has one online. Part of the config hash ("computer", appended only
2038
+ // when set), so switching the computer on or off reboots the host. The
2039
+ // server pins every computer call to the chat's computers itself.
2040
+ MEL_ASSISTANT_COMPUTER_READY: payload.computerReady ? "1" : "",
2027
2041
  // Connector tool sets the user enabled for this chat (comma-joined service
2028
2042
  // ids). The host seeds a lazy toolkit from these so ANY connector's tools
2029
2043
  // are reachable without exploding context.
@@ -53,6 +53,7 @@ export type RunnerToHostMessage = {
53
53
  tier: "view" | "click" | "full";
54
54
  expiresAt?: string;
55
55
  }>;
56
+ pauseBudgetMs?: number;
56
57
  } | {
57
58
  type: "computer.stop";
58
59
  reason: string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Stored plans on the user's own computer: the `plan: { file: "<name>.json" }`
3
+ * source of the grouped-step tools (computer batch / path / fast, browser and
4
+ * phone batch / fast). A local agent writes a big step list (a drawing, a
5
+ * 3000-step batch) to ~/.melaya/plans/<name>.json and references it; the
6
+ * server asks this runner for the file (runner:plan-read, answered on the
7
+ * socket.io ack) instead of the model writing every step into the call.
8
+ *
9
+ * Only ONE folder, ONE plain file name: no separators, no traversal, no hidden
10
+ * files, .json only, no symbolic links (the file itself, or the folder resolving
11
+ * elsewhere), no hard-linked files, at most 2 MB. The content is returned as
12
+ * text and parsed by the server; it is never executed or logged here.
13
+ *
14
+ * The desktop app runs this same runner (utilityProcess), so it answers too.
15
+ */
16
+ export declare const PLAN_FILE_MAX_BYTES: number;
17
+ /** A plain file name: same rule as the server (services/plans/stepPlans.ts). */
18
+ export declare const PLAN_FILE_RE: RegExp;
19
+ /** ~/.melaya/plans */
20
+ export declare function plansDir(home?: string): string;
21
+ export type PlanReadResult = {
22
+ ok: true;
23
+ text: string;
24
+ bytes: number;
25
+ } | {
26
+ ok: false;
27
+ code: "bad_name" | "not_found" | "not_a_file" | "too_large" | "outside" | "read_failed";
28
+ message: string;
29
+ };
30
+ /** Read one plan file from `dir` (default ~/.melaya/plans, created when missing). */
31
+ export declare function readPlanFile(name: unknown, dir?: string): Promise<PlanReadResult>;
32
+ /** The subset of a socket.io-client Socket used here (a fake in tests). */
33
+ export interface PlanSocketLike {
34
+ on(event: string, cb: (...args: any[]) => void): unknown;
35
+ }
36
+ /** runner:plan-read {file} -> ack(PlanReadResult). Old servers never send it. */
37
+ export declare function attachPlanSocketHandlers(socket: PlanSocketLike, dir?: () => string): void;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Stored plans on the user's own computer: the `plan: { file: "<name>.json" }`
3
+ * source of the grouped-step tools (computer batch / path / fast, browser and
4
+ * phone batch / fast). A local agent writes a big step list (a drawing, a
5
+ * 3000-step batch) to ~/.melaya/plans/<name>.json and references it; the
6
+ * server asks this runner for the file (runner:plan-read, answered on the
7
+ * socket.io ack) instead of the model writing every step into the call.
8
+ *
9
+ * Only ONE folder, ONE plain file name: no separators, no traversal, no hidden
10
+ * files, .json only, no symbolic links (the file itself, or the folder resolving
11
+ * elsewhere), no hard-linked files, at most 2 MB. The content is returned as
12
+ * text and parsed by the server; it is never executed or logged here.
13
+ *
14
+ * The desktop app runs this same runner (utilityProcess), so it answers too.
15
+ */
16
+ import { constants as FS, promises as fsp } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ export const PLAN_FILE_MAX_BYTES = 2 * 1024 * 1024;
20
+ /** A plain file name: same rule as the server (services/plans/stepPlans.ts). */
21
+ export const PLAN_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.json$/;
22
+ /** ~/.melaya/plans */
23
+ export function plansDir(home = homedir()) {
24
+ return join(home, ".melaya", "plans");
25
+ }
26
+ const fail = (code, message) => ({ ok: false, code, message });
27
+ /** Read one plan file from `dir` (default ~/.melaya/plans, created when missing). */
28
+ export async function readPlanFile(name, dir = plansDir()) {
29
+ if (typeof name !== "string" || !PLAN_FILE_RE.test(name)) {
30
+ return fail("bad_name", "a plan file is a plain .json name inside ~/.melaya/plans/ (letters, digits, . _ -).");
31
+ }
32
+ try {
33
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
34
+ }
35
+ catch { /* reported below as not_found */ }
36
+ const full = join(dir, name);
37
+ let st;
38
+ try {
39
+ st = await fsp.lstat(full);
40
+ }
41
+ catch (e) {
42
+ return e?.code === "ENOENT"
43
+ ? fail("not_found", `~/.melaya/plans/${name} does not exist.`)
44
+ : fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
45
+ }
46
+ if (st.isSymbolicLink())
47
+ return fail("outside", "symbolic links are not followed in ~/.melaya/plans/.");
48
+ if (!st.isFile())
49
+ return fail("not_a_file", `~/.melaya/plans/${name} is not a regular file.`);
50
+ if (st.nlink > 1)
51
+ return fail("outside", "hard-linked files are not read from ~/.melaya/plans/.");
52
+ if (st.size > PLAN_FILE_MAX_BYTES)
53
+ return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
54
+ // The folder itself may be a link: the file's real location must be the plans folder.
55
+ try {
56
+ const [realDir, realFile] = await Promise.all([fsp.realpath(dir), fsp.realpath(full)]);
57
+ if (dirname(realFile) !== realDir)
58
+ return fail("outside", "the plan file resolves outside ~/.melaya/plans/.");
59
+ }
60
+ catch {
61
+ return fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
62
+ }
63
+ let fh = null;
64
+ try {
65
+ // O_NOFOLLOW where the platform has it (not on Windows: lstat above covers it).
66
+ fh = await fsp.open(full, FS.O_RDONLY | (FS.O_NOFOLLOW ?? 0));
67
+ const st2 = await fh.stat();
68
+ if (!st2.isFile() || st2.size > PLAN_FILE_MAX_BYTES)
69
+ return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
70
+ const buf = Buffer.alloc(Math.min(st2.size, PLAN_FILE_MAX_BYTES) + 1);
71
+ let read = 0;
72
+ while (read < buf.length) {
73
+ const { bytesRead } = await fh.read(buf, read, buf.length - read, read);
74
+ if (!bytesRead)
75
+ break;
76
+ read += bytesRead;
77
+ }
78
+ if (read > PLAN_FILE_MAX_BYTES)
79
+ return fail("too_large", `~/.melaya/plans/${name} is larger than 2 MB.`);
80
+ return { ok: true, text: buf.subarray(0, read).toString("utf8"), bytes: read };
81
+ }
82
+ catch (e) {
83
+ return e?.code === "ELOOP"
84
+ ? fail("outside", "symbolic links are not followed in ~/.melaya/plans/.")
85
+ : fail("read_failed", `~/.melaya/plans/${name} could not be read.`);
86
+ }
87
+ finally {
88
+ await fh?.close().catch(() => undefined);
89
+ }
90
+ }
91
+ /** runner:plan-read {file} -> ack(PlanReadResult). Old servers never send it. */
92
+ export function attachPlanSocketHandlers(socket, dir) {
93
+ socket.on("runner:plan-read", async (p, ack) => {
94
+ if (typeof ack !== "function")
95
+ return;
96
+ try {
97
+ ack(await readPlanFile(p?.file, dir ? dir() : plansDir()));
98
+ }
99
+ catch {
100
+ ack(fail("read_failed", "the plan file could not be read."));
101
+ }
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.73",
3
+ "version": "1.1.75",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",