@deksden-com/dd-flow-cli 0.9.0-beta.123 → 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 (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -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/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -1,10 +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
- import { stopProcessGroup } from "./managed-daemon.mjs";
6
- import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
6
+ import { closeBridgeProcess } from "./managed-daemon.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";
7
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";
8
14
 
9
15
  const LATE_RESPONSE_GRACE_MS = 5_000;
10
16
  const CODEX_SESSION_CONFIG = Object.freeze({
@@ -34,43 +40,51 @@ export function parseCodexCliVersion(value) {
34
40
  return match[1];
35
41
  }
36
42
 
37
- function commandText(bin, args) {
38
- return new Promise((resolve, reject) => {
39
- const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
40
- let stdout = ""; let stderr = "";
41
- child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
42
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
43
- child.on("error", reject);
44
- child.on("exit", (code) => code === 0 ? resolve(stdout) : reject(Object.assign(new Error(`Codex CLI version failed: ${stderr.trim() || code}`), { code: "codex_version_unavailable" })));
45
- });
46
- }
43
+
47
44
 
48
45
  class Journal {
49
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; }
50
- 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; }
51
48
  flush() { return this.pending; }
52
49
  }
53
50
 
54
51
  export class CodexBridge {
55
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(); }
56
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
+
57
66
  async start() {
67
+ await this.restoreItemPhases();
58
68
  const bin = this.options.bin ?? process.env.DD_CODEX_BIN ?? "codex";
59
- 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"] });
60
- this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.stderr += String(chunk); this.journal.write("stderr", { text: String(chunk) }); });
61
- 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)));
62
74
  this.child.on("error", (error) => this.rejectAll(error));
63
- 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" })));
64
- 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));
65
77
  if (this.options.onSpawn) await this.options.onSpawn(this.child);
66
78
  await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
67
79
  }
68
80
 
69
81
  receive(line) {
70
82
  let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
71
- this.observeTopology(message);
72
- this.observeNativeHook(message);
83
+ this.observeItemPhase(message);
84
+ try { this.observeTopology(message); }
85
+ catch (error) { this.topologyError = error; this.rejectAll(error); return; }
73
86
  this.observeHookFailure(message);
87
+ this.observeNativeHook(message);
74
88
  if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
75
89
  const params = message.params ?? {}, id = params.threadId;
76
90
  let root = id; const seen = new Set();
@@ -82,16 +96,57 @@ export class CodexBridge {
82
96
  parentSessionId: this.descendants.get(id)?.parent_provider_session_id ?? null, requested: this.requestedProfiles.get(root),
83
97
  observed: { model: rerouted ? params.toModel : settings.model, provider: settings.modelProvider, reasoning: settings.effort },
84
98
  source: message.method, evidence: rerouted ? "response" : "configured", reason: params.reason ?? null, previousModel: params.fromModel ?? null
85
- })).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);
86
110
  }
87
111
  }
88
- 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)) {
89
128
  this.activity ??= new Map();
129
+ this.activityObservations ??= new Map();
90
130
  const visited = new Set();
91
131
  let id = message.params?.threadId;
92
132
  while (id && !visited.has(id)) {
93
- visited.add(id); this.activity.set(id, (this.activity.get(id) ?? 0) + 1);
94
- 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;
95
150
  }
96
151
  }
97
152
  // app-server currently returns complete turns even for `includeTurns:
@@ -116,6 +171,11 @@ export class CodexBridge {
116
171
  return;
117
172
  }
118
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
+ }
119
179
  if (pending.timedOut) this.journal.write("late_response", { ...responseSummary(message), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
120
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 ?? {});
121
181
  return;
@@ -125,7 +185,10 @@ export class CodexBridge {
125
185
  this.nativeErrors.set(key, { error: message.params.error, willRetry: message.params.willRetry });
126
186
  while (this.nativeErrors.size > 128) this.nativeErrors.delete(this.nativeErrors.keys().next().value);
127
187
  }
128
- 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
+ }
129
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 });
130
193
  const threadId = message.params?.threadId;
131
194
  if (threadId && message.method === "item/started") this.finalMessages.delete(threadId);
@@ -140,6 +203,14 @@ export class CodexBridge {
140
203
  }
141
204
 
142
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
+ }
143
214
  if (message.method === "turn/completed") {
144
215
  const { threadId, turn } = message.params ?? {};
145
216
  for (const [id, hook] of this.activeHooks ?? []) {
@@ -151,8 +222,12 @@ export class CodexBridge {
151
222
  const { threadId, turnId, run } = message.params ?? {};
152
223
  if (!this.isManagedHook(run) || typeof run.id !== "string" || !threadId) return;
153
224
  this.activeHooks ??= new Map();
154
- if (message.method === "hook/started") this.activeHooks.set(run.id, { threadId, turnId });
155
- 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);
156
231
  }
157
232
 
158
233
  verifyNativeHook(input) {
@@ -166,7 +241,8 @@ export class CodexBridge {
166
241
  seen.add(current); current = this.descendants.get(current)?.parent_provider_session_id;
167
242
  }
168
243
  if (!active || active.threadId !== sessionId || active.turnId !== turnId || !toolCallId
169
- || current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)) {
244
+ || current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)
245
+ || active.rootGeneration !== (this.hookPromptGenerations?.get(rootSessionId) ?? 0)) {
170
246
  fail("No matching active managed native hook", "native_hook_unproven", { provider_session_id: sessionId, tool_call_id: toolCallId });
171
247
  }
172
248
  return { rootSessionId, sessionId, toolCallId, parentSessionId: sessionId === rootSessionId ? null : this.descendants.get(sessionId)?.parent_provider_session_id };
@@ -179,6 +255,11 @@ export class CodexBridge {
179
255
  run.eventName !== "preToolUse" || run.handlerType !== "command" ||
180
256
  run.statusMessage !== "dd-flow runtime observation" || !home ||
181
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;
182
263
  const ancestors = new Set();
183
264
  let root;
184
265
  let current = threadId;
@@ -192,78 +273,120 @@ export class CodexBridge {
192
273
  if (this.hookFailures.has(root)) return;
193
274
  const entries = Array.isArray(run.entries) ? run.entries : [];
194
275
  const reason = entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("; ");
195
- const error = Object.assign(new Error(reason || "Managed Codex hook failed"), {
196
- code: /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed",
197
- details: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
198
- hook_run_id: run.id, effect: "unknown", duration_ms: run.durationMs }
199
- });
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
+ }
200
286
  this.hookFailures.set(root, error);
201
- this.journal.write("native_hook_failure", { code: error.code, message: error.message, ...error.details });
202
287
  for (const [id, pending] of this.pending) {
203
288
  if (ancestors.has(pending.threadId) && pending.method !== "turn/interrupt") {
204
289
  clearTimeout(pending.timer); this.pending.delete(id); pending.reject(error);
205
290
  }
206
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); }
207
294
  }
208
295
 
209
296
  assertHookHealthy(sessionId) {
297
+ if (this.topologyError) throw this.topologyError;
210
298
  const visited = new Set();
211
299
  while (sessionId && !visited.has(sessionId)) {
212
300
  visited.add(sessionId);
301
+ if (this.turnObservers?.get(sessionId)?.progressError) throw this.turnObservers.get(sessionId).progressError;
213
302
  const error = this.hookFailures?.get(sessionId);
214
303
  if (error) throw error;
215
304
  sessionId = this.descendants.get(sessionId)?.parent_provider_session_id;
216
305
  }
217
306
  }
218
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
+
219
334
  // Collaboration child Threads are provider facts. Keep only objects that
220
335
  // explicitly carry a parent relationship, including native spawn receipts.
221
336
  // A wait/send receiver is not necessarily a child of its sender.
222
- observeTopology(value, depth = 0) {
337
+ observeTopology(value, depth = 0, parentTurn = null) {
223
338
  if (!value || typeof value !== "object" || depth > 8) return;
224
- 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; }
225
340
  if (depth === 0 && ["turn/started", "turn/completed"].includes(value.method)) {
226
341
  const { threadId, turn } = value.params ?? {};
227
- 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);
228
343
  if (previous && typeof turn?.id === "string" && turn.id) {
229
344
  if (value.method === "turn/started" && previous.active_turn_id !== turn.id && this.turns.get(turn.id)?.status !== "completed") {
230
345
  this.descendants.set(threadId, { ...previous, status: "running", active_turn_id: turn.id, terminal_turn_id: null, provider_error: null });
231
346
  } else if (value.method === "turn/completed" && (!previous.active_turn_id || previous.active_turn_id === turn.id)) {
232
347
  this.descendants.set(threadId, { ...previous, status: turn.status === "completed" ? "completed" : turn.status ?? "unknown",
233
- active_turn_id: turn.id, terminal_turn_id: turn.id, terminal_observed_at: 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) });
234
349
  }
235
350
  }
236
351
  }
237
- const activity = value.params?.item;
352
+ const activity = depth === 0 && ["item/started", "item/completed"].includes(value.method) ? value.params?.item : null;
238
353
  const activityParent = value.params?.threadId;
239
354
  if (activity?.type === "subAgentActivity" && typeof activity.agentThreadId === "string" && activity.agentThreadId &&
240
- typeof activityParent === "string" && activityParent && activity.agentThreadId !== activityParent) {
355
+ typeof activityParent === "string" && activityParent) {
241
356
  const previous = this.descendants.get(activity.agentThreadId);
242
357
  const observed = activity.kind === "completed" ? "completed" : activity.kind === "interrupted" ? "interrupted" : "running";
243
358
  // Parent activity has no child Turn identity on some app-server builds.
244
359
  // It can discover a child, but cannot reopen or close a later known Turn.
245
360
  const status = previous?.active_turn_id || previous?.status === "completed" ? previous.status : observed;
246
- this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId,
247
- parent_provider_session_id: activityParent, status, agent_path: activity.agentPath ?? previous?.agent_path ?? null,
248
- 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
+ }
249
365
  }
250
366
  if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
251
367
  typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
252
368
  for (const child of value.receiverThreadIds) {
253
- if (typeof child !== "string" || !child || child === value.senderThreadId) continue;
369
+ if (typeof child !== "string" || !child) continue;
254
370
  const previous = this.descendants.get(child);
255
- 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,
256
373
  status: previous?.status === "completed" || previous?.active_turn_id ? previous.status : value.agentsStates?.[child]?.status ?? previous?.status ?? "unknown", source: "codex.app_server" });
257
374
  }
258
375
  }
259
- const id = value.threadId ?? value.id ?? null;
260
- 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;
261
379
  if (typeof id === "string" && id && typeof parent === "string" && parent) {
262
380
  const previous = this.descendants.get(id);
263
- this.descendants.set(id, { ...previous, provider_session_id: id, parent_provider_session_id: parent,
264
- 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);
265
389
  }
266
- for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
267
390
  }
268
391
 
269
392
  directChildren(sessionId) {
@@ -284,6 +407,15 @@ export class CodexBridge {
284
407
  }
285
408
 
286
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
+ }
287
419
  const id = this.nextId++;
288
420
  return new Promise((resolve, reject) => {
289
421
  const timer = observedTimeout(() => {
@@ -294,9 +426,15 @@ export class CodexBridge {
294
426
  pending.timer = observedTimeout(() => {
295
427
  if (this.pending.get(id) !== pending) return;
296
428
  this.pending.delete(id); reject(timeoutError);
297
- }, LATE_RESPONSE_GRACE_MS);
298
- }, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
299
- 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 });
300
438
  });
301
439
  }
302
440
  send(value) {
@@ -308,20 +446,20 @@ export class CodexBridge {
308
446
  throw error;
309
447
  }
310
448
  try { stdin.write(`${JSON.stringify(value)}\n`); }
311
- 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; }
312
450
  }
313
- 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(); }
314
452
  async close() {
315
- if (!this.child) return;
316
- this.child.stdin.end();
317
- await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2_000))]);
318
- await stopProcessGroup(this.child, 2_000);
319
- await this.modelObservations;
320
- await this.journal.flush();
453
+ return await closeBridgeProcess(this, async () => { await Promise.all([this.nativeDrain, this.diagnostics]); await this.modelObservations; await this.journal.flush(); });
321
454
  }
322
455
  }
323
456
 
324
- async function withBridge(options, action) { const bridge = new CodexBridge(options); try { await bridge.start(); return await action(bridge); } finally { await bridge.close(); } }
457
+ async function withBridge(options, action) {
458
+ const bridge = new CodexBridge(options); let primary, result;
459
+ try { await bridge.start(); result = await action(bridge); return result; }
460
+ catch (error) { primary = error; throw 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 }; } }
462
+ }
325
463
  function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
326
464
  function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
327
465
  function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
@@ -334,7 +472,7 @@ async function observedChildren(bridge, sessionId) {
334
472
  const turn = observed?.turn;
335
473
  if (turn?.id !== child.terminal_turn_id || turn.status !== child.status) return child;
336
474
  return { ...child, provider_error: nativeTurnError(bridge, child.provider_session_id, turn) ?? child.provider_error,
337
- native_turn_items: turnItemEvidence(turn) };
475
+ native_turn_items: turnItemEvidence(turn, bridge, child.provider_session_id) };
338
476
  }));
339
477
  }
340
478
  function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
@@ -412,6 +550,7 @@ export async function startSessionWithBridge(bridge, options) {
412
550
  bridge.finalMessages?.delete(sessionId);
413
551
  options.assertDispatch?.();
414
552
  bridge.assertHookHealthy?.(sessionId);
553
+ bridge.dispatchOperationId = options.operationId;
415
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);
416
555
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
417
556
  if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
@@ -420,6 +559,7 @@ export async function startSessionWithBridge(bridge, options) {
420
559
 
421
560
  export async function promptSessionWithBridge(bridge, options) {
422
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);
423
563
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
424
564
  // A final-message fallback belongs only to the Turn that produced it. A
425
565
  // new Turn may start before app-server emits its first item notification;
@@ -428,26 +568,44 @@ export async function promptSessionWithBridge(bridge, options) {
428
568
  bridge.finalMessages?.delete(sessionId);
429
569
  options.assertDispatch?.();
430
570
  bridge.assertHookHealthy?.(sessionId);
431
- 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);
432
574
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
433
575
  let completed;
434
- 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); }
435
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
+ }
436
583
  if (error?.code === "turn_interrupted") {
437
584
  try {
438
585
  const observed = await readTurn(bridge, sessionId, turnId, true);
439
586
  if (observed?.turn?.id === turnId && observed.turn.status === error.details?.terminal_status) {
440
- error.details.native_turn_items = turnItemEvidence(observed.turn);
587
+ error.details.native_turn_items = turnItemEvidence(observed.turn, bridge, sessionId);
441
588
  error.details.provider_error ??= observed.turn.error ?? null;
442
589
  }
443
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
+ }
444
596
  }
445
597
  throw error;
446
598
  }
447
- const thread = await readCompletedThread(bridge, sessionId);
448
- bridge.assertHookHealthy?.(sessionId);
449
- const assistantText = latestAssistantText(thread?.turns?.find(item => item?.id === turnId)) ?? latestAssistantText(completed);
450
- 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
+ }
451
609
  }
452
610
 
453
611
  async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
@@ -463,17 +621,17 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
463
621
  }, 30_000);
464
622
  }
465
623
 
466
- async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
467
- 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 }) })) {
468
625
  // app-server can briefly expose the new Turn as `interrupted` between the
469
626
  // turn/start response and its turn/started notification. Give that snapshot
470
627
  // one event-loop grace window before treating failure as authoritative.
471
628
  const failureNotBefore = Date.now() + 1_000;
472
629
  let nextStoredReadAt = Date.now();
473
- while (!clock.sample(bridge.activity?.get(sessionId))) {
630
+ while (true) {
474
631
  bridge.assertHookHealthy?.(sessionId);
475
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);
476
- if (Date.now() >= nextStoredReadAt) {
633
+ const expired = clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId));
634
+ if (expired || Date.now() >= nextStoredReadAt) {
477
635
  const stored = await readTurn(bridge, sessionId, turnId, false);
478
636
  if (stored?.turn && isAuthoritativeTerminal(bridge, turnId, stored.turn.status, stored.threadStatus, failureNotBefore)) return terminalTurn({ turn: stored.turn }, sessionId, turnId, bridge);
479
637
  // A loaded app-server can retain a stale `active` Thread state after a
@@ -515,9 +673,10 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
515
673
  }
516
674
  nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
517
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" });
518
678
  await new Promise((resolve) => setTimeout(resolve, 250));
519
679
  }
520
- fail("Codex turn timed out", "turn_timeout");
521
680
  }
522
681
 
523
682
  function terminalTurn(value, sessionId, turnId, bridge) {
@@ -528,7 +687,7 @@ function terminalTurn(value, sessionId, turnId, bridge) {
528
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 });
529
688
  const providerError = nativeTurnError(bridge, sessionId, turn);
530
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(),
531
- provider_error: providerError, native_turn_items: turnItemEvidence(turn) });
690
+ provider_error: providerError, native_turn_items: turnItemEvidence(turn, bridge, sessionId) });
532
691
  return value;
533
692
  }
534
693
 
@@ -539,30 +698,28 @@ function nativeTurnError(bridge, sessionId, turn) {
539
698
  return error && native?.willRetry !== undefined && typeof error === "object" ? { ...error, willRetry: native.willRetry } : error;
540
699
  }
541
700
 
542
- function turnItemEvidence(turn) {
701
+ export function turnItemEvidence(turn, bridge, sessionId) {
543
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
+ };
544
712
  return Array.isArray(items) && turn?.itemsView !== "notLoaded" ? { observed: true, count: items.length, types: items.map(item => item?.type ?? "unknown"),
545
- items: items.map(item => ({ id: item?.id ?? null, type: item?.type ?? "unknown", status: item?.status ?? null })),
546
- pending: items.some(item => !["agentMessage", "reasoning", "userMessage"].includes(item?.type) && !["completed", "failed", "declined"].includes(item?.status)),
547
- 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 };
548
720
  }
549
721
 
550
- export function terminalCodexOverload(error, expectedSessionId, expectedTurnId) {
551
- let current = error;
552
- for (let depth = 0; depth < 4 && current && typeof current === "object"; depth += 1) {
553
- if (current.code === "turn_interrupted") {
554
- const details = current.details ?? {}, native = details.provider_error;
555
- const code = native?.codexErrorInfo;
556
- return details.terminal_status === "failed" && typeof details.provider_session_id === "string" && details.provider_session_id === expectedSessionId
557
- && typeof details.turn_id === "string" && Boolean(details.turn_id) && details.native_turn_id === details.turn_id
558
- && (expectedTurnId === undefined || details.turn_id === expectedTurnId)
559
- && (code === "serverOverloaded" || code?.code === "serverOverloaded");
560
- }
561
- if (current.code !== "harness_adapter_failed") return false;
562
- current = current.details?.cause ?? current.cause;
563
- }
564
- return false;
565
- }
722
+ export { terminalCodexOverload } from "./codex-capacity-policy.mjs";
566
723
 
567
724
  function isTerminalTurnStatus(status) { return ["completed", "failed", "interrupted", "cancelled"].includes(status); }
568
725
  function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNotBefore) {
@@ -589,11 +746,12 @@ async function readPersistedTurn(bridge, sessionId, turnId) {
589
746
  }
590
747
 
591
748
  async function readCompletedThread(bridge, sessionId) {
749
+ let lastError;
592
750
  for (let attempt = 0; attempt < 8; attempt += 1) {
593
751
  try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
594
- 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)); }
595
753
  }
596
- return null;
754
+ throw lastError;
597
755
  }
598
756
 
599
757
  function latestAssistantText(value) {
@@ -618,7 +776,7 @@ export async function inspectSession(options) {
618
776
  export async function inspectSessionWithBridge(bridge, options) {
619
777
  const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
620
778
  const terminal = options.turnId ? (await readTurn(bridge, sessionId, options.turnId, true))?.turn : null;
621
- 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) } : {};
622
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") };
623
781
  }
624
782