mtech-ai-local-runner 0.1.68 → 0.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/README.md CHANGED
@@ -186,6 +186,19 @@ and at most 6 times an hour. With no such session the ask becomes one macOS noti
186
186
  seconds and logs only an error code, and the ask waits for the next session start or prompt. Hook text is fixed and
187
187
  carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
188
188
 
189
+ An ask or answer the runner puts into a session on its own, by a wake or at a turn end, also gets one macOS
190
+ notification naming that app ("It is in your Claude session."), since the person may be looking elsewhere (Work Item
191
+ 643dc395). It is sent within two minutes of the show and never for a delivery that already had a notification; one
192
+ shown because the person typed gets none. The Gateway's `shown_via` stays `rewake` or `stop`.
193
+
194
+ While the Gateway's sync says `ask_alert: true` for the person (their actor id is in
195
+ `AI_OPS_GATEWAY_WORK_ASK_AI_FIRST_ALERT_ACTOR_IDS`, empty by default), every ask notification is the banner and also an
196
+ alert: a `display alert` with Later and Open Claude or Open Codex that stays until it is closed and gives up after 10
197
+ minutes. The banner stays in Notification Center if the alert is closed unread. Open runs `open -b` with that app's
198
+ bundle id. One alert is on screen at a time; while one is, the next notification is the banner only. The runner never waits for an alert, kills one still
199
+ open after 610 seconds, and closes it at shutdown. A sync without `ask_alert` turns alerts off; the session-open wait
200
+ leaves the last value.
201
+
189
202
  The Gateway sends the ask keys on the wait only while asks are on for this runner. When a wait result comes without
190
203
  them, every stored delivery ends: it is dropped from `asks.json` with no report, and the Gateway's Slack fallback
191
204
  covers it. A stored delivery is forgotten an hour after it arrived, by which time the fallback (at most 15 minutes) has
@@ -0,0 +1,127 @@
1
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { OSASCRIPT, notificationText } from "./notifier.js";
4
+ /*
5
+ * Ask notifications as an alert that stays until it is closed (Work Item 643dc395, spec 2026-10-29, C), shown beside
6
+ * the banner while the Gateway's sync says alerts are on for this person (ask_alert). One alert at a time, and the runner never
7
+ * waits for it to close, so it holds no delivery handling and no shutdown. The text and the Open label reach
8
+ * AppleScript as arguments, never as script source, and nothing runs through a shell.
9
+ */
10
+ export const OPEN = "/usr/bin/open";
11
+ /** An alert that has not failed after this long counts as shown, the same promise a banner makes. */
12
+ export const ALERT_SETTLE_MS = 1_000;
13
+ /** AppleScript gives up after 600 s; an alert still open 10 s later is killed. */
14
+ export const ALERT_KILL_MS = 610_000;
15
+ export const BUNDLE_IDS = { claude: "com.anthropic.claudefordesktop", codex: "com.openai.codex" };
16
+ const OPEN_LABEL = { claude: "Open Claude", codex: "Open Codex" };
17
+ /** `activate` brings osascript itself to the front, so the alert is not hidden behind other windows; it sends no Apple event to another app. */
18
+ const SCRIPT = [
19
+ "on run argv",
20
+ "activate",
21
+ "display alert \"AI Gateway\" message (item 1 of argv) buttons {\"Later\", item 2 of argv} default button 2 giving up after 600",
22
+ "end run",
23
+ ];
24
+ /** `open -b` gives up after this, like the session opener's `open`. */
25
+ const OPEN_TIMEOUT_MS = 10_000;
26
+ /** The arguments osascript gets: the fixed script, then the text and the Open label. */
27
+ export function alertArguments(text, app) {
28
+ return [...SCRIPT.flatMap(line => ["-e", line]), text, OPEN_LABEL[app]];
29
+ }
30
+ const execFileAsync = promisify(execFile);
31
+ function errorCode(error) {
32
+ const code = error?.code;
33
+ return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN";
34
+ }
35
+ export function createAlerter(deps = {}) {
36
+ const platform = deps.platform ?? process.platform;
37
+ const settleMs = deps.settleMs ?? ALERT_SETTLE_MS;
38
+ const killMs = deps.killMs ?? ALERT_KILL_MS;
39
+ const spawn = deps.spawn ?? ((file, args) => nodeSpawn(file, args, { stdio: ["ignore", "pipe", "ignore"] }));
40
+ const run = deps.run ?? ((file, args) => execFileAsync(file, args, { timeout: OPEN_TIMEOUT_MS, killSignal: "SIGKILL" }));
41
+ const fail = (code) => {
42
+ try {
43
+ deps.log?.({ event: "ask_alert_failed", code });
44
+ }
45
+ catch { /* Logging never stops delivery. */ }
46
+ };
47
+ let open = null;
48
+ return {
49
+ get busy() { return open !== null; },
50
+ show(text, app) {
51
+ if (platform !== "darwin" || open !== null)
52
+ return Promise.resolve(false);
53
+ return new Promise(resolve => {
54
+ let child;
55
+ try {
56
+ child = spawn(OSASCRIPT, alertArguments(text, app));
57
+ }
58
+ catch {
59
+ fail("SPAWN_FAILED");
60
+ resolve(false);
61
+ return;
62
+ }
63
+ open = child;
64
+ let output = "";
65
+ let settled = false;
66
+ let ended = false;
67
+ const settle = (shown) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ resolve(shown);
72
+ };
73
+ const settleTimer = setTimeout(() => settle(true), settleMs);
74
+ const killTimer = setTimeout(() => child.kill("SIGKILL"), killMs);
75
+ const end = () => {
76
+ ended = true;
77
+ clearTimeout(settleTimer);
78
+ clearTimeout(killTimer);
79
+ if (open === child)
80
+ open = null;
81
+ };
82
+ child.stdout?.on("data", chunk => { output += chunk.toString("utf8"); });
83
+ child.on("error", () => {
84
+ if (ended)
85
+ return;
86
+ end();
87
+ if (!settled)
88
+ fail("SPAWN_FAILED");
89
+ settle(false);
90
+ });
91
+ child.on("close", code => {
92
+ if (ended)
93
+ return;
94
+ end();
95
+ // A failure before the alert counted as shown leaves the delivery to the banner.
96
+ if (code !== 0 && !settled)
97
+ fail(code === null ? "KILLED" : `EXIT_${code}`);
98
+ settle(code === 0);
99
+ if (code === 0 && output.includes(`button returned:${OPEN_LABEL[app]}`)) {
100
+ run(OPEN, ["-b", BUNDLE_IDS[app]]).catch(error => fail(errorCode(error)));
101
+ }
102
+ });
103
+ });
104
+ },
105
+ stop() {
106
+ const child = open;
107
+ open = null;
108
+ child?.kill("SIGKILL");
109
+ },
110
+ };
111
+ }
112
+ /**
113
+ * The runner's notifier: always the banner, and also the alert while alerts are on and none is open. The owner chose
114
+ * both on 2026-10-29: the alert stays until it is closed, and the banner stays in Notification Center if it is closed
115
+ * unread. The alert's app is the one the delivery is in, else the app of the most recent session with a typed prompt.
116
+ * The delivery counts as shown when either was.
117
+ */
118
+ export function withAlerts(banner, alerter, deps) {
119
+ return async (item, options = {}) => {
120
+ const bannerShown = await banner(item, options);
121
+ if (!deps.alertsOn() || alerter.busy)
122
+ return bannerShown;
123
+ const app = options.shownIn ?? options.heldIn ?? options.openedApp ?? deps.fallbackApp();
124
+ const alertShown = await alerter.show(notificationText(item, options), app);
125
+ return bannerShown || alertShown;
126
+ };
127
+ }
@@ -1,5 +1,5 @@
1
1
  import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
2
- import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
2
+ import { addDeliveries, claimOpen, claimShownNotifications, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, preferredApp, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
3
3
  import { droppableDeliveryId, parseAskDelivery } from "./types.js";
4
4
  import { renderOutput } from "./templates.js";
5
5
  import { PRESENCE_RECHECK_MS } from "./presence.js";
@@ -71,6 +71,10 @@ export class AskInbox {
71
71
  watchers: this.watchers.size,
72
72
  };
73
73
  }
74
+ /** The app of the session with the most recent typed prompt, for an alert's Open button (Work Item 643dc395). */
75
+ preferredApp() {
76
+ return preferredApp(this.state);
77
+ }
74
78
  /**
75
79
  * The answer resolves as soon as the change is made, before the save; the queue is held until the save is done. A
76
80
  * client that is gone gets nothing and changes nothing: marking an ask shown for a hook that cannot print it would
@@ -289,6 +293,8 @@ export class AskInbox {
289
293
  this.watchers.delete(key);
290
294
  confirmWake(this.state, key, output, now);
291
295
  }
296
+ // What a wake or a turn end just showed gets its one notification: the person may not be looking at that session.
297
+ const shown = claimShownNotifications(this.state, now);
292
298
  // Claimed before anything runs, so a delivery is never opened or notified twice.
293
299
  const opening = new Set(plan.open.map(item => item.deliveryId));
294
300
  claimOpen(this.state, [...opening], now);
@@ -302,6 +308,7 @@ export class AskInbox {
302
308
  return {
303
309
  notify: notify.filter(id => !opening.has(id)),
304
310
  held,
311
+ shown,
305
312
  // The notification of a delivery in the open waits for its outcome, which decides the text.
306
313
  open: opening.size === 0 ? null : { deliveryIds: [...opening], notifyAfter: notify.filter(id => opening.has(id)) },
307
314
  };
@@ -313,6 +320,8 @@ export class AskInbox {
313
320
  this.startNotifying(work.notify);
314
321
  for (const { deliveryId, app } of work.held)
315
322
  this.startNotifying([deliveryId], { heldIn: app });
323
+ for (const { deliveryId, app } of work.shown)
324
+ this.startNotifying([deliveryId], { shownIn: app });
316
325
  if (work.open !== null)
317
326
  this.startOpening(work.open);
318
327
  try {
@@ -4,16 +4,17 @@ import { execFile as nodeExecFile } from "node:child_process";
4
4
  * One per delivery, a fixed text per subject type and direction, no buttons. /usr/bin/osascript runs through
5
5
  * execFile, never a shell, and the text reaches AppleScript as an argument, never as script source.
6
6
  */
7
- const ASK_TEXT = {
8
- decision: "AI Gateway: a colleague asked you for a decision. Open Claude or ChatGPT to see it.",
9
- review: "AI Gateway: a colleague asked you for a review. Open Claude or ChatGPT to see it.",
10
- request: "AI Gateway: a colleague asked you for an answer. Open Claude or ChatGPT to see it.",
7
+ const ASK_LEAD = {
8
+ decision: "AI Gateway: a colleague asked you for a decision.",
9
+ review: "AI Gateway: a colleague asked you for a review.",
10
+ request: "AI Gateway: a colleague asked you for an answer.",
11
11
  };
12
- const ANSWER_TEXT = {
13
- decision: "AI Gateway: your decision request has been answered. Open Claude or ChatGPT to continue.",
14
- review: "AI Gateway: your review request has been answered. Open Claude or ChatGPT to continue.",
15
- request: "AI Gateway: your work request has been answered. Open Claude or ChatGPT to continue.",
12
+ const ANSWER_LEAD = {
13
+ decision: "AI Gateway: your decision request has been answered.",
14
+ review: "AI Gateway: your review request has been answered.",
15
+ request: "AI Gateway: your work request has been answered.",
16
16
  };
17
+ const APP_NAME = { claude: "Claude", codex: "Codex" };
17
18
  /** Sent together with an open (open-session design section 5): the session is already there, in this app. */
18
19
  const OPENED_TEXT = {
19
20
  ask: {
@@ -43,7 +44,10 @@ export function notificationText(item, options = {}) {
43
44
  return HELD_TEXT[options.heldIn];
44
45
  if (options.openedApp !== undefined)
45
46
  return OPENED_TEXT[item.direction][options.openedApp];
46
- return (item.direction === "ask" ? ASK_TEXT : ANSWER_TEXT)[item.subjectType];
47
+ const lead = (item.direction === "ask" ? ASK_LEAD : ANSWER_LEAD)[item.subjectType];
48
+ if (options.shownIn !== undefined)
49
+ return `${lead} It is in your ${APP_NAME[options.shownIn]} session.`;
50
+ return `${lead} ${item.direction === "ask" ? "Open Claude or ChatGPT to see it." : "Open Claude or ChatGPT to continue."}`;
47
51
  }
48
52
  /** The arguments osascript gets: the fixed script, then the text as its only argument. */
49
53
  export function notificationArguments(text) {
@@ -357,6 +357,26 @@ export function confirmNotified(state, deliveryId, now, shown) {
357
357
  item.outbox.push({ event: "shown", via: "notification" });
358
358
  }
359
359
  }
360
+ /** A delivery shown this long ago or longer gets no notification for it, so a just-upgraded runner does not announce old ones. */
361
+ export const SHOWN_NOTIFY_WINDOW_MS = 2 * 60 * 1000;
362
+ /**
363
+ * Deliveries the runner put into a session on its own, by rewake or stop, that have had no notification yet (Work Item
364
+ * 643dc395, B). Each is claimed (notifiedAt), so it is never notified twice, and returned with its session's app. A
365
+ * delivery shown because the person typed, or whose session is gone, is left alone.
366
+ */
367
+ export function claimShownNotifications(state, now) {
368
+ const claimed = [];
369
+ for (const item of itemsWhere(state, item => item.phase === "shown" && (item.shownVia === "rewake" || item.shownVia === "stop") && item.notifiedAt === null)) {
370
+ if (item.shownAt === null || now - item.shownAt > SHOWN_NOTIFY_WINDOW_MS)
371
+ continue;
372
+ const session = item.sessionKey === null ? undefined : state.sessions[item.sessionKey];
373
+ if (session === undefined)
374
+ continue;
375
+ item.notifiedAt = now;
376
+ claimed.push({ deliveryId: item.deliveryId, app: session.app });
377
+ }
378
+ return claimed;
379
+ }
360
380
  /** Claims one open for these deliveries before it runs, so none is opened twice. */
361
381
  export function claimOpen(state, deliveryIds, now) {
362
382
  for (const id of deliveryIds) {
@@ -4,6 +4,7 @@ import { AskStore } from "./store.js";
4
4
  import { refreshShim } from "./shim.js";
5
5
  import { installedApps } from "./integrations.js";
6
6
  import { createNotifier } from "./notifier.js";
7
+ import { createAlerter, withAlerts } from "./alerter.js";
7
8
  import { createPresenceReader } from "./presence.js";
8
9
  import { createSessionOpener } from "./opener.js";
9
10
  import { ASK_INBOX_VERSION } from "./types.js";
@@ -31,6 +32,9 @@ export class AskRuntime {
31
32
  deps;
32
33
  controller = new AbortController();
33
34
  inbox;
35
+ alerter;
36
+ /** The last sync's ask_alert (Work Item 643dc395); off until a sync says otherwise. */
37
+ alertsOn = false;
34
38
  hub;
35
39
  started = false;
36
40
  /** The one start of this run, kept so advertisement() can wait for it; null until start() is called. */
@@ -39,8 +43,10 @@ export class AskRuntime {
39
43
  flushAgain = false;
40
44
  constructor(deps) {
41
45
  this.deps = deps;
46
+ this.alerter = deps.alerter ?? createAlerter({ log: event => this.log(event) });
47
+ const banner = deps.notify ?? createNotifier({ log: event => this.log(event) });
42
48
  this.inbox = new AskInbox({ store: new AskStore(deps.stateRoot), now: deps.now,
43
- notify: deps.notify ?? createNotifier({ log: event => this.log(event) }),
49
+ notify: withAlerts(banner, this.alerter, { alertsOn: () => this.alertsOn, fallbackApp: () => this.inbox.preferredApp() ?? "claude" }),
44
50
  presence: deps.presence ?? createPresenceReader({ log: event => this.log(event) }),
45
51
  openSession: deps.openSession ?? createSessionOpener({ home: deps.env.HOME ?? "" }),
46
52
  presenceRecheckMs: deps.presenceRecheckMs,
@@ -86,6 +92,7 @@ export class AskRuntime {
86
92
  this.controller.abort();
87
93
  this.inbox.stopTimers();
88
94
  this.inbox.releaseAll("shutdown");
95
+ this.alerter.stop();
89
96
  try {
90
97
  await this.hub.stop();
91
98
  }
@@ -111,12 +118,19 @@ export class AskRuntime {
111
118
  * therefore means asks are off, and every stored delivery ends here with no report: the Gateway's fallback owns them.
112
119
  * A result with the key present but empty changes nothing.
113
120
  *
121
+ * A sync result also carries askAlert (Work Item 643dc395): whether notifications are alerts until the next sync. The
122
+ * session-open wait result has no askAlert and leaves the last value. Asks off means alerts off.
123
+ *
114
124
  * Resolves false when the result's closures could not be handled (the hub is down, or closing failed), so the push
115
125
  * transport does not acknowledge them and the Gateway lists them again (Work Item 25d8ea74).
116
126
  */
117
127
  async handleWait(result) {
118
128
  if (!this.started)
119
129
  return false;
130
+ if (result.askAlert !== undefined)
131
+ this.alertsOn = result.askAlert;
132
+ if (result.askDeliveries === undefined)
133
+ this.alertsOn = false;
120
134
  let closuresHandled = true;
121
135
  try {
122
136
  if (result.askDeliveries === undefined) {
@@ -75,7 +75,9 @@ export class ExecutionCoordinator {
75
75
  let localFixPublication;
76
76
  const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
77
77
  const isP1 = hasP1(job.taskPackage);
78
+ const isP2 = isP1 && job.taskPackage.verification?.gateway_verification !== undefined;
78
79
  let p1Result;
80
+ let p2Handoff;
79
81
  const p1ReceiptIds = [];
80
82
  const lifecycle = { frozen: false, current: true };
81
83
  const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
@@ -92,6 +94,8 @@ export class ExecutionCoordinator {
92
94
  throw Error('P1_HOST_REQUIRED');
93
95
  this.deps.p1Host.assertEligible(job);
94
96
  }
97
+ if (isP2 && !this.deps.gateway.stageGatewayVerification)
98
+ throw Error('P2_HOST_TRANSPORT_REQUIRED');
95
99
  if (isLocalFix && (!this.deps.localFixHost || pkg.repository?.access !== "write"))
96
100
  throw new LocalFixHostRequired();
97
101
  const repository = pkg.repository;
@@ -500,7 +504,9 @@ export class ExecutionCoordinator {
500
504
  await freezeOwnedModel();
501
505
  await readControls();
502
506
  executionStage = 'p1_verify';
503
- p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen }));
507
+ p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen,
508
+ exportCandidate: isP2 ? async (handoff) => { await readControls(); await assertModelFrozen(); if (p2Handoff)
509
+ throw Error('P2_DUPLICATE_HANDOFF'); p2Handoff = structuredClone(handoff); } : undefined }));
504
510
  executionStage = 'p1_receipts';
505
511
  await readControls();
506
512
  await assertModelFrozen();
@@ -514,6 +520,29 @@ export class ExecutionCoordinator {
514
520
  const id = await publicationStep(() => append({ eventType: 'test_result', summary: 'runner command observed', payload: { observer: 'runner', runner_receipt: receipt } }, true));
515
521
  p1ReceiptIds.push(id);
516
522
  }
523
+ if (isP2 && p1Result.receipts.length > 0 && p1Result.receipts.every(r => r.status === 'passed' && r.exit_code === 0)) {
524
+ if (!p2Handoff || p2Handoff.snapshot_fingerprint !== p1Result.snapshot_fingerprint || p2Handoff.candidate_commit_sha !== p1Result.candidate_commit_sha)
525
+ throw Error('P2_SEALED_HANDOFF_REQUIRED');
526
+ await readControls();
527
+ await assertModelFrozen();
528
+ try {
529
+ const result = await publicationStep(() => this.deps.gateway.stageGatewayVerification({ job, bindingId: binding.bindingId, handoff: p2Handoff, receiptEventIds: [...p1ReceiptIds], securityReceipt: adapter.securityReceipt?.() }));
530
+ if (typeof result.publication_id !== 'string' || !result.publication_id || typeof result.status !== 'string' || !/^p2_[a-z_]+$/.test(result.status))
531
+ throw Error('P2_STAGE_UNCONFIRMED');
532
+ await readControls();
533
+ await assertModelFrozen();
534
+ await append({ eventType: 'progress', summary: 'Gateway shadow verification recorded', payload: { gateway_verification: { publication_id: result.publication_id, status: result.status } } });
535
+ }
536
+ catch {
537
+ // An uncertain transport outcome is not a failing P1 command or permission to retry a POST.
538
+ await readControls();
539
+ await assertModelFrozen();
540
+ await append({ eventType: 'progress', summary: 'Gateway shadow stage outcome unconfirmed', payload: { reason: 'P2_STAGE_UNCONFIRMED' } });
541
+ }
542
+ finally {
543
+ p2Handoff = undefined;
544
+ }
545
+ }
517
546
  executionStage = 'event_stream';
518
547
  }
519
548
  if (event.eventType === "completed" && fixSession) {
@@ -103,6 +103,7 @@ export async function buildDaemon(input) {
103
103
  reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
104
104
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
105
105
  gateway: {
106
+ stageGatewayVerification: async (input) => (await gateway.stageGatewayVerification({ jobId: input.job.id, generation: input.job.generation, leaseToken: input.job.leaseToken, launchGrant: input.job.launchGrant, bindingId: input.bindingId, handoff: input.handoff, receiptEventIds: input.receiptEventIds, securityReceipt: input.securityReceipt })).result,
106
107
  renewJob: (job, signal) => gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken }, signal),
107
108
  confirmJobLease,
108
109
  shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
@@ -232,7 +233,7 @@ export async function buildDaemon(input) {
232
233
  return {
233
234
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
234
235
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
235
- capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
236
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode), poller.accepting),
236
237
  mode: "full",
237
238
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
238
239
  ...(guidance ? { guidance } : {}),
@@ -354,15 +355,16 @@ export async function reconcilePreparationState(previous, deps) {
354
355
  }
355
356
  /** Vendor capability text cannot claim a host-owned publication facility. */
356
357
  export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode) {
358
+ const modes = verificationMode === undefined ? [] : typeof verificationMode === 'string' ? [verificationMode] : verificationMode;
357
359
  return probes.map(probe => {
358
360
  const status = adapterStatus(probe);
359
361
  const authenticated = status.authenticated;
360
362
  const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
361
363
  if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
362
364
  capabilities.push('local_fix_publication_v1');
363
- if (verificationMode === 'gateway_ci_v1')
365
+ if (modes.includes('gateway_ci_v1'))
364
366
  capabilities.push('local_fix_gateway_ci_v1');
365
- if (verificationMode === 'gateway_publish_v1')
367
+ if (modes.includes('gateway_publish_v1'))
366
368
  capabilities.push('local_fix_gateway_publish_v1');
367
369
  }
368
370
  return { adapter: probe.id, version: probe.version, ...status, capabilities };
@@ -35,6 +35,7 @@ export function parseRunnerSync(result, runnerId) {
35
35
  ...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
36
36
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
37
37
  ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
38
+ ...(body.ask_alert === true ? { askAlert: true } : {}),
38
39
  jobAvailable: body.job_available === true,
39
40
  push: parsePush(body.push, runnerId),
40
41
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -731,6 +732,14 @@ export class McpGatewayClient {
731
732
  async localPublicationContext(input) {
732
733
  return this.callTool('local_agent_publication_context', this.localPublicationFence(input));
733
734
  }
735
+ async stageGatewayVerification(input) {
736
+ const security = { ...input.securityReceipt };
737
+ if (security.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(security.receipt_version))) {
738
+ delete security.preparation_timing;
739
+ delete security.launch_process;
740
+ }
741
+ return this.callTool('local_agent_server_publication', { ...this.localPublicationFence(input), operation: 'verification_stage', handoff: input.handoff, receipt_event_ids: input.receiptEventIds, security_receipt: security });
742
+ }
734
743
  async executeServerCiPublication(input) {
735
744
  const body = { ...this.localPublicationFence(input), operation: input.operation };
736
745
  if (input.operation === 'stage') {
@@ -29,6 +29,18 @@ async function readPrivateJson(file) {
29
29
  await f.close();
30
30
  }
31
31
  }
32
+ /** Serves each job with the registration of the mode frozen in its own Fix scope. */
33
+ function multiModeHost(hosts) {
34
+ const verificationModes = hosts.map(h => { if (h.verificationMode === undefined)
35
+ throw denied(); return h.verificationMode; });
36
+ return { requiresOwnedFreeze: true, verificationModes, prepare: async (input) => {
37
+ const mode = input.job.taskPackage?.local_fix_scope?.verification_mode;
38
+ const host = hosts.find(h => h.verificationMode === mode);
39
+ if (!host)
40
+ throw denied();
41
+ return host.prepare(input);
42
+ } };
43
+ }
32
44
  /** No installation file is created here. A missing file preserves investigation
33
45
  * behavior. Explicit approved installation is owner-only; credentials are read
34
46
  * only for an exact post-freeze Git operation, never during daemon discovery. */
@@ -49,19 +61,40 @@ export async function loadInstalledLocalFixHost(input) {
49
61
  const raw = await readPrivateJson(file);
50
62
  if (raw.schema === 'local-fix-host-installation-v2') {
51
63
  const config = raw;
52
- if (config.enabled !== true || !['gateway_ci_v1', 'gateway_publish_v1'].includes(config.verification_mode) || config.runnerId !== input.runnerId)
53
- throw denied();
54
- assertServerCiRegistration(config.registration);
55
- if (config.verification_mode !== (config.registration.verification_mode ?? 'gateway_ci_v1'))
64
+ if (config.enabled !== true || config.runnerId !== input.runnerId)
56
65
  throw denied();
66
+ let registrations;
67
+ if (config.registrations !== undefined) {
68
+ // One registration per mode, for one project and one runtime release; never mixed with the single form.
69
+ if (config.registration !== undefined || config.verification_mode !== undefined || !Array.isArray(config.registrations) || config.registrations.length < 1 || config.registrations.length > 2)
70
+ throw denied();
71
+ for (const r of config.registrations) {
72
+ assertServerCiRegistration(r);
73
+ if (!['gateway_ci_v1', 'gateway_publish_v1'].includes(r.verification_mode))
74
+ throw denied();
75
+ }
76
+ const [first] = config.registrations;
77
+ if (new Set(config.registrations.map(r => r.verification_mode)).size !== config.registrations.length
78
+ || config.registrations.some(r => r.project !== first.project || protocolHash(r.receipt) !== protocolHash(first.receipt)))
79
+ throw denied();
80
+ registrations = config.registrations;
81
+ }
82
+ else {
83
+ if (!config.registration || !['gateway_ci_v1', 'gateway_publish_v1'].includes(config.verification_mode))
84
+ throw denied();
85
+ assertServerCiRegistration(config.registration);
86
+ if (config.verification_mode !== (config.registration.verification_mode ?? 'gateway_ci_v1'))
87
+ throw denied();
88
+ registrations = [config.registration];
89
+ }
57
90
  const packageRoot = await realpath(fileURLToPath(new URL('../../../', import.meta.url)));
58
- const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, config.registration.receipt);
91
+ const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, registrations[0].receipt);
59
92
  await assertReleaseCurrent();
60
93
  await assertPinnedGit(config.git);
61
94
  const privateRoot = path.join(input.stateRoot, 'local-fix-publication'), s = await lstat(privateRoot);
62
95
  if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
63
96
  throw denied();
64
- return createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration: config.registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
97
+ const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
65
98
  let approvals;
66
99
  try {
67
100
  approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
@@ -75,7 +108,8 @@ export async function loadInstalledLocalFixHost(input) {
75
108
  if (id !== undefined && (typeof id !== 'string' || !/^[a-zA-Z0-9-]{1,191}$/.test(id)))
76
109
  throw denied();
77
110
  return id;
78
- } });
111
+ } }));
112
+ return config.registrations === undefined ? hosts[0] : multiModeHost(hosts);
79
113
  }
80
114
  const config = raw;
81
115
  if (config.schema !== 'local-fix-host-installation-v1' || config.enabled !== true || config.runnerId !== input.runnerId || !config.registration || config.registration.review.manifestFingerprint !== protocolHash(config.registration.manifest))
@@ -92,6 +92,7 @@ export function createSyncDispatcher(deps) {
92
92
  const handled = await deps.asks.handleWait({
93
93
  ...(result.askDeliveries !== undefined ? { askDeliveries: result.askDeliveries } : {}),
94
94
  ...(result.closedDeliveryIds !== undefined ? { closedDeliveryIds: result.closedDeliveryIds } : {}),
95
+ askAlert: result.askAlert === true,
95
96
  });
96
97
  // Acknowledged only once handled: a failed handleWait leaves them for the Gateway to list again.
97
98
  if (handled !== false)
@@ -1,4 +1,4 @@
1
- import { RunnerObservedVerifier } from './runner-observed-verifier.js';
1
+ import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner-observed-verifier.js';
2
2
  import { isP1WorkingBranch } from '../publication/git-snapshot.js';
3
3
  import { protocolHash } from '../publication/candidate.js';
4
4
  export class RunnerObservedExecutionHost {
@@ -13,7 +13,7 @@ export class RunnerObservedExecutionHost {
13
13
  return { root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: p.verification,
14
14
  fence: { binding_id: input.bindingId, job_id: input.job.id, generation: input.job.generation, contract_fingerprint: p.contract_fingerprint, base_commit_sha: r.base_commit_sha, working_branch: r.working_branch },
15
15
  allowedPaths: p.allowed_paths, blockedPaths: r.blocked_paths, candidateLimits: p.candidate_limits,
16
- current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal };
16
+ current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal, exportCandidate: input.exportCandidate };
17
17
  }
18
18
  assertEligible(job) {
19
19
  const r = this.registration;
@@ -33,6 +33,8 @@ export const hasP1 = (p) => {
33
33
  /** Compare host launch claims to authoritative WEC/bundle fields before start. */
34
34
  export function assertP1Bindings(p, canonical, bundle = false) {
35
35
  const v = record(p.verification), r = record(p.repository), limits = record(p.candidate_limits);
36
+ if (v.gateway_verification !== undefined)
37
+ assertGatewayVerificationScope(v.gateway_verification, r.key);
36
38
  if (p.local_fix_scope !== undefined || p.verification_mode === 'gateway_ci_v1' || p.runtime_adapter !== 'codex_cli' || p.runtime_adapter_version !== '1' || p.runtime_transport !== 'official_exec_v1' || v.schema_version !== 'local-runner-verification-v1' || v.policy_key !== 'local-runner-verified-v1' || v.mode !== 'runner_observed_v1' || v.runtime_adapter !== p.runtime_adapter || v.runtime_transport !== p.runtime_transport || r.access !== 'write' || !isP1WorkingBranch(r.working_branch) || !equal(p.allowed_paths, v.allowed_paths) || !equal(r.allowed_paths, v.allowed_paths) || !Array.isArray(r.blocked_paths))
37
39
  throw Error('P1_CONTRACT_REJECTED');
38
40
  if (Object.keys(limits).sort().join(',') !== 'max_changed_files,max_diff_bytes,max_file_bytes' || Object.values(limits).some(n => !Number.isSafeInteger(n) || Number(n) < 1))
@@ -23,6 +23,19 @@ const exactPath = (name) => typeof name === 'string' && /^[A-Za-z0-9_][A-Za-z0-9
23
23
  const isBlocked = (name, scopes) => scopes.some(p => p === '.env.*' ? name.startsWith('.env.') : p === '**' || name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/'));
24
24
  function keys(object, allowed) { if (!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k => !allowed.includes(k)))
25
25
  throw denied(); }
26
+ export function assertGatewayVerificationScope(value, repositoryKey) {
27
+ if (!value || typeof value !== 'object' || Array.isArray(value))
28
+ throw denied();
29
+ const g = value;
30
+ keys(g, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
31
+ if (g.schema_version !== 'gateway-verification-scope-v1' || g.intent !== 'verification_only' || g.mode !== 'shadow'
32
+ || typeof g.profile_id !== 'string' || !g.profile_id || typeof g.repository_key !== 'string' || !g.repository_key
33
+ || repositoryKey !== undefined && g.repository_key !== repositoryKey || !g.profile_definition || typeof g.profile_definition !== 'object'
34
+ || protocolHash(g.profile_definition) !== g.profile_fingerprint || g.profile_definition.reviewed !== true
35
+ || g.profile_definition.ref_prefix !== 'ai-verify/' || g.profile_definition.max_concurrent_runs !== 1
36
+ || !Array.isArray(g.profile_definition.repository_keys) || !g.profile_definition.repository_keys.includes(g.repository_key))
37
+ throw denied();
38
+ }
26
39
  /** Canonical P1 domain. Never uses legacy terminal T/U bytes or diff hashing. */
27
40
  export function buildP1ChangedPaths(input, bounds) {
28
41
  const entries = [...input.entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
@@ -48,7 +61,9 @@ export class RunnerObservedVerifier {
48
61
  } }
49
62
  validate() {
50
63
  const o = this.options, v = o.verification, p = v.profile_definition, r = o.registration, f = o.fence;
51
- keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required']);
64
+ keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required', 'gateway_verification']);
65
+ if (v.gateway_verification !== undefined)
66
+ assertGatewayVerificationScope(v.gateway_verification);
52
67
  keys(p, ['schema_version', 'reviewed', 'project_ids', 'runtime_adapters', 'runtime_transports', 'allowed_paths', 'bounds', 'commands', 'bindings']);
53
68
  keys(v.bounds, Object.keys(ceilings));
54
69
  if (!r || !r.review || ![f.binding_id, f.job_id].every(id => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id)) || !Number.isSafeInteger(f.generation) || f.generation < 1 || !hex(f.contract_fingerprint, 64) || !hex(f.base_commit_sha, 40) || !isP1WorkingBranch(f.working_branch) || v.schema_version !== 'local-runner-verification-v1' || v.mode !== 'runner_observed_v1' || v.policy_key !== 'local-runner-verified-v1' || !v.profile_id || p.schema_version !== 'local-runner-profile-v1' || p.reviewed !== true || r.profileId !== v.profile_id || r.profileFingerprint !== v.profile_fingerprint || protocolHash(p) !== v.profile_fingerprint || protocolHash(v.frozen_loop_contract) !== v.frozen_criteria_fingerprint)
@@ -133,7 +148,12 @@ export class RunnerObservedVerifier {
133
148
  policy.revoke();
134
149
  }
135
150
  await check();
136
- return { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
151
+ const result = { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
152
+ if (o.exportCandidate && receipts.length > 0 && receipts.every(r => r.status === 'passed' && r.exit_code === 0)) {
153
+ await o.exportCandidate(structuredClone({ schema_version: 'gateway-candidate-handoff-v1', ...result, fence: f, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, tree_fingerprint: sealed.treeFingerprint, diff: observation.diff }));
154
+ await check();
155
+ }
156
+ return result;
137
157
  }
138
158
  catch {
139
159
  throw denied();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {