@deksden-com/dd-flow-cli 0.9.0-beta.141 → 0.9.0-beta.143

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.
@@ -4,7 +4,7 @@ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import readline from "node:readline";
6
6
  import { closeBridgeProcess } from "./managed-daemon.mjs";
7
- import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
7
+ import { errorRecord, isObservationLoss, isProvenShellCorrection } from "./operation-errors.mjs";
8
8
  import { reportedFailure, commandText } from "./process-json.mjs";
9
9
  import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
10
10
  import { observeModel } from "./model-observations.mjs";
@@ -12,9 +12,105 @@ import { classifyCodexItem } from "./tool-observations.mjs";
12
12
  import { readNativeLines, streamNativeDiagnostics, nativeObservationError } from "./native-streams.mjs";
13
13
  import { operationProgressReporter } from "./operation-progress.mjs";
14
14
  import { publishSetup } from './setup-progress.mjs';
15
+ import { capacityBackoff } from './provider-overload-policy.mjs';
15
16
 
16
17
  export const CODEX_REQUEST_TIMEOUT_MS = 120_000;
17
18
  export const LATE_RESPONSE_GRACE_MS = 5_000;
19
+ export function codexReadPhase(bridge, options = {}) {
20
+ const timeoutMs = Math.min(CODEX_REQUEST_TIMEOUT_MS, options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS);
21
+ return { clock: new ObservationClock({ timeoutMs, ...options.observationClock,
22
+ onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "metadata_read" }) }),
23
+ random: options.readRandom ?? Math.random,
24
+ sleep: options.readSleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms))) };
25
+ }
26
+
27
+ function dispatchBudget(phase, method, limitMs) {
28
+ const clock = phase.outerClock ?? phase.clock;
29
+ if (clock.sample(phase.progress?.())) throw Object.assign(new Error("Codex owner observation window expired before dispatch"), {
30
+ code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout",
31
+ details: { method, phase: "before_dispatch", effect: "no_effect", observation: clock.state() }
32
+ });
33
+ const remaining = Math.max(1, Math.ceil(clock.timeoutMs - clock.elapsed));
34
+ const timeoutMs = Math.min(limitMs, remaining);
35
+ return [timeoutMs, { graceMs: Math.min(LATE_RESPONSE_GRACE_MS, remaining - timeoutMs) }];
36
+ }
37
+
38
+ export async function readCodexRpc(bridge, method, params, phase = codexReadPhase(bridge)) {
39
+ if (!["thread/read", "thread/turns/list"].includes(method)) throw new Error("Codex read policy only accepts read-only RPC");
40
+ const sample = () => phase.clock.sample(phase.progress?.());
41
+ let primary;
42
+ for (let attempt = 0; attempt < 3; attempt++) {
43
+ if (sample()) throw primary ?? Object.assign(new Error("Codex metadata observation window expired"), { code: phase.clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, phase: "metadata_read", observation: phase.clock.state() } });
44
+ const remaining = Math.min(CODEX_REQUEST_TIMEOUT_MS, Math.max(1, Math.ceil(phase.clock.timeoutMs - phase.clock.elapsed)));
45
+ try {
46
+ const result = await bridge.request(method, params, remaining, { graceMs: 0, observe: () => { sample(); phase.outerClock?.sample(); } });
47
+ if (!result || typeof result !== "object" || Array.isArray(result)
48
+ || method === "thread/read" && (!result.thread || typeof result.thread !== "object" || Array.isArray(result.thread) || typeof result.thread.id !== "string" || !result.thread.id)
49
+ || method === "thread/turns/list" && !Array.isArray(result.data))
50
+ throw Object.assign(new Error("Codex read returned invalid data"), { code: "rpc_schema_invalid", details: { method } });
51
+ if (method === "thread/read" && result.thread.id !== params.threadId)
52
+ throw Object.assign(new Error("Codex read returned a foreign Thread"), { code: "thread_identity_mismatch" });
53
+ return result;
54
+ }
55
+ catch (error) {
56
+ primary ??= error; bridge.assertHookHealthy?.(params.threadId);
57
+ // A broken process cannot be replaced under the guise of a read retry.
58
+ const current = bridge.currentTurns?.get(params.threadId);
59
+ const materializing = method === "thread/read" && current && !bridge.turns?.has(current)
60
+ && bridge.nativeHookTurns?.get(params.threadId) !== current && error?.code === "rpc_error" && error.details?.code === -32603;
61
+ const emptyMetadata = materializing && error.details.message?.match(/^failed to read thread: thread-store internal error: failed to read session metadata (.+): rollout at (.+) is empty$/);
62
+ const transient = error?.code === "rpc_timeout" || error?.code === "EAGAIN" || error?.code === "EINTR"
63
+ || Boolean(emptyMetadata && emptyMetadata[1] === emptyMetadata[2] && emptyMetadata[1].endsWith(`-${params.threadId}.jsonl`));
64
+ if (!transient) { if (error !== primary) error.previous_read_error = errorRecord(primary); throw error; }
65
+ if (attempt === 2 || sample()) { if (error !== primary) primary.secondary_error = errorRecord(error); throw primary; }
66
+ const delay = Math.min(capacityBackoff(error, attempt + 1, phase.clock.wall(), phase.random), Math.max(0, phase.clock.timeoutMs - phase.clock.elapsed));
67
+ bridge.journal?.write("thread_read_retry", { method, session_id: params.threadId, attempt: attempt + 1, delay_ms: delay, error: errorRecord(error) });
68
+ await phase.sleep(delay);
69
+ }
70
+ }
71
+ }
72
+
73
+ // Availability belongs to this read of this Thread, not to the whole binary.
74
+ export async function readCodexHistory(bridge, method, params, phase = codexReadPhase(bridge)) {
75
+ if (method !== "thread/turns/list" && !(method === "thread/read" && params.includeTurns === true))
76
+ throw new Error("Codex history policy only accepts history reads");
77
+ let result, error;
78
+ try {
79
+ result = await readCodexRpc(bridge, method, params, phase);
80
+ if (method === "thread/read" && result.thread.turns !== undefined && !Array.isArray(result.thread.turns))
81
+ fail("Codex history read returned invalid turns", "rpc_schema_invalid", { method });
82
+ }
83
+ catch (cause) {
84
+ bridge.assertHookHealthy?.(params.threadId);
85
+ const native = cause?.details;
86
+ const operation = method === "thread/read" ? "includeTurns" : "thread/turns/list";
87
+ const unsupported = native?.code === -32601 && native.message === "list_turns is not supported yet";
88
+ const unmaterialized = native?.code === -32600 && native.message ===
89
+ `thread ${params.threadId} is not materialized yet; ${operation} is unavailable before first user message`;
90
+ if (cause?.code !== "rpc_error" || !unsupported && !unmaterialized) throw cause;
91
+ error = errorRecord(cause);
92
+ }
93
+ const available = !error;
94
+ phase.history ??= new Map();
95
+ const key = `${params.threadId}:${method}`;
96
+ if (phase.history.get(key)?.available !== available) bridge.journal?.write("history_availability", {
97
+ method, session_id: params.threadId, available, ...(error ? { error } : {})
98
+ });
99
+ phase.history.set(key, { available, error });
100
+ return { available, result, error };
101
+ }
102
+
103
+ export function retainedCodexTurn(bridge, sessionId, turnId = bridge.currentTurns?.get(sessionId)) {
104
+ if (!turnId) return null;
105
+ const retained = bridge.turns?.get(turnId);
106
+ if (!retained) return null;
107
+ if (retained.threadId && retained.threadId !== sessionId || retained.value?.threadId && retained.value.threadId !== sessionId)
108
+ fail("Codex retained Thread identity differs", "turn_identity_mismatch");
109
+ if (!retained.threadId && !retained.value?.threadId) return null;
110
+ const turn = retained.value?.turn;
111
+ if (turn?.id !== turnId) fail("Codex retained Turn identity differs", "turn_identity_mismatch");
112
+ return turn;
113
+ }
18
114
  const CODEX_SESSION_CONFIG = Object.freeze({
19
115
  bypass_hook_trust: true,
20
116
  "features.plugins": false,
@@ -158,7 +254,7 @@ export class CodexBridge {
158
254
  // retain a compact read receipt instead of writing the same transcript on
159
255
  // every status poll.
160
256
  const request = message.id !== undefined ? this.pending.get(message.id) : null;
161
- if (request?.method === "thread/read") {
257
+ if (request?.method === "thread/read" && !message.error) {
162
258
  const thread = message.result?.thread ?? message.result ?? {};
163
259
  this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
164
260
  id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
@@ -410,7 +506,7 @@ export class CodexBridge {
410
506
  this.send({ jsonrpc: "2.0", id: message.id, error });
411
507
  }
412
508
 
413
- request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS) {
509
+ request(method, params = {}, timeoutMs = this.options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS, policy = {}) {
414
510
  const turnFloor = method === "turn/start" ? new Set([this.currentTurns?.get(params.threadId), ...[...this.turns].filter(([, value]) => value.threadId === params.threadId).map(([id]) => id)].filter(Boolean)) : null;
415
511
  if (method === "turn/start" && params.threadId) {
416
512
  // Only one productive root Turn is admitted by the daemon fence. Drop
@@ -422,16 +518,18 @@ export class CodexBridge {
422
518
  }
423
519
  const id = this.nextId++;
424
520
  return new Promise((resolve, reject) => {
425
- const timer = observedTimeout(() => {
521
+ const timer = observedTimeout(clock => {
426
522
  const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
427
523
  pending.timedOut = true;
428
- const timeoutError = Object.assign(new Error(`${method} timed out`), { code: "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: LATE_RESPONSE_GRACE_MS } });
524
+ const graceMs = policy.graceMs ?? LATE_RESPONSE_GRACE_MS;
525
+ const timeoutError = Object.assign(new Error(`${method} timed out`), { code: clock.observationLost ? "operation_observation_lost" : "rpc_timeout", details: { method, request_id: id, timeout_ms: timeoutMs, grace_ms: graceMs, observation: clock.state() } });
429
526
  this.journal.write("request_deadline", timeoutError.details);
527
+ if (!graceMs) { this.pending.delete(id); reject(timeoutError); return; }
430
528
  pending.timer = observedTimeout(() => {
431
529
  if (this.pending.get(id) !== pending) return;
432
530
  this.pending.delete(id); reject(timeoutError);
433
- }, LATE_RESPONSE_GRACE_MS, { progress: () => pending.progress });
434
- }, timeoutMs, { progress: () => this.pending.get(id)?.progress, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
531
+ }, graceMs, { progress: () => pending.progress });
532
+ }, timeoutMs, { progress: () => { policy.observe?.(); return this.pending.get(id)?.progress; }, onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
435
533
  const publish = operationProgressReporter();
436
534
  this.pending.set(id, { id, resolve, reject, timer, method, threadId: params.threadId, turnFloor });
437
535
  if (method === "turn/start" && params.threadId) {
@@ -467,12 +565,12 @@ async function withBridge(options, action) {
467
565
  function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
468
566
  function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
469
567
  function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
470
- async function observedChildren(bridge, sessionId) {
568
+ async function observedChildren(bridge, sessionId, phase) {
471
569
  const children = directChildren(bridge, sessionId);
472
570
  let reads = 0;
473
571
  return await Promise.all(children.map(async child => {
474
572
  if (child.status !== "failed" || !child.terminal_turn_id || reads++ >= 16) return child;
475
- const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true);
573
+ const observed = await readTurn(bridge, child.provider_session_id, child.terminal_turn_id, true, phase);
476
574
  const turn = observed?.turn;
477
575
  if (turn?.id !== child.terminal_turn_id || turn.status !== child.status) return child;
478
576
  return { ...child, provider_error: nativeTurnError(bridge, child.provider_session_id, turn) ?? child.provider_error,
@@ -481,8 +579,8 @@ async function observedChildren(bridge, sessionId) {
481
579
  }
482
580
  function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
483
581
 
484
- async function requestConfiguredThread(bridge, method, params, timeoutMs) {
485
- try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
582
+ async function requestConfiguredThread(bridge, method, params, timeoutMs, policy) {
583
+ try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs, policy); }
486
584
  catch (error) {
487
585
  const data = error?.details?.data;
488
586
  const key = data?.configKey ?? data?.config_key ?? null;
@@ -506,7 +604,7 @@ function threadReceipt(thread) {
506
604
 
507
605
  export async function doctor(options = {}) {
508
606
  const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"], { phase: 'native-version' }));
509
- return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
607
+ return await withBridge(options, async () => ({ harness: "codex-desktop", app_server: "available", observation_scope: "version_and_initialize", observed_runtime: { codex_cli, dd_harness_contract: "dd-codex-harness@1" }, requested_profile: profile(options), observed_at: now() }));
510
608
  }
511
609
 
512
610
  async function observeCodexProfile(bridge, options, id, observed, source) {
@@ -548,6 +646,9 @@ export async function startSession(options) {
548
646
  }
549
647
 
550
648
  export async function startSessionWithBridge(bridge, options) {
649
+ options = { ...options, readPhase: codexReadPhase(bridge, options) };
650
+ options.readPhase.outerClock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS,
651
+ ...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "start", session_id: options.sessionId }) });
551
652
  const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
552
653
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
553
654
  bridge.requestedProfiles?.set(sessionId, profile(options));
@@ -555,13 +656,16 @@ export async function startSessionWithBridge(bridge, options) {
555
656
  options.assertDispatch?.();
556
657
  bridge.assertHookHealthy?.(sessionId);
557
658
  bridge.dispatchOperationId = options.operationId;
558
- const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 30_000);
659
+ const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", options.timeoutMs ?? 30_000));
559
660
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
560
661
  if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
561
662
  return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "started", requested_profile: profile(options), observed_profile: { model: null, reasoning: null } };
562
663
  }
563
664
 
564
665
  export async function promptSessionWithBridge(bridge, options) {
666
+ options = { ...options, readPhase: codexReadPhase(bridge, options) };
667
+ const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, ...options.observationClock, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, phase: "prompt", session_id: options.sessionId }) });
668
+ options.readPhase.outerClock = clock;
565
669
  const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
566
670
  validateObservationDuration(options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000);
567
671
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
@@ -573,8 +677,7 @@ export async function promptSessionWithBridge(bridge, options) {
573
677
  options.assertDispatch?.();
574
678
  bridge.assertHookHealthy?.(sessionId);
575
679
  bridge.dispatchOperationId = options.operationId;
576
- const clock = new ObservationClock({ timeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 1_800_000, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId }) });
577
- const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, 30_000);
680
+ const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, ...dispatchBudget(options.readPhase, "turn/start", 30_000));
578
681
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
579
682
  let completed;
580
683
  try { completed = isTerminalTurnStatus(turn?.status) ? terminalTurn({ turn }, sessionId, turnId, bridge)
@@ -599,14 +702,34 @@ export async function promptSessionWithBridge(bridge, options) {
599
702
  details: { native_outcome: { provider_session_id: sessionId, turn_id: turnId, status: "failed", provider_error: error.details?.provider_error }, settled: false, observation_error: errorRecord(observationError) }, cause: error });
600
703
  }
601
704
  }
705
+ if (isObservationLoss(error)) throw Object.assign(new Error("Codex accepted Turn outcome could not be observed", { cause: error }), {
706
+ code: "operation_observation_lost", retryable: false, details: { provider_session_id: sessionId, turn_id: turnId,
707
+ operation_id: options.operationId ?? null, phase: "turn_observation", native_dispatch_started: true,
708
+ native_outcome: "unknown", observation_error: errorRecord(error), history_reads: Object.fromEntries(options.readPhase.history ?? []) }
709
+ });
602
710
  throw error;
603
711
  }
604
712
  const nativeOutcome = { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed };
605
713
  try {
606
- const thread = await readCompletedThread(bridge, sessionId);
714
+ const phase = codexReadPhase(bridge, options);
715
+ const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
716
+ const thread = history.available ? history.result.thread
717
+ : (await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase)).thread;
607
718
  bridge.assertHookHealthy?.(sessionId);
608
- const assistantText = latestAssistantText(thread?.turns?.find(item => item?.id === turnId)) ?? latestAssistantText(completed);
609
- return { ...nativeOutcome, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: await observedChildren(bridge, sessionId), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
719
+ const final = bridge.finalMessages?.get(sessionId);
720
+ const historyTurn = thread?.turns?.find(item => item?.id === turnId);
721
+ const completeHistory = history.available && (turnItemEvidence(historyTurn, bridge, sessionId).observed
722
+ // Preserve the existing idle + readable-history final-message receipt.
723
+ || completed?.turn?.synthetic === true && Array.isArray(thread.turns) && final?.turnId === turnId && thread.status?.type === "idle");
724
+ const assistantText = latestAssistantText(completed) ?? latestAssistantText(historyTurn)
725
+ ?? (final?.turnId === turnId ? latestAssistantText({ items: [final.item] }) : null);
726
+ if (!completeHistory && (!assistantText || !turnItemEvidence(completed?.turn ?? completed, bridge, sessionId).observed))
727
+ fail("Codex completed but its current output/items remain unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
728
+ if (!completeHistory && (options.model && !thread.model || options.reasoning && !thread.reasoningEffort))
729
+ fail("Codex completed but its applied profile remains unavailable", "operation_observation_lost", { history_error: history.error, provider_session_id: sessionId, turn_id: turnId });
730
+ const result = { ...nativeOutcome, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: await observedChildren(bridge, sessionId, phase), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
731
+ await bridge.journal?.flush?.();
732
+ return result;
610
733
  } catch (error) {
611
734
  throw Object.assign(new Error("Codex completed natively but mandatory observation failed"), { code: "native_outcome_observation_failed", retryable: false,
612
735
  details: { native_outcome: nativeOutcome, settled: false, observation_error: errorRecord(error) }, cause: error });
@@ -614,7 +737,7 @@ export async function promptSessionWithBridge(bridge, options) {
614
737
  }
615
738
 
616
739
  async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
617
- const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
740
+ const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, options.readPhase);
618
741
  const thread = value?.thread ?? value;
619
742
  if (thread?.status?.type !== "notLoaded") return;
620
743
  await requestConfiguredThread(bridge, "thread/resume", {
@@ -623,10 +746,14 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
623
746
  model: options.model ?? null,
624
747
  approvalPolicy: "never",
625
748
  sandbox: "danger-full-access"
626
- }, 30_000);
749
+ }, ...dispatchBudget(options.readPhase, "thread/resume", 30_000));
627
750
  }
628
751
 
629
752
  async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) }), startStatus) {
753
+ const readPhase = codexReadPhase(bridge, options);
754
+ readPhase.clock = clock;
755
+ readPhase.progress = () => bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId);
756
+ options = { ...options, readPhase };
630
757
  // app-server can briefly expose the new Turn as `interrupted` between the
631
758
  // turn/start response and its turn/started notification. Give that snapshot
632
759
  // one event-loop grace window before treating failure as authoritative.
@@ -636,7 +763,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
636
763
  if (status === "completed" || startStatus !== "inProgress" || bridge.turns.get(turnId)?.status === "completed") return true;
637
764
  // A provisional failure can contradict the accepted start before its
638
765
  // delayed notification arrives. Recheck this Turn, never start another.
639
- const current = await readTurn(bridge, sessionId, turnId, true);
766
+ const current = await readTurn(bridge, sessionId, turnId, true, readPhase);
640
767
  return bridge.turns.get(turnId)?.status === "completed" || current?.turn?.status === status
641
768
  && isAuthoritativeTerminal(bridge, turnId, status, current.threadStatus, failureNotBefore);
642
769
  };
@@ -646,27 +773,29 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
646
773
  const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed" && (!known.threadId || known.threadId === sessionId)) return terminalTurn(known.value, sessionId, turnId, bridge);
647
774
  const expired = clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId));
648
775
  if (expired || Date.now() >= nextStoredReadAt) {
649
- const stored = await readTurn(bridge, sessionId, turnId, false);
776
+ const stored = await readTurn(bridge, sessionId, turnId, false, readPhase);
650
777
  if (stored?.turn && await reconcileSnapshot(stored.turn.status, stored.threadStatus)) return terminalTurn({ turn: stored.turn }, sessionId, turnId, bridge);
651
778
  // A loaded app-server can retain a stale `active` Thread state after a
652
779
  // provider-side interruption. The persisted turn page is independent of
653
780
  // that live subscription and is the compact authority for its terminal
654
- // status; unlike `includeTurns`, it never hydrates the whole history.
655
- const persisted = await readPersistedTurn(bridge, sessionId, turnId);
656
- if (persisted && await reconcileSnapshot(persisted.status, stored?.threadStatus)) return terminalTurn({ turn: persisted }, sessionId, turnId, bridge);
781
+ // status. Hydrate only when that read surface is unavailable.
782
+ const persisted = await readPersistedTurn(bridge, sessionId, turnId, readPhase);
783
+ if (persisted.turn && await reconcileSnapshot(persisted.turn.status, persisted.threadStatus ?? stored?.threadStatus)) return terminalTurn({ turn: persisted.turn }, sessionId, turnId, bridge);
784
+ const native = bridge.turns.get(turnId);
785
+ if (native?.status === "completed" && (!native.threadId || native.threadId === sessionId)) return terminalTurn(native.value, sessionId, turnId, bridge);
657
786
  // Desktop may unload an idle Thread immediately after an interrupted
658
787
  // Turn. Reload it once before polling again: otherwise the compact
659
788
  // status remains `notLoaded` forever and hides the terminal Turn.
660
789
  if (stored?.threadStatus === "notLoaded") {
661
790
  await ensureThreadLoaded(bridge, sessionId, options, cwd);
662
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
791
+ const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
663
792
  if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
664
793
  }
665
794
  // Some app-server versions correctly omit turns for a compact read.
666
795
  // Once the thread is idle, hydrate exactly once to recover the terminal
667
796
  // turn instead of waiting for the global timeout.
668
797
  if (stored?.threadStatus === "idle") {
669
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
798
+ const hydrated = persisted.hydrated ? persisted : await readTurn(bridge, sessionId, turnId, true, readPhase);
670
799
  // "idle" only describes the Thread. Some app-server builds retain
671
800
  // an inProgress Turn after emitting the final agent message; treating
672
801
  // that stale Turn as terminal incorrectly aborts an otherwise usable
@@ -681,7 +810,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
681
810
  // reasoning and calling tools. They are terminal evidence only after
682
811
  // app-server reports the Thread idle.
683
812
  if (final?.turnId === turnId && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
684
- const hydrated = await readTurn(bridge, sessionId, turnId, true);
813
+ const hydrated = await readTurn(bridge, sessionId, turnId, true, readPhase);
685
814
  if (hydrated?.turn && await reconcileSnapshot(hydrated.turn.status, hydrated.threadStatus)) return terminalTurn({ turn: hydrated.turn }, sessionId, turnId, bridge);
686
815
  if (hydrated?.threadStatus === "idle" && !isTerminalTurnStatus(hydrated.turn?.status))
687
816
  return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, sessionId, turnId, bridge);
@@ -689,7 +818,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd, c
689
818
  nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
690
819
  }
691
820
  const terminal = bridge.turns.get(turnId); if (terminal?.status === "completed" && (!terminal.threadId || terminal.threadId === sessionId)) return terminalTurn(terminal.value, sessionId, turnId, bridge);
692
- if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, native_outcome: "unknown" });
821
+ if (expired && clock.sample(bridge.activityObservations?.get(sessionId) ?? bridge.activity?.get(sessionId))) fail("Codex native outcome remains unknown after its silence window", "operation_observation_lost", { provider_session_id: sessionId, turn_id: turnId, operation_id: options.operationId ?? null, native_dispatch_started: true, phase: "turn_observation", native_outcome: "unknown", history_reads: Object.fromEntries(readPhase.history ?? []) });
693
822
  await new Promise((resolve) => setTimeout(resolve, 250));
694
823
  }
695
824
  }
@@ -744,29 +873,23 @@ function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNo
744
873
  return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
745
874
  }
746
875
 
747
- async function readTurn(bridge, sessionId, turnId, includeTurns) {
876
+ async function readTurn(bridge, sessionId, turnId, includeTurns, phase) {
748
877
  if (!turnId) return null;
749
878
  try {
750
- const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
879
+ const history = includeTurns ? await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase) : null;
880
+ if (history && !history.available) return { turn: null, threadStatus: null, historyAvailable: false };
881
+ const result = history?.result ?? await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns }, phase);
751
882
  const thread = result?.thread ?? result;
752
883
  return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
753
- } catch { bridge.assertHookHealthy?.(sessionId); return null; }
884
+ } catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
754
885
  }
755
886
 
756
- async function readPersistedTurn(bridge, sessionId, turnId) {
887
+ async function readPersistedTurn(bridge, sessionId, turnId, phase) {
757
888
  try {
758
- const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
759
- return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
760
- } catch { bridge.assertHookHealthy?.(sessionId); return null; }
761
- }
762
-
763
- async function readCompletedThread(bridge, sessionId) {
764
- let lastError;
765
- for (let attempt = 0; attempt < 8; attempt += 1) {
766
- try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
767
- catch (error) { lastError = error; bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: errorRecord(error) }); await new Promise((resolve) => setTimeout(resolve, 150)); }
768
- }
769
- throw lastError;
889
+ const history = await readCodexHistory(bridge, "thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, phase);
890
+ if (!history.available) return { ...await readTurn(bridge, sessionId, turnId, true, phase), hydrated: true };
891
+ return { available: history.available, turn: history.result?.data.find((turn) => turn?.id === turnId) ?? null };
892
+ } catch (error) { bridge.assertHookHealthy?.(sessionId); throw error; }
770
893
  }
771
894
 
772
895
  function latestAssistantText(value) {
@@ -789,10 +912,11 @@ export async function inspectSession(options) {
789
912
  }
790
913
 
791
914
  export async function inspectSessionWithBridge(bridge, options) {
792
- const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
793
- const terminal = options.turnId ? (await readTurn(bridge, sessionId, options.turnId, true))?.turn : null;
915
+ const phase = options.readPhase ?? codexReadPhase(bridge, options);
916
+ const sessionId = required(options.sessionId, "--session-id"); const value = await readCodexRpc(bridge, "thread/read", { threadId: sessionId, includeTurns: false }, phase); const thread = value?.thread ?? value;
917
+ const terminal = options.turnId ? retainedCodexTurn(bridge, sessionId, options.turnId) ?? (await readTurn(bridge, sessionId, options.turnId, true, phase))?.turn : null;
794
918
  const exact = terminal?.id === options.turnId && terminal?.status === "failed" ? { terminal_turn_id: terminal.id, terminal_status: terminal.status, provider_error: nativeTurnError(bridge, sessionId, terminal), native_turn_items: turnItemEvidence(terminal, bridge, sessionId) } : {};
795
- return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
919
+ return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), ...exact, descendants: await observedChildren(bridge, sessionId, phase), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread.model ?? null, reasoning: thread.reasoningEffort ?? null, provider: thread.modelProvider ?? null }, "thread/read") };
796
920
  }
797
921
 
798
922
  export async function cancelSession(options) {
@@ -800,12 +924,21 @@ export async function cancelSession(options) {
800
924
  }
801
925
 
802
926
  export async function cancelSessionWithBridge(bridge, options) {
927
+ const phase = options.readPhase ?? codexReadPhase(bridge, options);
803
928
  const sessionId = required(options.sessionId, "--session-id");
804
929
  let turnId = options.turnId ?? null;
805
930
  if (!turnId) {
806
- const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000);
807
- const thread = result?.thread ?? result;
808
- turnId = [...(thread?.turns ?? [])].reverse().find((turn) => turn?.status === "inProgress")?.id ?? null;
931
+ const currentId = bridge.currentTurns?.get(sessionId);
932
+ const retained = retainedCodexTurn(bridge, sessionId, currentId);
933
+ turnId = currentId && !isTerminalTurnStatus(retained?.status) ? currentId : null;
934
+ if (!turnId) {
935
+ const history = await readCodexHistory(bridge, "thread/read", { threadId: sessionId, includeTurns: true }, phase);
936
+ if (!history.available || !Array.isArray(history.result.thread.turns)
937
+ || history.result.thread.turns.some(turn => typeof turn?.id !== "string" || !turn.id.trim()
938
+ || turn.status !== "inProgress" && !isTerminalTurnStatus(turn.status)))
939
+ fail("Codex active Turn cannot be established from incomplete history", "operation_observation_lost", { provider_session_id: sessionId, native_outcome: "unknown", history_error: history.error });
940
+ turnId = [...history.result.thread.turns].reverse().find((turn) => turn.status === "inProgress")?.id ?? null;
941
+ }
809
942
  }
810
943
  if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
811
944
  await bridge.request("turn/interrupt", { threadId: sessionId, turnId });
@@ -178,8 +178,26 @@ export async function startDaemon(options) {
178
178
  export const stopDaemon = async options => { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs, { readState: () => readState(options.stateDir) }); };
179
179
 
180
180
  export class Runtime {
181
- constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map((state.sessions ?? []).map(x => [x.provider_session_id, x])); this.active = null; this.persisting = Promise.resolve(); }
181
+ constructor(paths, state, client, child, providerProcess) { this.paths = paths; this.state = state; this.client = client; this.child = child; this.providerProcess = providerProcess; this.sessions = new Map();
182
+ for (const record of state.sessions ?? []) {
183
+ if (typeof record?.provider_session_id !== "string" || !record.provider_session_id || this.sessions.has(record.provider_session_id))
184
+ throw new DaemonError("session_identity_mismatch", "OpenCode retained Session identity is invalid or duplicated");
185
+ this.sessions.set(record.provider_session_id, record);
186
+ }
187
+ this.active = null; this.persisting = Promise.resolve(); }
182
188
  persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, sessions: [...this.sessions.values()] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.catch(() => {}).then(() => writeJson(this.paths.state, snapshot)); return this.persisting; }
189
+ retainSession(info, root) {
190
+ const id = info.id, parent = info.fork ? null : info.parentID ?? null, retained = this.sessions.get(id);
191
+ if (typeof id !== "string" || !id || typeof info.directory !== "string" || !path.isAbsolute(info.directory)
192
+ || retained && ((retained.parent_provider_session_id ?? null) !== parent
193
+ || retained.adapter_session_id && retained.adapter_session_id !== id
194
+ || retained.root_provider_session_id && retained.root_provider_session_id !== root))
195
+ throw new DaemonError("session_identity_mismatch", "OpenCode retained Session identity changed");
196
+ // Cwd is observed routing authority, not ownership identity: a moved owned
197
+ // Session must remain observable/cancellable. requireSettled fences dispatch.
198
+ const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent, root_provider_session_id: root, cwd: info.directory };
199
+ this.sessions.set(id, record); return record;
200
+ }
183
201
  async journal(operation, result) { const line = `${JSON.stringify({ schema_id: "dd-opencode/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), operation, active_tree: this.state.active_tree, session_id: result?.provider_session_id ?? null })}\n`; for (const journal of new Set([this.paths.journal, this.state.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
184
202
  async childTree(id, seen = new Set()) {
185
203
  if (seen.has(id)) throw new DaemonError("session_identity_mismatch", "OpenCode native topology contains a cycle");
@@ -195,13 +213,11 @@ export class Runtime {
195
213
  const parent = info.fork ? null : info.parentID ?? null, existing = this.sessions.get(id);
196
214
  if (info.id !== id || !existing && (!parent || !this.sessions.has(parent) || info.fork)
197
215
  || existing && (existing.parent_provider_session_id ?? null) !== parent) throw new DaemonError("foreign_session", "OpenCode Session is not in the retained owned tree");
198
- const record = { provider_session_id: id, adapter_session_id: id, parent_provider_session_id: parent,
199
- root_provider_session_id: parent ? this.sessions.get(parent)?.root_provider_session_id ?? parent : existing?.root_provider_session_id ?? id, cwd: info.directory };
200
- this.sessions.set(id, record);
216
+ const record = this.retainSession(info, parent ? this.sessions.get(parent)?.root_provider_session_id ?? parent : existing?.root_provider_session_id ?? id);
201
217
  for (const child of children) {
202
218
  const retained = this.sessions.get(child.id);
203
219
  if (child.fork || retained && retained.parent_provider_session_id !== child.parentID) throw new DaemonError("session_identity_mismatch", "OpenCode child ancestry changed");
204
- this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: child.parentID, root_provider_session_id: record.root_provider_session_id, cwd: child.directory });
220
+ this.retainSession(child, record.root_provider_session_id);
205
221
  }
206
222
  const pending = new Map();
207
223
  for (const session of [id, ...children.map(child => child.id)]) {
@@ -287,6 +303,7 @@ export class Runtime {
287
303
  if (seen.has(id)) return [];
288
304
  const native = await this.client.inspect(id), retained = this.sessions.get(id);
289
305
  if (native.id !== id || (retained.parent_provider_session_id ?? null) !== (native.fork ? null : native.parentID ?? null)) throw new DaemonError("session_identity_mismatch", "OpenCode cancellation Session ancestry changed");
306
+ this.retainSession(native, retained.root_provider_session_id ?? id);
290
307
  seen.add(id);
291
308
  const errors = []; let children = [];
292
309
  try { children = await this.client.children(id); } catch (error) { errors.push({ session_id: id, phase: "children", ...errorPayload(error) }); }
@@ -294,7 +311,7 @@ export class Runtime {
294
311
  if (child.parentID !== id || child.fork || this.sessions.has(child.id) && this.sessions.get(child.id).parent_provider_session_id !== id) {
295
312
  errors.push({ session_id: child.id, phase: "identity", code: "session_identity_mismatch" }); continue;
296
313
  }
297
- this.sessions.set(child.id, { provider_session_id: child.id, parent_provider_session_id: id, root_provider_session_id: this.sessions.get(id)?.root_provider_session_id ?? id, cwd: child.directory });
314
+ this.retainSession(child, this.sessions.get(id)?.root_provider_session_id ?? id);
298
315
  try { errors.push(...await this.cancelTree(child.id, seen)); }
299
316
  catch (error) { errors.push({ session_id: child.id, phase: "cancel_child", ...errorPayload(error) }); }
300
317
  }
@@ -499,7 +516,9 @@ export async function awaitOpenCodeStartup(client, state, child, { timeoutMs = S
499
516
  if (["loading", "activating", "registered"].includes(plugin?.state?.status)) await mark(`required_plugin_${plugin.state.status}`);
500
517
  } catch (error) {
501
518
  const transient = error.code === "opencode_unavailable" && error.retryable === true
502
- && (error.details?.native_type === "ServiceUnavailableError" || phase === "server" && ["TypeError", "ConnectionError", "Error"].includes(error.details?.native_type));
519
+ // Safe pre-identity reads use the normalized observation category,
520
+ // not a version-specific SDK error wrapper. No productive RPC here.
521
+ && (phase === "server" || error.details?.native_type === "ServiceUnavailableError");
503
522
  if (!transient) throw error;
504
523
  }
505
524
  const pause = Math.min([250, 500, 1000, 2000][Math.min(attempt++, 3)], remaining());
@@ -5,6 +5,15 @@
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "summary", "findings"],
7
7
  "properties": {
8
+ "accepted_intent_acknowledgement": {
9
+ "type": "object", "additionalProperties": false,
10
+ "required": ["reviewed_fingerprint", "current_fingerprint", "clarification_refs"],
11
+ "properties": {
12
+ "reviewed_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
13
+ "current_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
14
+ "clarification_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^HITL-[A-Za-z0-9._-]+$"}}
15
+ }
16
+ },
8
17
  "schema_id": {"const": "dd-flow/code-review-decision@3"},
9
18
  "summary": {"type": "string", "minLength": 1},
10
19
  "findings": {
@@ -39,6 +39,7 @@
39
39
  }
40
40
  },
41
41
  "verification_matrix_contract": {"const": "dd-flow/verification-matrix@1"},
42
+ "accepted_intent_contract": {"const": "dd-flow/accepted-intent@1"},
42
43
  "final_check_coverage_contract": {"const": "dd-flow/final-check-coverage@1"},
43
44
  "status": {"enum": ["created", "running", "paused", "waiting_for_user", "done", "failed", "blocked", "cancelled", "discarded"]},
44
45
  "verdict": {"type": ["string", "null"]},
@@ -5,6 +5,15 @@
5
5
  "additionalProperties": false,
6
6
  "required": ["schema_id", "outcome", "summary", "finding_decisions", "correction"],
7
7
  "properties": {
8
+ "accepted_intent_acknowledgement": {
9
+ "type": "object", "additionalProperties": false,
10
+ "required": ["reviewed_fingerprint", "current_fingerprint", "clarification_refs"],
11
+ "properties": {
12
+ "reviewed_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
13
+ "current_fingerprint": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
14
+ "clarification_refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^HITL-[A-Za-z0-9._-]+$"}}
15
+ }
16
+ },
8
17
  "schema_id": {"const": "dd-flow/plan-review-decision@4"},
9
18
  "outcome": {"enum": ["accepted", "failed", "cancelled"]},
10
19
  "summary": {"type": "string", "minLength": 1},