mtech-ai-local-runner 0.1.111 → 0.1.113

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.
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { connect } from "node:net";
3
- import { claudeModStatusOf, statusOf } from "./commands.js";
3
+ import { statusOf } from "./commands.js";
4
4
  import { hubSocketPath } from "./hub.js";
5
5
  import { MAX_LINE_BYTES, encodeLine } from "./hub-protocol.js";
6
6
  import { CLAUDE_PLUGIN_NAME } from "./integrations.js";
@@ -93,9 +93,8 @@ export async function asksDoctorReport(input) {
93
93
  const [status, statuses] = await Promise.all([hubStatus(input.stateRoot), Promise.all(APPS.map(app => statusOf(app, input.env, input.stateRoot)))]);
94
94
  const integrations = [];
95
95
  for (const report of statuses) {
96
- const mod = report.app === "claude" ? { mod: await claudeModStatusOf(input.env, input.stateRoot) } : {};
97
96
  if (report.state !== "installed") {
98
- integrations.push({ ...report, ...mod });
97
+ integrations.push(report);
99
98
  continue;
100
99
  }
101
100
  if (report.app === "codex") {
@@ -103,7 +102,7 @@ export async function asksDoctorReport(input) {
103
102
  continue;
104
103
  }
105
104
  const loaded = await claudePluginLoaded(input.env, input.run);
106
- integrations.push({ ...report, ...mod, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
105
+ integrations.push({ ...report, ...(loaded === null ? {} : { loaded }), ...(loaded === false ? { action: CLAUDE_NOT_LOADED } : {}) });
107
106
  }
108
107
  return { mode: input.mode, hub: status === null ? { status: "not_running" } : { status: "running", ...status }, integrations };
109
108
  }
package/dist/src/cli.js CHANGED
@@ -36,7 +36,7 @@ import { access, mkdir, rm } from "node:fs/promises";
36
36
  import { buildMessengerDaemon } from "./asks/messenger.js";
37
37
  import { removeMenubarInstall, runMenubarCommand, setupMenubarApp } from "./menubar/commands.js";
38
38
  import { runHookClient } from "./asks/hook-client.js";
39
- import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, runIntegrationsCommand, setupClaudeMod } from "./asks/commands.js";
39
+ import { installGuideIntegrations, installIntegrations, parseFeatures, parseSetupIntegrations, removeAsksInstall, removeGuideInstall, retireWorkRoomPane, runIntegrationsCommand } from "./asks/commands.js";
40
40
  import { asksDoctorReport } from "./asks/doctor.js";
41
41
  import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
42
42
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
@@ -316,8 +316,8 @@ export async function main(argv, env, io = {}) {
316
316
  const features = requestedFeatures ?? ["asks"];
317
317
  const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
318
318
  const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
319
- // The Work Room pane goes with every setup (owner decision 2026-10-31); --integrations claude has just installed it.
320
- const workRoomPane = integrations.some(report => report.app === "claude" && report.mod !== undefined) ? undefined : await setupClaudeMod(env, config.stateRoot);
319
+ // The Work Room pane mod is retired (owner decision 2026-11-12): every setup removes the copy an older runner installed.
320
+ const workRoomPane = await retireWorkRoomPane(env, config.stateRoot) ?? undefined;
321
321
  // The menu bar app (Work Item b9218937) goes with every setup unless the person removed it.
322
322
  const menubar = await setupMenubarApp(env, config.stateRoot);
323
323
  // The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
@@ -0,0 +1,94 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { connectCodexAppSetup } from "./codex-app-tools-host.js";
3
+ const object = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
4
+ const blocked = () => ({ status: "blocked", creationBlocked: true, gatewayWritesBlocked: true });
5
+ /**
6
+ * One ephemeral, read-only callback invocation across the supported Node REPL/app tools.
7
+ * No network listener, durable inbox, execution aggregate, credentials or live-mode option.
8
+ * The authenticated supervisor dispatches only the returned exact official read call, then
9
+ * supplies its raw MCP response. Correlation IDs identify RPCs, never app/Work Sessions.
10
+ */
11
+ export class CodexAppHostReadOnlyEntry {
12
+ connection;
13
+ scope;
14
+ started = false;
15
+ closed = false;
16
+ result;
17
+ waiting;
18
+ constructor(scope) {
19
+ this.scope = { ...scope };
20
+ const deny = async () => { throw new Error("Creation and Gateway writes are blocked in read-only host entry"); };
21
+ this.connection = connectCodexAppSetup({ approvedHostId: scope.hostId, approvedProjectId: scope.projectId,
22
+ tools: {
23
+ listProjects: args => this.request({ id: randomUUID(), tool: "list_projects", arguments: args }),
24
+ readThread: args => this.request({ id: randomUUID(), tool: "read_thread", arguments: args }),
25
+ createThread: deny,
26
+ }, claimDispatch: deny, report: deny });
27
+ }
28
+ start(input) {
29
+ if (this.started || this.closed)
30
+ throw new Error("Probe is single-use");
31
+ if (!object(input) || Object.keys(input).some(k => k !== "threadId") || (input.threadId !== undefined
32
+ && (typeof input.threadId !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(input.threadId))))
33
+ throw new Error("Exact existing thread ID or empty discovery input required");
34
+ const threadId = input.threadId;
35
+ this.started = true;
36
+ void this.probe(threadId).then(result => { if (!this.closed)
37
+ this.result = result; }, () => { if (!this.closed)
38
+ this.result = blocked(); });
39
+ }
40
+ async next() {
41
+ // Flush the local promise chain only; never waits for an external tool or polls a server.
42
+ await new Promise(resolve => setImmediate(resolve));
43
+ if (this.result)
44
+ return { kind: "done", result: { ...this.result } };
45
+ if (!this.waiting)
46
+ return { kind: this.started ? "awaiting_reply" : "idle" };
47
+ if (this.waiting.delivered)
48
+ return { kind: "awaiting_reply" };
49
+ this.waiting.delivered = true;
50
+ return { kind: "tool_call", call: structuredClone(this.waiting.call) };
51
+ }
52
+ reply(id, response) {
53
+ const pending = this.waiting;
54
+ if (this.closed || !pending?.delivered || pending.call.id !== id)
55
+ throw new Error("No matching outstanding official call");
56
+ this.waiting = undefined;
57
+ pending.resolve(response);
58
+ }
59
+ close() {
60
+ this.closed = true;
61
+ this.result = blocked();
62
+ const pending = this.waiting;
63
+ this.waiting = undefined;
64
+ pending?.reject(new Error("Host probe closed"));
65
+ }
66
+ /** No caller-controlled flag, receipt or copied approval can enable side effects here. */
67
+ execute(_input) { return { status: "creation_blocked" }; }
68
+ /** Local fixtures only: exercise the original adapter up to its denied write boundary. */
69
+ startAdapterCheck(input) {
70
+ if (this.started || this.closed)
71
+ throw new Error("Probe is single-use");
72
+ this.started = true;
73
+ void this.connection.adapter.execute(input).then(result => {
74
+ if (!this.closed)
75
+ this.result = { ...blocked(), adapterStatus: result.status };
76
+ }, () => { if (!this.closed)
77
+ this.result = blocked(); });
78
+ }
79
+ request(call) {
80
+ if (this.closed || this.waiting)
81
+ return Promise.reject(new Error("Probe cannot issue another call"));
82
+ return new Promise((resolve, reject) => { this.waiting = { call, delivered: false, resolve, reject }; });
83
+ }
84
+ async probe(threadId) {
85
+ const projects = await this.connection.host.listProjects();
86
+ if (!projects.some(p => p.hostId === this.scope.hostId && p.projectId === this.scope.projectId))
87
+ return blocked();
88
+ if (!threadId)
89
+ return { status: "connected_read_only", creationBlocked: true, gatewayWritesBlocked: true };
90
+ const observation = await this.connection.host.track({ threadId });
91
+ const verified = observation.status === "identity_observed";
92
+ return { ...blocked(), status: verified ? "connected_read_only" : "blocked", identity: verified ? "identity_observed" : "unverified" };
93
+ }
94
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto";
2
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
5
+ const only = (value, allowed) => Object.keys(value).every(key => allowed.includes(key));
6
+ /**
7
+ * Consumes a fresh Gateway prepare receipt, never creates its own authority or binds a recipient.
8
+ * Gateway atomically grants a one-use dispatch claim before the external side effect. The local
9
+ * fence additionally blocks duplicate calls on this adapter, including lost claim acknowledgements.
10
+ * No concrete native endpoint is wired here. A caller must supply the supported authenticated host.
11
+ */
12
+ export class CodexAppSetupAdapter {
13
+ deps;
14
+ attempted = new Set();
15
+ hostId;
16
+ projectId;
17
+ constructor(deps) {
18
+ this.deps = deps;
19
+ if (!text(deps.approvedHostId) || !text(deps.approvedProjectId))
20
+ throw new Error("Explicit approved host and project required");
21
+ this.hostId = deps.approvedHostId;
22
+ this.projectId = deps.approvedProjectId;
23
+ }
24
+ async execute(input) {
25
+ if (!this.validReceipt(input))
26
+ return { status: "rejected" };
27
+ if (!input.can_create || this.attempted.has(input.intent_id))
28
+ return { status: "skipped" };
29
+ if (input.setup_outcome !== "reserved" || input.target_ref !== null || input.authority_failure !== null
30
+ || input.thread_id !== undefined || input.pending_handle !== undefined || input.client_link_id !== undefined)
31
+ return { status: "rejected" };
32
+ const args = this.createArguments(input.create_thread);
33
+ if (!args || createHash("sha256").update(args.prompt, "utf8").digest("hex") !== input.setup_prompt_sha256)
34
+ return { status: "rejected" };
35
+ const intentId = input.intent_id;
36
+ const expiresAt = input.expires_at;
37
+ const setupPromptSha256 = input.setup_prompt_sha256;
38
+ // Fence before any await. A discovery failure cannot cause an accidental concurrent retry.
39
+ this.attempted.add(intentId);
40
+ try {
41
+ const projects = await this.deps.host.listProjects();
42
+ if (!Array.isArray(projects) || !projects.some(project => record(project)
43
+ && project.projectId === this.projectId && project.hostId === this.hostId))
44
+ return { status: "rejected" };
45
+ }
46
+ catch {
47
+ return { status: "rejected" };
48
+ }
49
+ if (expiresAt <= Date.now() / 1000)
50
+ return { status: "rejected" };
51
+ let claim;
52
+ try {
53
+ claim = await this.deps.claimDispatch({ intent_id: intentId, host_id: this.hostId, project_id: this.projectId, outcome: "dispatch" });
54
+ }
55
+ catch {
56
+ return { status: "uncertain", reportRecorded: false };
57
+ }
58
+ if (!this.validReceipt(claim) || claim.intent_id !== intentId)
59
+ return { status: "uncertain", reportRecorded: false };
60
+ if (!claim.can_create)
61
+ return { status: "skipped" };
62
+ if (claim.setup_prompt_sha256 !== setupPromptSha256 || createHash("sha256").update(args.prompt, "utf8").digest("hex") !== claim.setup_prompt_sha256
63
+ || claim.setup_outcome !== "dispatching" || claim.target_ref !== null || claim.authority_failure !== null
64
+ || claim.thread_id !== undefined || claim.pending_handle !== undefined || claim.client_link_id !== undefined
65
+ || expiresAt <= Date.now() / 1000)
66
+ return { status: "uncertain", reportRecorded: false };
67
+ const report = { intent_id: intentId, host_id: this.hostId, project_id: this.projectId, outcome: "uncertain" };
68
+ try {
69
+ const result = await this.deps.host.createThread(args);
70
+ if (record(result)) {
71
+ if (text(result.threadId) && UUID.test(result.threadId) && result.hostId === this.hostId && result.clientThreadId === undefined) {
72
+ report.outcome = "created";
73
+ report.thread_id = result.threadId;
74
+ }
75
+ else if (text(result.clientThreadId) && result.threadId === undefined
76
+ && (result.hostId === undefined || result.hostId === this.hostId)) {
77
+ report.outcome = "pending";
78
+ report.pending_handle = result.clientThreadId;
79
+ }
80
+ }
81
+ }
82
+ catch {
83
+ // The host may have created the thread before losing its acknowledgement. Never replay.
84
+ }
85
+ try {
86
+ await this.deps.report(report);
87
+ return { status: report.outcome, reportRecorded: true };
88
+ }
89
+ catch {
90
+ // Even a successful host response is not reconciled when Gateway's acknowledgement is lost.
91
+ return { status: "uncertain", reportRecorded: false };
92
+ }
93
+ }
94
+ validReceipt(input) {
95
+ if (!record(input) || !only(input, ["intent_id", "host_id", "project_id", "can_create", "create_thread", "setup_outcome", "target_ref", "grants_execution", "expires_at", "authority_failure", "thread_id", "pending_handle", "client_link_id", "setup_prompt_sha256"])
96
+ || !text(input.intent_id) || !UUID.test(input.intent_id) || input.host_id !== this.hostId || input.project_id !== this.projectId
97
+ || typeof input.setup_prompt_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.setup_prompt_sha256)
98
+ || typeof input.can_create !== "boolean" || input.grants_execution !== false
99
+ || typeof input.expires_at !== "number" || !Number.isSafeInteger(input.expires_at) || input.expires_at <= Date.now() / 1000
100
+ || (input.authority_failure !== null && !text(input.authority_failure))
101
+ || !text(input.setup_outcome) || (input.target_ref !== null && !text(input.target_ref)))
102
+ return false;
103
+ for (const key of ["thread_id", "client_link_id"]) {
104
+ if (input[key] !== undefined && (!text(input[key]) || !UUID.test(input[key])))
105
+ return false;
106
+ }
107
+ if (input.pending_handle !== undefined && (!text(input.pending_handle) || !/^[a-zA-Z0-9_:.-]{1,128}$/.test(input.pending_handle)))
108
+ return false;
109
+ return true;
110
+ }
111
+ createArguments(input) {
112
+ if (!record(input) || !only(input, ["prompt", "title", "target"]) || !text(input.prompt)
113
+ || (input.title !== undefined && !text(input.title)))
114
+ return null;
115
+ const target = input.target;
116
+ if (!record(target) || !only(target, ["type", "projectId", "environment"])
117
+ || target.type !== "project" || target.projectId !== this.projectId
118
+ || !record(target.environment) || !only(target.environment, ["type"])
119
+ || target.environment.type !== "local")
120
+ return null;
121
+ // Copy only validated fields before awaiting discovery; do not pass mutable caller objects.
122
+ return { prompt: input.prompt, ...(input.title === undefined ? {} : { title: input.title }),
123
+ target: { type: "project", projectId: this.projectId, environment: { type: "local" } } };
124
+ }
125
+ }
@@ -0,0 +1,82 @@
1
+ import { CodexAppSetupAdapter } from "./codex-app-setup.js";
2
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
3
+ const uuid = (value) => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
4
+ /** The observed official host envelope has one JSON text block. Fail closed on schema drift. */
5
+ function data(value) {
6
+ if (!object(value) || (value.isError !== undefined && value.isError !== false)
7
+ || !Array.isArray(value.content) || value.content.length !== 1)
8
+ throw new Error("Unsupported app tool envelope");
9
+ const block = value.content[0];
10
+ if (!object(block) || block.type !== "text" || typeof block.text !== "string")
11
+ throw new Error("Missing app tool JSON");
12
+ const parsed = JSON.parse(block.text);
13
+ if (!object(parsed))
14
+ throw new Error("Unsupported app tool result");
15
+ return parsed;
16
+ }
17
+ export class CodexAppToolsHost {
18
+ tools;
19
+ hostId;
20
+ constructor(tools, hostId) {
21
+ this.tools = tools;
22
+ this.hostId = hostId;
23
+ }
24
+ async listProjects() {
25
+ const result = data(await this.tools.listProjects({}));
26
+ if (result.schemaVersion !== 2 || !Array.isArray(result.projects))
27
+ throw new Error("Unsupported project schema");
28
+ return result.projects.filter((project) => object(project)
29
+ && project.projectKind === "local" && typeof project.projectId === "string" && project.projectId.length > 0
30
+ && project.hostId === this.hostId).map(project => ({ projectId: project.projectId, hostId: this.hostId }));
31
+ }
32
+ async createThread(args) {
33
+ // Only the existing adapter calls this after Gateway's durable dispatch claim.
34
+ return data(await this.tools.createThread(args));
35
+ }
36
+ /** Observation only: never binds a session, reports bound, starts a turn or retries creation. */
37
+ async track(receipt) {
38
+ const unverified = { status: "unverified", grantsExecution: false };
39
+ if (!object(receipt) || Object.keys(receipt).some(key => !["threadId", "pendingHandle"].includes(key)))
40
+ return unverified;
41
+ if (receipt.pendingHandle !== undefined) {
42
+ if (receipt.threadId !== undefined || typeof receipt.pendingHandle !== "string"
43
+ || !/^[a-zA-Z0-9_:.-]{1,128}$/.test(receipt.pendingHandle))
44
+ return unverified;
45
+ // No exposed app tool resolves clientThreadId. In particular do not feed it to read_thread.
46
+ return { status: "pending_resolution_unsupported", pendingHandle: receipt.pendingHandle, grantsExecution: false };
47
+ }
48
+ if (!uuid(receipt.threadId))
49
+ return unverified;
50
+ const threadId = receipt.threadId;
51
+ try {
52
+ const result = data(await this.tools.readThread({ threadId, hostId: this.hostId, turnLimit: 1, includeOutputs: false, maxOutputCharsPerItem: 100 }));
53
+ if (result.schemaVersion !== 1 || !object(result.thread) || result.thread.id !== threadId
54
+ || result.thread.hostId !== this.hostId || result.thread.kind !== "codex")
55
+ return unverified;
56
+ return { status: "identity_observed", threadId, hostId: this.hostId, grantsExecution: false };
57
+ }
58
+ catch {
59
+ return unverified;
60
+ }
61
+ }
62
+ }
63
+ export function connectCodexAppSetup(deps) {
64
+ const host = new CodexAppToolsHost(deps.tools, deps.approvedHostId);
65
+ return { host, adapter: new CodexAppSetupAdapter({ ...deps, host }),
66
+ /** Pass normalized data from Gateway report(outcome: status), not an old prepare response. */
67
+ trackReceipt: async (receipt) => {
68
+ const unverified = { status: "unverified", grantsExecution: false };
69
+ if (!object(receipt) || !uuid(receipt.intent_id) || receipt.host_id !== deps.approvedHostId
70
+ || receipt.project_id !== deps.approvedProjectId || receipt.can_create !== false
71
+ || receipt.grants_execution !== false || receipt.authority_failure !== null
72
+ || receipt.create_thread !== undefined)
73
+ return unverified;
74
+ if (receipt.setup_outcome === "pending" && receipt.thread_id === undefined && typeof receipt.pending_handle === "string") {
75
+ return host.track({ pendingHandle: receipt.pending_handle });
76
+ }
77
+ if (receipt.setup_outcome === "created" && uuid(receipt.thread_id)) {
78
+ return host.track({ threadId: receipt.thread_id });
79
+ }
80
+ return unverified;
81
+ } };
82
+ }
@@ -1,7 +1,8 @@
1
1
  import { assertClaimedExecutionAttempt, assertBoundExecutionAttempt } from '../verification/continuation-runner-contract.js';
2
2
  import { completionRejectionDiagnostic } from '../adapters/completion-report.js';
3
+ import { runtimeFailureDiagnostic } from '../adapters/runtime-failure.js';
3
4
  import { assertCandidateReviewPackages, candidateReviewReport } from '../safety/candidate-review-report.js';
4
- import { D4_OBSERVED_EXECUTION_SCHEMA } from '../safety/d4-execution-limits.js';
5
+ import { isD4ExecutionSchema } from '../safety/d4-execution-limits.js';
5
6
  import { publicationError } from '../publication/failure.js';
6
7
  import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
7
8
  import { assertP1Bindings, hasP1, p1ModelEvent } from '../verification/p1-execution-host.js';
@@ -179,7 +180,7 @@ export class ExecutionCoordinator {
179
180
  assertBoundExecutionAttempt(job, binding.taskPackage, binding.iterationId);
180
181
  if (executionAttempt && binding.bindingReplayed !== false)
181
182
  throw Error('CORRECTION_BINDING_RECONCILIATION_REQUIRED');
182
- if (pkg.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
183
+ if (isD4ExecutionSchema(pkg.candidate_review_execution?.schema_version)
183
184
  && (job.generation !== 1 || binding.bindingReplayed !== false))
184
185
  throw Error('D4_BINDING_RECONCILIATION_REQUIRED');
185
186
  binding = immutable(structuredClone(binding));
@@ -449,7 +450,7 @@ export class ExecutionCoordinator {
449
450
  if (isP1 && !hostProof)
450
451
  event = p1ModelEvent(event);
451
452
  const previousStage = executionStage;
452
- const d4StopAck = job.taskPackage.candidate_review_execution?.schema_version === D4_OBSERVED_EXECUTION_SCHEMA
453
+ const d4StopAck = isD4ExecutionSchema(job.taskPackage.candidate_review_execution?.schema_version)
453
454
  && ['failed', 'cancelled', 'blocked'].includes(event.eventType);
454
455
  let payload = event.payload;
455
456
  if (isTerminalEventType(event.eventType) && !d4StopAck) {
@@ -833,6 +834,8 @@ export class ExecutionCoordinator {
833
834
  const reportDiagnostic = diagnostic && (status === "blocked"
834
835
  ? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
835
836
  const rejectedCompletion = completionRejectionDiagnostic(terminal.payload?.completion_rejection);
837
+ // Why the provider CLI stopped, as a bounded code; only on a failed job.
838
+ const runtimeFailure = status === 'failed' ? runtimeFailureDiagnostic(terminal.payload?.runtime_failure) : undefined;
836
839
  const rejectedCounts = reportDiagnostic?.rejection_counts;
837
840
  if (rejectedCompletion || rejectedCounts) {
838
841
  try {
@@ -842,7 +845,7 @@ export class ExecutionCoordinator {
842
845
  }
843
846
  const securityReceipt = adapter.securityReceipt?.();
844
847
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
845
- const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
848
+ const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reason ? { reason } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(rejectedCompletion ? { completion_rejection: rejectedCompletion } : {}), ...(runtimeFailure ? { runtime_failure: runtimeFailure } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
846
849
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
847
850
  phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
848
851
  completionRequest: structuredClone(completionRequest) });
@@ -1,3 +1,5 @@
1
+ import { composeReviewedD4Host } from '../safety/d4-host-composition.js';
2
+ import { protocolHash } from '../publication/candidate.js';
1
3
  import { createClaudeProductionHost } from '../safety/claude-production-host.js';
2
4
  import { adapterStatus } from '../adapters/status.js';
3
5
  import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
@@ -15,7 +17,7 @@ import { CodexPlugin } from "../adapters/codex/plugin.js";
15
17
  import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
16
18
  import { AdapterRegistry } from "../adapters/registry.js";
17
19
  import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
18
- import { D4_OBSERVED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
20
+ import { D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY } from '../safety/d4-execution-limits.js';
19
21
  import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
20
22
  import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
21
23
  import { WorkspaceManager } from "../workspace/workspace-manager.js";
@@ -76,6 +78,39 @@ export async function buildDaemon(input) {
76
78
  pendingCodexClaim.job.lease_expires_at = expiresAt;
77
79
  };
78
80
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
81
+ // Only this daemon's currently executing claim may revalidate a D4 pre-spawn request.
82
+ let activeD4Job, activeD4Binding;
83
+ let d4Host = input.d4ExecutionHost, d4ProductionBinding;
84
+ if (!d4Host) {
85
+ const composition = await composeReviewedD4Host({ currentJob: () => {
86
+ const job = activeD4Job;
87
+ if (!job || !activeD4Binding)
88
+ return undefined;
89
+ const pkg = job.taskPackage, repository = pkg.repository;
90
+ return { descriptor: pkg.candidate_review_execution, context: { jobId: job.id, bindingId: activeD4Binding, generation: job.generation,
91
+ launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint },
92
+ request: { adapterId: String(pkg.runtime_adapter), adapterVersion: String(pkg.runtime_adapter_version), jobId: job.id, generation: job.generation,
93
+ modelId: String(pkg.resolved_model_id), sourceSha: String(repository?.base_commit_sha), requirements: pkg.security_requirements,
94
+ launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint } };
95
+ }, binaryIdentity: () => claude.binaryIdentity(), revalidateJob: async (descriptor, context) => {
96
+ const job = activeD4Job;
97
+ if (!job || context.jobId !== job.id || context.bindingId !== activeD4Binding || context.generation !== 1 || job.generation !== 1
98
+ || context.launchFingerprint !== job.launchFingerprint || context.contractFingerprint !== job.gatewayContractFingerprint
99
+ || context.taskPackageFingerprint !== job.gatewayTaskPackageFingerprint
100
+ || protocolHash(job.taskPackage) !== job.gatewayTaskPackageFingerprint
101
+ || protocolHash(job.taskPackage.candidate_review_execution) !== protocolHash(descriptor))
102
+ throw Error('D4_JOB_CONTEXT_REJECTED');
103
+ const { job: current } = await gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken });
104
+ if (activeD4Job !== job || activeD4Binding !== context.bindingId || current.id !== job.id || current.generation !== 1
105
+ || !['claimed', 'shadowing', 'running'].includes(current.status) || !Number.isSafeInteger(current.lease_expires_at)
106
+ || current.lease_expires_at <= Math.floor(Date.now() / 1000))
107
+ throw Error('D4_JOB_CONTEXT_REJECTED');
108
+ } });
109
+ d4Host = composition.host;
110
+ if (composition.status === 'composed')
111
+ d4ProductionBinding = composition.productionBinding;
112
+ process.stderr.write(`${JSON.stringify({ event: 'd4_host', status: composition.status })}\n`);
113
+ }
79
114
  // The menu bar app (Work Item b9218937): counts and clickable notices; osascript stays the fallback.
80
115
  let roomActivityEnabled = false;
81
116
  const menubar = buildMenubar({ stateRoot: input.stateRoot, home: input.env.HOME ?? "", gatewayMcpUrl: input.gatewayMcpUrl,
@@ -121,10 +156,12 @@ export async function buildDaemon(input) {
121
156
  options: async ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
122
157
  worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
123
158
  maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
124
- securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
159
+ securityHost: activeD4Job?.id === jobId && claudeProduction
160
+ ? d4ProductionBinding ? claudeProduction.d4SecurityHost(d4ProductionBinding) : async () => { throw Error('D4_REVIEW_HOST_REQUIRED'); }
161
+ : claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
125
162
  pullRequestEvidence: (args, signal) => gateway.readPullRequestEvidence(args, signal),
126
163
  readCandidateReviewContext: loopId => gateway.readCandidateReviewContext(loopId),
127
- d4ExecutionHost: input.d4ExecutionHost,
164
+ d4ExecutionHost: d4Host,
128
165
  }),
129
166
  });
130
167
  const codex = new CodexPlugin({
@@ -156,10 +193,15 @@ export async function buildDaemon(input) {
156
193
  shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
157
194
  contractFingerprint: job.gatewayContractFingerprint ?? "",
158
195
  taskPackageFingerprint: job.gatewayTaskPackageFingerprint ?? "" }, signal),
159
- createBinding: async (job, signal) => gateway.createLocalJobBinding({
160
- jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
161
- launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
162
- }, signal),
196
+ createBinding: async (job, signal) => {
197
+ const binding = await gateway.createLocalJobBinding({
198
+ jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
199
+ launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath, claimedJob: job,
200
+ }, signal);
201
+ if (activeD4Job?.id === job.id)
202
+ activeD4Binding = binding.bindingId;
203
+ return binding;
204
+ },
163
205
  publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
164
206
  syncPublicationCursor: (bindingId, cursor, job, receipt) => gateway.syncLocalPublicationCursor(bindingId, cursor, job, receipt),
165
207
  confirmServerPublicationCompletion: (bindingId, cursor, job, receipt) => gateway.confirmServerPublicationCompletion(bindingId, cursor, job, receipt),
@@ -221,7 +263,22 @@ export async function buildDaemon(input) {
221
263
  return { status: "claimed", job };
222
264
  },
223
265
  execute: async (job) => {
224
- await coordinator.execute(job);
266
+ const claimed = job;
267
+ if (claimed.taskPackage.candidate_review_execution) {
268
+ if (activeD4Job)
269
+ throw Error('D4_JOB_CONTEXT_REJECTED');
270
+ activeD4Job = structuredClone(claimed);
271
+ activeD4Binding = undefined;
272
+ try {
273
+ await coordinator.execute(claimed);
274
+ }
275
+ finally {
276
+ activeD4Job = undefined;
277
+ activeD4Binding = undefined;
278
+ }
279
+ }
280
+ else
281
+ await coordinator.execute(claimed);
225
282
  },
226
283
  requestSync: reason => { transport?.requestSync(reason); },
227
284
  // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
@@ -298,10 +355,16 @@ export async function buildDaemon(input) {
298
355
  }
299
356
  const askInbox = await asks.advertisement();
300
357
  const guidance = await guide.advertisement();
358
+ let d4Ready;
359
+ try {
360
+ if (d4Host && (!d4Host.ready || await d4Host.ready()))
361
+ d4Ready = d4Host;
362
+ }
363
+ catch { /* D4 readiness never disables other workflows. */ }
301
364
  return {
302
365
  deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
303
366
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
304
- capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], input.d4ExecutionHost), poller.accepting),
367
+ capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode, ['claude_code_cli', ...(production ? ['codex_cli'] : [])], d4Ready), poller.accepting),
305
368
  mode: "full",
306
369
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
307
370
  ...(guidance ? { guidance } : {}),
@@ -471,7 +534,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
471
534
  return probes.map(probe => {
472
535
  const status = adapterStatus(probe);
473
536
  const authenticated = status.authenticated;
474
- const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY);
537
+ const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1' && value !== 'pull_request_evidence_v1' && value !== D4_OBSERVED_EXECUTION_CAPABILITY && value !== D4_RECORDED_EXECUTION_CAPABILITY);
475
538
  if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
476
539
  capabilities.push('local_fix_publication_v1');
477
540
  if (modes.includes('gateway_ci_v1'))
@@ -484,7 +547,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
484
547
  capabilities.push('pull_request_evidence_v1');
485
548
  const d4 = authenticated && probe.id === 'claude_code_cli' && d4Host;
486
549
  if (d4)
487
- capabilities.push(D4_OBSERVED_EXECUTION_CAPABILITY);
550
+ capabilities.push(...(d4.capabilities ?? [D4_OBSERVED_EXECUTION_CAPABILITY, D4_RECORDED_EXECUTION_CAPABILITY]));
488
551
  return { adapter: probe.id, version: probe.version, ...status, capabilities, ...(d4 ? { runner_release_sha256: d4.runnerReleaseSha256, host_policy_sha256: d4.hostPolicySha256 } : {}) };
489
552
  });
490
553
  }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { assertReviewedD4Request } from './d4-host-composition.js';
2
3
  import { execFile, spawn } from 'node:child_process';
3
4
  import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from './claude-runner-token.js';
4
5
  import { lstat, realpath } from 'node:fs/promises';
@@ -256,13 +257,15 @@ export function createClaudeProductionHost(input) {
256
257
  };
257
258
  const blocked = (code, executableVersion) => ({ available: false, authenticated: false, loginStatus: 'unknown', capabilities: [], reason: code,
258
259
  ...(executableVersion ? { executableVersion } : {}), admission: { checks: [{ id: CHECK_IDS[code] ?? 'release_copy', status: 'blocked', code }] } });
259
- const securityHost = async (request) => {
260
+ const createSecurityHost = (d4) => async (request) => {
260
261
  if (closed)
261
262
  throw Error('CLAUDE_HOST_CLOSED');
262
263
  const requirements = requireClaudeRequirements(request.requirements, { adapterId: request.adapterId, adapterVersion: request.adapterVersion }), release = requirements.release;
264
+ if (d4)
265
+ await assertReviewedD4Request(d4, request);
263
266
  const { release: synced, binary } = await verifiedCopy();
264
267
  if (release.version !== synced.version || release.platform !== synced.platform || release.binary_sha256 !== synced.binary_sha256
265
- || !isDeepStrictEqual([...release.model_ids], [...synced.model_ids]) || release.platform !== platform || !release.model_ids.includes(request.modelId))
268
+ || (!d4 && !isDeepStrictEqual([...release.model_ids], [...synced.model_ids])) || release.platform !== platform || !release.model_ids.includes(request.modelId))
266
269
  throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
267
270
  const tools = claudeReleaseToolInventory(release);
268
271
  await verifyClaudeCodeSignature(binary, input.codesign);
@@ -277,6 +280,8 @@ export function createClaudeProductionHost(input) {
277
280
  verify: async (captured) => {
278
281
  if (closed || captured.binarySha256 !== release.binary_sha256 || captured.securityMode !== CLAUDE_RELEASE_MODE)
279
282
  throw Error('CLAUDE_RELEASE_BINDING_REJECTED');
283
+ if (d4)
284
+ await assertReviewedD4Request(d4, request);
280
285
  await verifyClaudeReleaseCopy(input.home, release).catch(error => { probeMemo = undefined; throw error; });
281
286
  return { version: 2, pin: captured, checks: CLAUDE_MANDATORY_GATES.map(gate => ({ gate, status: 'passed' })) };
282
287
  } };
@@ -328,7 +333,13 @@ export function createClaudeProductionHost(input) {
328
333
  const { binary } = await verifiedCopy();
329
334
  return captureClaudeBinaryIdentity({ launchPath: binary, allowedRoots: [claudeReleaseRoot(input.home)] });
330
335
  },
331
- securityHost,
336
+ securityHost: createSecurityHost(),
337
+ /** A separate capability issued by reviewed composition; no task/env field can relax generic model equality. */
338
+ d4SecurityHost(binding) {
339
+ if (!binding)
340
+ throw Error('D4_HOST_INPUT_REJECTED');
341
+ return createSecurityHost(binding);
342
+ },
332
343
  async close() { closed = true; abort.abort(); await pending; },
333
344
  };
334
345
  }
@@ -23,6 +23,14 @@ function textOf(content) {
23
23
  const parts = content.filter((block) => record(block) && block.type === 'text' && typeof block.text === 'string').map(block => block.text);
24
24
  return parts.length ? parts.join('') : null;
25
25
  }
26
+ /** Tool evidence accepts text only. Never discard an unrecognized block and salvage a passing JSON object. */
27
+ function toolResultText(content) {
28
+ if (typeof content === 'string')
29
+ return content;
30
+ if (!Array.isArray(content) || content.length === 0 || !content.every((block) => record(block) && Object.keys(block).length === 2 && block.type === 'text' && typeof block.text === 'string'))
31
+ return 'CLAUDE_REVIEW_TOOL_RESULT_CONTENT_REJECTED';
32
+ return content.map(block => block.text).join('');
33
+ }
26
34
  const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
27
35
  /** The user's own text. The real CLI prepends context in system-reminder tags, in its own block or the same one. */
28
36
  function userText(content) {
@@ -131,7 +139,7 @@ export async function startClaudeReviewProvider(script) {
131
139
  continue;
132
140
  toolResultIds.push(block.tool_use_id);
133
141
  if (!toolResults.has(block.tool_use_id))
134
- toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? '') });
142
+ toolResults.set(block.tool_use_id, { isError: block.is_error === true, text: toolResultText(block.content) });
135
143
  }
136
144
  const firstUser = messages.find(message => message.role === 'user'), { text: firstUserText, reminders } = firstUser ? userText(firstUser.content) : { text: null, reminders: 0 };
137
145
  // The scripted turn: the launched model, the exact review prompt as the first user text and an offered tool list.