@deksden-com/dd-flow-cli 0.9.0-beta.124 → 0.9.0-beta.127

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.
Files changed (113) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  14. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  17. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  18. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  19. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  20. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  21. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  22. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  23. package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
  24. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
  25. package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
  26. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
  27. package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
  28. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
  29. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  30. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
  31. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
  33. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  34. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
  35. package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
  36. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  37. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  38. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  39. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  40. package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
  41. package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
  42. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  43. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  44. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  45. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  46. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  47. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  48. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  49. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  50. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  51. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  52. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  53. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  54. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  55. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  56. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  57. package/dist/runtime/context.js +8 -4
  58. package/dist/schemas/run-control-receipt.schema.json +19 -0
  59. package/dist/services/code-checks.js +79 -9
  60. package/dist/services/codex-capacity.js +117 -41
  61. package/dist/services/codex-hook-delivery.js +2 -2
  62. package/dist/services/compatibility-preflight.js +4 -1
  63. package/dist/services/controller-fanout.js +9 -76
  64. package/dist/services/engines.js +70 -20
  65. package/dist/services/eval-snapshots.js +179 -40
  66. package/dist/services/external-work-launch.js +13 -8
  67. package/dist/services/harness-adapter.js +175 -11
  68. package/dist/services/hooks.js +65 -21
  69. package/dist/services/lifecycle-invocations.js +404 -32
  70. package/dist/services/managed-lease-monitor.js +128 -0
  71. package/dist/services/managed-processes.js +103 -47
  72. package/dist/services/merge-server.js +58 -21
  73. package/dist/services/recovery-observation-budget.js +67 -7
  74. package/dist/services/recovery-snapshot-database.js +7 -4
  75. package/dist/services/run-control-receipt.js +1 -1
  76. package/dist/services/run-control-worker.js +177 -118
  77. package/dist/services/run-control.js +99 -8
  78. package/dist/services/run-controller-adapter.js +169 -46
  79. package/dist/services/run-controller-capture.js +14 -2
  80. package/dist/services/run-controller-process.js +11 -4
  81. package/dist/services/run-controller-state.js +68 -7
  82. package/dist/services/run-controller.js +166 -94
  83. package/dist/services/run-fork.js +8 -3
  84. package/dist/services/run-recovery-guard.js +75 -0
  85. package/dist/services/run-recovery-runtime.js +1 -56
  86. package/dist/services/run-recovery.js +17 -13
  87. package/dist/services/runs.js +39 -6
  88. package/dist/services/runtime-budget.js +32 -15
  89. package/dist/services/runtime-scope-control.js +8 -0
  90. package/dist/services/runtime-scope-resume.js +30 -8
  91. package/dist/services/runtime-scope-worker.js +118 -73
  92. package/dist/services/runtime-service.js +6 -7
  93. package/dist/services/scope-writer-cleanup.js +3 -2
  94. package/dist/services/stage-context.js +135 -0
  95. package/dist/services/stage-lifecycle.js +3 -15
  96. package/dist/services/stage-pause.js +3 -2
  97. package/dist/services/usage.js +14 -3
  98. package/dist/services/vnext-code-review.js +10 -9
  99. package/dist/services/vnext-code.js +10 -8
  100. package/dist/services/vnext-contracts.js +10 -1
  101. package/dist/services/vnext-execution-profile.js +75 -2
  102. package/dist/services/vnext-fanout.js +5 -0
  103. package/dist/services/vnext-merge.js +10 -9
  104. package/dist/services/vnext-plan-review.js +21 -17
  105. package/dist/services/vnext-plan.js +9 -7
  106. package/dist/services/vnext-protocolize.js +15 -9
  107. package/dist/services/vnext-specify.js +6 -6
  108. package/dist/services/work-registry.js +34 -4
  109. package/dist/services/workspace-bootstrap.js +14 -3
  110. package/dist/shared/errors.js +11 -6
  111. package/dist/storage/agent-output.js +26 -0
  112. package/dist/storage/database.js +80 -21
  113. package/package.json +7 -4
@@ -1,11 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createReadStream } from "node:fs";
2
3
  import { appendFile, mkdir, readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import readline from "node:readline";
5
6
  import { closeBridgeProcess } from "./managed-daemon.mjs";
6
- import { errorRecord } from "./operation-errors.mjs";
7
- import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
7
+ import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
8
+ import { reportedFailure, commandText } from "./process-json.mjs";
9
+ import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
8
10
  import { observeModel } from "./model-observations.mjs";
11
+ import { classifyCodexItem } from "./tool-observations.mjs";
12
+ import { readNativeLines, streamNativeDiagnostics, nativeObservationError } from "./native-streams.mjs";
13
+ import { operationProgressReporter } from "./operation-progress.mjs";
9
14
 
10
15
  const LATE_RESPONSE_GRACE_MS = 5_000;
11
16
  const CODEX_SESSION_CONFIG = Object.freeze({
@@ -35,43 +40,51 @@ export function parseCodexCliVersion(value) {
35
40
  return match[1];
36
41
  }
37
42
 
38
- function commandText(bin, args) {
39
- return new Promise((resolve, reject) => {
40
- const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
41
- let stdout = ""; let stderr = "";
42
- child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
43
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
44
- child.on("error", reject);
45
- child.on("exit", (code) => code === 0 ? resolve(stdout) : reject(Object.assign(new Error(`Codex CLI version failed: ${stderr.trim() || code}`), { code: "codex_version_unavailable" })));
46
- });
47
- }
43
+
48
44
 
49
45
  class Journal {
50
46
  constructor(file) { this.file = file ? absolute(file, "--journal") : null; this.pending = this.file ? mkdir(path.dirname(this.file), { recursive: true }) : Promise.resolve(); this.order = 0; }
51
- write(kind, payload) { if (!this.file) return; this.pending = this.pending.then(() => appendFile(this.file, `${JSON.stringify({ order: ++this.order, observed_at: now(), kind, payload })}\n`)); }
47
+ write(kind, payload) { if (!this.file) return; this.pending = this.pending.then(() => appendFile(this.file, `${JSON.stringify({ order: ++this.order, observed_at: now(), kind, payload })}\n`)); void this.pending.catch(() => {}); return this.pending; }
52
48
  flush() { return this.pending; }
53
49
  }
54
50
 
55
51
  export class CodexBridge {
56
52
  constructor(options = {}) { this.options = options; this.nextId = 1; this.pending = new Map(); this.lateResponses = new Map(); this.turns = new Map(); this.nativeErrors = new Map(); this.finalMessages = new Map(); this.descendants = new Map(); this.journal = new Journal(options.journal); this.stderr = ""; this.requestedProfiles = new Map(); this.modelObservations = Promise.resolve(); }
57
53
 
54
+ async restoreItemPhases() {
55
+ if (this.options.journal) {
56
+ const source = createReadStream(this.options.journal, { encoding: "utf8" });
57
+ try {
58
+ for await (const line of readline.createInterface({ input: source })) {
59
+ let record; try { record = JSON.parse(line); } catch { continue; }
60
+ if (record.direction === "inbound" || record.kind === "inbound") this.observeItemPhase(record.payload ?? record.message ?? record);
61
+ }
62
+ } catch (error) { if (error.code !== "ENOENT") throw error; }
63
+ }
64
+ }
65
+
58
66
  async start() {
67
+ await this.restoreItemPhases();
59
68
  const bin = this.options.bin ?? process.env.DD_CODEX_BIN ?? "codex";
60
- this.child = spawn(bin, ["app-server"], { cwd: this.options.cwd ? absolute(this.options.cwd, "--cwd") : process.cwd(), env: { ...process.env, ...(this.options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
61
- this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.stderr += String(chunk); this.journal.write("stderr", { text: String(chunk) }); });
62
- this.child.stdin?.on("error", (error) => this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })));
69
+ // Thread overrides arrive too late to prevent app-server's background
70
+ // marketplace clones. Apply the same isolation policy before startup.
71
+ this.child = spawn(bin, ["-c", `features.plugins=${CODEX_SESSION_CONFIG["features.plugins"]}`, "app-server"], { cwd: this.options.cwd ? absolute(this.options.cwd, "--cwd") : process.cwd(), env: { ...process.env, ...(this.options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
72
+ this.diagnostics = streamNativeDiagnostics(this.child.stderr, async chunk => { this.stderr = (this.stderr + chunk).slice(-65_536); await this.journal.write("stderr", { text: chunk }); }, error => this.rejectAll(error));
73
+ this.child.stdin?.on("error", (error) => this.rejectAll(nativeObservationError(error)));
63
74
  this.child.on("error", (error) => this.rejectAll(error));
64
- this.child.on("exit", (code) => this.rejectAll(Object.assign(new Error(`Codex app-server exited ${code}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`), { code: "bridge_exited" })));
65
- readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
75
+ this.child.on("close", code => { void Promise.all([this.nativeDrain, this.diagnostics]).then(() => this.rejectAll(Object.assign(new Error(`Codex app-server exited ${code}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`), { code: "bridge_exited" }))); });
76
+ this.nativeDrain = readNativeLines(this.child.stdout, async line => { this.receive(line); await this.journal.flush(); }, error => this.rejectAll(error));
66
77
  if (this.options.onSpawn) await this.options.onSpawn(this.child);
67
78
  await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
68
79
  }
69
80
 
70
81
  receive(line) {
71
82
  let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
72
- this.observeTopology(message);
73
- this.observeNativeHook(message);
83
+ this.observeItemPhase(message);
84
+ try { this.observeTopology(message); }
85
+ catch (error) { this.topologyError = error; this.rejectAll(error); return; }
74
86
  this.observeHookFailure(message);
87
+ this.observeNativeHook(message);
75
88
  if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
76
89
  const params = message.params ?? {}, id = params.threadId;
77
90
  let root = id; const seen = new Set();
@@ -83,16 +96,57 @@ export class CodexBridge {
83
96
  parentSessionId: this.descendants.get(id)?.parent_provider_session_id ?? null, requested: this.requestedProfiles.get(root),
84
97
  observed: { model: rerouted ? params.toModel : settings.model, provider: settings.modelProvider, reasoning: settings.effort },
85
98
  source: message.method, evidence: rerouted ? "response" : "configured", reason: params.reason ?? null, previousModel: params.fromModel ?? null
86
- })).catch(error => { this.modelObservationError = error; this.rejectAll(error); });
99
+ })).catch(cause => { const error = nativeObservationError(cause); this.modelObservationError = error; this.rejectAll(error); });
100
+ }
101
+ }
102
+ if (message.method === "turn/started") {
103
+ this.currentTurns ??= new Map(); const id = message.params?.turn?.id, thread = message.params?.threadId;
104
+ const pendingStart = [...this.pending.values()].find(value => value.method === "turn/start" && value.threadId === thread);
105
+ const dispatching = pendingStart && !pendingStart.turnFloor?.has(id);
106
+ const child = this.descendants.get(thread), parentCurrent = child?.parent_turn_id && child.parent_turn_id === this.currentTurns.get(child.parent_provider_session_id);
107
+ if (id && this.turns.get(id)?.status !== "completed" && (dispatching || parentCurrent && child.status === "running" || !this.currentTurns.has(thread) && !this.requestedProfiles.has(thread))) {
108
+ if (this.currentTurns.get(thread) !== id) this.usageMarkers?.delete(thread);
109
+ this.currentTurns.set(thread, id);
87
110
  }
88
111
  }
89
- if (message.id === undefined && (message.method?.startsWith("item/") || message.method === "turn/started" || message.method === "thread/tokenUsage/updated")) {
112
+ const params = message.params ?? {}, currentTurn = this.currentTurns?.get(params.threadId);
113
+ let productive = message.method === "turn/started" || /^item\/(started|completed|[^/]+\/(delta|outputDelta|summaryTextDelta|textDelta))$/.test(message.method ?? "");
114
+ if (productive && currentTurn && (params.turnId ?? params.turn?.id) === currentTurn && ["turn/started", "item/started", "item/completed"].includes(message.method)) {
115
+ this.progressPhases ??= new Map(); const key = JSON.stringify([params.threadId, currentTurn, message.method, params.item?.id ?? null]);
116
+ if (this.progressPhases.has(key)) productive = false;
117
+ else if (this.progressPhases.size < 1_024) this.progressPhases.set(key, true);
118
+ else productive = false;
119
+ }
120
+ if (productive && /(?:Delta|delta)$/.test(message.method ?? "") && !params.delta && !params.outputDelta && !params.text) productive = false;
121
+ let usageProgress = false;
122
+ if (message.method === "thread/tokenUsage/updated" && params.turnId === currentTurn) {
123
+ this.usageMarkers ??= new Map(); const count = params.tokenUsage?.last?.totalTokens ?? params.tokenUsage?.total?.totalTokens;
124
+ const prior = this.usageMarkers.get(params.threadId) ?? 0;
125
+ if (Number.isFinite(count) && count > prior) { usageProgress = true; this.usageMarkers.set(params.threadId, count); }
126
+ }
127
+ if (message.id === undefined && currentTurn && (params.turnId ?? params.turn?.id) === currentTurn && (productive || usageProgress)) {
90
128
  this.activity ??= new Map();
129
+ this.activityObservations ??= new Map();
91
130
  const visited = new Set();
92
131
  let id = message.params?.threadId;
93
132
  while (id && !visited.has(id)) {
94
- visited.add(id); this.activity.set(id, (this.activity.get(id) ?? 0) + 1);
95
- id = this.descendants.get(id)?.parent_provider_session_id;
133
+ visited.add(id); const cursor = (this.activity.get(id) ?? 0) + 1; this.activity.set(id, cursor); this.activityObservations.set(id, { cursor, observed_at: Date.now() });
134
+ const observer = this.turnObservers?.get(id), turnId = this.currentTurns?.get(id);
135
+ if (observer && turnId && !observer.turnFloor.has(turnId)) {
136
+ const pending = this.pending.get(observer.id);
137
+ if (pending) pending.progress = this.activityObservations.get(id);
138
+ const session = id;
139
+ void observer.publish("native_progress", { native_request_id: String(observer.id), session_id: id, observed_at: new Date(this.activityObservations.get(id).observed_at).toISOString() }).catch(cause => {
140
+ // Pipe publications can finish after this Turn's terminal response.
141
+ // Never attribute an old operation's storage failure to its successor.
142
+ if (this.turnObservers?.get(session) !== observer) return;
143
+ const error = nativeObservationError(cause); observer.progressError ??= error;
144
+ const current = this.pending.get(observer.id);
145
+ if (current === pending && current) { clearTimeout(current.timer); this.pending.delete(observer.id); current.reject(error); }
146
+ });
147
+ }
148
+ const child = this.descendants.get(id), parent = child?.parent_provider_session_id;
149
+ id = parent && child.parent_turn_id && child.parent_turn_id === this.currentTurns?.get(parent) ? parent : null;
96
150
  }
97
151
  }
98
152
  // app-server currently returns complete turns even for `includeTurns:
@@ -117,6 +171,11 @@ export class CodexBridge {
117
171
  return;
118
172
  }
119
173
  clearTimeout(pending.timer); this.pending.delete(message.id);
174
+ if (pending.method === "turn/start" && pending.threadId && message.result?.turn?.id) {
175
+ this.currentTurns ??= new Map();
176
+ if (this.currentTurns.get(pending.threadId) !== message.result.turn.id) this.usageMarkers?.delete(pending.threadId);
177
+ this.currentTurns.set(pending.threadId, message.result.turn.id);
178
+ }
120
179
  if (pending.timedOut) this.journal.write("late_response", { ...responseSummary(message), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
121
180
  message.error ? pending.reject(Object.assign(new Error(message.error.message ?? JSON.stringify(message.error)), { code: "rpc_error", details: message.error })) : pending.resolve(message.result ?? {});
122
181
  return;
@@ -126,7 +185,10 @@ export class CodexBridge {
126
185
  this.nativeErrors.set(key, { error: message.params.error, willRetry: message.params.willRetry });
127
186
  while (this.nativeErrors.size > 128) this.nativeErrors.delete(this.nativeErrors.keys().next().value);
128
187
  }
129
- if (message.method === "turn/completed") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "completed", value: message.params, threadId: message.params?.threadId });
188
+ if (message.method === "turn/completed") {
189
+ this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "completed", value: message.params, threadId: message.params?.threadId });
190
+ if (this.currentTurns?.get(message.params?.threadId) === message.params?.turn?.id) this.turnObservers?.delete(message.params.threadId);
191
+ }
130
192
  if (message.method === "turn/started" && this.turns.get(message.params?.turn?.id)?.status !== "completed") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "running", value: message.params, threadId: message.params?.threadId });
131
193
  const threadId = message.params?.threadId;
132
194
  if (threadId && message.method === "item/started") this.finalMessages.delete(threadId);
@@ -141,6 +203,14 @@ export class CodexBridge {
141
203
  }
142
204
 
143
205
  observeNativeHook(message) {
206
+ if (message.method === "turn/started") {
207
+ const { threadId, turn } = message.params ?? {};
208
+ if (threadId && turn?.id && this.turns.get(turn.id)?.status !== "completed") {
209
+ this.nativeHookTurns ??= new Map(); this.nativeHookTurns.set(threadId, turn.id);
210
+ for (const [id, hook] of this.activeHooks ?? []) if (hook.threadId === threadId && hook.turnId !== turn.id) this.activeHooks.delete(id);
211
+ }
212
+ return;
213
+ }
144
214
  if (message.method === "turn/completed") {
145
215
  const { threadId, turn } = message.params ?? {};
146
216
  for (const [id, hook] of this.activeHooks ?? []) {
@@ -152,8 +222,12 @@ export class CodexBridge {
152
222
  const { threadId, turnId, run } = message.params ?? {};
153
223
  if (!this.isManagedHook(run) || typeof run.id !== "string" || !threadId) return;
154
224
  this.activeHooks ??= new Map();
155
- if (message.method === "hook/started") this.activeHooks.set(run.id, { threadId, turnId });
156
- else this.activeHooks.delete(run.id);
225
+ if (message.method === "hook/started") {
226
+ if (this.nativeHookTurns?.has(threadId) && this.nativeHookTurns.get(threadId) !== turnId) return;
227
+ let root = threadId; const seen = new Set();
228
+ while (this.descendants.has(root) && !seen.has(root)) { seen.add(root); root = this.descendants.get(root).parent_provider_session_id; }
229
+ this.activeHooks.set(run.id, { threadId, turnId, root, rootTurnId: this.nativeHookTurns?.get(root) ?? null, rootGeneration: this.hookPromptGenerations?.get(root) ?? 0 });
230
+ } else if (this.activeHooks.get(run.id)?.turnId === turnId) this.activeHooks.delete(run.id);
157
231
  }
158
232
 
159
233
  verifyNativeHook(input) {
@@ -167,7 +241,8 @@ export class CodexBridge {
167
241
  seen.add(current); current = this.descendants.get(current)?.parent_provider_session_id;
168
242
  }
169
243
  if (!active || active.threadId !== sessionId || active.turnId !== turnId || !toolCallId
170
- || current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)) {
244
+ || current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)
245
+ || active.rootGeneration !== (this.hookPromptGenerations?.get(rootSessionId) ?? 0)) {
171
246
  fail("No matching active managed native hook", "native_hook_unproven", { provider_session_id: sessionId, tool_call_id: toolCallId });
172
247
  }
173
248
  return { rootSessionId, sessionId, toolCallId, parentSessionId: sessionId === rootSessionId ? null : this.descendants.get(sessionId)?.parent_provider_session_id };
@@ -180,6 +255,11 @@ export class CodexBridge {
180
255
  run.eventName !== "preToolUse" || run.handlerType !== "command" ||
181
256
  run.statusMessage !== "dd-flow runtime observation" || !home ||
182
257
  run.sourcePath !== path.join(home, "hooks.json")) return;
258
+ const active = this.activeHooks?.get(run.id);
259
+ if (!active || active.threadId !== threadId || active.turnId !== turnId) return;
260
+ if (this.nativeHookTurns?.has(threadId) && this.nativeHookTurns.get(threadId) !== turnId
261
+ || active.rootTurnId !== null && this.nativeHookTurns?.get(active.root) !== active.rootTurnId
262
+ || active.rootGeneration !== (this.hookPromptGenerations?.get(active.root) ?? 0)) return;
183
263
  const ancestors = new Set();
184
264
  let root;
185
265
  let current = threadId;
@@ -193,78 +273,120 @@ export class CodexBridge {
193
273
  if (this.hookFailures.has(root)) return;
194
274
  const entries = Array.isArray(run.entries) ? run.entries : [];
195
275
  const reason = entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("; ");
196
- const error = Object.assign(new Error(reason || "Managed Codex hook failed"), {
197
- code: /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed",
198
- details: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
199
- hook_run_id: run.id, effect: "unknown", duration_ms: run.durationMs }
200
- });
276
+ const error = reportedFailure(entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("\n"), "", reason || "Managed Codex hook failed", /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed");
277
+ const correction = isProvenShellCorrection(error);
278
+ error.details = { ...error.details, native_hook_binding: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
279
+ hook_run_id: run.id, source: "codex.hook/completed" }, provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
280
+ hook_run_id: run.id, effect: correction ? "no_effect" : "unknown", duration_ms: run.durationMs };
281
+ if (correction) {
282
+ try { void Promise.resolve(this.journal.write("native_hook_correction", errorRecord(error))).catch(storage => { error.cleanup_error ??= errorRecord(storage); }); }
283
+ catch (storage) { error.cleanup_error ??= errorRecord(storage); }
284
+ return;
285
+ }
201
286
  this.hookFailures.set(root, error);
202
- this.journal.write("native_hook_failure", { code: error.code, message: error.message, ...error.details });
203
287
  for (const [id, pending] of this.pending) {
204
288
  if (ancestors.has(pending.threadId) && pending.method !== "turn/interrupt") {
205
289
  clearTimeout(pending.timer); this.pending.delete(id); pending.reject(error);
206
290
  }
207
291
  }
292
+ try { void Promise.resolve(this.journal.write("native_hook_failure", errorRecord(error))).catch(storage => { error.cleanup_error ??= errorRecord(storage); }); }
293
+ catch (storage) { error.cleanup_error ??= errorRecord(storage); }
208
294
  }
209
295
 
210
296
  assertHookHealthy(sessionId) {
297
+ if (this.topologyError) throw this.topologyError;
211
298
  const visited = new Set();
212
299
  while (sessionId && !visited.has(sessionId)) {
213
300
  visited.add(sessionId);
301
+ if (this.turnObservers?.get(sessionId)?.progressError) throw this.turnObservers.get(sessionId).progressError;
214
302
  const error = this.hookFailures?.get(sessionId);
215
303
  if (error) throw error;
216
304
  sessionId = this.descendants.get(sessionId)?.parent_provider_session_id;
217
305
  }
218
306
  }
219
307
 
308
+ observeItemPhase(message) {
309
+ const { threadId, turnId, item } = message?.params ?? {};
310
+ if (message?.method !== "item/completed" || !threadId || !turnId || !item?.id || classifyCodexItem(item.type) !== "tool") return;
311
+ this.itemPhases ??= new Map();
312
+ const key = JSON.stringify([threadId, turnId, item.id]);
313
+ const previous = this.itemPhases.get(key);
314
+ if (previous && (previous.type !== item.type || previous.status !== (item.status ?? null))) {
315
+ this.topologyError = Object.assign(new Error("Conflicting native item completion evidence"), { code: "native_item_completion_conflict", details: { provider_session_id: threadId, turn_id: turnId, item_id: item.id } });
316
+ this.rejectAll(this.topologyError); return;
317
+ }
318
+ this.itemPhases.set(key, { type: item.type, status: item.status ?? null });
319
+ }
320
+
321
+ bindChild(child, parent, facts) {
322
+ const previous = this.descendants.get(child);
323
+ if (child === parent || this.requestedProfiles.has(child) || previous?.parent_provider_session_id && previous.parent_provider_session_id !== parent) {
324
+ fail("Conflicting native child parent identity", "native_topology_conflict", { child_session_id: child, parent_session_id: parent, retained_parent: previous?.parent_provider_session_id ?? null });
325
+ }
326
+ const seen = new Set([child]);
327
+ for (let ancestor = parent; ancestor; ancestor = this.descendants.get(ancestor)?.parent_provider_session_id) {
328
+ if (seen.has(ancestor)) fail("Native child ancestry contains a cycle", "native_topology_cycle", { child_session_id: child, parent_session_id: parent });
329
+ seen.add(ancestor);
330
+ }
331
+ this.descendants.set(child, { ...previous, ...facts, parent_turn_id: facts.parent_turn_id ?? previous?.parent_turn_id ?? null, provider_session_id: child, parent_provider_session_id: parent });
332
+ }
333
+
220
334
  // Collaboration child Threads are provider facts. Keep only objects that
221
335
  // explicitly carry a parent relationship, including native spawn receipts.
222
336
  // A wait/send receiver is not necessarily a child of its sender.
223
- observeTopology(value, depth = 0) {
337
+ observeTopology(value, depth = 0, parentTurn = null) {
224
338
  if (!value || typeof value !== "object" || depth > 8) return;
225
- if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
339
+ if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1, parentTurn); return; }
226
340
  if (depth === 0 && ["turn/started", "turn/completed"].includes(value.method)) {
227
341
  const { threadId, turn } = value.params ?? {};
228
- const previous = this.descendants.get(threadId);
342
+ const previous = this.descendants.get(threadId) ?? (threadId && !this.requestedProfiles.has(threadId) ? { provider_session_id: threadId, status: "unknown" } : null);
229
343
  if (previous && typeof turn?.id === "string" && turn.id) {
230
344
  if (value.method === "turn/started" && previous.active_turn_id !== turn.id && this.turns.get(turn.id)?.status !== "completed") {
231
345
  this.descendants.set(threadId, { ...previous, status: "running", active_turn_id: turn.id, terminal_turn_id: null, provider_error: null });
232
346
  } else if (value.method === "turn/completed" && (!previous.active_turn_id || previous.active_turn_id === turn.id)) {
233
347
  this.descendants.set(threadId, { ...previous, status: turn.status === "completed" ? "completed" : turn.status ?? "unknown",
234
- active_turn_id: turn.id, terminal_turn_id: turn.id, terminal_observed_at: previous.terminal_turn_id === turn.id ? previous.terminal_observed_at ?? now() : now(), provider_error: nativeTurnError(this, threadId, turn), native_turn_items: turnItemEvidence(turn) });
348
+ active_turn_id: turn.id, terminal_turn_id: turn.id, terminal_observed_at: previous.terminal_turn_id === turn.id ? previous.terminal_observed_at ?? now() : now(), provider_error: nativeTurnError(this, threadId, turn), native_turn_items: turnItemEvidence(turn, this, threadId) });
235
349
  }
236
350
  }
237
351
  }
238
- const activity = value.params?.item;
352
+ const activity = depth === 0 && ["item/started", "item/completed"].includes(value.method) ? value.params?.item : null;
239
353
  const activityParent = value.params?.threadId;
240
354
  if (activity?.type === "subAgentActivity" && typeof activity.agentThreadId === "string" && activity.agentThreadId &&
241
- typeof activityParent === "string" && activityParent && activity.agentThreadId !== activityParent) {
355
+ typeof activityParent === "string" && activityParent) {
242
356
  const previous = this.descendants.get(activity.agentThreadId);
243
357
  const observed = activity.kind === "completed" ? "completed" : activity.kind === "interrupted" ? "interrupted" : "running";
244
358
  // Parent activity has no child Turn identity on some app-server builds.
245
359
  // It can discover a child, but cannot reopen or close a later known Turn.
246
360
  const status = previous?.active_turn_id || previous?.status === "completed" ? previous.status : observed;
247
- this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId,
248
- parent_provider_session_id: activityParent, status, agent_path: activity.agentPath ?? previous?.agent_path ?? null,
249
- source: "codex.app_server.subAgentActivity" });
361
+ if (activity.kind === "started") this.bindChild(activity.agentThreadId, activityParent, { status, parent_turn_id: value.params?.turnId === this.currentTurns?.get(activityParent) ? value.params.turnId : null, agent_path: activity.agentPath ?? previous?.agent_path ?? null, source: "codex.app_server.subAgentActivity" });
362
+ else if (["completed", "interrupted"].includes(activity.kind) && activity.agentThreadId !== activityParent && !this.requestedProfiles.has(activity.agentThreadId)) {
363
+ this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId, status });
364
+ }
250
365
  }
251
366
  if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
252
367
  typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
253
368
  for (const child of value.receiverThreadIds) {
254
- if (typeof child !== "string" || !child || child === value.senderThreadId) continue;
369
+ if (typeof child !== "string" || !child) continue;
255
370
  const previous = this.descendants.get(child);
256
- this.descendants.set(child, { ...previous, provider_session_id: child, parent_provider_session_id: value.senderThreadId,
371
+ this.bindChild(child, value.senderThreadId, {
372
+ parent_turn_id: parentTurn === this.currentTurns?.get(value.senderThreadId) ? parentTurn : null,
257
373
  status: previous?.status === "completed" || previous?.active_turn_id ? previous.status : value.agentsStates?.[child]?.status ?? previous?.status ?? "unknown", source: "codex.app_server" });
258
374
  }
259
375
  }
260
- const id = value.threadId ?? value.id ?? null;
261
- const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
376
+ const thread = depth === 0 && ["thread/started", "thread/status/changed"].includes(value.method) ? value.params?.thread : null;
377
+ const id = thread?.id ?? null;
378
+ const parent = thread?.parentThreadId ?? thread?.parent_thread_id ?? null;
262
379
  if (typeof id === "string" && id && typeof parent === "string" && parent) {
263
380
  const previous = this.descendants.get(id);
264
- this.descendants.set(id, { ...previous, provider_session_id: id, parent_provider_session_id: parent,
265
- status: previous?.status === "completed" || previous?.active_turn_id ? previous.status : value.status?.type ?? value.status ?? previous?.status ?? "unknown", source: "codex.app_server" });
381
+ this.bindChild(id, parent, {
382
+ parent_turn_id: !previous ? this.currentTurns?.get(parent) ?? null : previous.parent_turn_id,
383
+ status: previous?.status === "completed" || previous?.active_turn_id ? previous.status : thread.status?.type ?? thread.status ?? previous?.status ?? "unknown", source: "codex.app_server" });
384
+ }
385
+ if (depth === 0) {
386
+ if (["item/started", "item/completed"].includes(value.method)) this.observeTopology(value.params?.item, depth + 1, value.params?.turnId);
387
+ const nativeThread = this.pending.get(value.id)?.method === "thread/read" ? value.result?.thread : null;
388
+ if (nativeThread && Array.isArray(nativeThread.turns)) for (const turn of nativeThread.turns) this.observeTopology(turn.items, depth + 1, turn.id);
266
389
  }
267
- for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
268
390
  }
269
391
 
270
392
  directChildren(sessionId) {
@@ -285,6 +407,15 @@ export class CodexBridge {
285
407
  }
286
408
 
287
409
  request(method, params = {}, timeoutMs = this.options.timeoutMs ?? 120_000) {
410
+ 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;
411
+ if (method === "turn/start" && params.threadId) {
412
+ // Only one productive root Turn is admitted by the daemon fence. Drop
413
+ // lifecycle replay keys at the next dispatch, not at arbitrary reads.
414
+ this.progressPhases?.clear();
415
+ this.hookPromptGenerations ??= new Map();
416
+ this.hookPromptGenerations.set(params.threadId, (this.hookPromptGenerations.get(params.threadId) ?? 0) + 1);
417
+ this.currentTurns?.delete(params.threadId);
418
+ }
288
419
  const id = this.nextId++;
289
420
  return new Promise((resolve, reject) => {
290
421
  const timer = observedTimeout(() => {
@@ -295,9 +426,15 @@ export class CodexBridge {
295
426
  pending.timer = observedTimeout(() => {
296
427
  if (this.pending.get(id) !== pending) return;
297
428
  this.pending.delete(id); reject(timeoutError);
298
- }, LATE_RESPONSE_GRACE_MS);
299
- }, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
300
- this.pending.set(id, { resolve, reject, timer, method, threadId: params.threadId }); this.send({ jsonrpc: "2.0", id, method, params });
429
+ }, LATE_RESPONSE_GRACE_MS, { progress: () => pending.progress });
430
+ }, timeoutMs, { progress: () => this.pending.get(id)?.progress, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
431
+ const publish = operationProgressReporter();
432
+ this.pending.set(id, { id, resolve, reject, timer, method, threadId: params.threadId, turnFloor });
433
+ if (method === "turn/start" && params.threadId) {
434
+ this.turnObservers ??= new Map(); this.turnObservers.set(params.threadId, { id, turnFloor, publish });
435
+ }
436
+ this.send({ jsonrpc: "2.0", id, method, params });
437
+ if (method === "turn/start") this.journal.write("native_dispatch", { operation_id: this.dispatchOperationId ?? process.env.DD_EVAL_OPERATION_ID ?? null, provider_session_id: params.threadId, native_request_id: id });
301
438
  });
302
439
  }
303
440
  send(value) {
@@ -309,19 +446,19 @@ export class CodexBridge {
309
446
  throw error;
310
447
  }
311
448
  try { stdin.write(`${JSON.stringify(value)}\n`); }
312
- catch (error) { this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })); throw error; }
449
+ catch (cause) { const error = nativeObservationError(cause); this.rejectAll(error); throw error; }
313
450
  }
314
- rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); }
451
+ rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); this.turnObservers?.clear(); }
315
452
  async close() {
316
- return await closeBridgeProcess(this, async () => { await this.modelObservations; await this.journal.flush(); });
453
+ return await closeBridgeProcess(this, async () => { await Promise.all([this.nativeDrain, this.diagnostics]); await this.modelObservations; await this.journal.flush(); });
317
454
  }
318
455
  }
319
456
 
320
457
  async function withBridge(options, action) {
321
- const bridge = new CodexBridge(options); let primary;
322
- try { await bridge.start(); return await action(bridge); }
458
+ const bridge = new CodexBridge(options); let primary, result;
459
+ try { await bridge.start(); result = await action(bridge); return result; }
323
460
  catch (error) { primary = error; throw error; }
324
- finally { try { await bridge.close(); } catch (error) { if (!primary) throw error; primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } }
461
+ finally { try { await bridge.close(); } catch (error) { if (!primary) throw nativeObservationError(error, result === undefined ? {} : { result }); primary.cleanup_error = errorRecord(error); primary.details = { ...primary.details, cleanup_error: primary.cleanup_error }; } }
325
462
  }
326
463
  function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
327
464
  function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
@@ -335,7 +472,7 @@ async function observedChildren(bridge, sessionId) {
335
472
  const turn = observed?.turn;
336
473
  if (turn?.id !== child.terminal_turn_id || turn.status !== child.status) return child;
337
474
  return { ...child, provider_error: nativeTurnError(bridge, child.provider_session_id, turn) ?? child.provider_error,
338
- native_turn_items: turnItemEvidence(turn) };
475
+ native_turn_items: turnItemEvidence(turn, bridge, child.provider_session_id) };
339
476
  }));
340
477
  }
341
478
  function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
@@ -413,6 +550,7 @@ export async function startSessionWithBridge(bridge, options) {
413
550
  bridge.finalMessages?.delete(sessionId);
414
551
  options.assertDispatch?.();
415
552
  bridge.assertHookHealthy?.(sessionId);
553
+ bridge.dispatchOperationId = options.operationId;
416
554
  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);
417
555
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
418
556
  if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
@@ -421,6 +559,7 @@ export async function startSessionWithBridge(bridge, options) {
421
559
 
422
560
  export async function promptSessionWithBridge(bridge, options) {
423
561
  const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
562
+ validateObservationDuration(options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000);
424
563
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
425
564
  // A final-message fallback belongs only to the Turn that produced it. A
426
565
  // new Turn may start before app-server emits its first item notification;
@@ -429,26 +568,44 @@ export async function promptSessionWithBridge(bridge, options) {
429
568
  bridge.finalMessages?.delete(sessionId);
430
569
  options.assertDispatch?.();
431
570
  bridge.assertHookHealthy?.(sessionId);
432
- 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 ?? 1_800_000);
571
+ bridge.dispatchOperationId = options.operationId;
572
+ const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId }) });
573
+ 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);
433
574
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
434
575
  let completed;
435
- try { completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd); }
576
+ try { completed = await waitForTurn(bridge, sessionId, turnId, options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, options, cwd, clock); }
436
577
  catch (error) {
578
+ const retained = bridge.turns?.get(turnId);
579
+ if (retained?.value?.threadId === sessionId && retained.value.turn?.id === turnId && retained.value.turn.status === "completed") {
580
+ throw Object.assign(new Error("Codex completed natively but mandatory observation failed"), { code: "native_outcome_observation_failed", retryable: false,
581
+ details: { native_outcome: { harness: "codex-desktop", provider_session_id: sessionId, turn_id: turnId, status: "completed", turn: retained.value.turn }, settled: false, observation_error: errorRecord(error) }, cause: error });
582
+ }
437
583
  if (error?.code === "turn_interrupted") {
438
584
  try {
439
585
  const observed = await readTurn(bridge, sessionId, turnId, true);
440
586
  if (observed?.turn?.id === turnId && observed.turn.status === error.details?.terminal_status) {
441
- error.details.native_turn_items = turnItemEvidence(observed.turn);
587
+ error.details.native_turn_items = turnItemEvidence(observed.turn, bridge, sessionId);
442
588
  error.details.provider_error ??= observed.turn.error ?? null;
443
589
  }
444
590
  } catch { /* The primary native terminal failure remains authoritative. */ }
591
+ try { await bridge.journal?.flush?.(); }
592
+ catch (observationError) {
593
+ throw Object.assign(new Error("Codex terminal failure lacks durable native item evidence"), { code: "native_outcome_observation_failed", retryable: false,
594
+ 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 });
595
+ }
445
596
  }
446
597
  throw error;
447
598
  }
448
- const thread = await readCompletedThread(bridge, sessionId);
449
- bridge.assertHookHealthy?.(sessionId);
450
- const assistantText = latestAssistantText(thread?.turns?.find(item => item?.id === turnId)) ?? latestAssistantText(completed);
451
- return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed, ...(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") };
599
+ const nativeOutcome = { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed };
600
+ try {
601
+ const thread = await readCompletedThread(bridge, sessionId);
602
+ bridge.assertHookHealthy?.(sessionId);
603
+ const assistantText = latestAssistantText(thread?.turns?.find(item => item?.id === turnId)) ?? latestAssistantText(completed);
604
+ 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") };
605
+ } catch (error) {
606
+ throw Object.assign(new Error("Codex completed natively but mandatory observation failed"), { code: "native_outcome_observation_failed", retryable: false,
607
+ details: { native_outcome: nativeOutcome, settled: false, observation_error: errorRecord(error) }, cause: error });
608
+ }
452
609
  }
453
610
 
454
611
  async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
@@ -464,17 +621,17 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
464
621
  }, 30_000);
465
622
  }
466
623
 
467
- async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
468
- const clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) });
624
+ 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 }) })) {
469
625
  // app-server can briefly expose the new Turn as `interrupted` between the
470
626
  // turn/start response and its turn/started notification. Give that snapshot
471
627
  // one event-loop grace window before treating failure as authoritative.
472
628
  const failureNotBefore = Date.now() + 1_000;
473
629
  let nextStoredReadAt = Date.now();
474
- while (!clock.sample(bridge.activity?.get(sessionId))) {
630
+ while (true) {
475
631
  bridge.assertHookHealthy?.(sessionId);
476
632
  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);
477
- if (Date.now() >= nextStoredReadAt) {
633
+ const expired = clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId));
634
+ if (expired || Date.now() >= nextStoredReadAt) {
478
635
  const stored = await readTurn(bridge, sessionId, turnId, false);
479
636
  if (stored?.turn && isAuthoritativeTerminal(bridge, turnId, stored.turn.status, stored.threadStatus, failureNotBefore)) return terminalTurn({ turn: stored.turn }, sessionId, turnId, bridge);
480
637
  // A loaded app-server can retain a stale `active` Thread state after a
@@ -516,9 +673,10 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
516
673
  }
517
674
  nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
518
675
  }
676
+ const terminal = bridge.turns.get(turnId); if (terminal?.status === "completed" && (!terminal.threadId || terminal.threadId === sessionId)) return terminalTurn(terminal.value, sessionId, turnId, bridge);
677
+ 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" });
519
678
  await new Promise((resolve) => setTimeout(resolve, 250));
520
679
  }
521
- fail("Codex turn timed out", "turn_timeout");
522
680
  }
523
681
 
524
682
  function terminalTurn(value, sessionId, turnId, bridge) {
@@ -529,7 +687,7 @@ function terminalTurn(value, sessionId, turnId, bridge) {
529
687
  if (turn?.id && turn.id !== turnId || value?.threadId && value.threadId !== sessionId) fail("Codex terminal Turn identity does not match the requested Session/Turn", "turn_identity_mismatch", { provider_session_id: sessionId, turn_id: turnId, native_thread_id: value?.threadId ?? null, native_turn_id: turn?.id ?? null });
530
688
  const providerError = nativeTurnError(bridge, sessionId, turn);
531
689
  if (turn?.status && turn.status !== "completed") fail(`Codex turn ${turnId ?? "unknown"} ended as ${turn.status}`, "turn_interrupted", { provider_session_id: sessionId, turn_id: turnId, native_turn_id: turn.id ?? null, terminal_status: turn.status, observed_at: now(),
532
- provider_error: providerError, native_turn_items: turnItemEvidence(turn) });
690
+ provider_error: providerError, native_turn_items: turnItemEvidence(turn, bridge, sessionId) });
533
691
  return value;
534
692
  }
535
693
 
@@ -540,12 +698,25 @@ function nativeTurnError(bridge, sessionId, turn) {
540
698
  return error && native?.willRetry !== undefined && typeof error === "object" ? { ...error, willRetry: native.willRetry } : error;
541
699
  }
542
700
 
543
- function turnItemEvidence(turn) {
701
+ export function turnItemEvidence(turn, bridge, sessionId) {
544
702
  const items = turn?.items;
703
+ const pending = item => {
704
+ const kind = classifyCodexItem(item?.type);
705
+ if (kind === "non_tool") return false;
706
+ if (kind === "unknown") return true;
707
+ const phase = bridge?.itemPhases?.get(JSON.stringify([sessionId, turn?.id, item?.id]));
708
+ if (phase && (phase.type !== item?.type || phase.status !== null && item?.status !== undefined && phase.status !== item.status)) return true;
709
+ if (phase && ![null, "completed", "failed", "declined", "interrupted"].includes(phase.status)) return true;
710
+ return !["completed", "failed", "declined", "interrupted"].includes(item?.status) && !(phase && phase.type === item?.type && (item?.status === undefined || item?.status === null));
711
+ };
545
712
  return Array.isArray(items) && turn?.itemsView !== "notLoaded" ? { observed: true, count: items.length, types: items.map(item => item?.type ?? "unknown"),
546
- items: items.map(item => ({ id: item?.id ?? null, type: item?.type ?? "unknown", status: item?.status ?? null })),
547
- pending: items.some(item => !["agentMessage", "reasoning", "userMessage"].includes(item?.type) && !["completed", "failed", "declined"].includes(item?.status)),
548
- possible_effects: items.some(item => !["agentMessage", "reasoning", "userMessage"].includes(item?.type)) } : { observed: false };
713
+ items: items.map(item => {
714
+ const phase = bridge?.itemPhases?.get(JSON.stringify([sessionId, turn?.id, item?.id]));
715
+ return { id: item?.id ?? null, type: item?.type ?? "unknown", status: item?.status ?? null,
716
+ ...(phase && !pending(item) ? { completion_phase: { session_id: sessionId, turn_id: turn.id, item_id: item.id, status: phase.status, source: "codex.item/completed" } } : {}) };
717
+ }),
718
+ pending: items.some(pending),
719
+ possible_effects: items.some(item => classifyCodexItem(item?.type) !== "non_tool") } : { observed: false };
549
720
  }
550
721
 
551
722
  export { terminalCodexOverload } from "./codex-capacity-policy.mjs";
@@ -575,11 +746,12 @@ async function readPersistedTurn(bridge, sessionId, turnId) {
575
746
  }
576
747
 
577
748
  async function readCompletedThread(bridge, sessionId) {
749
+ let lastError;
578
750
  for (let attempt = 0; attempt < 8; attempt += 1) {
579
751
  try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
580
- catch (error) { bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
752
+ 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)); }
581
753
  }
582
- return null;
754
+ throw lastError;
583
755
  }
584
756
 
585
757
  function latestAssistantText(value) {
@@ -604,7 +776,7 @@ export async function inspectSession(options) {
604
776
  export async function inspectSessionWithBridge(bridge, options) {
605
777
  const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
606
778
  const terminal = options.turnId ? (await readTurn(bridge, sessionId, options.turnId, true))?.turn : null;
607
- 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) } : {};
779
+ 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) } : {};
608
780
  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") };
609
781
  }
610
782