@melaya/runner 1.1.72 → 1.1.74

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.
@@ -149,7 +149,9 @@ def _config_hash() -> str:
149
149
  ONLY when the capability env is set, so a browser-less host hashes exactly
150
150
  like a pre-browser server build (parity preserved). The TARGET GRANT itself
151
151
  is NEVER hashed and never in the env at boot: it arrives per turn on the
152
- turn frame and is purged at turn end (see _apply_browser_turn_grant)."""
152
+ turn frame and is purged at turn end (see _apply_browser_turn_grant).
153
+ Computer Control (protocol 5) follows the same rule: "computer" right after
154
+ "browser", only when MEL_ASSISTANT_COMPUTER_READY is set."""
153
155
  import hashlib
154
156
  raw_conn = os.environ.get("MEL_ASSISTANT_CONNECTORS", "") or ""
155
157
  connectors = ",".join(sorted(c.lower() for c in raw_conn.split(",") if c.strip()))
@@ -162,6 +164,11 @@ def _config_hash() -> str:
162
164
  ]
163
165
  if _browser_capable():
164
166
  fields.append("browser")
167
+ # Computer Control PRESENCE (protocol 5), same rule as browser: appended
168
+ # only when on, right after "browser", byte-for-byte like
169
+ # runnerAssistantRelay.ts assistantConfigHash (COMPUTER_HASH_MARKER).
170
+ if _computer_ready():
171
+ fields.append("computer")
165
172
  # Surgical tool pinning: PRESENCE of pinned tool names is part of the host
166
173
  # boot config, exactly like browser capability above — a warm host that
167
174
  # never registered a pinned tool active must reboot when a turn first
@@ -198,6 +205,14 @@ def _browser_capable() -> bool:
198
205
  return (os.environ.get("MEL_ASSISTANT_BROWSER_CAPABLE", "") or "") in ("1", "true", "True")
199
206
 
200
207
 
208
+ def _computer_ready() -> bool:
209
+ """Computer Control on this host (assistant protocol 5): the server sets
210
+ MEL_ASSISTANT_COMPUTER_READY when a computer is on for the chat (Devices
211
+ tile) or, without a selection, the user has one online. The server pins
212
+ every computer call to the chat's computers (/api/v1/computer/command)."""
213
+ return (os.environ.get("MEL_ASSISTANT_COMPUTER_READY", "") or "") in ("1", "true", "True")
214
+
215
+
201
216
  def _render_summary_text(summary) -> str:
202
217
  """Render a server AssistantSummary ({facts, provenance}) to a data-only text
203
218
  block for the compressed-summary slot. Never policy — reference only."""
@@ -918,10 +933,18 @@ def _build_agent():
918
933
  # are trusted-UI-only and never part of this category (plan 0.1).
919
934
  browser_enabled = _browser_capable()
920
935
 
936
+ # Computer Control (protocol 5): the computer_* toolkit (registry category
937
+ # "computer", shared/tools/computer.py) when the server says a computer is on
938
+ # for this chat. Each call posts to /api/v1/computer/command, where the
939
+ # server pins it to the chat's computers and applies the allowlist, app
940
+ # tiers, approvals and the kill switch.
941
+ computer_enabled = _computer_ready()
942
+
921
943
  # Read-only platform tools (melaya_agent); phone control (Device Control) on
922
944
  # mobile only. These POST to /api/v1/private/assistant-tool + /phone/command
923
945
  # with MELAYA_API_KEY; tenant scope is enforced server-side.
924
- categories = ["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
946
+ categories = (["melaya_agent"] + (["phone"] if phone_enabled else []) + (["browser"] if browser_enabled else [])
947
+ + (["computer"] if computer_enabled else []))
925
948
  # Connector tool sets the user enabled for THIS chat (any service — odoo,
926
949
  # stripe, shopify, …). When present, use a LAZY toolkit: base tools stay
927
950
  # active+pinned, and the connectors' tools are deferred + discoverable via
@@ -977,6 +1000,12 @@ def _build_agent():
977
1000
  # management + every browser action is directly callable, no search/activate.
978
1001
  if browser_enabled:
979
1002
  _budget = max(_budget, 64)
1003
+ # Same for the computer: ~38 computer_* tools must all stay pinned (no
1004
+ # search_tools/activate_tool per click, and an activated write would go
1005
+ # through the connector card on top of the server's own approval).
1006
+ # 64 holds melaya_agent + computer; each other device set adds ~40.
1007
+ if computer_enabled:
1008
+ _budget = max(_budget, 24 + 40 * (1 + int(phone_enabled) + int(browser_enabled)))
980
1009
  # Surgical tool pinning: widen the budget so every pinned tool fits
981
1010
  # alongside the base active set (melaya_agent + phone/browser if
982
1011
  # enabled) — mirrors the phone_enabled/browser_enabled widening above,
@@ -1124,6 +1153,45 @@ def _build_agent():
1124
1153
  "or route around it; tell the user if the task needs another site.\n"
1125
1154
  if browser_enabled else ""
1126
1155
  )
1156
+ # Computer Control discipline: a hand-mirror of the cloud Assistant's
1157
+ # computer guidance (assistantComputerTools.ts COMPUTER_PROMPT_HINT,
1158
+ # computerVerbs.ts COMPUTER_ROUTING / COMPUTER_LOOP / COMPUTER_LIMITS,
1159
+ # fastSchema.ts COMPUTER_FAST_GUIDANCE). Keep them in step.
1160
+ computer_rule = (
1161
+ "- COMPUTER CONTROL: you can operate the user's own computer (Windows, macOS or "
1162
+ "Linux, through the Melaya desktop app) with the computer_* tools. When the user "
1163
+ "asks whether you can access or use their computer, the answer is YES. Use the "
1164
+ "computer for desktop apps; prefer a connected service, the browser or the phone "
1165
+ "when they can do the job. Call computer_status first, and whenever a computer "
1166
+ "tool says the app is unreachable.\n"
1167
+ "- SEE THE SCREEN FIRST, then read, act, verify: read the window with "
1168
+ "computer_screen (computer_screenshot, computer_zoom or computer_ocr for a canvas, "
1169
+ "game, remote desktop or video that has no element tree), act on ONE element by "
1170
+ "its @eN ref or exact label, then check the result's observed change. A no_effect "
1171
+ "result means nothing changed: do not repeat the action, read again and pick "
1172
+ "another target. NEVER ask the user to describe, paste or screenshot their own "
1173
+ "screen: you have eyes, use them.\n"
1174
+ "- GO FAST: once you have read the window and can name the next steps, send them in "
1175
+ "ONE computer_fast call (click / type / select / press keys and chords / scroll / "
1176
+ "wait / expect / read by the names you read, collect for lists, for_each for rows) "
1177
+ "instead of one call per click; it re-finds every target, stops at the first "
1178
+ "surprise and hands the window back. computer_batch runs a known list of actions "
1179
+ "in one call; computer_path draws or traces strokes (canvas ink, signatures, "
1180
+ "sliders). Use the single computer_* tools to explore an unread window, for "
1181
+ "logins, secure fields and file dialogs.\n"
1182
+ "- APPROVALS AND LIMITS: writes follow this chat's autonomy mode, decided by the "
1183
+ "SYSTEM. If a result says an approval is waiting, a card is already in front of "
1184
+ "the user: re-issue the SAME call once, never ask in chat instead. Only apps the "
1185
+ "user allowed in Computer Control can be driven, each at its level (not_allowed / "
1186
+ "tier_denied: ask the user to allow it, never open another app or work around it). "
1187
+ "Never type into password, PIN, OTP or card fields (secure_field), and let the user "
1188
+ "answer admin / UAC prompts themselves (elevated_window, secure_desktop).\n"
1189
+ "- STOP: the user can stop you at any moment with the Stop button or the stop "
1190
+ "shortcut (Ctrl+Alt+End). After a stopped result make no more computer calls and "
1191
+ "report honestly what was done. Call computer_stop yourself the moment the user "
1192
+ "says stop or something goes wrong on screen.\n"
1193
+ if computer_enabled else ""
1194
+ )
1127
1195
  # Surgical tool pinning: those exact tool names are already ACTIVE (not
1128
1196
  # deferred), so the rule says to call them directly. Per-turn connectors
1129
1197
  # (protocol 3): connector_services is empty here and the rule is rendered per
@@ -1197,12 +1265,15 @@ def _build_agent():
1197
1265
  "mode the user approves each one). That is your CURRENT TOOLBOX, not a "
1198
1266
  "description of Melaya"
1199
1267
  + (" — plus phone-control tools to drive the user's paired phone." if phone_enabled else ".")
1268
+ + (" Computer-control tools (computer_*) drive the user's own computer through the "
1269
+ "Melaya desktop app." if computer_enabled else "")
1200
1270
  + "\nRules:\n"
1201
1271
  "- Use tools to answer questions about the user's pipelines, spend, usage, "
1202
1272
  "templates or evals — never invent numbers. For cost, melaya_cost_summary "
1203
1273
  "supports dimension='pipeline' to find which pipeline cost the most.\n"
1204
1274
  + phone_rule
1205
1275
  + browser_rule
1276
+ + computer_rule
1206
1277
  + connector_rule
1207
1278
  + core_rule
1208
1279
  + "- Be concise and concrete; format small tables or bullet lists when comparing items.\n"
@@ -1279,7 +1350,8 @@ def _build_agent():
1279
1350
  # wall-clock budget is enforced separately in _run_turn.
1280
1351
  max_iters=(int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_PHONE", "300") or "300") if phone_enabled
1281
1352
  else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_BROWSER", "300") or "300") if browser_enabled
1282
- else (40 if (connector_services or _turn_connectors_mode()) else 8))),
1353
+ else (int(os.environ.get("MEL_ASSISTANT_MAX_ITERS_COMPUTER", "300") or "300") if computer_enabled
1354
+ else (40 if (connector_services or _turn_connectors_mode()) else 8)))),
1283
1355
  # EVERY assistant turn is long-horizon, not only device-driving ones.
1284
1356
  #
1285
1357
  # The codex cap is a latency default written for pipeline phases. An
@@ -1,4 +1,4 @@
1
- export declare const COMPUTER_HELPER_VERSION = "1.0.1";
1
+ export declare const COMPUTER_HELPER_VERSION = "1.2.0";
2
2
  export declare const COMPUTER_HELPER_MANIFEST: Record<string, {
3
3
  url: string;
4
4
  sha256: string;
@@ -5,14 +5,14 @@
5
5
  // Pinned computer helper releases for the runner CLI host (computerHostCli.ts),
6
6
  // keyed by `${process.platform}-${process.arch}`. A download is only accepted
7
7
  // when its sha256 matches.
8
- export const COMPUTER_HELPER_VERSION = "1.0.1";
8
+ export const COMPUTER_HELPER_VERSION = "1.2.0";
9
9
  export const COMPUTER_HELPER_MANIFEST = {
10
10
  "linux-arm64": {
11
- "url": "https://api.melaya.org/desktop/helpers/1.0.1/melaya-computer-linux-aarch64",
12
- "sha256": "f5e6825fc00b589178b0540ed6f2e350dc8ac77bb97e08ae17395282465fec36"
11
+ "url": "https://api.melaya.org/desktop/helpers/1.2.0/melaya-computer-linux-aarch64",
12
+ "sha256": "8f1ddb5a4bdcc7bda35439de6ec1aa962e16b199dcb510e52fb48c00782eeef9"
13
13
  },
14
14
  "linux-x64": {
15
- "url": "https://api.melaya.org/desktop/helpers/1.0.1/melaya-computer-linux-x86_64",
16
- "sha256": "795933f10803f2bc25c95207e5a196ff6413e2e55f038ccd54c3f6f19e7c764c"
15
+ "url": "https://api.melaya.org/desktop/helpers/1.2.0/melaya-computer-linux-x86_64",
16
+ "sha256": "1431df7dd6f6a5d8412e4120fdadc82c247965a1139b03dd585ce93b21d5e88a"
17
17
  }
18
18
  };
@@ -77,10 +77,11 @@ export type ComputerCapabilitiesLike = Record<string, unknown> & {
77
77
  kinds: string[];
78
78
  host?: "desktop" | "cli";
79
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"];
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", "path"];
81
81
  /** Kinds that do not act on an app window: an empty allowlist does not block them. */
82
82
  export declare const APP_INDEPENDENT_KINDS: Set<string>;
83
- export declare const COMPUTER_MAX_BATCH_STEPS = 100;
83
+ /** Steps per batch: a whole drawing in one call (raised from 100 on 2026-10-23). */
84
+ export declare const COMPUTER_MAX_BATCH_STEPS = 5000;
84
85
  /** The lowest app tier that allows this action (same rule as the server and the helpers). */
85
86
  export declare function requiredTierOf(action: Record<string, unknown>): ComputerAppTier;
86
87
  /**
@@ -120,7 +121,7 @@ export declare function checkCommand(msg: {
120
121
  * accepted when its sha256 matches. Empty until the first release: use
121
122
  * MELAYA_COMPUTER_HELPER or --computer-helper meanwhile.
122
123
  */
123
- export declare const HELPER_VERSION = "1.0.1";
124
+ export declare const HELPER_VERSION = "1.2.0";
124
125
  export declare const HELPER_MANIFEST: Record<string, {
125
126
  url: string;
126
127
  sha256: string;
@@ -184,6 +185,8 @@ export declare class HelperProcess {
184
185
  * Aborts one command (or every one). A command that has not answered `stopped`
185
186
  * within the grace period gets the helper killed; it restarts on the next command.
186
187
  */
188
+ /** Releases every key / button the helper holds (helper 1.2.0+; older helpers ignore the op). */
189
+ release(): void;
187
190
  cancel(target?: string): void;
188
191
  get running(): boolean;
189
192
  dispose(): void;
@@ -56,6 +56,8 @@ export const COMPUTER_ACTION_KINDS = [
56
56
  "toggle", "window", "clipboard_write", "file_dialog", "batch",
57
57
  "zoom", "cursor_position", "read_table", "expect", "mouse", "hold_key", "select_text", "quit_app",
58
58
  "save_dialog", "handoff", "keep_awake",
59
+ // helper 1.2.0 (2026-10-23): pointer paths (planned by the server, replayed by the helper)
60
+ "path",
59
61
  ];
60
62
  const READ_KINDS = new Set([
61
63
  "screen", "screenshot", "find", "get_text", "current_app", "list_windows", "list_apps",
@@ -67,7 +69,8 @@ const KIND_SET = new Set(COMPUTER_ACTION_KINDS);
67
69
  export const APP_INDEPENDENT_KINDS = new Set(["list_apps", "list_displays", "cursor_position", "keep_awake", "handoff"]);
68
70
  /** File handles need the desktop app's handle store (it sends `resolved.path`); the CLI has none. */
69
71
  const NEEDS_FILE_HANDLES = new Set(["open_file", "file_dialog", "save_dialog"]);
70
- export const COMPUTER_MAX_BATCH_STEPS = 100;
72
+ /** Steps per batch: a whole drawing in one call (raised from 100 on 2026-10-23). */
73
+ export const COMPUTER_MAX_BATCH_STEPS = 5000;
71
74
  const DEFAULT_DEADLINE_MS = 15_000;
72
75
  const MAX_DEADLINE_MS = 31 * 60_000;
73
76
  const PROTOCOL_VERSION = 1;
@@ -465,6 +468,10 @@ export class HelperProcess {
465
468
  * Aborts one command (or every one). A command that has not answered `stopped`
466
469
  * within the grace period gets the helper killed; it restarts on the next command.
467
470
  */
471
+ /** Releases every key / button the helper holds (helper 1.2.0+; older helpers ignore the op). */
472
+ release() {
473
+ this.send({ op: "release" });
474
+ }
468
475
  cancel(target) {
469
476
  this.send(target ? { op: "cancel", target } : { op: "cancel" });
470
477
  if (!target || !this.child)
@@ -734,6 +741,11 @@ export function createCliComputerHost(options = {}) {
734
741
  const forced = !expectFailed && ["stopped", "timeout", "user_interrupted", "screen_locked", "secure_desktop"].includes(code);
735
742
  if (onError || forced) {
736
743
  const msg = r.error?.message || "";
744
+ // A batch that did not finish leaves nothing pressed (hold_key / mouse down without their up).
745
+ try {
746
+ helper?.release();
747
+ }
748
+ catch { /* best effort */ }
737
749
  return { ok: false, kind: "batch", error: { code, message: `Step ${i + 1} failed: ${msg}` }, steps: results, elapsedMs: Date.now() - started };
738
750
  }
739
751
  }
@@ -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.
@@ -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.72",
3
+ "version": "1.1.74",
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",