@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.
- package/dist/assistantHost.py +75 -3
- package/dist/computerHostCli.d.ts +10 -1
- package/dist/computerHostCli.js +191 -49
- package/dist/computerPause.d.ts +179 -0
- package/dist/computerPause.js +488 -0
- package/dist/computerRelay.js +8 -1
- package/dist/connection.js +15 -1
- package/dist/hostChannel.d.ts +1 -0
- package/dist/stepPlans.d.ts +37 -0
- package/dist/stepPlans.js +103 -0
- package/package.json +1 -1
package/dist/computerRelay.js
CHANGED
|
@@ -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`);
|
package/dist/connection.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/dist/hostChannel.d.ts
CHANGED
|
@@ -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
|
+
}
|