@melaya/runner 1.1.68 → 1.1.70
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/browserBridge.js +178 -0
- package/dist/browserFast.d.ts +144 -0
- package/dist/browserFast.js +363 -0
- package/dist/browserProvisioner.d.ts +39 -0
- package/dist/browserProvisioner.js +96 -0
- package/dist/cli.js +190 -65
- package/dist/codeWorker.js +33 -17
- package/dist/computerHelperManifest.d.ts +5 -0
- package/dist/computerHelperManifest.js +18 -0
- package/dist/computerHostCli.d.ts +233 -0
- package/dist/computerHostCli.js +815 -0
- package/dist/computerRelay.d.ts +73 -0
- package/dist/computerRelay.js +280 -0
- package/dist/connection.d.ts +18 -2
- package/dist/connection.js +322 -82
- package/dist/detect.d.ts +40 -1
- package/dist/detect.js +194 -36
- package/dist/hostChannel.d.ts +126 -0
- package/dist/hostChannel.js +117 -0
- package/dist/hostFiles.d.ts +25 -0
- package/dist/hostFiles.js +90 -0
- package/dist/linkedinLogin.js +13 -17
- package/dist/lumaLogin.js +11 -17
- package/dist/modelRescan.d.ts +36 -0
- package/dist/modelRescan.js +80 -0
- package/dist/processTree.d.ts +65 -0
- package/dist/processTree.js +237 -0
- package/dist/publishGate.js +14 -4
- package/dist/pythonEnv.js +6 -7
- package/dist/sharedVendor.js +3 -1
- package/dist/singleInstance.js +10 -1
- package/dist/startupCache.d.ts +22 -0
- package/dist/startupCache.js +45 -0
- package/dist/startupTimings.d.ts +34 -0
- package/dist/startupTimings.js +81 -0
- package/dist/uvBootstrap.js +32 -24
- package/dist/vaults.js +3 -1
- package/package.json +1 -1
package/dist/detect.d.ts
CHANGED
|
@@ -2,4 +2,43 @@ export interface DetectedModel {
|
|
|
2
2
|
provider: "lmstudio" | "ollama" | "claude_code" | "codex" | "github_copilot";
|
|
3
3
|
name: string;
|
|
4
4
|
}
|
|
5
|
-
|
|
5
|
+
/** Per-probe deadlines (ms). Local servers answer in milliseconds when up. */
|
|
6
|
+
export declare const PROBE_TIMEOUTS_MS: {
|
|
7
|
+
readonly lmstudio: 2500;
|
|
8
|
+
readonly ollama: 2500;
|
|
9
|
+
readonly claude_code: 9000;
|
|
10
|
+
readonly codex: 3000;
|
|
11
|
+
readonly github_copilot: 13000;
|
|
12
|
+
};
|
|
13
|
+
/** Test seam. */
|
|
14
|
+
export declare function _clearDetectCaches(): void;
|
|
15
|
+
export type ProviderId = DetectedModel["provider"];
|
|
16
|
+
export interface ProviderScan {
|
|
17
|
+
/** Models in a stable provider order: lmstudio, ollama, claude_code, codex, github_copilot. */
|
|
18
|
+
models: DetectedModel[];
|
|
19
|
+
/** Local servers answering right now (a server with zero models still counts). */
|
|
20
|
+
running: {
|
|
21
|
+
lmstudio: boolean;
|
|
22
|
+
ollama: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** Per-provider probe duration, and which probes hit their deadline. */
|
|
25
|
+
probeMs: Record<ProviderId, number>;
|
|
26
|
+
timedOut: ProviderId[];
|
|
27
|
+
/** Wall time of the whole scan. */
|
|
28
|
+
ms: number;
|
|
29
|
+
at: number;
|
|
30
|
+
}
|
|
31
|
+
export interface ScanOptions {
|
|
32
|
+
/** Bypass the per-credential network cache (a user-triggered refresh). */
|
|
33
|
+
force?: boolean;
|
|
34
|
+
/** Override per-probe deadlines (tests). */
|
|
35
|
+
timeouts?: Partial<Record<ProviderId, number>>;
|
|
36
|
+
}
|
|
37
|
+
/** Scan every provider in parallel. Concurrent callers share one scan. */
|
|
38
|
+
export declare function scanProviders(opts?: ScanOptions): Promise<ProviderScan>;
|
|
39
|
+
/** The last completed scan, when it is younger than maxAgeMs. */
|
|
40
|
+
export declare function cachedScan(maxAgeMs: number): ProviderScan | null;
|
|
41
|
+
/** Models only (kept for existing callers). Same parallel scan as scanProviders(). */
|
|
42
|
+
export declare function detectModels(opts?: ScanOptions): Promise<DetectedModel[]>;
|
|
43
|
+
/** Fingerprint of the local provider state (files + ports). No network, no subprocess. */
|
|
44
|
+
export declare function providerFingerprint(): Promise<string>;
|
package/dist/detect.js
CHANGED
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { execFile } from "child_process";
|
|
6
6
|
import { promisify } from "util";
|
|
7
|
-
import { readFile } from "fs/promises";
|
|
7
|
+
import { readFile, stat } from "fs/promises";
|
|
8
|
+
import { createHash } from "crypto";
|
|
8
9
|
import { homedir } from "os";
|
|
9
10
|
import { join } from "path";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
// Fallback ONLY when the live /models fetch fails. Must be ENABLED-by-default
|
|
12
13
|
// Copilot models (policy!=disabled) — Claude/Gemini default to disabled and 400.
|
|
13
|
-
|
|
14
|
+
// gpt-4o / gpt-4.1 / gpt-5-mini are retired from Copilot (github.blog changelog,
|
|
15
|
+
// 2026-10-19); GitHub names GPT-6 Luna as the GPT-5 mini replacement.
|
|
16
|
+
const COPILOT_MODELS_FALLBACK = ["gpt-6-luna"];
|
|
14
17
|
/** Fallbacks used ONLY when live discovery fails (offline runner, transient
|
|
15
18
|
* error, missing cache). The REAL lists are fetched dynamically below so a new
|
|
16
19
|
* claude/codex release is available on Melaya automatically — no code change,
|
|
17
20
|
* no redeploy. Kept minimal + in step with the registry `staticModels`. */
|
|
18
|
-
const CODEX_MODELS_FALLBACK = ["gpt-
|
|
21
|
+
const CODEX_MODELS_FALLBACK = ["gpt-6.1-sol", "gpt-6-astra", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"];
|
|
19
22
|
const CLAUDE_ALIASES_FALLBACK = ["sonnet", "opus", "fable", "haiku"];
|
|
20
23
|
/** Discover the claude model FAMILIES this subscription serves, so a new
|
|
21
24
|
* family (a future tier) appears on Melaya automatically. Hits the same
|
|
@@ -24,7 +27,10 @@ const CLAUDE_ALIASES_FALLBACK = ["sonnet", "opus", "fable", "haiku"];
|
|
|
24
27
|
* to their family aliases (opus/sonnet/haiku/…). Each alias resolves to the
|
|
25
28
|
* NEWEST model of its family at call time, so within-family releases are picked
|
|
26
29
|
* up for free too. Best-effort: any failure falls back to the known aliases. */
|
|
27
|
-
async function fetchClaudeFamilies(token) {
|
|
30
|
+
async function fetchClaudeFamilies(token, force = false) {
|
|
31
|
+
const cached = networkCacheGet(`claude:${tokenKey(token)}`, force);
|
|
32
|
+
if (cached)
|
|
33
|
+
return cached;
|
|
28
34
|
try {
|
|
29
35
|
const r = await fetch("https://api.anthropic.com/v1/models?limit=100", {
|
|
30
36
|
headers: {
|
|
@@ -45,7 +51,7 @@ async function fetchClaudeFamilies(token) {
|
|
|
45
51
|
fams.add(mm[1]);
|
|
46
52
|
}
|
|
47
53
|
if (fams.size)
|
|
48
|
-
return [...fams];
|
|
54
|
+
return networkCacheSet(`claude:${tokenKey(token)}`, [...fams]);
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
catch { /* offline / transient → fallback */ }
|
|
@@ -80,7 +86,7 @@ async function readCodexModels() {
|
|
|
80
86
|
* of truth on Windows/Linux; macOS keeps the grant in the Keychain, where
|
|
81
87
|
* `security` is a subprocess but pops NO window. Zero windows, faster, and
|
|
82
88
|
* it mirrors exactly what the runtime (model.py `_read_claude_oauth`) reads. */
|
|
83
|
-
async function detectClaudeCode() {
|
|
89
|
+
async function detectClaudeCode(force = false) {
|
|
84
90
|
let raw = null;
|
|
85
91
|
try {
|
|
86
92
|
raw = await readFile(join(homedir(), ".claude", ".credentials.json"), "utf8");
|
|
@@ -102,7 +108,7 @@ async function detectClaudeCode() {
|
|
|
102
108
|
const oauth = (JSON.parse(raw)?.claudeAiOauth ?? {});
|
|
103
109
|
const expiresAt = Number(oauth.expiresAt ?? 0);
|
|
104
110
|
if (oauth.accessToken && (!expiresAt || Date.now() < expiresAt)) {
|
|
105
|
-
const names = await fetchClaudeFamilies(oauth.accessToken);
|
|
111
|
+
const names = await fetchClaudeFamilies(oauth.accessToken, force);
|
|
106
112
|
return names.map((name) => ({ provider: "claude_code", name }));
|
|
107
113
|
}
|
|
108
114
|
}
|
|
@@ -178,10 +184,21 @@ async function readGithubCopilotToken() {
|
|
|
178
184
|
* is present on THIS machine, exchange it for a short-lived Copilot token and
|
|
179
185
|
* list the models the subscription serves, so the picker shows TRUE local
|
|
180
186
|
* availability (copilot pipelines execute HERE, same as claude_code/codex). */
|
|
181
|
-
async function detectGithubCopilot() {
|
|
187
|
+
async function detectGithubCopilot(force = false) {
|
|
182
188
|
const gh = await readGithubCopilotToken();
|
|
183
189
|
if (!gh)
|
|
184
190
|
return [];
|
|
191
|
+
const key = `copilot:${tokenKey(gh)}`;
|
|
192
|
+
const cached = networkCacheGet(key, force);
|
|
193
|
+
if (cached)
|
|
194
|
+
return cached;
|
|
195
|
+
const result = await detectGithubCopilotWith(gh);
|
|
196
|
+
if (result)
|
|
197
|
+
networkCacheSet(key, result);
|
|
198
|
+
return result ?? [];
|
|
199
|
+
}
|
|
200
|
+
/** The Copilot token exchange + model list. null = offline / transient (not cached). */
|
|
201
|
+
async function detectGithubCopilotWith(gh) {
|
|
185
202
|
try {
|
|
186
203
|
const ex = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
187
204
|
headers: {
|
|
@@ -232,37 +249,178 @@ async function detectGithubCopilot() {
|
|
|
232
249
|
return COPILOT_MODELS_FALLBACK.map((name) => ({ provider: "github_copilot", name }));
|
|
233
250
|
}
|
|
234
251
|
catch { /* offline / transient → not advertised */ }
|
|
235
|
-
return
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
// ── One parallel provider scan ─────────────────────────────────────────────
|
|
255
|
+
// Every provider is probed at the same time, each under its own deadline, so
|
|
256
|
+
// the whole scan costs the slowest probe (not their sum: up to ~24 s before).
|
|
257
|
+
// One scan returns both the models and whether the local servers answer, so
|
|
258
|
+
// callers do not probe LM Studio / Ollama a second time.
|
|
259
|
+
//
|
|
260
|
+
// Authenticated network calls (Claude model families, the Copilot token
|
|
261
|
+
// exchange) are cached per credential for NETWORK_CACHE_MS: a periodic rescan
|
|
262
|
+
// with unchanged credentials only re-reads local files and localhost servers.
|
|
263
|
+
// A new or changed credential (different token) misses the cache at once.
|
|
264
|
+
/** Per-probe deadlines (ms). Local servers answer in milliseconds when up. */
|
|
265
|
+
export const PROBE_TIMEOUTS_MS = {
|
|
266
|
+
lmstudio: 2500,
|
|
267
|
+
ollama: 2500,
|
|
268
|
+
claude_code: 9000,
|
|
269
|
+
codex: 3000,
|
|
270
|
+
github_copilot: 13000,
|
|
271
|
+
};
|
|
272
|
+
const NETWORK_CACHE_MS = 6 * 60 * 60_000;
|
|
273
|
+
const networkCache = new Map();
|
|
274
|
+
function tokenKey(token) {
|
|
275
|
+
return createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
276
|
+
}
|
|
277
|
+
function networkCacheGet(key, force) {
|
|
278
|
+
if (force)
|
|
279
|
+
return null;
|
|
280
|
+
const hit = networkCache.get(key);
|
|
281
|
+
if (!hit || Date.now() - hit.at > NETWORK_CACHE_MS)
|
|
282
|
+
return null;
|
|
283
|
+
return hit.value;
|
|
284
|
+
}
|
|
285
|
+
function networkCacheSet(key, value) {
|
|
286
|
+
networkCache.set(key, { at: Date.now(), value });
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
/** Test seam. */
|
|
290
|
+
export function _clearDetectCaches() {
|
|
291
|
+
networkCache.clear();
|
|
292
|
+
lastScan = null;
|
|
293
|
+
}
|
|
294
|
+
function withDeadline(p, ms, fallback) {
|
|
295
|
+
return new Promise((resolve) => {
|
|
296
|
+
let done = false;
|
|
297
|
+
const t = setTimeout(() => { if (!done) {
|
|
298
|
+
done = true;
|
|
299
|
+
resolve({ value: fallback, timedOut: true });
|
|
300
|
+
} }, ms);
|
|
301
|
+
p.then((v) => { if (!done) {
|
|
302
|
+
done = true;
|
|
303
|
+
clearTimeout(t);
|
|
304
|
+
resolve({ value: v, timedOut: false });
|
|
305
|
+
} }, () => { if (!done) {
|
|
306
|
+
done = true;
|
|
307
|
+
clearTimeout(t);
|
|
308
|
+
resolve({ value: fallback, timedOut: false });
|
|
309
|
+
} });
|
|
310
|
+
});
|
|
236
311
|
}
|
|
237
|
-
|
|
238
|
-
const models = [];
|
|
239
|
-
// LM Studio — OpenAI-compatible at :1234
|
|
312
|
+
async function probeLocalServer(url, timeoutMs, pick) {
|
|
240
313
|
try {
|
|
241
|
-
const r = await fetch(
|
|
242
|
-
if (r.ok)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
}
|
|
314
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
315
|
+
if (!r.ok)
|
|
316
|
+
return { up: false, names: [] };
|
|
317
|
+
const d = await r.json().catch(() => ({}));
|
|
318
|
+
return { up: true, names: pick(d) };
|
|
248
319
|
}
|
|
249
|
-
catch {
|
|
250
|
-
|
|
320
|
+
catch {
|
|
321
|
+
return { up: false, names: [] };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let lastScan = null;
|
|
325
|
+
let scanInFlight = null;
|
|
326
|
+
/** Scan every provider in parallel. Concurrent callers share one scan. */
|
|
327
|
+
export function scanProviders(opts = {}) {
|
|
328
|
+
if (scanInFlight && !opts.force)
|
|
329
|
+
return scanInFlight;
|
|
330
|
+
const p = runScan(opts).finally(() => { if (scanInFlight === p)
|
|
331
|
+
scanInFlight = null; });
|
|
332
|
+
scanInFlight = p;
|
|
333
|
+
return p;
|
|
334
|
+
}
|
|
335
|
+
/** The last completed scan, when it is younger than maxAgeMs. */
|
|
336
|
+
export function cachedScan(maxAgeMs) {
|
|
337
|
+
return lastScan && Date.now() - lastScan.at <= maxAgeMs ? lastScan : null;
|
|
338
|
+
}
|
|
339
|
+
async function runScan(opts) {
|
|
340
|
+
const t0 = Date.now();
|
|
341
|
+
const to = { ...PROBE_TIMEOUTS_MS, ...(opts.timeouts ?? {}) };
|
|
342
|
+
const force = !!opts.force;
|
|
343
|
+
const probeMs = {};
|
|
344
|
+
const timedOut = [];
|
|
345
|
+
const timed = async (id, p, fallback) => {
|
|
346
|
+
const s = Date.now();
|
|
347
|
+
const r = await withDeadline(p, to[id], fallback);
|
|
348
|
+
probeMs[id] = Date.now() - s;
|
|
349
|
+
if (r.timedOut)
|
|
350
|
+
timedOut.push(id);
|
|
351
|
+
return r.value;
|
|
352
|
+
};
|
|
353
|
+
const down = { up: false, names: [] };
|
|
354
|
+
const [lm, ol, claude, codex, copilot] = await Promise.all([
|
|
355
|
+
// LM Studio: OpenAI-compatible at :1234
|
|
356
|
+
timed("lmstudio", probeLocalServer("http://127.0.0.1:1234/v1/models", to.lmstudio, (d) => (d?.data ?? []).map((m) => m.id)), down),
|
|
357
|
+
// Ollama at :11434
|
|
358
|
+
timed("ollama", probeLocalServer("http://127.0.0.1:11434/api/tags", to.ollama, (d) => (d?.models ?? []).map((m) => m.name)), down),
|
|
359
|
+
// Claude Code CLI: subscription auth on THIS machine
|
|
360
|
+
timed("claude_code", detectClaudeCode(force), []),
|
|
361
|
+
// OpenAI Codex CLI: subscription auth on THIS machine
|
|
362
|
+
timed("codex", detectCodex(), []),
|
|
363
|
+
// GitHub Copilot: GitHub OAuth token readable on THIS machine
|
|
364
|
+
timed("github_copilot", detectGithubCopilot(force), []),
|
|
365
|
+
]);
|
|
366
|
+
const models = [
|
|
367
|
+
...lm.names.map((name) => ({ provider: "lmstudio", name })),
|
|
368
|
+
...ol.names.map((name) => ({ provider: "ollama", name })),
|
|
369
|
+
...claude,
|
|
370
|
+
...codex,
|
|
371
|
+
...copilot,
|
|
372
|
+
];
|
|
373
|
+
const scan = { models, running: { lmstudio: lm.up, ollama: ol.up }, probeMs, timedOut, ms: Date.now() - t0, at: Date.now() };
|
|
374
|
+
lastScan = scan;
|
|
375
|
+
return scan;
|
|
376
|
+
}
|
|
377
|
+
/** Models only (kept for existing callers). Same parallel scan as scanProviders(). */
|
|
378
|
+
export async function detectModels(opts = {}) {
|
|
379
|
+
return (await scanProviders(opts)).models;
|
|
380
|
+
}
|
|
381
|
+
// ── Change detection for periodic rescans ──────────────────────────────────
|
|
382
|
+
// A cheap fingerprint of everything a scan depends on: credential / cache file
|
|
383
|
+
// mtimes and whether the local server ports accept a connection. Unchanged
|
|
384
|
+
// fingerprint + a recent scan = nothing to rescan.
|
|
385
|
+
const FINGERPRINT_FILES = [
|
|
386
|
+
[".claude", ".credentials.json"],
|
|
387
|
+
[".codex", "auth.json"],
|
|
388
|
+
[".codex", "models_cache.json"],
|
|
389
|
+
[".config", "github-copilot", "apps.json"],
|
|
390
|
+
[".config", "github-copilot", "hosts.json"],
|
|
391
|
+
[".melaya-runner", "github_copilot.json"],
|
|
392
|
+
];
|
|
393
|
+
async function fileStamp(path) {
|
|
251
394
|
try {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
}
|
|
395
|
+
const st = await stat(path);
|
|
396
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return "-";
|
|
259
400
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
401
|
+
}
|
|
402
|
+
async function portOpen(port, timeoutMs = 400) {
|
|
403
|
+
const { createConnection } = await import("net");
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
const sock = createConnection({ host: "127.0.0.1", port });
|
|
406
|
+
const done = (v) => { try {
|
|
407
|
+
sock.destroy();
|
|
408
|
+
}
|
|
409
|
+
catch { /* closed */ } resolve(v); };
|
|
410
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
411
|
+
sock.once("connect", () => done(true));
|
|
412
|
+
sock.once("error", () => done(false));
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/** Fingerprint of the local provider state (files + ports). No network, no subprocess. */
|
|
416
|
+
export async function providerFingerprint() {
|
|
417
|
+
const home = homedir();
|
|
418
|
+
const parts = await Promise.all([
|
|
419
|
+
...FINGERPRINT_FILES.map((rel) => fileStamp(join(home, ...rel))),
|
|
420
|
+
portOpen(1234).then((v) => (v ? "lm:up" : "lm:down")),
|
|
421
|
+
portOpen(11434).then((v) => (v ? "ol:up" : "ol:down")),
|
|
422
|
+
]);
|
|
423
|
+
parts.push(process.env.GH_TOKEN ? `gh:${tokenKey(process.env.GH_TOKEN)}` : "gh:-");
|
|
424
|
+
parts.push(process.env.GITHUB_TOKEN ? `ghx:${tokenKey(process.env.GITHUB_TOKEN)}` : "ghx:-");
|
|
425
|
+
return parts.join("|");
|
|
268
426
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host channel: the runner running inside the Melaya desktop app.
|
|
3
|
+
*
|
|
4
|
+
* The desktop app (desktop/src/main/runnerHost.ts) starts this runner in an
|
|
5
|
+
* Electron utilityProcess. There, `process.parentPort` is a message port to the
|
|
6
|
+
* app's main process; under plain Node (`npx @melaya/runner`) it is undefined
|
|
7
|
+
* and every function here is a no-op, so the CLI behaves exactly as before.
|
|
8
|
+
*
|
|
9
|
+
* The runner token arrives as the first host message ({ type: "start", token })
|
|
10
|
+
* and never through argv or env, so it does not show up in process listings.
|
|
11
|
+
*
|
|
12
|
+
* The message types below MIRROR packages/desktop-contract/src/index.ts
|
|
13
|
+
* (RunnerToHostMessage / HostToRunnerMessage) and src/computer.ts. The runner
|
|
14
|
+
* build (tsc, rootDir src) cannot import that package, so keep both in step.
|
|
15
|
+
*/
|
|
16
|
+
export type HostedRunnerState = "starting" | "online" | "reconnecting" | "blocked" | "error";
|
|
17
|
+
/** Why a terminal "error" happened, so the app can react without parsing text. */
|
|
18
|
+
export type HostedStatusCode = "token_rejected" | "server_disconnect";
|
|
19
|
+
/** Mirror of the contract's RunnerToHostMessage (computer messages kept loose). */
|
|
20
|
+
export type RunnerToHostMessage = {
|
|
21
|
+
type: "status";
|
|
22
|
+
state: HostedRunnerState;
|
|
23
|
+
lastError?: string;
|
|
24
|
+
code?: HostedStatusCode;
|
|
25
|
+
} | {
|
|
26
|
+
type: "log";
|
|
27
|
+
level: "info" | "warn" | "error";
|
|
28
|
+
line: string;
|
|
29
|
+
} | {
|
|
30
|
+
type: "version";
|
|
31
|
+
runnerVersion: string;
|
|
32
|
+
}
|
|
33
|
+
/** Additive (runner side first; the contract mirror is the coordinator's): startup stage offsets in ms from process start. */
|
|
34
|
+
| {
|
|
35
|
+
type: "timings";
|
|
36
|
+
stages: Partial<Record<"process_start" | "config" | "socket_connected" | "online" | "python_ready" | "discovery_done", number>>;
|
|
37
|
+
}
|
|
38
|
+
/** Additive: background setup progress. `waiting` = a request is waiting for this task to finish. */
|
|
39
|
+
| {
|
|
40
|
+
type: "progress";
|
|
41
|
+
task: "runtime_setup";
|
|
42
|
+
state: "running" | "done" | "failed";
|
|
43
|
+
step?: string;
|
|
44
|
+
waiting?: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
type: "computer.command";
|
|
47
|
+
id: string;
|
|
48
|
+
action: unknown;
|
|
49
|
+
deadlineMs: number;
|
|
50
|
+
allowedAppIds: string[];
|
|
51
|
+
allowedApps?: Array<{
|
|
52
|
+
appId: string;
|
|
53
|
+
tier: "view" | "click" | "full";
|
|
54
|
+
expiresAt?: string;
|
|
55
|
+
}>;
|
|
56
|
+
} | {
|
|
57
|
+
type: "computer.stop";
|
|
58
|
+
reason: string;
|
|
59
|
+
} | {
|
|
60
|
+
type: "computer.capabilities.request";
|
|
61
|
+
} | {
|
|
62
|
+
type: "computer.file";
|
|
63
|
+
path: string;
|
|
64
|
+
name: string;
|
|
65
|
+
origin: "download" | "output";
|
|
66
|
+
handleHint?: string;
|
|
67
|
+
} | {
|
|
68
|
+
type: "computer.approval.request";
|
|
69
|
+
approvalId: string;
|
|
70
|
+
summary: string;
|
|
71
|
+
effect: string;
|
|
72
|
+
appName?: string;
|
|
73
|
+
expiresAt: string;
|
|
74
|
+
} | {
|
|
75
|
+
type: "computer.approval.closed";
|
|
76
|
+
approvalId: string;
|
|
77
|
+
}
|
|
78
|
+
/** Additive: something the user must fix on this computer; the app shows it natively. */
|
|
79
|
+
| {
|
|
80
|
+
type: "notice";
|
|
81
|
+
code: "browser_not_installed";
|
|
82
|
+
};
|
|
83
|
+
/** Mirror of the contract's HostToRunnerMessage (computer messages kept loose). */
|
|
84
|
+
export type HostToRunnerMessage = {
|
|
85
|
+
type: "stop";
|
|
86
|
+
} | {
|
|
87
|
+
type: "start";
|
|
88
|
+
token: string;
|
|
89
|
+
}
|
|
90
|
+
/** Additive: rescan model providers now and publish the list through runner:models. */
|
|
91
|
+
| {
|
|
92
|
+
type: "rescan_models";
|
|
93
|
+
} | {
|
|
94
|
+
type: "computer.result";
|
|
95
|
+
id: string;
|
|
96
|
+
result: unknown;
|
|
97
|
+
} | {
|
|
98
|
+
type: "computer.capabilities";
|
|
99
|
+
capabilities: unknown;
|
|
100
|
+
} | {
|
|
101
|
+
type: "computer.approval.answer";
|
|
102
|
+
approvalId: string;
|
|
103
|
+
decision: "approve" | "decline";
|
|
104
|
+
};
|
|
105
|
+
/** True when this runner was started by the Melaya desktop app. */
|
|
106
|
+
export declare function isHosted(): boolean;
|
|
107
|
+
/** Send one message to the desktop app. No-op outside the app; never throws. */
|
|
108
|
+
export declare function sendToHost(msg: RunnerToHostMessage): void;
|
|
109
|
+
/** Subscribe to messages from the desktop app. Returns an unsubscribe function. */
|
|
110
|
+
export declare function onHostMessage(cb: (msg: HostToRunnerMessage) => void): () => void;
|
|
111
|
+
/** Strip anything token-shaped from a reason before it leaves the process. */
|
|
112
|
+
export declare function redactReason(s: string): string;
|
|
113
|
+
/** Report a state change to the desktop app. No-op outside the app. */
|
|
114
|
+
export declare function hostStatus(state: HostedRunnerState, lastError?: string, code?: HostedStatusCode): void;
|
|
115
|
+
/** Background setup progress for the desktop app. No-op outside the app. */
|
|
116
|
+
export declare function hostProgress(state: "running" | "done" | "failed", step?: string, waiting?: boolean): void;
|
|
117
|
+
/**
|
|
118
|
+
* Wait for the token from the desktop app. Resolves with the token, or rejects
|
|
119
|
+
* when nothing valid arrives within `timeoutMs`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function waitForHostToken(timeoutMs?: number): Promise<string>;
|
|
122
|
+
/**
|
|
123
|
+
* Exit after giving the last host message time to leave the process. The
|
|
124
|
+
* parentPort queue is asynchronous; exiting in the same tick can drop it.
|
|
125
|
+
*/
|
|
126
|
+
export declare function exitHostedSoon(code: number, delayMs?: number): void;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host channel: the runner running inside the Melaya desktop app.
|
|
3
|
+
*
|
|
4
|
+
* The desktop app (desktop/src/main/runnerHost.ts) starts this runner in an
|
|
5
|
+
* Electron utilityProcess. There, `process.parentPort` is a message port to the
|
|
6
|
+
* app's main process; under plain Node (`npx @melaya/runner`) it is undefined
|
|
7
|
+
* and every function here is a no-op, so the CLI behaves exactly as before.
|
|
8
|
+
*
|
|
9
|
+
* The runner token arrives as the first host message ({ type: "start", token })
|
|
10
|
+
* and never through argv or env, so it does not show up in process listings.
|
|
11
|
+
*
|
|
12
|
+
* The message types below MIRROR packages/desktop-contract/src/index.ts
|
|
13
|
+
* (RunnerToHostMessage / HostToRunnerMessage) and src/computer.ts. The runner
|
|
14
|
+
* build (tsc, rootDir src) cannot import that package, so keep both in step.
|
|
15
|
+
*/
|
|
16
|
+
function parentPort() {
|
|
17
|
+
const p = process.parentPort;
|
|
18
|
+
return p && typeof p.postMessage === "function" ? p : null;
|
|
19
|
+
}
|
|
20
|
+
/** True when this runner was started by the Melaya desktop app. */
|
|
21
|
+
export function isHosted() {
|
|
22
|
+
return parentPort() !== null;
|
|
23
|
+
}
|
|
24
|
+
/** Send one message to the desktop app. No-op outside the app; never throws. */
|
|
25
|
+
export function sendToHost(msg) {
|
|
26
|
+
const p = parentPort();
|
|
27
|
+
if (!p)
|
|
28
|
+
return;
|
|
29
|
+
try {
|
|
30
|
+
p.postMessage(msg);
|
|
31
|
+
}
|
|
32
|
+
catch { /* host gone: nothing to tell */ }
|
|
33
|
+
}
|
|
34
|
+
/** Subscribe to messages from the desktop app. Returns an unsubscribe function. */
|
|
35
|
+
export function onHostMessage(cb) {
|
|
36
|
+
const p = parentPort();
|
|
37
|
+
if (!p)
|
|
38
|
+
return () => { };
|
|
39
|
+
const listener = (e) => {
|
|
40
|
+
const data = e?.data;
|
|
41
|
+
if (!data || typeof data !== "object" || typeof data.type !== "string")
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
cb(data);
|
|
45
|
+
}
|
|
46
|
+
catch { /* a bad handler must not kill the runner */ }
|
|
47
|
+
};
|
|
48
|
+
p.on("message", listener);
|
|
49
|
+
return () => {
|
|
50
|
+
try {
|
|
51
|
+
if (typeof p.removeListener === "function")
|
|
52
|
+
p.removeListener("message", listener);
|
|
53
|
+
else if (typeof p.off === "function")
|
|
54
|
+
p.off("message", listener);
|
|
55
|
+
}
|
|
56
|
+
catch { /* ignore */ }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Strip anything token-shaped from a reason before it leaves the process. */
|
|
60
|
+
export function redactReason(s) {
|
|
61
|
+
return String(s || "")
|
|
62
|
+
.replace(/mel_run_[A-Za-z0-9_\-]+/g, "mel_run_[redacted]")
|
|
63
|
+
.replace(/\b(bearer)\s+[A-Za-z0-9._~+/=\-]+/gi, "$1 [redacted]")
|
|
64
|
+
.replace(/\beyJ[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{5,}\.[A-Za-z0-9_\-]{5,}/g, "[jwt redacted]")
|
|
65
|
+
.replace(/([?&](?:token|code|state|key)=)[^&\s]+/gi, "$1[redacted]")
|
|
66
|
+
.slice(0, 300);
|
|
67
|
+
}
|
|
68
|
+
/** Report a state change to the desktop app. No-op outside the app. */
|
|
69
|
+
export function hostStatus(state, lastError, code) {
|
|
70
|
+
if (!isHosted())
|
|
71
|
+
return;
|
|
72
|
+
sendToHost({
|
|
73
|
+
type: "status",
|
|
74
|
+
state,
|
|
75
|
+
...(lastError ? { lastError: redactReason(lastError) } : {}),
|
|
76
|
+
...(code ? { code } : {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Background setup progress for the desktop app. No-op outside the app. */
|
|
80
|
+
export function hostProgress(state, step, waiting) {
|
|
81
|
+
if (!isHosted())
|
|
82
|
+
return;
|
|
83
|
+
sendToHost({
|
|
84
|
+
type: "progress",
|
|
85
|
+
task: "runtime_setup",
|
|
86
|
+
state,
|
|
87
|
+
...(step ? { step: redactReason(step).slice(0, 120) } : {}),
|
|
88
|
+
...(waiting ? { waiting: true } : {}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wait for the token from the desktop app. Resolves with the token, or rejects
|
|
93
|
+
* when nothing valid arrives within `timeoutMs`.
|
|
94
|
+
*/
|
|
95
|
+
export function waitForHostToken(timeoutMs = 30_000) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
let off = () => { };
|
|
98
|
+
const timer = setTimeout(() => { off(); reject(new Error("the desktop app did not send a runner token")); }, timeoutMs);
|
|
99
|
+
off = onHostMessage((msg) => {
|
|
100
|
+
if (msg.type !== "start")
|
|
101
|
+
return;
|
|
102
|
+
const token = msg.token;
|
|
103
|
+
if (typeof token !== "string" || !token.startsWith("mel_run_"))
|
|
104
|
+
return;
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
off();
|
|
107
|
+
resolve(token);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Exit after giving the last host message time to leave the process. The
|
|
113
|
+
* parentPort queue is asynchronous; exiting in the same tick can drop it.
|
|
114
|
+
*/
|
|
115
|
+
export function exitHostedSoon(code, delayMs = 300) {
|
|
116
|
+
setTimeout(() => process.exit(code), delayMs);
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
/** At most this many files per run, so a tool that writes thousands of files cannot flood the host. */
|
|
16
|
+
export declare const MAX_REPORTED_PER_RUN = 25;
|
|
17
|
+
/** Report one file. Returns true when a message was sent. */
|
|
18
|
+
export declare function reportHostFile(path: string, origin: "download" | "output"): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Files in `dir` (and one level of sub-folders) modified at or after `sinceMs`,
|
|
21
|
+
* newest first, capped. Pure apart from reading the folder.
|
|
22
|
+
*/
|
|
23
|
+
export declare function filesWrittenSince(dir: string, sinceMs: number, max?: number): string[];
|
|
24
|
+
/** After a run: report the outputs it wrote. No-op outside the desktop app. */
|
|
25
|
+
export declare function reportOutputsSince(dir: string | undefined, sinceMs: number): number;
|