mtech-ai-local-runner 0.1.60 → 0.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.
package/README.md CHANGED
@@ -889,10 +889,12 @@ The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It
889
889
  ### Local Fix with disclosed validation
890
890
 
891
891
  The existing daemon can use a reviewed `gateway_publish_v1` installation for
892
- approved local Codex fixes. It freezes the owned model and sends only sealed,
893
- bounded changed text files to Gateway's existing publication service. Server
894
- credentials create the generated branch and canonical development PR; no local
895
- Git token, ECR, new CI installation or native PHP verifier is required.
892
+ approved local Codex fixes, and Claude fixes once the Gateway lists a reviewed
893
+ Claude release and the runner reports Claude ready. It freezes the owned model
894
+ and sends only sealed, bounded changed text files to Gateway's existing
895
+ publication service. Server credentials create the generated branch and
896
+ canonical development PR; no local Git token, ECR, new CI installation or native
897
+ PHP verifier is required.
896
898
 
897
899
  Validation is explicitly **UNVERIFIED** in this mode, with CI **not run**.
898
900
  Executor-reported checks, failures and checks not run stay attributed to the
@@ -915,6 +917,10 @@ the older `authenticated` field still means execution eligibility. A logged-in
915
917
  Claude account can remain blocked by unverified security admission. Do not log out
916
918
  or change credentials just because that legacy field is false. Unknown status is
917
919
  not evidence of logout. These diagnostics do not bypass per-job admission.
920
+ Claude reports `CLAUDE_RELEASE_NOT_LISTED` while the Gateway lists no reviewed
921
+ Claude release. Claude readiness may lag a Claude login or logout by up to 5
922
+ minutes, because the runner asks the pinned copy for the login state at most that
923
+ often.
918
924
 
919
925
  ### 0.1.50: small edits and publication-only Fix
920
926
 
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
3
+ import { CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT } from '../../safety/claude-release.js';
3
4
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
4
5
  import { isTerminalEventType } from "../../protocol.js";
5
6
  import { parseClaudeLine } from "./stream-parser.js";
@@ -92,7 +93,11 @@ export class ClaudeCodeAdapter {
92
93
  if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
93
94
  || ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
94
95
  throw new Error("SECURITY_REQUIREMENTS_REJECTED");
95
- requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
96
+ const requirements = requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
97
+ // Release mode (decision 9095c7a8): the approved package carries the fixed stream-json transport and a model of its release.
98
+ if (requirements.security_mode === CLAUDE_RELEASE_MODE && (ctx.approvedTaskPackage?.runtime_transport !== CLAUDE_TRANSPORT
99
+ || !requirements.model_ids.includes(resolvedModelId)))
100
+ throw new Error("SECURITY_REQUIREMENTS_REJECTED");
96
101
  };
97
102
  assertAdmission();
98
103
  if (!ctx.securityBinding)
@@ -277,6 +282,23 @@ export class ClaudeCodeAdapter {
277
282
  });
278
283
  return this.cancelPromise;
279
284
  }
285
+ /** Owned exit before credential-bearing publication, with Codex semantics (decision 9095c7a8). It revokes the job
286
+ * security session and the MCP endpoint, stops the owned process group and confirms it within 10 seconds. */
287
+ async freezeForPublication() {
288
+ if (!this.started || !this.handle?.terminateOwned)
289
+ throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
290
+ let timer;
291
+ try {
292
+ await Promise.race([this.cancel(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED')), 10_000); })]);
293
+ }
294
+ catch {
295
+ throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
296
+ }
297
+ finally {
298
+ if (timer)
299
+ clearTimeout(timer);
300
+ }
301
+ }
280
302
  securityRevoked = false;
281
303
  revokeSecurity() {
282
304
  if (this.securityRevoked)
@@ -369,6 +391,6 @@ export class ClaudeCodePlugin {
369
391
  return { ...result, capabilities: result.capabilities ?? this.capabilities };
370
392
  }
371
393
  async create(input) {
372
- return new ClaudeCodeAdapter(this.pluginOptions.options(input));
394
+ return new ClaudeCodeAdapter(await this.pluginOptions.options(input));
373
395
  }
374
396
  }
@@ -0,0 +1,15 @@
1
+ /** Reads `claude auth status --json` stdout without returning raw output. `loggedOutExit` marks Claude's exit 1,
2
+ * which never proves a login. Anything unparsable stays unknown. */
3
+ export function claudeLoginFromStatus(stdout, loggedOutExit = false) {
4
+ try {
5
+ const value = JSON.parse(stdout);
6
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
7
+ const loggedIn = value.loggedIn;
8
+ if (loggedOutExit && loggedIn)
9
+ return { authenticated: false, loginStatus: 'unknown' };
10
+ return { authenticated: loggedIn, loginStatus: loggedIn ? 'authenticated' : 'unauthenticated' };
11
+ }
12
+ }
13
+ catch { /* Unknown evidence stays closed; never expose raw output. */ }
14
+ return { authenticated: false, loginStatus: 'unknown' };
15
+ }
@@ -1,4 +1,5 @@
1
1
  import { adapterStatus } from './status.js';
2
+ import { claudeLoginFromStatus } from './claude-code/login-status.js';
2
3
  import { createCodexProductionHost } from '../safety/codex-production-host.js';
3
4
  import { execFile } from 'node:child_process';
4
5
  import { promisify } from 'node:util';
@@ -22,16 +23,6 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
22
23
  throw new Error('invalid version');
23
24
  let authenticated = false;
24
25
  let loginStatus = 'unknown';
25
- const parseClaudeStatus = (stdout) => {
26
- try {
27
- const value = JSON.parse(stdout);
28
- if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
29
- authenticated = value.loggedIn;
30
- loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
31
- }
32
- }
33
- catch { /* Unknown evidence stays closed; never expose raw output. */ }
34
- };
35
26
  try {
36
27
  const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status', '--json']);
37
28
  if (codex) {
@@ -39,20 +30,15 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
39
30
  loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
40
31
  }
41
32
  else
42
- parseClaudeStatus(auth.stdout);
33
+ ({ authenticated, loginStatus } = claudeLoginFromStatus(auth.stdout));
43
34
  }
44
35
  catch (error) {
45
36
  // Claude exits 1 for a known logged-out JSON response. Other failures
46
37
  // (including timeout/permission errors) do not prove logout.
47
38
  if (!codex && error && typeof error === 'object' && error.code === 1) {
48
39
  const stdout = error.stdout;
49
- if (typeof stdout === 'string') {
50
- parseClaudeStatus(stdout);
51
- if (authenticated) {
52
- authenticated = false;
53
- loginStatus = 'unknown';
54
- }
55
- }
40
+ if (typeof stdout === 'string')
41
+ ({ authenticated, loginStatus } = claudeLoginFromStatus(stdout, true));
56
42
  }
57
43
  }
58
44
  if (codex) {
@@ -1,5 +1,5 @@
1
1
  import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
2
- import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, planRoutes, preferredApp, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
2
+ import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, 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";
@@ -292,11 +292,16 @@ export class AskInbox {
292
292
  // Claimed before anything runs, so a delivery is never opened or notified twice.
293
293
  const opening = new Set(plan.open.map(item => item.deliveryId));
294
294
  claimOpen(this.state, [...opening], now);
295
- for (const item of plan.notify)
295
+ for (const item of [...plan.notify, ...plan.held])
296
296
  confirmNotified(this.state, item.deliveryId, now, false);
297
297
  const notify = plan.notify.map(item => item.deliveryId);
298
+ const held = plan.held.flatMap(item => {
299
+ const session = item.sessionKey === null ? undefined : this.state.sessions[item.sessionKey];
300
+ return session === undefined ? [] : [{ deliveryId: item.deliveryId, app: session.app }];
301
+ });
298
302
  return {
299
303
  notify: notify.filter(id => !opening.has(id)),
304
+ held,
300
305
  // The notification of a delivery in the open waits for its outcome, which decides the text.
301
306
  open: opening.size === 0 ? null : { deliveryIds: [...opening], notifyAfter: notify.filter(id => opening.has(id)) },
302
307
  };
@@ -306,6 +311,8 @@ export class AskInbox {
306
311
  await this.persist();
307
312
  if (work.notify.length > 0)
308
313
  this.startNotifying(work.notify);
314
+ for (const { deliveryId, app } of work.held)
315
+ this.startNotifying([deliveryId], { heldIn: app });
309
316
  if (work.open !== null)
310
317
  this.startOpening(work.open);
311
318
  try {
@@ -411,7 +418,7 @@ export class AskInbox {
411
418
  return { status: "skipped" };
412
419
  const kinds = { asks: items.some(item => item.direction === "ask"), answers: items.some(item => item.direction === "answer") };
413
420
  try {
414
- return await this.deps.openSession(preferredApp(this.state), kinds);
421
+ return await this.deps.openSession(openApp(this.state, items), kinds);
415
422
  }
416
423
  catch {
417
424
  return { status: "failed", code: "open_failed" };
@@ -25,6 +25,11 @@ const OPENED_TEXT = {
25
25
  codex: "AI Gateway: your ask has an answer. A Codex session is open to continue.",
26
26
  },
27
27
  };
28
+ /** An answer held in its live asking session (Work Decision 8562d191): no session is opened, the person goes back to it. */
29
+ const HELD_TEXT = {
30
+ claude: "AI Gateway: your ask has an answer. It is waiting in the Claude session that asked. Type there to see it.",
31
+ codex: "AI Gateway: your ask has an answer. It is waiting in the Codex session that asked. Type there to see it.",
32
+ };
28
33
  export const OSASCRIPT = "/usr/bin/osascript";
29
34
  const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title \"AI Gateway\"", "end run"];
30
35
  /**
@@ -34,6 +39,8 @@ const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title
34
39
  */
35
40
  export const NOTIFY_TIMEOUT_MS = 3_000;
36
41
  export function notificationText(item, options = {}) {
42
+ if (options.heldIn !== undefined)
43
+ return HELD_TEXT[options.heldIn];
37
44
  if (options.openedApp !== undefined)
38
45
  return OPENED_TEXT[item.direction][options.openedApp];
39
46
  return (item.direction === "ask" ? ASK_TEXT : ANSWER_TEXT)[item.subjectType];
@@ -59,13 +59,46 @@ export function preferredApp(state) {
59
59
  }
60
60
  return best?.app ?? null;
61
61
  }
62
+ /**
63
+ * The live session on this Mac that made an answer's ask (Work Decision 8562d191): null for an ask, without a hash, or
64
+ * when that session is unknown here or no longer live.
65
+ */
66
+ export function askerSessionKey(item, state, now) {
67
+ if (item.direction !== "answer" || item.askerSessionHash === undefined || item.askerSessionHash === null)
68
+ return null;
69
+ for (const [key, session] of Object.entries(state.sessions)) {
70
+ if (isLive(session, now) && hashSessionId(session.sessionId) === item.askerSessionHash)
71
+ return key;
72
+ }
73
+ return null;
74
+ }
75
+ /**
76
+ * The app an open uses: for answers only, the app of the first one whose asking session this Mac still knows, live or
77
+ * ended, so the new session opens where the person asked; otherwise preferredApp.
78
+ */
79
+ export function openApp(state, items) {
80
+ if (items.length > 0 && items.every(item => item.direction === "answer")) {
81
+ for (const item of items) {
82
+ if (item.askerSessionHash === undefined || item.askerSessionHash === null)
83
+ continue;
84
+ const asking = Object.values(state.sessions).find(session => hashSessionId(session.sessionId) === item.askerSessionHash);
85
+ if (asking !== undefined)
86
+ return asking.app;
87
+ }
88
+ }
89
+ return preferredApp(state);
90
+ }
62
91
  /**
63
92
  * Spec section 5: a live session the delivery names, else the live session with the most recent human prompt. A named
64
93
  * session counts only with a human prompt in the last 30 minutes: named sessions stay live until closed, and an older
65
94
  * one would otherwise keep the delivery from the session the person is using (2026-10-26 Test B). Among named sessions
66
- * the one listed first wins: the Gateway lists the session that made the ask first (2026-10-26 test C).
95
+ * the one listed first wins: the Gateway lists the session that made the ask first (2026-10-26 test C). An answer whose
96
+ * asking session is live goes there, however long ago it was typed in (Work Decision 8562d191).
67
97
  */
68
98
  export function chooseSession(item, state, now) {
99
+ const asking = askerSessionKey(item, state, now);
100
+ if (asking !== null)
101
+ return asking;
69
102
  const live = Object.entries(state.sessions).filter(([, session]) => isLive(session, now));
70
103
  const order = new Map(item.targetSessionHashes.map((hash, index) => [hash, index]));
71
104
  const rank = (session) => order.get(hashSessionId(session.sessionId)) ?? order.size;
@@ -84,17 +117,22 @@ export function chooseSession(item, state, now) {
84
117
  * becomes the chosen one by that prompt (see promptSubmitted);
85
118
  * - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
86
119
  * - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
120
+ * An answer whose asking session is live is pinned or woken there by the same rules, and otherwise held there: it waits
121
+ * for that session, with a notification that says so, and no session is opened for it (Work Decision 8562d191).
87
122
  */
88
123
  export function routeItem(item, state, now, watching) {
89
- const key = chooseSession(item, state, now);
124
+ const asking = askerSessionKey(item, state, now);
125
+ const key = asking ?? chooseSession(item, state, now);
90
126
  const session = key === null ? undefined : state.sessions[key];
91
- if (key === null || session === undefined || !humanPromptWithin(session, now))
127
+ if (key === null || session === undefined)
128
+ return { action: "notify" };
129
+ if (asking === null && !humanPromptWithin(session, now))
92
130
  return { action: "notify" };
93
131
  if (turnInProgress(session))
94
132
  return { action: "pin", sessionKey: key };
95
- if (session.app === "claude" && watching.has(key) && wakesLeft(session, now) > 0)
133
+ if (session.app === "claude" && humanPromptWithin(session, now) && watching.has(key) && wakesLeft(session, now) > 0)
96
134
  return { action: "wake", sessionKey: key };
97
- return { action: "notify" };
135
+ return asking === null ? { action: "notify" } : { action: "hold", sessionKey: key };
98
136
  }
99
137
  /** At most 5 spelled out, oldest first; the rest are summarized by one "and N more" line. */
100
138
  export function selectForOutput(items) {
@@ -263,6 +301,7 @@ export function sessionEnded(state, context) {
263
301
  export function planRoutes(state, now, watching, options = {}) {
264
302
  const wakeGroups = new Map();
265
303
  const notify = [];
304
+ const held = [];
266
305
  const unseen = [];
267
306
  for (const item of itemsWhere(state, item => item.phase === "waiting" || item.phase === "pinned")) {
268
307
  const decision = routeItem(item, state, now, watching);
@@ -276,12 +315,14 @@ export function planRoutes(state, now, watching, options = {}) {
276
315
  }
277
316
  item.phase = "pinned";
278
317
  item.sessionKey = decision.sessionKey;
318
+ if (decision.action === "hold" && item.notifiedAt === null)
319
+ held.push(item);
279
320
  if (decision.action === "wake")
280
321
  wakeGroups.set(decision.sessionKey, [...wakeGroups.get(decision.sessionKey) ?? [], item]);
281
322
  }
282
323
  const openable = unseen.filter(item => item.openedAt === null);
283
324
  const open = options.canOpen === true && openable.length > 0 && !openPending(state, now) && opensLeft(state, now) > 0 ? openable : [];
284
- return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, open };
325
+ return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, held, open };
285
326
  }
286
327
  /** The hub delivered a wake to the session's watcher: everything in it counts as shown there. */
287
328
  export function confirmWake(state, key, output, now) {
@@ -6,6 +6,7 @@ import { installedApps } from "./integrations.js";
6
6
  import { createNotifier } from "./notifier.js";
7
7
  import { createPresenceReader } from "./presence.js";
8
8
  import { createSessionOpener } from "./opener.js";
9
+ import { ASK_INBOX_VERSION } from "./types.js";
9
10
  /*
10
11
  * AI-first ask delivery inside the daemon (Work Item e1c08b33). It rides on the existing session-open loop:
11
12
  * no new long-poll. The hub runs for as long as that loop runs, deliveries from each wait are stored and
@@ -152,7 +153,7 @@ export class AskRuntime {
152
153
  catch {
153
154
  apps = [];
154
155
  }
155
- return apps.length > 0 ? { version: 1, apps: [...apps] } : undefined;
156
+ return apps.length > 0 ? { version: ASK_INBOX_VERSION, apps: [...apps] } : undefined;
156
157
  }
157
158
  /** Resolves when the start settles or the bound passes, whichever comes first. Never rejects: start() never throws. */
158
159
  async waitForStart(starting) {
@@ -79,6 +79,10 @@ function normalizeItem(key, value) {
79
79
  // Missing in a file written before opens existed: never opened.
80
80
  if (v.openedAt !== undefined && !isTimeOrNull(v.openedAt))
81
81
  return null;
82
+ // Missing in a file written before 0.1.61 or from a seven-key delivery: no asking session known.
83
+ if (v.askerSessionHash !== undefined && v.askerSessionHash !== null
84
+ && (typeof v.askerSessionHash !== "string" || !SESSION_HASH.test(v.askerSessionHash)))
85
+ return null;
82
86
  const outbox = v.outbox.map(normalizeReport);
83
87
  if (outbox.some(report => report === null))
84
88
  return null;
@@ -86,7 +90,8 @@ function normalizeItem(key, value) {
86
90
  targetSessionHashes: [...v.targetSessionHashes], createdAt: v.createdAt, receivedAt: v.receivedAt,
87
91
  phase: settlePhase(v.phase, v.sessionKey),
88
92
  sessionKey: v.sessionKey, shownVia: v.shownVia, shownAt: v.shownAt, firstShownVia: v.firstShownVia,
89
- notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox };
93
+ notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox,
94
+ ...(v.askerSessionHash === undefined ? {} : { askerSessionHash: v.askerSessionHash }) };
90
95
  }
91
96
  function normalizeSession(key, value) {
92
97
  if (value === null || typeof value !== "object")
@@ -9,6 +9,11 @@ export const SUBJECT_TYPES = ["decision", "review", "request"];
9
9
  export const DIRECTIONS = ["ask", "answer"];
10
10
  export const VIAS = ["prompt", "session_start", "stop", "rewake", "notification"];
11
11
  export const REPORT_EVENTS = ["received", "shown", "seen", "dropped"];
12
+ /**
13
+ * heartbeat runner.session_open.ask_inbox: the integrations actually installed on this Mac. Version 2 says the runner
14
+ * reads asker_session_hash on deliveries (Work Decision 8562d191); it needs a Gateway that accepts version 2.
15
+ */
16
+ export const ASK_INBOX_VERSION = 2;
12
17
  /** Gateway ids are lowercase UUIDs (contract: subject keys must be lowercase UUIDs). */
13
18
  export const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
14
19
  /** sha256 of a client session id, as the Gateway stores external_session_id_hash. */
@@ -22,8 +27,10 @@ export const OPEN_ASKS_LEAD = "AI Gateway: colleagues' AIs are waiting for you."
22
27
  export const OPEN_ANSWERS_LEAD = "AI Gateway: your asks have answers.";
23
28
  /** The most target session hashes one delivery may carry: a longer list is refused on the wire and dropped on load. */
24
29
  export const MAX_TARGET_HASHES = 50;
25
- // Sorted, so they compare directly with the sorted keys of a wire item.
30
+ // Sorted, so they compare directly with the sorted keys of a wire item. A Gateway that knows this runner reads
31
+ // ask_inbox version 2 adds asker_session_hash; an older one sends the seven keys only.
26
32
  const DELIVERY_FIELDS = ["created_at", "delivery_id", "direction", "subject_key", "subject_type", "target_session_hashes", "work_item_id"];
33
+ const DELIVERY_FIELDS_WITH_ASKER = ["asker_session_hash", ...DELIVERY_FIELDS];
27
34
  function oneOf(values, value) {
28
35
  return typeof value === "string" && values.includes(value);
29
36
  }
@@ -39,9 +46,13 @@ export function parseAskDelivery(input) {
39
46
  return null;
40
47
  const record = input;
41
48
  const keys = Object.keys(record).sort();
42
- if (keys.length !== DELIVERY_FIELDS.length || keys.some((key, index) => key !== DELIVERY_FIELDS[index]))
49
+ const withAsker = keys.includes("asker_session_hash");
50
+ const fields = withAsker ? DELIVERY_FIELDS_WITH_ASKER : DELIVERY_FIELDS;
51
+ if (keys.length !== fields.length || keys.some((key, index) => key !== fields[index]))
52
+ return null;
53
+ const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt, asker_session_hash: asker } = record;
54
+ if (withAsker && asker !== null && (typeof asker !== "string" || !SESSION_HASH.test(asker)))
43
55
  return null;
44
- const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt } = record;
45
56
  if (typeof deliveryId !== "string" || !LOWER_UUID.test(deliveryId))
46
57
  return null;
47
58
  if (!isSubjectType(subjectType) || !isDirection(direction))
@@ -56,7 +67,8 @@ export function parseAskDelivery(input) {
56
67
  return null;
57
68
  if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0)
58
69
  return null;
59
- return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt };
70
+ return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt,
71
+ ...(withAsker ? { askerSessionHash: asker } : {}) };
60
72
  }
61
73
  /** The id of a wire item that failed validation, when the id itself is valid, so it can be reported as dropped. */
62
74
  export function droppableDeliveryId(input) {
@@ -1,5 +1,7 @@
1
+ import { createClaudeProductionHost } from '../safety/claude-production-host.js';
1
2
  import { adapterStatus } from '../adapters/status.js';
2
3
  import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
4
+ import { isLocalFixAdapter } from '../publication/local-fix-adapters.js';
3
5
  import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
4
6
  import { reconcileTerminalState } from './terminal-recovery.js';
5
7
  import { packageVersion } from '../package-version.js';
@@ -36,6 +38,15 @@ export async function buildDaemon(input) {
36
38
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
37
39
  if (claudeParts !== 0 && claudeParts !== 3)
38
40
  throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
41
+ // Claude admission (decision 9095c7a8): the compiled release host, unless a test injects the whole trusted tuple.
42
+ const claudeProduction = claudeParts === 0 ? createClaudeProductionHost({ home: input.env.HOME ?? '', env: input.env, fetchFn: input.fetchFn,
43
+ report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } }) : undefined;
44
+ const claude = {
45
+ probe: input.claudeSupportedProbe ?? (() => claudeProduction.probe()),
46
+ securityHost: input.claudeSecurityHost ?? claudeProduction.securityHost,
47
+ // Per job: a release change moves the pinned launch path, so the identity is captured at adapter creation.
48
+ binaryIdentity: async () => input.claudeBinaryIdentity ?? claudeProduction.binaryIdentity(),
49
+ };
39
50
  if (input.codexSupportedProbe && !input.codexSecurityHost)
40
51
  throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
41
52
  const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
@@ -70,12 +81,11 @@ export async function buildDaemon(input) {
70
81
  // The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
71
82
  const hook = packagedHookCommandParts();
72
83
  const plugin = new ClaudeCodePlugin({
73
- probe: async () => input.claudeSupportedProbe ? input.claudeSupportedProbe() : (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
74
- options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
84
+ probe: () => claude.probe(),
85
+ options: async ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
75
86
  worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
76
87
  maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
77
- binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
78
- securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
88
+ securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
79
89
  }),
80
90
  });
81
91
  const codex = new CodexPlugin({
@@ -162,24 +172,26 @@ export async function buildDaemon(input) {
162
172
  snapshot: async () => {
163
173
  // Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
164
174
  if (!poller.running) {
165
- const probe = (async () => {
166
- if (input.claudeSupportedProbe) {
167
- let current;
168
- try {
169
- current = await plugin.probe();
170
- }
171
- catch {
172
- current = { available: false, authenticated: false, capabilities: [] };
173
- }
174
- const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
175
- const index = probes.findIndex(p => p.id === 'claude_code_cli');
176
- if (index >= 0)
177
- probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
178
- if (ready)
179
- versions.claude_code_cli = '1';
180
- else
181
- delete versions.claude_code_cli;
175
+ // Claude readiness follows the synced release list, so it is refreshed like Codex. The two refreshes run
176
+ // concurrently and each keeps its own failure handling, so Claude neither delays nor changes the Codex result.
177
+ const refreshClaude = async () => {
178
+ let current;
179
+ try {
180
+ current = await plugin.probe();
182
181
  }
182
+ catch {
183
+ current = { available: false, authenticated: false, capabilities: [] };
184
+ }
185
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
186
+ const index = probes.findIndex(p => p.id === 'claude_code_cli');
187
+ if (index >= 0)
188
+ probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
189
+ if (ready)
190
+ versions.claude_code_cli = '1';
191
+ else
192
+ delete versions.claude_code_cli;
193
+ };
194
+ const refreshCodex = async () => {
183
195
  let current;
184
196
  try {
185
197
  current = await codex.probe();
@@ -195,6 +207,14 @@ export async function buildDaemon(input) {
195
207
  versions.codex_cli = '1';
196
208
  else
197
209
  delete versions.codex_cli;
210
+ };
211
+ // Both refreshes always run to the end, so a failure outside one never leaves the other writing under a claim;
212
+ // the first failure then surfaces as it did before.
213
+ const probe = (async () => {
214
+ const settled = await Promise.allSettled([refreshClaude(), refreshCodex()]);
215
+ const failed = settled.find((result) => result.status === "rejected");
216
+ if (failed)
217
+ throw failed.reason;
198
218
  })();
199
219
  probing = probe.catch(() => undefined);
200
220
  await probe;
@@ -231,6 +251,7 @@ export async function buildDaemon(input) {
231
251
  failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
232
252
  },
233
253
  guide,
254
+ ...(claudeProduction ? { claude: claudeProduction } : {}),
234
255
  asks,
235
256
  closureAcks,
236
257
  jobs: poller,
@@ -273,7 +294,18 @@ export async function buildDaemon(input) {
273
294
  reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
274
295
  });
275
296
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
276
- return { daemon, close: async () => { daemon.stop(); await production?.close(); } };
297
+ // The Claude host always closes; a failing Claude close never masks a Codex close failure.
298
+ return { daemon, close: async () => {
299
+ daemon.stop();
300
+ try {
301
+ await production?.close();
302
+ }
303
+ catch (error) {
304
+ await claudeProduction?.close().catch(() => undefined);
305
+ throw error;
306
+ }
307
+ await claudeProduction?.close();
308
+ } };
277
309
  }
278
310
  export function runnerDisplayName() { return `${hostname()} local AI`; }
279
311
  /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
@@ -326,7 +358,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
326
358
  const status = adapterStatus(probe);
327
359
  const authenticated = status.authenticated;
328
360
  const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
329
- if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli') {
361
+ if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
330
362
  capabilities.push('local_fix_publication_v1');
331
363
  if (verificationMode === 'gateway_ci_v1')
332
364
  capabilities.push('local_fix_gateway_ci_v1');
@@ -1,5 +1,6 @@
1
1
  import { publicationError } from './publication/failure.js';
2
2
  import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
3
+ import { parseClaudeSyncRelease } from "./safety/claude-release.js";
3
4
  import { createHash } from "node:crypto";
4
5
  import { v5 as uuidv5 } from "uuid";
5
6
  import { bindingApprovalIdFromEnv, redact } from "./config.js";
@@ -33,6 +34,7 @@ export function parseRunnerSync(result, runnerId) {
33
34
  ...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
34
35
  ...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
35
36
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
37
+ ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
36
38
  jobAvailable: body.job_available === true,
37
39
  push: parsePush(body.push, runnerId),
38
40
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -0,0 +1,4 @@
1
+ /** Runtime adapters whose admitted jobs may use employee-local Fix publication (decision 9095c7a8 adds Claude).
2
+ * Membership grants nothing alone: each job still needs its adapter's host admission and the Gateway's Fix policy. */
3
+ export const LOCAL_FIX_ADAPTERS = Object.freeze(['codex_cli', 'claude_code_cli']);
4
+ export function isLocalFixAdapter(value) { return typeof value === 'string' && LOCAL_FIX_ADAPTERS.includes(value); }
@@ -4,6 +4,7 @@ import { lstat, realpath, open, rename, unlink } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { CandidateVerifier, protocolHash } from './candidate.js';
6
6
  import { createLocalFixExecutionHost } from './execution-host.js';
7
+ import { isLocalFixAdapter } from './local-fix-adapters.js';
7
8
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
8
9
  import { createSealedIsolatedVerifier } from './isolated-verifier.js';
9
10
  import { FilePublicationJournal } from './journal.js';
@@ -29,7 +30,7 @@ export function createProductionLocalFixHost(options) {
29
30
  if (!lifecycle)
30
31
  throw Error('LOCAL_FIX_OWNED_LIFECYCLE_REQUIRED');
31
32
  const { job, bindingId } = input, pkg = job.taskPackage, repo = pkg.repository, scope = pkg.local_fix_scope;
32
- if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || pkg.runtime_adapter !== 'codex_cli')
33
+ if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || !isLocalFixAdapter(pkg.runtime_adapter))
33
34
  throw denied();
34
35
  const root = await realpath(options.privateRoot), stat = await lstat(root);
35
36
  if (root !== options.privateRoot || !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
@@ -12,6 +12,7 @@ import { protocolHash } from './candidate.js';
12
12
  import { FilePublicationJournal } from './journal.js';
13
13
  import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
14
14
  import { PublicationOutcomeUnknownError } from './publisher.js';
15
+ import { isLocalFixAdapter } from './local-fix-adapters.js';
15
16
  const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
16
17
  const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
17
18
  const matches = (p, s) => s.endsWith('/**') ? safePath(s.slice(0, -3)) && (p === s.slice(0, -3) || p.startsWith(s.slice(0, -2))) : safePath(s) && p === s;
@@ -70,7 +71,7 @@ export function createServerCiLocalFixHost(options) {
70
71
  throw denied();
71
72
  const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, rawLimits = scope?.candidate_limits, allowed = scope?.allowed_paths;
72
73
  const root = await realpath(options.privateRoot), st = await lstat(root);
73
- if (!lifecycle.isCurrent() || pkg.runtime_adapter !== 'codex_cli' || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
74
+ if (!lifecycle.isCurrent() || !isLocalFixAdapter(pkg.runtime_adapter) || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
74
75
  throw denied();
75
76
  const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
76
77
  const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };