@deksden-com/dd-flow-cli 0.9.0-beta.142 → 0.9.0-beta.144

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/CHANGELOG.md CHANGED
@@ -1,11 +1,27 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.144
4
+
5
+ ### Patch Changes
6
+
7
+ - Preserve exact failed-Turn tool-settlement evidence when a fresh overload inspection leaves native items unloaded. Continue the same Session with bounded backoff; contradictory pending effects and unknown native outcomes remain blocked.
8
+
9
+ ## 0.9.0-beta.143
10
+
11
+ ### Patch Changes
12
+
13
+ - 60daf29: Keep an early AGY provider rejection as the readiness failure rather than a stream-drain cleanup failure. Preserve strict journal and physical cleanup errors.
14
+ - d86445c: Observe accepted Codex Turns while persisted history is unavailable, preserving exact native outcomes, cancellation identity and conservative tree settlement. Keep RPC errors intact in compact journals.
15
+
16
+ Treat successful metadata-only history responses as incomplete in cancellation and final result admission; require exact current Turn item evidence before bypassing retained-output/profile safeguards. Reject incomplete cancellation records and terminal settlement without a valid Turn identity. Prefer native completed output over stale snapshots of the same Turn.
17
+
3
18
  ## 0.9.0-beta.142
4
19
 
5
20
  ### Patch Changes
6
21
 
7
22
  - Wait for the owned OpenCode v2 listener through typed SDK transport startup failures and expose bounded read-only scope inventory batches for validated retirement-proof reuse.
8
23
  - Retry no-effect physical confirmation on the same held check, bootstrap or service process before dispatch, without replaying commands or extending exhausted authority.
24
+ - Backfilled from published artifact commit `19ca01d6`: Keep native Session observation stable, use bounded shared Codex read phases, scope check evidence to declared inputs, retain accepted intake/HITL review sources, and reject stale review coverage without productive replay. These changes were already shipped; beta.143 does not count them as new work.
9
25
 
10
26
  ## 0.9.0-beta.141
11
27
 
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.142",
4
- "cli_commit": "a5b068e5cc52809ac0ea0687e40a49f9379bb198",
5
- "built_at": "2026-10-18T10:20:25.955Z",
3
+ "cli_version": "0.9.0-beta.144",
4
+ "cli_commit": "fc729af5d6f9865bab178e40b34dd9b0a99a0318",
5
+ "built_at": "2026-10-20T05:55:05.754Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.2",
8
- "commit": "1ddedf9d3f086e046f8e7b194c739ec386e7c8b9",
8
+ "commit": "a1cfe8b06329c691c314064b6ea6a3da6628cceb",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
- "repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-cp231-release",
11
- "memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-cp231-release/.memory-bank",
12
- "flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-cp231-release/.memory-bank/dd-flow",
10
+ "repo_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092",
11
+ "memorybank_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092/.memory-bank",
12
+ "flow_root": "/Users/deksden/Documents/_Projects/_worktrees/dd-canon-plan092/.memory-bank/dd-flow",
13
13
  "layout": "dot_memory_bank"
14
14
  }
15
15
  }
@@ -294,7 +294,7 @@ export class Runtime {
294
294
  let allowProviderFinalization;
295
295
  const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
296
296
  const observationFailure = error => { this.drainingError ??= error; readyReject(error); this.active?.reject(error); };
297
- this.nativeDrain = readNativeLines(this.child.stdout, line => { this.buffer = `${line}\n`; return this.draining = this.drain(readyResolve); }, observationFailure);
297
+ this.nativeDrain = readNativeLines(this.child.stdout, line => { this.buffer = `${line}\n`; return this.draining = this.drain(readyResolve, readyReject); }, observationFailure);
298
298
  this.diagnostics = streamNativeDiagnostics(this.child.stderr, chunk => this.journal("stderr", { text: chunk }), observationFailure);
299
299
  this.child.on("error", error => { readyReject(error); if (this.active) { this.active.reject(error); this.active = null; } });
300
300
  // A dead leader is not yet a finalized tree: stop must also join the
@@ -329,7 +329,7 @@ export class Runtime {
329
329
  try { return await Promise.race([this.readyPromise, new Promise((_, reject) => { readyTimer = observedTimeout(() => reject(new DaemonError("agy_init_timeout", "Antigravity init timed out", true)), SETUP_STARTUP_MS, { progress: () => this.lastActivityAt }); })]); }
330
330
  finally { clearTimeout(readyTimer); }
331
331
  }
332
- async drain(readyResolve) { let index; while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).trim(); this.buffer = this.buffer.slice(index + 1); if (!line) continue; let event; try { event = JSON.parse(line); } catch { await this.journal("invalid_json", { line: line.slice(0, 1000) }); continue; }
332
+ async drain(readyResolve, readyReject) { let index; while ((index = this.buffer.indexOf("\n")) >= 0) { const line = this.buffer.slice(0, index).trim(); this.buffer = this.buffer.slice(index + 1); if (!line) continue; let event; try { event = JSON.parse(line); } catch { await this.journal("invalid_json", { line: line.slice(0, 1000) }); continue; }
333
333
  // Record an early provider rejection before journalling: a short-lived
334
334
  // child can close while the asynchronous write is still pending.
335
335
  if (event.event === "result" && ["ERROR", "CANCELLED"].includes(event.result?.status)) {
@@ -344,7 +344,12 @@ export class Runtime {
344
344
  }
345
345
  this.observeProviderActivity(event);
346
346
  if (event.event === "result" && this.init && !this.startupError) { await this.finishTurn(event.result ?? {}, event.terminal_metadata, event); continue; }
347
- await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") { if (this.startupError) throw this.startupError; await this.finishTurn(event.result ?? {}); } } }
347
+ await this.journal("provider_event", { event }); if (event.event === "init") { this.init = { conversation_id: event.conversation_id, ...(event.init ?? {}) }; if (!this.init.conversation_id) throw new DaemonError("agy_init_missing", "Antigravity init has no conversation ID"); const requested = { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode, permission_mode: "always-proceed" }; this.profile = assertProfile(requested, observedProfile(this.init, requested)); await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: this.init.conversation_id, requested, observed: this.profile.observed, source: "native.init" }); await this.persist({ shutdown_state: "running", active_tree: false }); readyResolve(this.init); } else if (event.event === "step_update") this.observeStep(event.step_update ?? {}); else if (event.event === "result") {
348
+ // A successfully read/journalled provider refusal rejects readiness, not
349
+ // the stream drain that physical cleanup must still join.
350
+ if (this.startupError) { readyReject(this.startupError); continue; }
351
+ await this.finishTurn(event.result ?? {});
352
+ } } }
348
353
  observeStep(step) {
349
354
  const sessionId = step.conversation_id ?? this.init?.conversation_id, observation = this.sessionObservations.get(sessionId) ?? {};
350
355
  const fresh = Number.isInteger(step.step_index) && step.step_index > (observation.last_step_index ?? -1) && step.step_index > (observation.step_floor ?? -1);
@@ -14,7 +14,7 @@ import path from "node:path";
14
14
  import { spawn } from "node:child_process";
15
15
  import { waitForSettlement } from "./session-settlement.mjs";
16
16
  import { errorRecord } from "./operation-errors.mjs";
17
- import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
17
+ import { CODEX_REQUEST_TIMEOUT_MS, LATE_RESPONSE_GRACE_MS, CodexBridge, codexReadPhase, readCodexHistory, retainedCodexTurn, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
18
18
  import { assertDaemonOwnership, assertStartupChild, prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
19
19
 
20
20
  export function codexBridgeEnv(config, daemonId) { return { ...config.env, ...managedFlowEnv(config, daemonId) }; }
@@ -28,10 +28,11 @@ async function readState(file) { try { return JSON.parse(await readFile(file, "u
28
28
  async function writeState(file, value) { const temp = `${file}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temp, file); }
29
29
  async function removeSocket(socket) { try { const info = await lstat(socket); if (!info.isSocket()) throw Object.assign(new Error("refusing to remove non-socket daemon path"), { code: "unsafe_socket" }); await unlink(socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
30
30
  const errorPayload = errorRecord;
31
- const terminalTurn = turn => ["completed", "interrupted", "failed", "cancelled"].includes(turn?.status);
32
- export function settledCodexSession(status, latestTurn, active) {
31
+ const terminalTurn = turn => typeof turn?.id === "string" && turn.id.trim().length > 0 && ["completed", "interrupted", "failed", "cancelled"].includes(turn.status);
32
+ export function settledCodexSession(status, latestTurn, active, historyAvailable = true) {
33
+ if (latestTurn && !terminalTurn(latestTurn)) return false;
33
34
  if (status === "idle") return true;
34
- if (status === "notLoaded") return !latestTurn || terminalTurn(latestTurn);
35
+ if (status === "notLoaded") return terminalTurn(latestTurn) || historyAvailable && !latestTurn;
35
36
  // `systemError` alone is not evidence that the provider stopped. A current
36
37
  // terminal Turn plus no daemon operation is the narrow settled case.
37
38
  return status === "systemError" && !active && terminalTurn(latestTurn);
@@ -49,26 +50,46 @@ async function observeCodexSessions(bridge, options, sessions, active, remember
49
50
  const observed = await inspectSessionWithBridge(bridge, { ...options, sessionId, turnId: sessionId === options.sessionId ? options.turnId : undefined });
50
51
  for (const child of observed.descendants ?? []) { sessions.add(child.provider_session_id); remember(child.provider_session_id); }
51
52
  const status = observed.status?.type ?? observed.status;
52
- const stored = ["notLoaded", "systemError"].includes(status) ? await bridge.request("thread/turns/list", { threadId: sessionId, limit: 1, sortDirection: "desc", itemsView: "summary" }, 5_000) : null;
53
- const settled = settledCodexSession(status, stored?.data?.[0] ?? null, active());
53
+ const currentId = bridge.currentTurns?.get(sessionId) ?? (sessionId === options.sessionId ? options.turnId : undefined);
54
+ const retained = retainedCodexTurn(bridge, sessionId, currentId);
55
+ const compactTurn = observed.thread?.turns?.findLast(turn => !currentId || turn?.id === currentId);
56
+ const stored = ["notLoaded", "systemError"].includes(status) || currentId && !terminalTurn(retained)
57
+ ? await readCodexHistory(bridge, "thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, options.readPhase) : null;
58
+ const hydrated = stored && !stored.available
59
+ ? await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, options.readPhase) : null;
60
+ const hydratedAvailable = hydrated?.available && Array.isArray(hydrated.result.thread.turns);
61
+ const hydratedTurn = hydratedAvailable ? hydrated.result.thread.turns.findLast(turn => !currentId || turn?.id === currentId) : null;
62
+ const malformedHistory = [stored?.result?.data, hydrated?.result?.thread.turns, observed.thread?.turns].some(turns =>
63
+ turns?.some(turn => typeof turn?.id !== "string" || !turn.id.trim() || !["inProgress", "completed", "interrupted", "failed", "cancelled"].includes(turn.status)));
64
+ const latest = retained && terminalTurn(retained) ? retained
65
+ : malformedHistory ? { status: "unknown" }
66
+ : stored?.result?.data.find(turn => !currentId || turn?.id === currentId) ?? hydratedTurn ?? compactTurn ?? retained
67
+ ?? (currentId ? { id: currentId, status: "unknown" } : null);
68
+ const observedStatus = hydrated?.result?.thread.status ?? observed.status;
69
+ const settled = settledCodexSession(observedStatus?.type ?? observedStatus, latest, active(), (stored?.available ?? true) || hydratedAvailable);
54
70
  if (!settled) unsettled.push(sessionId);
55
- observations.push({ ...observed, settled });
71
+ observations.push({ ...observed, status: observedStatus, settled });
56
72
  }
57
73
  return { sessions: unsettled.reverse(), active: Boolean(active()), observations };
58
74
  }
59
75
 
60
76
  export async function inspectCodexTree(bridge, options, { remember = () => {}, active = () => null } = {}) {
77
+ options = { ...options, readPhase: options.readPhase ?? codexReadPhase(bridge, options) };
61
78
  const sessions = new Set([options.sessionId]);
62
79
  const observed = await observeCodexSessions(bridge, options, sessions, active, remember);
63
80
  return { ...observed.observations[0], settled: !observed.active && !observed.sessions.length, unsettled_sessions: observed.sessions, tree_session_ids: [...sessions], tree_observations: observed.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) };
64
81
  }
65
82
 
66
83
  export async function cancelCodexTree(bridge, options, { remember = () => {}, active = () => null, timeoutMs = 5_000 } = {}) {
84
+ // Zero requests one best-effort cancellation/observation pass, not an
85
+ // invalid zero-length RPC window or permission to skip owned children.
86
+ const readBudget = timeoutMs === 0 ? CODEX_REQUEST_TIMEOUT_MS : timeoutMs;
87
+ options = { ...options, readPhase: options.readPhase ?? codexReadPhase(bridge, { ...options, timeoutMs: readBudget, livenessTimeoutMs: readBudget }) };
67
88
  const sessions = new Set([options.sessionId]);
68
89
  const settlement = await waitForSettlement({
69
90
  timeoutMs, knownSessions: () => [...sessions],
70
91
  cancel: async sessionId => {
71
- try { await cancelSessionWithBridge(bridge, { ...options, sessionId, turnId: null }); }
92
+ try { await cancelSessionWithBridge(bridge, { ...options, sessionId, turnId: sessionId === options.sessionId ? options.turnId : undefined }); }
72
93
  catch (error) { if (error.code !== "turn_not_active") throw error; }
73
94
  },
74
95
  observe: () => observeCodexSessions(bridge, options, sessions, active, remember)
@@ -4,7 +4,7 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline";
6
6
  import { closeBridgeProcess } from "./managed-daemon.mjs";
7
- import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
7
+ import { errorRecord, isObservationLoss, isProvenShellCorrection } from "./operation-errors.mjs";
8
8
  import { reportedFailure, commandText } from "./process-json.mjs";
9
9
  import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
10
10
  import { observeModel } from "./model-observations.mjs";
@@ -12,9 +12,105 @@ import { classifyCodexItem } from "./tool-observations.mjs";
12
12
  import { readNativeLines, streamNativeDiagnostics, nativeObservationError } from "./native-streams.mjs";
13
13
  import { operationProgressReporter } from "./operation-progress.mjs";
14
14
  import { publishSetup } from './setup-progress.mjs';
15
+ import { capacityBackoff } from './provider-overload-policy.mjs';
15
16
 
16
17
  export const CODEX_REQUEST_TIMEOUT_MS = 120_000;
17
18
  export const LATE_RESPONSE_GRACE_MS = 5_000;
19
+ export function codexReadPhase(bridge, options = {}) {
20
+ const timeoutMs = Math.min(CODEX_REQUEST_TIMEOUT_MS, options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS);
21
+ return { clock: new ObservationClock({ timeoutMs, ...options.observationClock,
22
+ onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "metadata_read" }) }),
23
+ random: options.readRandom ?? Math.random,
24
+ sleep: options.readSleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) };
25
+ }
26
+
27
+ function dispatchBudget(phase, method, limitMs) {
28
+ const clock = phase.outerClock ?? phase.clock;
29
+ if (clock.sample(phase.progress?.())) throw Object.assign(new Error("Codex owner observation window expired before dispatch"), {
30
+ code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout",
31
+ details: { method, phase: "before_dispatch", effect: "no_effect", observation: clock.state() }
32
+ });
33
+ const remaining = Math.max(1, Math.ceil(clock.timeoutMs - clock.elapsed));
34
+ const timeoutMs = Math.min(limitMs, remaining);
35
+ return [timeoutMs, { graceMs: Math.min(LATE_RESPONSE_GRACE_MS, remaining - timeoutMs) }];
36
+ }
37
+
38
+ export async function readCodexRpc(bridge, method, params, phase = codexReadPhase(bridge)) {
39
+ if (!["thread/read", "thread/turns/list"].includes(method)) throw new Error("Codex read policy only accepts read-only RPC");
40
+ const sample = () => phase.clock.sample(phase.progress?.());
41
+ let primary;
42
+ for (let attempt = 0; attempt < 3; attempt++) {
43
+ if (sample()) throw primary ?? Object.assign(new Error("Codex metadata observation window expired"), { code: phase.clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, phase: "metadata_read", observation: phase.clock.state() } });
44
+ const remaining = Math.min(CODEX_REQUEST_TIMEOUT_MS, Math.max(1, Math.ceil(phase.clock.timeoutMs - phase.clock.elapsed)));
45
+ try {
46
+ const result = await bridge.request(method, params, remaining, { graceMs: 0, observe: () => { sample(); phase.outerClock?.sample(); } });
47
+ if (!result || typeof result !== "object" || Array.isArray(result)
48
+ || method === "thread/read" && (!result.thread || typeof result.thread !== "object" || Array.isArray(result.thread) || typeof result.thread.id !== "string" || !result.thread.id)
49
+ || method === "thread/turns/list" && !Array.isArray(result.data))
50
+ throw Object.assign(new Error("Codex read returned invalid data"), { code: "rpc_schema_invalid", details: { method } });
51
+ if (method === "thread/read" && result.thread.id !== params.threadId)
52
+ throw Object.assign(new Error("Codex read returned a foreign Thread"), { code: "thread_identity_mismatch" });
53
+ return result;
54
+ }
55
+ catch (error) {
56
+ primary ??= error; bridge.assertHookHealthy?.(params.threadId);
57
+ // A broken process cannot be replaced under the guise of a read retry.
58
+ const current = bridge.currentTurns?.get(params.threadId);
59
+ const materializing = method === "thread/read" && current && !bridge.turns?.has(current)
60
+ && bridge.nativeHookTurns?.get(params.threadId) !== current && error?.code === "rpc_error" && error.details?.code === -32603;
61
+ const emptyMetadata = materializing && error.details.message?.match(/^failed to read thread: thread-store internal error: failed to read session metadata (.+): rollout at (.+) is empty$/);
62
+ const transient = error?.code === "rpc_timeout" || error?.code === "EAGAIN" || error?.code === "EINTR"
63
+ || Boolean(emptyMetadata && emptyMetadata[1] === emptyMetadata[2] && emptyMetadata[1].endsWith(`-${params.threadId}.jsonl`));
64
+ if (!transient) { if (error !== primary) error.previous_read_error = errorRecord(primary); throw error; }
65
+ if (attempt === 2 || sample()) { if (error !== primary) primary.secondary_error = errorRecord(error); throw primary; }
66
+ const delay = Math.min(capacityBackoff(error, attempt + 1, phase.clock.wall(), phase.random), Math.max(0, phase.clock.timeoutMs - phase.clock.elapsed));
67
+ bridge.journal?.write("thread_read_retry", { method, session_id: params.threadId, attempt: attempt + 1, delay_ms: delay, error: errorRecord(error) });
68
+ await phase.sleep(delay);
69
+ }
70
+ }
71
+ }
72
+
73
+ // Availability belongs to this read of this Thread, not to the whole binary.
74
+ export async function readCodexHistory(bridge, method, params, phase = codexReadPhase(bridge)) {
75
+ if (method !== "thread/turns/list" && !(method === "thread/read" && params.includeTurns === true))
76
+ throw new Error("Codex history policy only accepts history reads");
77
+ let result, error;
78
+ try {
79
+ result = await readCodexRpc(bridge, method, params, phase);
80
+ if (method === "thread/read" && result.thread.turns !== undefined && !Array.isArray(result.thread.turns))
81
+ fail("Codex history read returned invalid turns", "rpc_schema_invalid", { method });
82
+ }
83
+ catch (cause) {
84
+ bridge.assertHookHealthy?.(params.threadId);
85
+ const native = cause?.details;
86
+ const operation = method === "thread/read" ? "includeTurns" : "thread/turns/list";
87
+ const unsupported = native?.code === -32601 && native.message === "list_turns is not supported yet";
88
+ const unmaterialized = native?.code === -32600 && native.message ===
89
+ `thread ${params.threadId} is not materialized yet; ${operation} is unavailable before first user message`;
90
+ if (cause?.code !== "rpc_error" || !unsupported && !unmaterialized) throw cause;
91
+ error = errorRecord(cause);
92
+ }
93
+ const available = !error;
94
+ phase.history ??= new Map();
95
+ const key = `${params.threadId}:${method}`;
96
+ if (phase.history.get(key)?.available !== available) bridge.journal?.write("history_availability", {
97
+ method, session_id: params.threadId, available, ...(error ? { error } : {})
98
+ });
99
+ phase.history.set(key, { available, error });
100
+ return { available, result, error };
101
+ }
102
+
103
+ export function retainedCodexTurn(bridge, sessionId, turnId = bridge.currentTurns?.get(sessionId)) {
104
+ if (!turnId) return null;
105
+ const retained = bridge.turns?.get(turnId);
106
+ if (!retained) return null;
107
+ if (retained.threadId && retained.threadId !== sessionId || retained.value?.threadId && retained.value.threadId !== sessionId)
108
+ fail("Codex retained Thread identity differs", "turn_identity_mismatch");
109
+ if (!retained.threadId && !retained.value?.threadId) return null;
110
+ const turn = retained.value?.turn;
111
+ if (turn?.id !== turnId) fail("Codex retained Turn identity differs", "turn_identity_mismatch");
112
+ return turn;
113
+ }
18
114
  const CODEX_SESSION_CONFIG = Object.freeze({
19
115
  bypass_hook_trust: true,
20
116
  "features.plugins": false,
@@ -158,7 +254,7 @@ export class CodexBridge {
158
254
  // retain a compact read receipt instead of writing the same transcript on
159
255
  // every status poll.
160
256
  const request = message.id !== undefined ? this.pending.get(message.id) : null;
161
- if (request?.method === "thread/read") {
257
+ if (request?.method === "thread/read" && !message.error) {
162
258
  const thread = message.result?.thread ?? message.result ?? {};
163
259
  this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
164
260
  id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
@@ -410,7 +506,7 @@ export class CodexBridge {
410
506
  this.send({ jsonrpc: "2.0", id: message.id, error });
411
507
  }
412
508
 
413
- request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS) {
509
+ request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS, policy = {}) {
414
510
  const turnFloor = method === "turn/start" ? new Set([this.currentTurns?.get(params.threadId), ...[...this.turns].filter(([, value]) => value.threadId === params.threadId).map(([id]) => id)].filter(Boolean)) : null;
415
511
  if (method === "turn/start" && params.threadId) {
416
512
  // Only one productive root Turn is admitted by the daemon fence. Drop
@@ -422,16 +518,18 @@ export class CodexBridge {
422
518
  }
423
519
  const id = this.nextId++;
424
520
  return new Promise((resolve, reject) => {
425
- const timer = observedTimeout(() => {
521
+ const timer = observedTimeout(clock => {
426
522
  const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
427
523
  pending.timedOut = true;
428
- const timeoutError = Object.assign(new Error(`${method} timed out`), { code: "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS } });
524
+ const graceMs = policy.graceMs ?? LATE_RESPONSE_GRACE_MS;
525
+ const timeoutError = Object.assign(new Error(`${method} timed out`), { code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: graceMs, observation: clock.state() } });
429
526
  this.journal.write("request_deadline", timeoutError.details);
527
+ if (!graceMs) { this.pending.delete(id); reject(timeoutError); return; }
430
528
  pending.timer = observedTimeout(() => {
431
529
  if (this.pending.get(id) !== pending) return;
432
530
  this.pending.delete(id); reject(timeoutError);
433
- }, LATE_RESPONSE_GRACE_MS, { progress: () => pending.progress });
434
- }, timeoutMs, { progress: () => this.pending.get(id)?.progress, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
531
+ }, graceMs, { progress: () => pending.progress });
532
+ }, timeoutMs, { progress: () => { policy.observe?.(); return this.pending.get(id)?.progress; }, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
435
533
  const publish = operationProgressReporter();
436
534
  this.pending.set(id, { id, resolve, reject, timer, method, threadId: params.threadId, turnFloor });
437
535
  if (method === "turn/start" && params.threadId) {
@@ -467,12 +565,12 @@ async function withBridge(options, action) {
467
565
  function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
468
566
  function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
469
567
  function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
470
- async function observedChildren(bridge, sessionId) {
568
+ async function observedChildren(bridge, sessionId, phase) {
471
569
  const children = directChildren(bridge, sessionId);
472
570
  let reads = 0;
473
571
  return await Promise.all(children.map(async child => {
474
572
  if (child.status !== "failed" || !child.terminal_turn_id || reads++ >= 16) return child;
475
- const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true);
573
+ const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true, phase);
476
574
  const turn = observed?.turn;
477
575
  if (turn?.id !== child.terminal_turn_id || turn.status !== child.status) return child;
478
576
  return { ...child, provider_error: nativeTurnError(bridge, child.provider_session_id, turn) ?? child.provider_error,
@@ -481,8 +579,8 @@ async function observedChildren(bridge, sessionId) {
481
579
  }
482
580
  function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
483
581
 
484
- async function requestConfiguredThread(bridge, method, params, timeoutMs) {
485
- try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
582
+ async function requestConfiguredThread(bridge, method, params, timeoutMs, policy) {
583
+ try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs, policy); }
486
584
  catch (error) {
487
585
  const data = error?.details?.data;
488
586
  const key = data?.configKey ?? data?.config_key ?? null;
@@ -506,7 +604,7 @@ function threadReceipt(thread) {
506
604
 
507
605
  export async function doctor(options = {}) {
508
606
  const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"], { phase: 'native-version' }));
509
- return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
607
+ return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observation_scope: "version_and_initialize", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
510
608
  }
511
609
 
512
610
  async function observeCodexProfile(bridge, options, id, observed, source) {
@@ -548,6 +646,9 @@ export async function startSession(options) {
548
646
  }
549
647
 
550
648
  export async function startSessionWithBridge(bridge, options) {
649
+ options = { ...options, readPhase: codexReadPhase(bridge, options) };
650
+ options.readPhase.outerClock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS,
651
+ ...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "start", session_id: options.sessionId }) });
551
652
  const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
552
653
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
553
654
  bridge.requestedProfiles?.set(sessionId, profile(options));
@@ -555,13 +656,16 @@ export async function startSessionWithBridge(bridge, options) {
555
656
  options.assertDispatch?.();
556
657
  bridge.assertHookHealthy?.(sessionId);
557
658
  bridge.dispatchOperationId = options.operationId;
558
- const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 30_000);
659
+ const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", options.timeoutMs ?? 30_000));
559
660
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
560
661
  if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
561
662
  return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "started", requested_profile: profile(options), observed_profile: { model: null, reasoning: null } };
562
663
  }
563
664
 
564
665
  export async function promptSessionWithBridge(bridge, options) {
666
+ options = { ...options, readPhase: codexReadPhase(bridge, options) };
667
+ const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, ...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "prompt", session_id: options.sessionId }) });
668
+ options.readPhase.outerClock = clock;
565
669
  const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
566
670
  validateObservationDuration(options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000);
567
671
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
@@ -573,8 +677,7 @@ export async function promptSessionWithBridge(bridge, options) {
573
677
  options.assertDispatch?.();
574
678
  bridge.assertHookHealthy?.(sessionId);
575
679
  bridge.dispatchOperationId = options.operationId;
576
- const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId }) });
577
- const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, 30_000);
680
+ const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", 30_000));
578
681
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
579
682
  let completed;
580
683
  try { completed = isTerminalTurnStatus(turn?.status) ? terminalTurn({ turn }, sessionId, turnId, bridge)
@@ -599,14 +702,34 @@ export async function promptSessionWithBridge(bridge, options) {
599
702
  details: { native_outcome: { provider_session_id: sessionId, turn_id: turnId, status: "failed", provider_error: error.details?.provider_error }, settled: false, observation_error: errorRecord(observationError) }, cause: error });
600
703
  }
601
704
  }
705
+ if (isObservationLoss(error)) throw Object.assign(new Error("Codex accepted Turn outcome could not be observed", { cause: error }), {
706
+ code: "operation_observation_lost", retryable: false, details: { provider_session_id: sessionId, turn_id: turnId,
707
+ operation_id: options.operationId ?? null, phase: "turn_observation", native_dispatch_started: true,
708
+ native_outcome: "unknown", observation_error: errorRecord(error), history_reads: Object.fromEntries(options.readPhase.history ?? []) }
709
+ });
602
710
  throw error;
603
711
  }
604
712
  const nativeOutcome = { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed };
605
713
  try {
606
- const thread = await readCompletedThread(bridge, sessionId);
714
+ const phase = codexReadPhase(bridge, options);
715
+ const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
716
+ const thread = history.available ? history.result.thread
717
+ : (await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase)).thread;
607
718
  bridge.assertHookHealthy?.(sessionId);
608
- const assistantText = latestAssistantText(thread?.turns?.find(item => item?.id === turnId)) ?? latestAssistantText(completed);
609
- return { ...nativeOutcome, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: await observedChildren(bridge, sessionId), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
719
+ const final = bridge.finalMessages?.get(sessionId);
720
+ const historyTurn = thread?.turns?.find(item => item?.id === turnId);
721
+ const completeHistory = history.available && (turnItemEvidence(historyTurn, bridge, sessionId).observed
722
+ // Preserve the existing idle + readable-history final-message receipt.
723
+ || completed?.turn?.synthetic === true && Array.isArray(thread.turns) && final?.turnId === turnId && thread.status?.type === "idle");
724
+ const assistantText = latestAssistantText(completed) ?? latestAssistantText(historyTurn)
725
+ ?? (final?.turnId === turnId ? latestAssistantText({ items: [final.item] }) : null);
726
+ if (!completeHistory && (!assistantText || !turnItemEvidence(completed?.turn ?? completed, bridge, sessionId).observed))
727
+ fail("Codex completed but its current output/items remain unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
728
+ if (!completeHistory && (options.model && !thread.model || options.reasoning && !thread.reasoningEffort))
729
+ fail("Codex completed but its applied profile remains unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
730
+ const result = { ...nativeOutcome, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: await observedChildren(bridge, sessionId, phase), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
731
+ await bridge.journal?.flush?.();
732
+ return result;
610
733
  } catch (error) {
611
734
  throw Object.assign(new Error("Codex completed natively but mandatory observation failed"), { code: "native_outcome_observation_failed", retryable: false,
612
735
  details: { native_outcome: nativeOutcome, settled: false, observation_error: errorRecord(error) }, cause: error });
@@ -614,7 +737,7 @@ export async function promptSessionWithBridge(bridge, options) {
614
737
  }
615
738
 
616
739
  async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
617
- const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
740
+ const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, options.readPhase);
618
741
  const thread = value?.thread ?? value;
619
742
  if (thread?.status?.type !== "notLoaded") return;
620
743
  await requestConfiguredThread(bridge, "thread/resume", {
@@ -623,10 +746,14 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
623
746
  model: options.model ?? null,
624
747
  approvalPolicy: "never",
625
748
  sandbox: "danger-full-access"
626
- }, 30_000);
749
+ }, ...dispatchBudget(options.readPhase, "thread/resume", 30_000));
627
750
  }
628
751
 
629
752
  async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) }), startStatus) {
753
+ const readPhase = codexReadPhase(bridge, options);
754
+ readPhase.clock = clock;
755
+ readPhase.progress = () => bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId);
756
+ options = { ...options, readPhase };
630
757
  // app-server can briefly expose the new Turn as `interrupted` between the
631
758
  // turn/start response and its turn/started notification. Give that snapshot
632
759
  // one event-loop grace window before treating failure as authoritative.
@@ -636,7 +763,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
636
763
  if (status === "completed" || startStatus !== "inProgress" || bridge.turns.get(turnId)?.status === "completed") return true;
637
764
  // A provisional failure can contradict the accepted start before its
638
765
  // delayed notification arrives. Recheck this Turn, never start another.
639
- const current = await readTurn(bridge, sessionId, turnId, true);
766
+ const current = await readTurn(bridge, sessionId, turnId, true, readPhase);
640
767
  return bridge.turns.get(turnId)?.status === "completed" || current?.turn?.status === status
641
768
  && isAuthoritativeTerminal(bridge, turnId, status, current.threadStatus, failureNotBefore);
642
769
  };
@@ -646,27 +773,29 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
646
773
  const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed" && (!known.threadId || known.threadId === sessionId)) return terminalTurn(known.value, sessionId, turnId, bridge);
647
774
  const expired = clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId));
648
775
  if (expired || Date.now() >= nextStoredReadAt) {
649
- const stored = await readTurn(bridge, sessionId, turnId, false);
776
+ const stored = await readTurn(bridge, sessionId, turnId, false, readPhase);
650
777
  if (stored?.turn && await reconcileSnapshot(stored.turn.status, stored.threadStatus)) return terminalTurn({ turn: stored.turn }, sessionId, turnId, bridge);
651
778
  // A loaded app-server can retain a stale `active` Thread state after a
652
779
  // provider-side interruption. The persisted turn page is independent of
653
780
  // that live subscription and is the compact authority for its terminal
654
- // status; unlike `includeTurns`, it never hydrates the whole history.
655
- const persisted = await readPersistedTurn(bridge, sessionId, turnId);
656
- if (persisted && await reconcileSnapshot(persisted.status, stored?.threadStatus)) return terminalTurn({ turn: persisted }, sessionId, turnId, bridge);
781
+ // status. Hydrate only when that read surface is unavailable.
782
+ const persisted = await readPersistedTurn(bridge, sessionId, turnId, readPhase);
783
+ if (persisted.turn && await reconcileSnapshot(persisted.turn.status, persisted.threadStatus ?? stored?.threadStatus)) return terminalTurn({ turn: persisted.turn }, sessionId, turnId, bridge);
784
+ const native = bridge.turns.get(turnId);
785
+ if (native?.status === "completed" && (!native.threadId || native.threadId === sessionId)) return terminalTurn(native.value, sessionId, turnId, bridge);
657
786
  // Desktop may unload an idle Thread immediately after an interrupted
658
787
  // Turn. Reload it once before polling again: otherwise the compact
659
788
  // status remains `notLoaded` forever and hides the terminal Turn.
660
789
  if (stored?.threadStatus === "notLoaded") {
661
790
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
662
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
791
+ const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
663
792
  if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
664
793
  }
665
794
  // Some app-server versions correctly omit turns for a compact read.
666
795
  // Once the thread is idle, hydrate exactly once to recover the terminal
667
796
  // turn instead of waiting for the global timeout.
668
797
  if (stored?.threadStatus === "idle") {
669
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
798
+ const hydrated = persisted.hydrated ? persisted : await readTurn(bridge, sessionId, turnId, true, readPhase);
670
799
  // "idle" only describes the Thread. Some app-server builds retain
671
800
  // an inProgress Turn after emitting the final agent message; treating
672
801
  // that stale Turn as terminal incorrectly aborts an otherwise usable
@@ -681,7 +810,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
681
810
  // reasoning and calling tools. They are terminal evidence only after
682
811
  // app-server reports the Thread idle.
683
812
  if (final?.turnId === turnId && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
684
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
813
+ const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
685
814
  if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
686
815
  if (hydrated?.threadStatus === "idle" && !isTerminalTurnStatus(hydrated.turn?.status))
687
816
  return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, sessionId, turnId, bridge);
@@ -689,7 +818,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
689
818
  nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
690
819
  }
691
820
  const terminal = bridge.turns.get(turnId); if (terminal?.status === "completed" && (!terminal.threadId || terminal.threadId === sessionId)) return terminalTurn(terminal.value, sessionId, turnId, bridge);
692
- if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, native_outcome: "unknown" });
821
+ if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, operation_id: options.operationId ?? null, native_dispatch_started: true, phase: "turn_observation", native_outcome: "unknown", history_reads: Object.fromEntries(readPhase.history ?? []) });
693
822
  await new Promise((resolve) => setTimeout(resolve, 250));
694
823
  }
695
824
  }
@@ -744,29 +873,23 @@ function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNo
744
873
  return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
745
874
  }
746
875
 
747
- async function readTurn(bridge, sessionId, turnId, includeTurns) {
876
+ async function readTurn(bridge, sessionId, turnId, includeTurns, phase) {
748
877
  if (!turnId) return null;
749
878
  try {
750
- const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
879
+ const history = includeTurns ? await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase) : null;
880
+ if (history && !history.available) return { turn: null, threadStatus: null, historyAvailable: false };
881
+ const result = history?.result ?? await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase);
751
882
  const thread = result?.thread ?? result;
752
883
  return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
753
- } catch { bridge.assertHookHealthy?.(sessionId); return null; }
884
+ } catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
754
885
  }
755
886
 
756
- async function readPersistedTurn(bridge, sessionId, turnId) {
887
+ async function readPersistedTurn(bridge, sessionId, turnId, phase) {
757
888
  try {
758
- const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
759
- return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
760
- } catch { bridge.assertHookHealthy?.(sessionId); return null; }
761
- }
762
-
763
- async function readCompletedThread(bridge, sessionId) {
764
- let lastError;
765
- for (let attempt = 0; attempt < 8; attempt += 1) {
766
- try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
767
- catch (error) { lastError = error; bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: errorRecord(error) }); await new Promise((resolve) => setTimeout(resolve, 150)); }
768
- }
769
- throw lastError;
889
+ const history = await readCodexHistory(bridge, "thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, phase);
890
+ if (!history.available) return { ...await readTurn(bridge, sessionId, turnId, true, phase), hydrated: true };
891
+ return { available: history.available, turn: history.result?.data.find((turn) => turn?.id === turnId) ?? null };
892
+ } catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
770
893
  }
771
894
 
772
895
  function latestAssistantText(value) {
@@ -789,10 +912,11 @@ export async function inspectSession(options) {
789
912
  }
790
913
 
791
914
  export async function inspectSessionWithBridge(bridge, options) {
792
- const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
793
- const terminal = options.turnId ? (await readTurn(bridge, sessionId, options.turnId, true))?.turn : null;
915
+ const phase = options.readPhase ?? codexReadPhase(bridge, options);
916
+ const sessionId = required(options.sessionId, "--session-id"); const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase); const thread = value?.thread ?? value;
917
+ const terminal = options.turnId ? retainedCodexTurn(bridge, sessionId, options.turnId) ?? (await readTurn(bridge, sessionId, options.turnId, true, phase))?.turn : null;
794
918
  const exact = terminal?.id === options.turnId && terminal?.status === "failed" ? { terminal_turn_id: terminal.id, terminal_status: terminal.status, provider_error: nativeTurnError(bridge, sessionId, terminal), native_turn_items: turnItemEvidence(terminal, bridge, sessionId) } : {};
795
- return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
919
+ return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId, phase), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
796
920
  }
797
921
 
798
922
  export async function cancelSession(options) {
@@ -800,12 +924,21 @@ export async function cancelSession(options) {
800
924
  }
801
925
 
802
926
  export async function cancelSessionWithBridge(bridge, options) {
927
+ const phase = options.readPhase ?? codexReadPhase(bridge, options);
803
928
  const sessionId = required(options.sessionId, "--session-id");
804
929
  let turnId = options.turnId ?? null;
805
930
  if (!turnId) {
806
- const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000);
807
- const thread = result?.thread ?? result;
808
- turnId = [...(thread?.turns ?? [])].reverse().find((turn) => turn?.status === "inProgress")?.id ?? null;
931
+ const currentId = bridge.currentTurns?.get(sessionId);
932
+ const retained = retainedCodexTurn(bridge, sessionId, currentId);
933
+ turnId = currentId && !isTerminalTurnStatus(retained?.status) ? currentId : null;
934
+ if (!turnId) {
935
+ const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
936
+ if (!history.available || !Array.isArray(history.result.thread.turns)
937
+ || history.result.thread.turns.some(turn => typeof turn?.id !== "string" || !turn.id.trim()
938
+ || turn.status !== "inProgress" && !isTerminalTurnStatus(turn.status)))
939
+ fail("Codex active Turn cannot be established from incomplete history", "operation_observation_lost", { provider_session_id: sessionId, native_outcome: "unknown", history_error: history.error });
940
+ turnId = [...history.result.thread.turns].reverse().find((turn) => turn.status === "inProgress")?.id ?? null;
941
+ }
809
942
  }
810
943
  if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
811
944
  await bridge.request("turn/interrupt", { threadId: sessionId, turnId });