mtech-ai-local-runner 0.1.47 → 0.1.49

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.
@@ -0,0 +1,99 @@
1
+ export class JobRunner {
2
+ deps;
3
+ armed = false;
4
+ busy = false;
5
+ pending = false;
6
+ stopped = false;
7
+ inFlight;
8
+ release;
9
+ done = new Promise(resolve => { this.release = resolve; });
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ /** True while a claim or a job is in progress. */
14
+ get running() {
15
+ return this.busy;
16
+ }
17
+ /**
18
+ * True while this runner may claim: after run() started and before stop(). A runner that does not accept jobs
19
+ * (still reconciling, or suspended after an unknown outcome) must not look ready to the Gateway.
20
+ */
21
+ get accepting() {
22
+ return this.armed && !this.stopped;
23
+ }
24
+ notify() {
25
+ if (this.stopped)
26
+ return;
27
+ if (!this.armed || this.busy) {
28
+ this.pending = true;
29
+ return;
30
+ }
31
+ this.start();
32
+ }
33
+ async run() {
34
+ // A pending promise alone does not keep Node alive; this referenced timer does until stop().
35
+ const keepAlive = setInterval(() => undefined, 60_000);
36
+ try {
37
+ this.armed = true;
38
+ if (!this.stopped) {
39
+ try {
40
+ this.deps.onArmed?.();
41
+ }
42
+ catch { /* Telling the Gateway is best effort; claims still start. */ }
43
+ }
44
+ if (this.pending && !this.stopped) {
45
+ this.pending = false;
46
+ this.start();
47
+ }
48
+ await this.done;
49
+ // Like the old poll loop, run() ends only after the claim or job in progress has finished.
50
+ await this.inFlight;
51
+ }
52
+ finally {
53
+ clearInterval(keepAlive);
54
+ }
55
+ }
56
+ stop() {
57
+ if (this.stopped)
58
+ return;
59
+ this.stopped = true;
60
+ this.release();
61
+ }
62
+ start() {
63
+ const cycle = this.cycle();
64
+ this.inFlight = cycle;
65
+ void cycle.finally(() => { if (this.inFlight === cycle)
66
+ this.inFlight = undefined; });
67
+ }
68
+ async cycle() {
69
+ this.busy = true;
70
+ let stage = "claim";
71
+ try {
72
+ const claim = await this.deps.claim();
73
+ if (claim.status === "claimed" && claim.job !== undefined && !this.stopped) {
74
+ stage = "execute";
75
+ await this.deps.execute(claim.job);
76
+ this.deps.requestSync("after_job");
77
+ }
78
+ }
79
+ catch {
80
+ try {
81
+ this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
82
+ }
83
+ catch { /* Observational logging cannot bypass the stop below or discard recovery state. */ }
84
+ // Unknown execution or terminal outcomes must reconcile before any new claim.
85
+ if (stage === "execute") {
86
+ this.busy = false;
87
+ this.stop();
88
+ return;
89
+ }
90
+ }
91
+ finally {
92
+ this.busy = false;
93
+ }
94
+ if (this.pending && !this.stopped) {
95
+ this.pending = false;
96
+ this.start();
97
+ }
98
+ }
99
+ }
@@ -17,14 +17,20 @@ import { WorkspaceManager } from "../workspace/workspace-manager.js";
17
17
  import { GitClient } from "../workspace/git-client.js";
18
18
  import { FileDaemonStateStore } from "../state-store.js";
19
19
  import { ExecutionCoordinator } from "./execution-coordinator.js";
20
- import { JobPoller } from "./job-poller.js";
20
+ import { JobRunner } from "./job-runner.js";
21
+ import { HeartbeatScheduler } from "./heartbeat-scheduler.js";
21
22
  import { ProcessLock } from "./process-lock.js";
22
23
  import { Daemon } from "./daemon.js";
23
24
  import { SessionOpener, sessionOpenAdvertisement } from "./session-opener.js";
24
- import { SessionOpenPoller, abortableDelay } from "./session-open-poller.js";
25
+ import { RunnerTransport } from "../transport/runner-transport.js";
26
+ import { ReverbSocket } from "../transport/reverb-socket.js";
27
+ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-dispatch.js";
28
+ import { abortableDelay } from "../transport/abortable-delay.js";
29
+ import { sideLoops } from "../transport/side-loops.js";
25
30
  import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
26
31
  import { packagedHookCommandParts } from "../safety/hook-command.js";
27
32
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
33
+ import { AskRuntime, withAsks } from '../asks/runtime.js';
28
34
  export async function buildDaemon(input) {
29
35
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
30
36
  if (claudeParts !== 0 && claudeParts !== 3)
@@ -44,6 +50,10 @@ export async function buildDaemon(input) {
44
50
  pendingCodexClaim.job.lease_expires_at = expiresAt;
45
51
  };
46
52
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
53
+ // AI-first ask delivery (Work Item e1c08b33) rides on the session-open loop below; it starts no executor.
54
+ const asks = new AskRuntime({ stateRoot: input.stateRoot, env: input.env,
55
+ report: (deliveryId, report, signal) => gateway.reportAskDelivery({ deliveryId, ...report }, signal),
56
+ log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
47
57
  const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
48
58
  // A test-injected host wins; otherwise only the bundled reviewed registration can enable P1.
49
59
  let p1Host = input.p1Host;
@@ -110,58 +120,15 @@ export async function buildDaemon(input) {
110
120
  workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
111
121
  state,
112
122
  });
113
- const poller = new JobPoller({
123
+ let transport;
124
+ /** The adapter probe in progress, if any: a claim waits for it, so versions never change under a claim. */
125
+ let probing = Promise.resolve();
126
+ const poller = new JobRunner({
114
127
  reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
115
- heartbeat: async () => {
116
- if (input.claudeSupportedProbe) {
117
- let current;
118
- try {
119
- current = await plugin.probe();
120
- }
121
- catch {
122
- current = { available: false, authenticated: false, capabilities: [] };
123
- }
124
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
125
- const index = probes.findIndex(p => p.id === 'claude_code_cli');
126
- if (index >= 0)
127
- probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
128
- if (ready)
129
- versions.claude_code_cli = '1';
130
- else
131
- delete versions.claude_code_cli;
132
- }
133
- let current;
134
- try {
135
- current = await codex.probe();
136
- }
137
- catch {
138
- current = { available: false, authenticated: false, capabilities: [] };
139
- }
140
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
141
- const index = probes.findIndex(p => p.id === 'codex_cli');
142
- if (index >= 0)
143
- probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
144
- if (ready)
145
- versions.codex_cli = '1';
146
- else
147
- delete versions.codex_cli;
148
- let sessionOpen;
149
- // A detection error must not stop job claims: the heartbeat simply omits the field.
150
- try {
151
- sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
152
- }
153
- catch {
154
- sessionOpen = undefined;
155
- }
156
- await gateway.heartbeatLocalRunner({
157
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
158
- displayName: runnerDisplayName(), packageVersion: packageVersion(),
159
- capabilities: localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode),
160
- ...(sessionOpen ? { sessionOpen } : {}),
161
- });
162
- },
163
- wait: async () => {
164
- const claim = await gateway.waitForLocalJob(versions, 25);
128
+ // Push transport (Work Item 25d8ea74): claimed only when a sync says a job is claimable, with no wait.
129
+ claim: async () => {
130
+ await probing;
131
+ const claim = await gateway.waitForLocalJob(versions, 0);
165
132
  if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
166
133
  return { status: "no_job" };
167
134
  if (production && claim.task_package.runtime_adapter === 'codex_cli')
@@ -179,19 +146,107 @@ export async function buildDaemon(input) {
179
146
  execute: async (job) => {
180
147
  await coordinator.execute(job);
181
148
  },
182
- delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
149
+ requestSync: reason => { transport?.requestSync(reason); },
150
+ // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
151
+ onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
152
+ });
153
+ const heartbeats = new HeartbeatScheduler({
154
+ intervalMs: 30_000,
155
+ snapshot: async () => {
156
+ // Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
157
+ if (!poller.running) {
158
+ const probe = (async () => {
159
+ if (input.claudeSupportedProbe) {
160
+ let current;
161
+ try {
162
+ current = await plugin.probe();
163
+ }
164
+ catch {
165
+ current = { available: false, authenticated: false, capabilities: [] };
166
+ }
167
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
168
+ const index = probes.findIndex(p => p.id === 'claude_code_cli');
169
+ if (index >= 0)
170
+ probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
171
+ if (ready)
172
+ versions.claude_code_cli = '1';
173
+ else
174
+ delete versions.claude_code_cli;
175
+ }
176
+ let current;
177
+ try {
178
+ current = await codex.probe();
179
+ }
180
+ catch {
181
+ current = { available: false, authenticated: false, capabilities: [] };
182
+ }
183
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
184
+ const index = probes.findIndex(p => p.id === 'codex_cli');
185
+ if (index >= 0)
186
+ probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
187
+ if (ready)
188
+ versions.codex_cli = '1';
189
+ else
190
+ delete versions.codex_cli;
191
+ })();
192
+ probing = probe.catch(() => undefined);
193
+ await probe;
194
+ }
195
+ let sessionOpen;
196
+ // A detection error must not stop job claims: the heartbeat simply omits the field.
197
+ try {
198
+ sessionOpen = await sessionOpenAdvertisement({ home: input.env.HOME ?? "" });
199
+ }
200
+ catch {
201
+ sessionOpen = undefined;
202
+ }
203
+ const askInbox = await asks.advertisement();
204
+ return {
205
+ deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
206
+ displayName: runnerDisplayName(), packageVersion: packageVersion(),
207
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
208
+ mode: "full",
209
+ ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
210
+ };
211
+ },
212
+ send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
213
+ reportError: code => { process.stderr.write(`${JSON.stringify({ event: "heartbeat_failed", code })}\n`); },
183
214
  });
215
+ const stopping = new AbortController();
216
+ const closureAcks = new PendingClosureAcks();
184
217
  const opener = new SessionOpener({ home: input.env.HOME ?? "" });
185
- const sessionOpens = new SessionOpenPoller({
186
- wait: signal => gateway.waitForSessionOpens(25, signal),
218
+ const dispatch = createSyncDispatcher({
187
219
  open: request => opener.open(request),
188
220
  report: async (openId, result, signal) => {
189
221
  await gateway.reportSessionOpen({ openId, status: result.status, surface: result.surface,
190
222
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
191
223
  },
224
+ asks,
225
+ closureAcks,
226
+ jobs: poller,
192
227
  delay: abortableDelay,
228
+ signal: stopping.signal,
193
229
  reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
194
230
  });
231
+ const pushTransport = new RunnerTransport({
232
+ sync: async (reason, signal) => {
233
+ const acknowledged = closureAcks.take();
234
+ // A runner that takes no jobs right now (reconciling, or suspended for recovery) names no adapter.
235
+ try {
236
+ return await gateway.syncRunner({ adapterVersions: poller.accepting ? versions : {}, reason, acknowledgedClosureIds: acknowledged }, signal);
237
+ }
238
+ catch (error) {
239
+ closureAcks.restore(acknowledged);
240
+ throw error;
241
+ }
242
+ },
243
+ authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
244
+ dispatch,
245
+ openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
246
+ report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
247
+ });
248
+ transport = pushTransport;
249
+ const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
195
250
  const daemon = new Daemon({
196
251
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
197
252
  reconcile: async (stored) => {
@@ -204,13 +259,27 @@ export async function buildDaemon(input) {
204
259
  return;
205
260
  throw new Error('Runner state requires authoritative reconciliation');
206
261
  },
207
- poller, sessionOpens,
262
+ poller, sessionOpens: withAsks(asks, sessionOpens),
208
263
  reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
209
264
  });
210
265
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
211
266
  return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
212
267
  }
213
268
  export function runnerDisplayName() { return `${hostname()} local AI`; }
269
+ /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
270
+ export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
271
+ /*
272
+ * Work Item 25d8ea74: sync keeps presence fresh even while the daemon reconciles or is suspended for recovery, which
273
+ * the old job loop's heartbeat never did. So the heartbeat itself must say the runner takes no jobs, or the Gateway
274
+ * would route a job to a runner that never claims it. The Gateway requires at least one capability from a full
275
+ * runner, so each adapter is reported blocked, with this code, instead of being left out.
276
+ */
277
+ export function acceptingJobs(capabilities, accepting) {
278
+ if (accepting)
279
+ return capabilities;
280
+ return capabilities.map(capability => ({ ...capability, authenticated: false, execution_status: "blocked",
281
+ execution_reason_codes: [...new Set([RUNNER_NOT_ACCEPTING_JOBS, ...capability.execution_reason_codes])].slice(0, 20) }));
282
+ }
214
283
  /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
215
284
  export async function reconcilePreparationState(previous, deps) {
216
285
  if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
@@ -12,6 +12,51 @@ export class GatewayToolError extends Error {
12
12
  this.code = code;
13
13
  }
14
14
  }
15
+ const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
16
+ const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
17
+ const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
18
+ /*
19
+ * A sync result without the ask keys ends every stored delivery (the e1c08b33 kill switch), so a result that is not
20
+ * clearly a sync answer is refused instead of being read as "asks off": the transport retries it with backoff.
21
+ */
22
+ export function parseRunnerSync(result, runnerId) {
23
+ const body = result && typeof result === "object" && !Array.isArray(result) ? result : undefined;
24
+ const malformed = (key) => body !== undefined && body[key] !== undefined && !Array.isArray(body[key]);
25
+ if (body === undefined || !Array.isArray(body.opens) || malformed("ask_deliveries") || malformed("closed_delivery_ids")) {
26
+ throw new GatewayToolError("Gateway returned an invalid runner sync result", "local_agent_runner_sync", "GATEWAY_INVALID_TOOL_RESULT");
27
+ }
28
+ return {
29
+ opens: body.opens,
30
+ ...(Array.isArray(body.ask_deliveries) ? { askDeliveries: body.ask_deliveries } : {}),
31
+ ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
32
+ jobAvailable: body.job_available === true,
33
+ push: parsePush(body.push, runnerId),
34
+ syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
35
+ fallbackSyncSeconds: clampSeconds(body.fallback_sync_seconds, 5, 30, 8),
36
+ };
37
+ }
38
+ function clampSeconds(value, min, max, fallback) {
39
+ return typeof value === "number" && Number.isInteger(value) ? Math.min(max, Math.max(min, value)) : fallback;
40
+ }
41
+ function parsePush(value, runnerId) {
42
+ if (!value || typeof value !== "object")
43
+ return null;
44
+ const { url, app_key: appKey, channel, event } = value;
45
+ if (typeof url !== "string" || typeof appKey !== "string" || typeof channel !== "string" || event !== "runner.sync")
46
+ return null;
47
+ let parsed;
48
+ try {
49
+ parsed = new URL(url);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (parsed.protocol !== "wss:" || !PUSH_APP_KEY.test(appKey) || !PUSH_CHANNEL.test(channel))
55
+ return null;
56
+ if (!channel.endsWith(`.runner.${runnerId.toLowerCase()}`))
57
+ return null;
58
+ return { url, appKey, channel, event };
59
+ }
15
60
  function nested(value, path) {
16
61
  return path.split(".").reduce((current, key) => {
17
62
  if (!current || typeof current !== "object")
@@ -431,9 +476,10 @@ export class McpGatewayClient {
431
476
  display_name: input.displayName,
432
477
  package_version: input.packageVersion,
433
478
  capabilities: input.capabilities,
479
+ ...(input.mode ? { mode: input.mode } : {}),
434
480
  });
435
481
  }
436
- async heartbeatLocalRunner(input) {
482
+ async heartbeatLocalRunner(input, signal) {
437
483
  return this.callTool("local_agent_runner_heartbeat", {
438
484
  runner_id: this.runnerId,
439
485
  runner: {
@@ -441,9 +487,10 @@ export class McpGatewayClient {
441
487
  display_name: input.displayName,
442
488
  package_version: input.packageVersion,
443
489
  capabilities: input.capabilities,
490
+ ...(input.mode ? { mode: input.mode } : {}),
444
491
  ...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
445
492
  },
446
- });
493
+ }, signal);
447
494
  }
448
495
  async waitForSessionOpens(waitSeconds = 25, signal) {
449
496
  const result = await this.callTool("local_agent_session_opens_wait", {
@@ -453,7 +500,20 @@ export class McpGatewayClient {
453
500
  // The Gateway asks for a longer pause while session open is off. Only a sane whole number of seconds is honoured.
454
501
  const retryAfter = result?.retry_after_seconds;
455
502
  const retryAfterSeconds = typeof retryAfter === "number" && Number.isInteger(retryAfter) && retryAfter >= 1 && retryAfter <= 3600 ? retryAfter : undefined;
456
- return { opens: Array.isArray(result?.opens) ? result.opens : [], retryAfterSeconds };
503
+ return { opens: Array.isArray(result?.opens) ? result.opens : [], retryAfterSeconds,
504
+ // AI-first ask delivery (Work Item e1c08b33): ids only, present while the Gateway's switch is on.
505
+ ...(Array.isArray(result?.ask_deliveries) ? { askDeliveries: result.ask_deliveries } : {}),
506
+ ...(Array.isArray(result?.closed_delivery_ids) ? { closedDeliveryIds: result.closed_delivery_ids } : {}) };
507
+ }
508
+ /** Reports one delivery's progress. No session id, cwd, prompt or other text is ever sent. */
509
+ async reportAskDelivery(input, signal) {
510
+ return this.callTool("local_agent_ask_delivery_report", {
511
+ runner_id: this.runnerId,
512
+ delivery_id: input.deliveryId,
513
+ event: input.event,
514
+ ...(input.via ? { via: input.via } : {}),
515
+ ...(input.app ? { app: input.app } : {}),
516
+ }, signal);
457
517
  }
458
518
  async reportSessionOpen(input, signal) {
459
519
  return this.callTool("local_agent_session_open_report", {
@@ -464,6 +524,27 @@ export class McpGatewayClient {
464
524
  ...(input.failureCode ? { failure_code: input.failureCode } : {}),
465
525
  }, signal);
466
526
  }
527
+ /** acknowledgedClosureIds: closures handled since the last sync; the Gateway lists each again until acknowledged. */
528
+ async syncRunner(input, signal) {
529
+ const acknowledged = (input.acknowledgedClosureIds ?? []).slice(0, 50);
530
+ const result = await this.callTool("local_agent_runner_sync", {
531
+ runner_id: this.runnerId,
532
+ adapter_versions: input.adapterVersions,
533
+ reason: input.reason,
534
+ ...(acknowledged.length > 0 ? { acknowledged_closure_ids: acknowledged } : {}),
535
+ }, signal);
536
+ return parseRunnerSync(result, this.runnerId);
537
+ }
538
+ async authorizeRunnerChannel(socketId, signal) {
539
+ const result = await this.callTool("local_agent_runner_channel_auth", {
540
+ runner_id: this.runnerId,
541
+ socket_id: socketId,
542
+ }, signal);
543
+ if (typeof result?.channel !== "string" || typeof result.auth !== "string" || !CHANNEL_AUTH.test(result.auth)) {
544
+ throw new GatewayToolError("Gateway returned an invalid channel authorization", "local_agent_runner_channel_auth", "GATEWAY_INVALID_TOOL_RESULT");
545
+ }
546
+ return { channel: result.channel, auth: result.auth };
547
+ }
467
548
  async localRunnerStatus() {
468
549
  return this.callTool("local_agent_runner_status", { runner_id: this.runnerId });
469
550
  }
@@ -1,6 +1,10 @@
1
1
  import { chmod, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import { isAbsolute, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
+ /** The mode a stored configuration runs in (Work Item e1c08b33): a missing mode is a full runner. */
5
+ export function runnerMode(config) {
6
+ return config.mode ?? "full";
7
+ }
4
8
  export class FileLocalConfigStore {
5
9
  stateRoot;
6
10
  filePath;
@@ -49,8 +53,9 @@ export class FileLocalConfigStore {
49
53
  || typeof candidate.workspaceRoot !== "string"
50
54
  || !isAbsolute(candidate.workspaceRoot)
51
55
  || candidate.concurrency !== 1
56
+ || (candidate.mode !== undefined && candidate.mode !== "full" && candidate.mode !== "messenger")
52
57
  || Object.keys(candidate).some((key) => ![
53
- "schemaVersion", "gatewayMcpUrl", "runnerId", "workspaceRoot", "concurrency",
58
+ "schemaVersion", "gatewayMcpUrl", "runnerId", "workspaceRoot", "concurrency", "mode",
54
59
  ].includes(key))) {
55
60
  return null;
56
61
  }
@@ -60,6 +65,7 @@ export class FileLocalConfigStore {
60
65
  runnerId: candidate.runnerId,
61
66
  workspaceRoot: candidate.workspaceRoot,
62
67
  concurrency: 1,
68
+ ...(candidate.mode === undefined ? {} : { mode: candidate.mode }),
63
69
  };
64
70
  }
65
71
  }
@@ -0,0 +1,14 @@
1
+ /** local_edit accepts the complete replacement file, never an edit script.
2
+ * Pure function: the identical predicate is embedded in the isolated worker.
3
+ * Ordinary source strings and YAML separators are not patch headers. */
4
+ export function isPatchContent(content) {
5
+ const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/);
6
+ return lines.some((line, index) => {
7
+ const value = line.trimStart();
8
+ return /^\*\*\* (?:Begin Patch|End Patch|End of File|(?:Update|Add|Delete) File:|Move to:)(?:\s|$)/.test(value)
9
+ || /^@@(?:\s|$)/.test(value)
10
+ || /^diff --git\s/.test(value)
11
+ || /^(?:--- (?:a\/|\/dev\/null)|\+\+\+ (?:b\/|\/dev\/null))/.test(value)
12
+ || /^---[ \t]+\S/.test(value) && /^\+\+\+[ \t]+\S/.test(lines[index + 1]?.trimStart() ?? '');
13
+ });
14
+ }
@@ -1,3 +1,4 @@
1
+ import { isPatchContent } from './local-edit-content.js';
1
2
  import path from 'node:path';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { lstat, realpath } from 'node:fs/promises';
@@ -46,6 +47,8 @@ export class LocalToolPolicy {
46
47
  const file = await this.authorizedFile(input.path, tool === 'local_edit');
47
48
  if (tool === 'local_edit' && (typeof input.content !== 'string' || Buffer.byteLength(input.content) > 1024 * 1024))
48
49
  throw new Error('DENIED_CONTENT');
50
+ if (tool === 'local_edit' && isPatchContent(input.content))
51
+ throw new Error('DENIED_PATCH_CONTENT');
49
52
  if (tool === 'local_search' && (typeof input.query !== 'string' || input.query.length > 4096))
50
53
  throw new Error('DENIED_QUERY');
51
54
  const temp = file + '.runner-edit-' + randomBytes(16).toString('hex');
@@ -23,12 +23,12 @@ const MAX_BODY = 1024 * 1024;
23
23
  const schemas = {
24
24
  local_read: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
25
25
  local_search: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' } }, required: ['path', 'query'], additionalProperties: false },
26
- local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false },
26
+ local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
27
27
  local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
28
28
  };
29
29
  /** Same immutable-by-copy inventory used by native admission and MCP discovery. */
30
30
  export function localToolInventory() {
31
- return LOCAL_TOOL_NAMES.map(name => ({ name, description: `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
31
+ return LOCAL_TOOL_NAMES.map(name => ({ name, description: name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
32
32
  }
33
33
  /** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
34
34
  export class LocalToolServer {
@@ -1,7 +1,10 @@
1
+ import { isPatchContent } from './local-edit-content.js';
1
2
  /** Standalone worker: --eval avoids ambient module/config lookup. All paths are host-supplied. */
2
3
  export const LOCAL_TOOL_WORKER = String.raw `
3
4
  const fs = require('node:fs');
4
5
  const input = JSON.parse(process.argv[1]);
6
+ const isPatchContent = ${isPatchContent.toString()};
7
+ if (input.tool === 'local_edit' && isPatchContent(input.content)) throw Error('DENIED_PATCH_CONTENT');
5
8
  let current = '';
6
9
  for (const part of input.path.split('/').filter(Boolean)) {
7
10
  current += '/' + part;
@@ -0,0 +1,12 @@
1
+ /** Resolves after the delay or at once on abort, and never leaves a listener on a long-lived signal. */
2
+ export function abortableDelay(milliseconds, signal) {
3
+ return new Promise(resolve => {
4
+ if (signal.aborted) {
5
+ resolve();
6
+ return;
7
+ }
8
+ const onAbort = () => { clearTimeout(timer); resolve(); };
9
+ const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, milliseconds);
10
+ signal.addEventListener("abort", onAbort, { once: true });
11
+ });
12
+ }
@@ -0,0 +1,66 @@
1
+ const SOCKET_ID = /^\d{1,20}\.\d{1,20}$/;
2
+ const DEFAULT_ACTIVITY_TIMEOUT_SECONDS = 30;
3
+ export function subscribeFrame(channel, auth) {
4
+ return JSON.stringify({ event: "pusher:subscribe", data: { channel, auth } });
5
+ }
6
+ export function pingFrame() {
7
+ return JSON.stringify({ event: "pusher:ping", data: {} });
8
+ }
9
+ export function pongFrame() {
10
+ return JSON.stringify({ event: "pusher:pong", data: {} });
11
+ }
12
+ export function decodeFrame(raw, expected) {
13
+ let frame;
14
+ try {
15
+ frame = JSON.parse(raw);
16
+ }
17
+ catch {
18
+ return { kind: "ignored" };
19
+ }
20
+ if (!frame || typeof frame !== "object" || Array.isArray(frame))
21
+ return { kind: "ignored" };
22
+ const { event, channel, data } = frame;
23
+ if (typeof event !== "string")
24
+ return { kind: "ignored" };
25
+ switch (event) {
26
+ case "pusher:connection_established": {
27
+ const payload = parseData(data);
28
+ const socketId = payload?.socket_id;
29
+ if (typeof socketId !== "string" || !SOCKET_ID.test(socketId))
30
+ return { kind: "error" };
31
+ const timeout = payload?.activity_timeout;
32
+ const activityTimeoutSeconds = typeof timeout === "number" && Number.isInteger(timeout) && timeout >= 5 && timeout <= 600
33
+ ? timeout : DEFAULT_ACTIVITY_TIMEOUT_SECONDS;
34
+ return { kind: "connected", socketId, activityTimeoutSeconds };
35
+ }
36
+ case "pusher_internal:subscription_succeeded":
37
+ return channel === expected.channel ? { kind: "subscribed" } : { kind: "ignored" };
38
+ case "pusher:ping":
39
+ return { kind: "ping" };
40
+ case "pusher:pong":
41
+ return { kind: "pong" };
42
+ case "pusher:error": {
43
+ const code = parseData(data)?.code;
44
+ return typeof code === "number" ? { kind: "error", code } : { kind: "error" };
45
+ }
46
+ case "pusher:subscription_error":
47
+ return { kind: "error" };
48
+ default:
49
+ break;
50
+ }
51
+ if (event.startsWith("client-") || event.startsWith("pusher"))
52
+ return { kind: "ignored" };
53
+ return channel === expected.channel && event === expected.event ? { kind: "signal" } : { kind: "ignored" };
54
+ }
55
+ function parseData(data) {
56
+ if (typeof data === "string") {
57
+ try {
58
+ const parsed = JSON.parse(data);
59
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
60
+ }
61
+ catch {
62
+ return undefined;
63
+ }
64
+ }
65
+ return data && typeof data === "object" && !Array.isArray(data) ? data : undefined;
66
+ }