@melaya/runner 1.1.60 → 1.1.62

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.
@@ -17,7 +17,7 @@ import { spawn } from "child_process";
17
17
  import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
18
18
  import { dirname, join } from "path";
19
19
  import { fileURLToPath } from "url";
20
- import { tmpdir, homedir } from "os";
20
+ import { tmpdir, homedir, hostname } from "os";
21
21
  import { createHash, createHmac, timingSafeEqual } from "crypto";
22
22
  import { startLocalRelay, setActiveProject } from "./localRelay.js";
23
23
  // ESM equivalent of __dirname — required for the rag:ingest handler to
@@ -34,6 +34,15 @@ import { verifyBrowserGrant, createInMemoryReplayStore, loadPinnedPublicKeysFrom
34
34
  import { discoverEngines } from "./browserProvisioner.js";
35
35
  import { getRunnerDeviceId } from "./sessionManager.js";
36
36
  import { scrubTriggeredEnv } from "./triggerEnv.js";
37
+ import { pickNativeFolder } from "./folderPicker.js";
38
+ import { newTurnFileState, readTurnFile, turnOutputDir } from "./assistantFiles.js";
39
+ // Obsidian vault host (docs/plans/obsidian-vault-protocol.md): device vaults
40
+ // registered on this computer, served after the runner verifies each
41
+ // capability itself.
42
+ import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, attachVaultSocketHandlers, findVaultHostScript, setVaultRelayPort, vaultToolEnv, trustRunVaultCapability, } from "./vaults.js";
43
+ // Obsidian app bridge: App ops through the user's running Obsidian (CLI /
44
+ // Local REST API); the REST key stays in ~/.melaya-runner/vaults.
45
+ import { ObsidianAppBridge } from "./obsidianApp.js";
37
46
  // Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
38
47
  // compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
39
48
  const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
@@ -54,7 +63,19 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
54
63
  // versioned ready/hello). The server enforces a MINIMUM: a runner advertising a
55
64
  // lower protocol never gets an assistant session routed to it (it gets a clear
56
65
  // "update your runner" instead of a silent stale-memory turn).
57
- const ASSISTANT_PROTOCOL_VERSION = 2;
66
+ // v3: per-turn connectors. The server boots the host WITHOUT connectors (and
67
+ // without their credentials) when it sees v3, sends the chat's selection + env
68
+ // on every runner:assistant_turn frame, and pushes mid-turn changes (autonomy
69
+ // mode, connectors) with runner:assistant_turn_update. Changing a connector or
70
+ // the autonomy mode never reboots the host, and a warm spare stays adoptable
71
+ // whatever the selection. A v2 server keeps sending connectors at boot; this
72
+ // runner then behaves exactly as before (turnConnectors absent).
73
+ // v4: output files. A turn frame with `fileDelivery` gets a per-turn output dir
74
+ // (MELAYA_OUTPUT_DIR for that turn); the files the agent wrote there are relayed
75
+ // to the server as `file` frames with their bytes, under the caps and path
76
+ // confinement of assistantFiles.ts. A server that does not send `fileDelivery`
77
+ // gets exactly the v3 behaviour (files stay on this computer only).
78
+ const ASSISTANT_PROTOCOL_VERSION = 4;
58
79
  // Best-effort read of our own package version (mirrors cli.ts). Advertised in the
59
80
  // hello so the server can log / gate on a package floor too.
60
81
  let RUNNER_VERSION = "0.0.0";
@@ -143,6 +164,63 @@ export async function connect(opts) {
143
164
  // is refused (plan Section 4 binding rule).
144
165
  let socketGeneration = 0;
145
166
  const runnerDeviceId = getRunnerDeviceId();
167
+ // ── Obsidian vault host ───────────────────────────────────────────────
168
+ // Registrations persist in ~/.melaya-runner/vaults/registry.json; the
169
+ // Python host (localVault.py, engine from the shared bundle) starts lazily
170
+ // on the first register/op, or now when vaults are already registered.
171
+ const vaultRegistry = new VaultRegistry();
172
+ vaultRegistry.load();
173
+ const vaultLog = (m) => { if (opts.verbose)
174
+ console.log(chalk.gray(` [vaults] ${m}`)); };
175
+ const vaultHost = new VaultHostProcess({
176
+ wanted: () => vaultRegistry.list().length > 0,
177
+ log: vaultLog,
178
+ launch: async () => {
179
+ const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
180
+ const { getLocalSharedVersion } = await import("./sharedVendor.js");
181
+ let localVersion = getLocalSharedVersion();
182
+ if (!localVersion) {
183
+ await ensureSharedModules(opts.serverUrl, "latest", opts.token);
184
+ localVersion = getLocalSharedVersion();
185
+ }
186
+ if (!localVersion)
187
+ throw new Error("runner python runtime still setting up");
188
+ const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => vaultLog(`venv: ${m}`));
189
+ if (!env.ok)
190
+ throw new Error(`venv bootstrap failed: ${env.reason}`);
191
+ const script = findVaultHostScript(__dirname);
192
+ if (!script)
193
+ throw new Error("localVault.py not found — update @melaya/runner");
194
+ mkdirSync(vaultRegistry.dir, { recursive: true, mode: 0o700 });
195
+ const certBundle = getCertBundlePath();
196
+ return {
197
+ pythonPath: env.pythonPath,
198
+ scriptPath: script,
199
+ cwd: vaultRegistry.dir,
200
+ env: {
201
+ ...process.env,
202
+ PYTHONPATH: getSharedDir(),
203
+ PYTHONIOENCODING: "utf-8",
204
+ PYTHONUTF8: "1",
205
+ ...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
206
+ },
207
+ };
208
+ },
209
+ });
210
+ const vaultService = new VaultService({
211
+ rnrId: runnerDeviceId,
212
+ registry: vaultRegistry,
213
+ host: vaultHost,
214
+ serverHttpBase: opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
215
+ emitStatus: (ev) => { if (socket.connected)
216
+ socket.emit("runner:vault-status", ev); },
217
+ log: vaultLog,
218
+ app: new ObsidianAppBridge({ dir: vaultRegistry.dir, log: vaultLog }),
219
+ });
220
+ attachVaultSocketHandlers(socket, vaultService, () => pickNativeFolder("Select your Obsidian vault folder"));
221
+ if (vaultRegistry.list().length) {
222
+ void vaultService.restore().catch((e) => vaultLog(`restore failed: ${e?.message || e}`));
223
+ }
146
224
  const _ensureBrowserBridge = async () => {
147
225
  if (browserBridge)
148
226
  return browserBridge;
@@ -339,7 +417,9 @@ export async function connect(opts) {
339
417
  // event_triggers_v1: this runner writes payload.triggerJson to
340
418
  // <runDir>/trigger.json + MEL_TRIGGER_FILE. The server reports
341
419
  // triggerAccepted from it; older runners silently drop the block.
342
- capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY],
420
+ // vault_host_v1: this runner hosts Obsidian vaults on this computer
421
+ // (runner:vault-* events); the server then pushes runner:vaultKeys.
422
+ capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY, VAULT_HOST_CAPABILITY],
343
423
  package: "@melaya/runner",
344
424
  version: RUNNER_VERSION,
345
425
  assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
@@ -351,10 +431,22 @@ export async function connect(opts) {
351
431
  cdp: true,
352
432
  engines: browserEngines,
353
433
  },
434
+ // Device vaults bind to this stable install id (protocol §3).
435
+ vaultHost: {
436
+ runnerDevice: runnerDeviceId,
437
+ deviceName: hostname(),
438
+ platform: process.platform,
439
+ },
354
440
  });
441
+ // Index statuses produced while disconnected (or before this socket).
442
+ for (const ev of vaultService.latestStatuses())
443
+ socket.emit("runner:vault-status", ev);
355
444
  // Start local event relay
356
445
  if (!relay) {
357
- relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
446
+ relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl, {
447
+ vaultOp: (req, res) => vaultService.handleRelayHttp(req, res),
448
+ });
449
+ setVaultRelayPort(relay.port);
358
450
  if (opts.verbose) {
359
451
  console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
360
452
  }
@@ -991,6 +1083,19 @@ export async function connect(opts) {
991
1083
  ...(isTriggeredRun
992
1084
  ? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
993
1085
  : {}),
1086
+ // Obsidian vaults, reserved LAST: the capability the server issued for
1087
+ // this run on this computer, served by the local relay (a vault on this
1088
+ // computer is read in place, any other through Melaya). A credential
1089
+ // can never replace these names.
1090
+ MEL_VAULT_CAP: "",
1091
+ MEL_VAULT_API_URL: "",
1092
+ ...(() => {
1093
+ const cap = typeof payload.vaultCapability === "string"
1094
+ ? String(payload.vaultCapability)
1095
+ : "";
1096
+ trustRunVaultCapability(cap);
1097
+ return vaultToolEnv(cap);
1098
+ })(),
994
1099
  };
995
1100
  // Surface the tier to the chat panel as a system message so the
996
1101
  // operator sees WHY a small model is running with tighter caps.
@@ -1506,6 +1611,23 @@ export async function connect(opts) {
1506
1611
  console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
1507
1612
  try {
1508
1613
  const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
1614
+ // v4 output file: the host only names a path; the runner reads it
1615
+ // (confined to the turn's dir, capped) and relays the bytes. A frame
1616
+ // for a turn without fileDelivery, or a refused file, is dropped:
1617
+ // the raw path never leaves this computer.
1618
+ if (parsed?.kind === "file") {
1619
+ const st = session.turnFiles?.get(String(parsed.turnId || ""));
1620
+ if (st && session.forward) {
1621
+ const r = readTurnFile(st, parsed.path);
1622
+ if (r.ok)
1623
+ socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
1624
+ else if (opts.verbose)
1625
+ console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
1626
+ }
1627
+ continue;
1628
+ }
1629
+ if (parsed?.kind === "done" && typeof parsed.turnId === "string")
1630
+ session.turnFiles?.delete(parsed.turnId);
1509
1631
  if (typeof parsed?.memoryWatermark === "number")
1510
1632
  session.memoryWatermark = parsed.memoryWatermark;
1511
1633
  if (parsed?.kind === "ready") {
@@ -1515,11 +1637,20 @@ export async function connect(opts) {
1515
1637
  session.configHash = parsed.configHash;
1516
1638
  session.ready = true;
1517
1639
  parsed.protocol = ASSISTANT_PROTOCOL_VERSION;
1640
+ // A spare adopted while still booting reports the generation it
1641
+ // booted under; the session's is the one the adopt frame set.
1642
+ if (session.forward)
1643
+ parsed.generation = session.generation;
1518
1644
  }
1519
1645
  // A spare's frames are NOT forwarded (no session yet); adoption flips
1520
1646
  // session.forward and re-emits `ready` with the real sid.
1521
1647
  if (session.forward)
1522
1648
  socket.emit("runner:assistant_event", { sessionId: session.sid, ...parsed });
1649
+ // A session host is up: NOW the warm spare may boot. Booting both at
1650
+ // once made them compile + import the shared bundle concurrently
1651
+ // (prod 2026-10-11, Windows: ~50 s each instead of ~7 s).
1652
+ if (parsed?.kind === "ready" && session.forward)
1653
+ schedulePrewarm();
1523
1654
  }
1524
1655
  catch {
1525
1656
  // The literal "MELASSIST " appeared inside raw text (not a real frame)
@@ -1594,11 +1725,17 @@ export async function connect(opts) {
1594
1725
  // turns it off.
1595
1726
  const WARM_POOL_ENABLED = process.env.MEL_ASSISTANT_WARM_POOL !== "0";
1596
1727
  let _prewarmInFlight = false;
1728
+ // Never while a session host is booting (it would compete for the same cold
1729
+ // import of the shared bundle): the session's `ready` frame schedules it.
1730
+ const _sessionHostBooting = () => bootingAssistants.size > 0 || [...activeAssistants.values()].some((s) => s.forward && !s.ready);
1597
1731
  const schedulePrewarm = () => {
1598
1732
  if (!WARM_POOL_ENABLED || !warmSpareSeed || _prewarmInFlight)
1599
1733
  return;
1734
+ if (_sessionHostBooting())
1735
+ return;
1600
1736
  if (warmSpare) {
1601
- if (warmSpare.configHash && warmSpare.configHash !== warmSpareSeed.configHash) {
1737
+ const spareHash = warmSpare.configHash || warmSpare.seedConfigHash || "";
1738
+ if (spareHash && spareHash !== warmSpareSeed.configHash) {
1602
1739
  try {
1603
1740
  warmSpare.proc.kill();
1604
1741
  }
@@ -1624,6 +1761,7 @@ export async function connect(opts) {
1624
1761
  const spareEnv = { ...seed.env };
1625
1762
  const spare = launchAssistantHost({ env: spareEnv, pythonPath: seed.pythonPath, workDir: wd, stagedHost: staged, sid: `__spare__:${stamp}`, generation: seed.generation, forward: false, providerLabel: "prewarm" });
1626
1763
  spare.credHash = seed.credHash;
1764
+ spare.seedConfigHash = seed.configHash;
1627
1765
  warmSpare = spare;
1628
1766
  const t = setTimeout(() => { if (warmSpare === spare && !spare.forward) {
1629
1767
  try {
@@ -1688,7 +1826,12 @@ export async function connect(opts) {
1688
1826
  // Connector tool sets the user enabled for this chat (comma-joined service
1689
1827
  // ids). The host seeds a lazy toolkit from these so ANY connector's tools
1690
1828
  // are reachable without exploding context.
1691
- MEL_ASSISTANT_CONNECTORS: Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
1829
+ // Per-turn connectors (v3): empty at boot, the turn frame carries them.
1830
+ MEL_ASSISTANT_CONNECTORS: !payload.turnConnectors && Array.isArray(payload.connectors) ? payload.connectors.join(",") : "",
1831
+ // Tells the host to take connectors per turn (lazy registry scope + env
1832
+ // per turn, connector rule in the per-turn prompt). Part of its config
1833
+ // hash ("turn-connectors-v1"), byte-aligned with the server.
1834
+ MEL_ASSISTANT_TURN_CONNECTORS: payload.turnConnectors ? "1" : "",
1692
1835
  // Surgical tool pinning: exact connector tool NAMES (not just service ids)
1693
1836
  // the server already resolved creds for. The host registers these ACTIVE
1694
1837
  // at boot instead of dumping them into the deferred pool, eliminating the
@@ -1696,7 +1839,8 @@ export async function connect(opts) {
1696
1839
  // Byte-for-byte source of truth for _config_hash's pinned-tools field.
1697
1840
  MEL_ASSISTANT_PINNED_TOOLS: payload.pinnedTools?.join(",") ?? "",
1698
1841
  // Gate write connector-tools behind the in-chat approval card (fail-safe).
1699
- MEL_ASSISTANT_CONNECTOR_HITL: Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
1842
+ // Per-turn mode: the host arms it itself whenever a turn selects a connector.
1843
+ MEL_ASSISTANT_CONNECTOR_HITL: !payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.length ? "1" : "",
1700
1844
  // HITL autonomy mode ("safe" | "autonomous" | "payments_only"). The host
1701
1845
  // reads this at start; each turn's frame can flip it (see assistant_turn).
1702
1846
  // Fail-safe: unknown ⇒ "safe".
@@ -1849,12 +1993,17 @@ export async function connect(opts) {
1849
1993
  // conversation's via an `adopt` frame, so a restored conversation's
1850
1994
  // generation-bound rehydrate still applies (the old exact-generation match
1851
1995
  // meant a spare could almost never be adopted).
1996
+ // A spare that is STILL BOOTING with the same seed config + credentials is
1997
+ // adopted too: the session waits for ITS ready frame (forwarded under this
1998
+ // sid once `forward` flips) instead of booting a second host next to it.
1852
1999
  if (!activeAssistants.has(sid) && !bootingAssistants.has(sid)
1853
- && warmSpare && warmSpare.ready && !warmSpare.killed && expectedConfig
1854
- && warmSpare.configHash === expectedConfig && (warmSpare.memoryWatermark || 0) === 0
2000
+ && warmSpare && !warmSpare.killed && expectedConfig
2001
+ && (warmSpare.ready ? warmSpare.configHash : warmSpare.seedConfigHash) === expectedConfig
2002
+ && (warmSpare.memoryWatermark || 0) === 0
1855
2003
  && warmSpare.credHash === _assistantCredHash(payload.credentials)) {
1856
2004
  const spare = warmSpare;
1857
2005
  warmSpare = null;
2006
+ // Queued on the host's stdin: a booting host reads it right after `ready`.
1858
2007
  try {
1859
2008
  spare.proc.stdin?.write(JSON.stringify({ kind: "adopt", generation }) + "\n");
1860
2009
  }
@@ -1864,9 +2013,14 @@ export async function connect(opts) {
1864
2013
  spare.forward = true;
1865
2014
  spare.lastActivity = Date.now();
1866
2015
  activeAssistants.set(sid, spare);
1867
- console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
1868
- emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
1869
- schedulePrewarm(); // refill the pool for the next session
2016
+ if (spare.ready) {
2017
+ console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted warm spare — cold boot skipped`));
2018
+ emitEv({ kind: "ready", memoryWatermark: spare.memoryWatermark || 0, generation: spare.generation, hostInstanceId: spare.hostInstanceId, configHash: spare.configHash, protocol: ASSISTANT_PROTOCOL_VERSION });
2019
+ schedulePrewarm(); // refill the pool for the next session
2020
+ }
2021
+ else {
2022
+ console.log(chalk.green(` ◆ Assistant ${sid.slice(0, 10)}… adopted the warm spare still booting, no second host`));
2023
+ }
1870
2024
  return;
1871
2025
  }
1872
2026
  // A boot is already in flight for this sid (concurrent start / server restart
@@ -1904,7 +2058,7 @@ export async function connect(opts) {
1904
2058
  // Cloudflare's write-bot rule. Without it the tool falls back to aiohttp,
1905
2059
  // which 403s ("cloudflare_mitigation" / "velocity_throttled") on every RSVP.
1906
2060
  // Idempotent + best-effort; only paid when luma is actually selected.
1907
- if (Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
2061
+ if (!payload.turnConnectors && Array.isArray(payload.connectors) && payload.connectors.includes("luma")) {
1908
2062
  await _ensureLumaBridge();
1909
2063
  }
1910
2064
  const env = await buildAssistantEnv(payload, sid, generation);
@@ -1917,7 +2071,7 @@ export async function connect(opts) {
1917
2071
  // matching spare for the NEXT new session — which then adopts it and skips
1918
2072
  // the cold boot (see the ADOPTION fast path + schedulePrewarm above).
1919
2073
  warmSpareSeed = { env, configHash: expectedConfig, generation, pythonPath: envResult.pythonPath, workDirBase: tmpdir(), hostScript, credHash: _assistantCredHash(payload.credentials) };
1920
- schedulePrewarm();
2074
+ // The spare boots once this session reports ready (see the stdout parser).
1921
2075
  }
1922
2076
  catch (e) {
1923
2077
  emitEv({ kind: "error", message: `assistant start failed: ${e?.message || e}` });
@@ -2076,6 +2230,42 @@ export async function connect(opts) {
2076
2230
  if (opts.verbose)
2077
2231
  console.log(chalk.gray(` [browser-bridge] ext-proxy turn (session=${payload.sessionId.slice(0, 10)} url=${turnBridgeUrl})`));
2078
2232
  }
2233
+ // Per-turn connectors (v3). Luma writes go through the local browser bridge
2234
+ // (Cloudflare write rule): bring it up when this turn selects luma, and hand
2235
+ // its url/token to the host with luma's env for the turn.
2236
+ const turnConnectors = Array.isArray(payload.connectors) ? payload.connectors.filter((c) => typeof c === "string") : null;
2237
+ const turnConnectorEnv = {};
2238
+ if (turnConnectors) {
2239
+ for (const [svc, env] of Object.entries(payload.connectorEnv || {})) {
2240
+ if (env && typeof env === "object")
2241
+ turnConnectorEnv[svc] = { ...env };
2242
+ }
2243
+ // Obsidian vaults: the server sends only the capability; the local relay
2244
+ // serves vaults on this computer and forwards the rest to Melaya.
2245
+ if (turnConnectorEnv.obsidian?.MEL_VAULT_CAP) {
2246
+ trustRunVaultCapability(turnConnectorEnv.obsidian.MEL_VAULT_CAP);
2247
+ turnConnectorEnv.obsidian = { ...turnConnectorEnv.obsidian, ...vaultToolEnv(turnConnectorEnv.obsidian.MEL_VAULT_CAP) };
2248
+ }
2249
+ if (turnConnectors.includes("luma")) {
2250
+ await _ensureLumaBridge().catch(() => { });
2251
+ if (lumaBridge)
2252
+ turnConnectorEnv.luma = { ...(turnConnectorEnv.luma || {}), MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token };
2253
+ }
2254
+ }
2255
+ // v4 output files: a fresh dir for this turn (MELAYA_OUTPUT_DIR in the host
2256
+ // for the turn); its files are relayed at turn end (stdout `file` frames).
2257
+ let turnOutDir = "";
2258
+ if (payload.fileDelivery === true && payload.turnId) {
2259
+ try {
2260
+ turnOutDir = turnOutputDir(String(payload.turnId));
2261
+ (s.turnFiles ??= new Map()).set(String(payload.turnId), newTurnFileState(turnOutDir));
2262
+ }
2263
+ catch (e) {
2264
+ turnOutDir = "";
2265
+ if (opts.verbose)
2266
+ console.log(chalk.gray(` [assistant] no output dir for this turn: ${e?.message || e}`));
2267
+ }
2268
+ }
2079
2269
  // Capture for the stdout "done" handler closure below.
2080
2270
  const _turnBridgeRunId = turnBridgeRunId;
2081
2271
  const _turnId = payload.turnId;
@@ -2136,6 +2326,12 @@ export async function connect(opts) {
2136
2326
  ...(turnBridgeUrl && turnBridgeToken
2137
2327
  ? { mel_browser_url: turnBridgeUrl, mel_browser_token: turnBridgeToken }
2138
2328
  : {}),
2329
+ // v3 per-turn connectors (absent from a v2 server: the host keeps its
2330
+ // boot-time connectors).
2331
+ ...(turnConnectors ? { connectors: turnConnectors, connector_env: turnConnectorEnv } : {}),
2332
+ // v4: the turn's output dir (absent: the host keeps its boot-time dir
2333
+ // and reports no files).
2334
+ ...(turnOutDir ? { output_dir: turnOutDir } : {}),
2139
2335
  }) + "\n");
2140
2336
  }
2141
2337
  catch (e) {
@@ -2148,6 +2344,28 @@ export async function connect(opts) {
2148
2344
  socket.emit("runner:assistant_event", { sessionId: payload.sessionId, turnId: payload.turnId, kind: "error", message: `turn write failed: ${e?.message || e}` });
2149
2345
  }
2150
2346
  });
2347
+ // v3 live mid-turn update: the user changed the autonomy mode or the connector
2348
+ // selection while the reply streams. The host's stdin reader applies it
2349
+ // between tool calls (like STOP), so the NEXT tool call of the running turn
2350
+ // uses it; a pending approval stays pending. Ignored for any other turn.
2351
+ socket.on("runner:assistant_turn_update", (payload) => {
2352
+ const s = activeAssistants.get(String(payload?.sessionId || ""));
2353
+ if (!s || !payload?.turnId)
2354
+ return;
2355
+ s.lastActivity = Date.now();
2356
+ const frame = { kind: "turn_update", turnId: payload.turnId };
2357
+ if (typeof payload.hitlMode === "string") {
2358
+ frame.hitl_mode = (payload.hitlMode === "autonomous" || payload.hitlMode === "payments_only") ? payload.hitlMode : "safe";
2359
+ }
2360
+ if (Array.isArray(payload.connectors)) {
2361
+ frame.connectors = payload.connectors.filter((c) => typeof c === "string");
2362
+ frame.connector_env = payload.connectorEnv && typeof payload.connectorEnv === "object" ? payload.connectorEnv : {};
2363
+ }
2364
+ try {
2365
+ s.proc.stdin?.write(JSON.stringify(frame) + "\n");
2366
+ }
2367
+ catch { /* best-effort */ }
2368
+ });
2151
2369
  socket.on("runner:assistant_stop", (payload) => {
2152
2370
  const s = activeAssistants.get(String(payload.sessionId || ""));
2153
2371
  if (s) {
@@ -2219,60 +2437,13 @@ export async function connect(opts) {
2219
2437
  socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
2220
2438
  };
2221
2439
  try {
2222
- const { execFile } = await import("child_process");
2223
- const platform = process.platform;
2224
- const run = (cmd, args) => new Promise((resolve, reject) => {
2225
- execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
2226
- if (err) {
2227
- const out = (stdout || "").trim();
2228
- const errMsg = (stderr || err.message || "").trim();
2229
- // osascript returns code 1 when user cancels — empty stdout
2230
- if (!out && /User canceled|cancelled/i.test(errMsg))
2231
- return resolve("");
2232
- return reject(new Error(errMsg || "pick failed"));
2233
- }
2234
- resolve((stdout || "").trim());
2235
- });
2236
- });
2237
- let picked = "";
2238
- if (platform === "darwin") {
2239
- // osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
2240
- const raw = await run("osascript", [
2241
- "-e", 'tell application "System Events"',
2242
- "-e", "activate",
2243
- "-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
2244
- "-e", "end tell",
2245
- "-e", "return POSIX path of p",
2246
- ]);
2247
- picked = raw.replace(/\/$/, "");
2248
- }
2249
- else if (platform === "win32") {
2250
- const ps = [
2251
- "Add-Type -AssemblyName System.Windows.Forms;",
2252
- "$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
2253
- "$f.Description = 'Select your reference docs folder';",
2254
- "$f.ShowNewFolderButton = $true;",
2255
- "if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
2256
- ].join(" ");
2257
- picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
2258
- }
2259
- else {
2260
- // Linux — try zenity, fall back to error if missing
2261
- picked = await run("zenity", [
2262
- "--file-selection", "--directory",
2263
- "--title=Select your reference docs folder",
2264
- ]);
2265
- }
2266
- if (!picked)
2440
+ const r = await pickNativeFolder("Select your reference docs folder");
2441
+ if (!("path" in r))
2267
2442
  return reply(false, null, "cancelled");
2268
- reply(true, picked);
2443
+ reply(true, r.path);
2269
2444
  }
2270
2445
  catch (e) {
2271
- const msg = String(e?.message || "pick failed");
2272
- const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
2273
- ? "Install 'zenity' (apt install zenity) or type the path manually."
2274
- : "";
2275
- reply(false, null, hint ? `${msg} — ${hint}` : msg);
2446
+ reply(false, null, String(e?.message || "pick failed"));
2276
2447
  }
2277
2448
  });
2278
2449
  socket.on("rag:ingest", async (payload) => {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
3
+ * folders) and runner:vault-pick-folder (Obsidian vaults).
4
+ *
5
+ * Browsers can't reveal absolute paths for security, so the runner — which
6
+ * lives on the user's box — pops the dialog. Mac: osascript. Windows:
7
+ * PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
8
+ * chosen by the caller, never user or model input.
9
+ */
10
+ export type FolderPickResult = {
11
+ path: string;
12
+ } | {
13
+ cancelled: true;
14
+ };
15
+ export declare function pickNativeFolder(prompt: string): Promise<FolderPickResult>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
3
+ * folders) and runner:vault-pick-folder (Obsidian vaults).
4
+ *
5
+ * Browsers can't reveal absolute paths for security, so the runner — which
6
+ * lives on the user's box — pops the dialog. Mac: osascript. Windows:
7
+ * PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
8
+ * chosen by the caller, never user or model input.
9
+ */
10
+ import { execFile } from "child_process";
11
+ export async function pickNativeFolder(prompt) {
12
+ const platform = process.platform;
13
+ const run = (cmd, args) => new Promise((resolve, reject) => {
14
+ execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
15
+ if (err) {
16
+ const out = (stdout || "").trim();
17
+ const errMsg = (stderr || err.message || "").trim();
18
+ // osascript returns code 1 when user cancels — empty stdout
19
+ if (!out && /User canceled|cancelled/i.test(errMsg))
20
+ return resolve("");
21
+ return reject(new Error(errMsg || "pick failed"));
22
+ }
23
+ resolve((stdout || "").trim());
24
+ });
25
+ });
26
+ // Quotes would break out of the AppleScript / PowerShell string literal.
27
+ const safePrompt = prompt.replace(/["'`$\\]/g, "");
28
+ let picked = "";
29
+ try {
30
+ if (platform === "darwin") {
31
+ // osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
32
+ const raw = await run("osascript", [
33
+ "-e", 'tell application "System Events"',
34
+ "-e", "activate",
35
+ "-e", `set p to choose folder with prompt "${safePrompt}"`,
36
+ "-e", "end tell",
37
+ "-e", "return POSIX path of p",
38
+ ]);
39
+ picked = raw.replace(/\/$/, "");
40
+ }
41
+ else if (platform === "win32") {
42
+ const ps = [
43
+ "Add-Type -AssemblyName System.Windows.Forms;",
44
+ "$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
45
+ `$f.Description = '${safePrompt}';`,
46
+ "$f.ShowNewFolderButton = $true;",
47
+ "if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
48
+ ].join(" ");
49
+ picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
50
+ }
51
+ else {
52
+ // Linux — try zenity, fall back to error if missing
53
+ picked = await run("zenity", [
54
+ "--file-selection", "--directory",
55
+ `--title=${safePrompt}`,
56
+ ]);
57
+ }
58
+ }
59
+ catch (e) {
60
+ const msg = String(e?.message || "pick failed");
61
+ const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
62
+ ? "Install 'zenity' (apt install zenity) or type the path manually."
63
+ : "";
64
+ throw new Error(hint ? `${msg} — ${hint}` : msg);
65
+ }
66
+ if (!picked)
67
+ return { cancelled: true };
68
+ return { path: picked };
69
+ }
@@ -9,6 +9,7 @@
9
9
  * - Per-run nonce required in the URL path to prevent injection from
10
10
  * other processes on the same machine
11
11
  */
12
+ import http from "http";
12
13
  import type { Socket } from "socket.io-client";
13
14
  export interface LocalRelay {
14
15
  port: number;
@@ -16,4 +17,10 @@ export interface LocalRelay {
16
17
  close: () => void;
17
18
  }
18
19
  export declare function setActiveProject(project: string): void;
19
- export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string): Promise<LocalRelay>;
20
+ export interface LocalRelayRoutes {
21
+ /** POST /vaults/op (Obsidian vault tools, protocol §2). Authenticated by the
22
+ * vault capability in `Authorization: Vault <cap>`, not by the nonce: the
23
+ * handler verifies it (local device vaults) or forwards it to the server. */
24
+ vaultOp?: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void;
25
+ }
26
+ export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string, routes?: LocalRelayRoutes): Promise<LocalRelay>;
@@ -55,7 +55,7 @@ function _flushThrottle(socket, verbose) {
55
55
  _throttleQueue = [];
56
56
  _throttleTimer = null;
57
57
  }
58
- export function startLocalRelay(socket, verbose, serverUrl) {
58
+ export function startLocalRelay(socket, verbose, serverUrl, routes = {}) {
59
59
  const nonce = crypto.randomUUID().replace(/-/g, "");
60
60
  return new Promise((resolve, reject) => {
61
61
  const expectedPath = `/events/relay/${nonce}`;
@@ -147,6 +147,26 @@ export function startLocalRelay(socket, verbose, serverUrl) {
147
147
  });
148
148
  return;
149
149
  }
150
+ // ── Obsidian vault ops (docs/plans/obsidian-vault-protocol.md §2) ──
151
+ if (req.method === "POST" && req.url && /^\/vaults\/op\/?(\?.*)?$/.test(req.url)) {
152
+ if (!routes.vaultOp) {
153
+ res.writeHead(404);
154
+ res.end("Not found");
155
+ return;
156
+ }
157
+ Promise.resolve(routes.vaultOp(req, res)).catch(() => {
158
+ if (res.headersSent) {
159
+ try {
160
+ res.end();
161
+ }
162
+ catch { /* closed */ }
163
+ return;
164
+ }
165
+ res.writeHead(500, { "Content-Type": "application/json" });
166
+ res.end('{"ok":false,"error":"relay_failed"}');
167
+ });
168
+ return;
169
+ }
150
170
  if (req.method !== "POST" || req.url !== expectedPath) {
151
171
  res.writeHead(404);
152
172
  res.end("Not found");