@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +396 -0
- package/README.md +65 -0
- package/dist/build-info.json +8 -8
- package/dist/cli/command-inputs.js +300 -0
- package/dist/cli/help.js +81 -9
- package/dist/cli/hook-ingress.js +82 -0
- package/dist/cli/input-preparation.js +115 -0
- package/dist/cli/run-cli.js +1342 -375
- package/dist/cli.js +6 -2
- package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
- package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
- package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
- package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
- package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
- package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
- package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
- package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
- package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
- package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
- package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
- package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
- package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
- package/dist/harness-runtime/lib/model-observations.mjs +77 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
- package/dist/harness-runtime/lib/operation-context.mjs +5 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
- package/dist/harness-runtime/lib/process-json.mjs +70 -0
- package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
- package/dist/harness-runtime/lib/runner-events.mjs +200 -0
- package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
- package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
- package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
- package/dist/schemas/agent-profile.schema.json +1 -1
- package/dist/schemas/code-review-result.schema.json +2 -2
- package/dist/schemas/code-work-batch.schema.json +4 -3
- package/dist/schemas/code-work-result.schema.json +4 -4
- package/dist/schemas/plan-review-result.schema.json +2 -2
- package/dist/schemas/run-control-receipt.schema.json +99 -0
- package/dist/schemas/run-control-request.schema.json +36 -0
- package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
- package/dist/services/canon.js +11 -8
- package/dist/services/cleanup.js +57 -13
- package/dist/services/cli-operation-classifier.js +22 -24
- package/dist/services/code-checks.js +543 -96
- package/dist/services/codex-hook-delivery.js +28 -0
- package/dist/services/config.js +11 -8
- package/dist/services/controller-fanout.js +116 -0
- package/dist/services/dashboard.js +103 -31
- package/dist/services/engines.js +121 -76
- package/dist/services/eval-snapshots.js +750 -73
- package/dist/services/execution-policy.js +170 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +2 -0
- package/dist/services/hooks.js +553 -285
- package/dist/services/lanes.js +61 -53
- package/dist/services/lifecycle-command.js +87 -7
- package/dist/services/lifecycle-invocations.js +828 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +283 -27
- package/dist/services/merge-queue.js +179 -104
- package/dist/services/merge-server.js +73 -28
- package/dist/services/migrations.js +14 -9
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/plan-runtime.js +19 -8
- package/dist/services/plans.js +28 -25
- package/dist/services/projects.js +18 -5
- package/dist/services/prompts.js +23 -16
- package/dist/services/protocols.js +64 -20
- package/dist/services/recovery-observation-budget.js +61 -0
- package/dist/services/recovery-snapshot-database.js +107 -0
- package/dist/services/run-control-receipt.js +56 -0
- package/dist/services/run-control-worker.js +363 -0
- package/dist/services/run-control.js +876 -0
- package/dist/services/run-controller-adapter.js +222 -0
- package/dist/services/run-controller-capture.js +134 -0
- package/dist/services/run-controller-process.js +189 -0
- package/dist/services/run-controller-recovery.js +222 -0
- package/dist/services/run-controller-state.js +36 -0
- package/dist/services/run-controller.js +779 -0
- package/dist/services/run-engine-bindings.js +1 -1
- package/dist/services/run-fork.js +138 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +342 -0
- package/dist/services/runs.js +368 -74
- package/dist/services/runtime-budget.js +434 -0
- package/dist/services/runtime-scope-capture.js +50 -0
- package/dist/services/runtime-scope-control.js +450 -0
- package/dist/services/runtime-scope-resume.js +565 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +243 -0
- package/dist/services/runtime-service.js +97 -0
- package/dist/services/schema-validation.js +10 -8
- package/dist/services/sessions.js +35 -69
- package/dist/services/stage-blocker.js +17 -7
- package/dist/services/stage-context.js +44 -18
- package/dist/services/stage-lifecycle.js +99 -102
- package/dist/services/stage-pause.js +65 -23
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +189 -85
- package/dist/services/vnext-code.js +257 -243
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +134 -19
- package/dist/services/vnext-merge.js +285 -102
- package/dist/services/vnext-plan-review.js +148 -94
- package/dist/services/vnext-plan.js +141 -72
- package/dist/services/vnext-protocolize.js +129 -56
- package/dist/services/vnext-specify.js +87 -64
- package/dist/services/work-registry.js +707 -156
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/services/worktrees.js +85 -35
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +444 -32
- package/dist/storage/paths.js +17 -4
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +11 -4
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
import { stopProcessGroup } from "./managed-daemon.mjs";
|
|
6
|
+
import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
|
|
7
|
+
import { observeModel } from "./model-observations.mjs";
|
|
8
|
+
|
|
9
|
+
const LATE_RESPONSE_GRACE_MS = 5_000;
|
|
10
|
+
const CODEX_SESSION_CONFIG = Object.freeze({
|
|
11
|
+
bypass_hook_trust: true,
|
|
12
|
+
"features.plugins": false,
|
|
13
|
+
"features.multi_agent_v2.enabled": true,
|
|
14
|
+
"features.multi_agent_v2.tool_namespace": "collaboration",
|
|
15
|
+
"features.multi_agent_v2.wait_agent_enabled": true
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
|
|
19
|
+
// intentionally one-shot: the provider owns durable Threads, while the caller
|
|
20
|
+
// owns the runner journal and can safely resume after a process restart.
|
|
21
|
+
function fail(message, code, details) { const error = new Error(message); if (code) error.code = code; if (details) error.details = details; throw error; }
|
|
22
|
+
function required(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
23
|
+
function absolute(value, label) { const resolved = path.resolve(required(value, label)); if (!path.isAbsolute(resolved)) fail(`${label} must be absolute`); return resolved; }
|
|
24
|
+
function now() { return new Date().toISOString(); }
|
|
25
|
+
function responseSummary(message) {
|
|
26
|
+
const result = message?.result;
|
|
27
|
+
return { request_id: message?.id ?? null, has_result: result !== undefined,
|
|
28
|
+
result_keys: result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).slice(0, 32) : [], error: message?.error ?? null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseCodexCliVersion(value) {
|
|
32
|
+
const match = String(value).trim().match(/^codex-cli\s+(\S+)$/m);
|
|
33
|
+
if (!match) fail("Codex CLI did not report its version", "codex_version_unavailable");
|
|
34
|
+
return match[1];
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
}
|
|
47
|
+
|
|
48
|
+
class Journal {
|
|
49
|
+
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`)); }
|
|
51
|
+
flush() { return this.pending; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class CodexBridge {
|
|
55
|
+
constructor(options = {}) { this.options = options; this.nextId = 1; this.pending = new Map(); this.lateResponses = new Map(); this.turns = 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
|
+
|
|
57
|
+
async start() {
|
|
58
|
+
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" })));
|
|
62
|
+
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));
|
|
65
|
+
await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
receive(line) {
|
|
69
|
+
let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
|
|
70
|
+
this.observeTopology(message);
|
|
71
|
+
this.observeNativeHook(message);
|
|
72
|
+
this.observeHookFailure(message);
|
|
73
|
+
if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
|
|
74
|
+
const params = message.params ?? {}, id = params.threadId;
|
|
75
|
+
let root = id; const seen = new Set();
|
|
76
|
+
while (root && !this.requestedProfiles.has(root) && !seen.has(root)) { seen.add(root); root = this.descendants.get(root)?.parent_provider_session_id; }
|
|
77
|
+
if (root && this.requestedProfiles.has(root)) {
|
|
78
|
+
const settings = params.threadSettings ?? {};
|
|
79
|
+
const rerouted = message.method === "model/rerouted";
|
|
80
|
+
this.modelObservations = this.modelObservations.then(() => observeModel({ journal: this.options.journal, harness: "codex-desktop", sessionId: id,
|
|
81
|
+
parentSessionId: this.descendants.get(id)?.parent_provider_session_id ?? null, requested: this.requestedProfiles.get(root),
|
|
82
|
+
observed: { model: rerouted ? params.toModel : settings.model, provider: settings.modelProvider, reasoning: settings.effort },
|
|
83
|
+
source: message.method, evidence: rerouted ? "response" : "configured", reason: params.reason ?? null, previousModel: params.fromModel ?? null
|
|
84
|
+
})).catch(error => { this.modelObservationError = error; this.rejectAll(error); });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (message.id === undefined && (message.method?.startsWith("item/") || message.method === "turn/started" || message.method === "thread/tokenUsage/updated")) {
|
|
88
|
+
this.activity ??= new Map();
|
|
89
|
+
const visited = new Set();
|
|
90
|
+
let id = message.params?.threadId;
|
|
91
|
+
while (id && !visited.has(id)) {
|
|
92
|
+
visited.add(id); this.activity.set(id, (this.activity.get(id) ?? 0) + 1);
|
|
93
|
+
id = this.descendants.get(id)?.parent_provider_session_id;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// app-server currently returns complete turns even for `includeTurns:
|
|
97
|
+
// false`. The individual item notifications are already journalled, so
|
|
98
|
+
// retain a compact read receipt instead of writing the same transcript on
|
|
99
|
+
// every status poll.
|
|
100
|
+
const request = message.id !== undefined ? this.pending.get(message.id) : null;
|
|
101
|
+
if (request?.method === "thread/read") {
|
|
102
|
+
const thread = message.result?.thread ?? message.result ?? {};
|
|
103
|
+
this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
|
|
104
|
+
id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
|
|
105
|
+
turns: Array.isArray(thread.turns) ? thread.turns.map((turn) => ({ id: turn?.id ?? null, status: turn?.status ?? null, startedAt: turn?.startedAt ?? null, completedAt: turn?.completedAt ?? null })) : []
|
|
106
|
+
} } });
|
|
107
|
+
} else this.journal.write("inbound", message);
|
|
108
|
+
if (message.id !== undefined && message.method) { void this.answerRequest(message); return; }
|
|
109
|
+
if (message.id !== undefined) {
|
|
110
|
+
const pending = this.pending.get(message.id);
|
|
111
|
+
if (!pending) {
|
|
112
|
+
this.lateResponses.set(message.id, message);
|
|
113
|
+
while (this.lateResponses.size > 128) this.lateResponses.delete(this.lateResponses.keys().next().value);
|
|
114
|
+
this.journal.write("late_response", responseSummary(message));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
clearTimeout(pending.timer); this.pending.delete(message.id);
|
|
118
|
+
if (pending.timedOut) this.journal.write("late_response", { ...responseSummary(message), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
119
|
+
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 ?? {});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (message.method === "turn/completed") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "completed", value: message.params });
|
|
123
|
+
if (message.method === "turn/started") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "running", value: message.params });
|
|
124
|
+
const threadId = message.params?.threadId;
|
|
125
|
+
if (threadId && message.method === "item/started") this.finalMessages.delete(threadId);
|
|
126
|
+
if (threadId && message.method === "item/completed" && message.params?.item?.type === "agentMessage") this.finalMessages.set(threadId, { item: message.params.item, completedAt: Date.now() });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
isManagedHook(run) {
|
|
130
|
+
const home = this.options.env?.CODEX_HOME;
|
|
131
|
+
return Boolean(home && run?.eventName === "preToolUse" && run.handlerType === "command"
|
|
132
|
+
&& run.executionMode === "sync" && run.statusMessage === "dd-flow runtime observation"
|
|
133
|
+
&& run.sourcePath === path.join(home, "hooks.json"));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
observeNativeHook(message) {
|
|
137
|
+
if (message.method === "turn/completed") {
|
|
138
|
+
const { threadId, turn } = message.params ?? {};
|
|
139
|
+
for (const [id, hook] of this.activeHooks ?? []) {
|
|
140
|
+
if (hook.threadId === threadId && hook.turnId === turn?.id) this.activeHooks.delete(id);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!["hook/started", "hook/completed"].includes(message.method)) return;
|
|
145
|
+
const { threadId, turnId, run } = message.params ?? {};
|
|
146
|
+
if (!this.isManagedHook(run) || typeof run.id !== "string" || !threadId) return;
|
|
147
|
+
this.activeHooks ??= new Map();
|
|
148
|
+
if (message.method === "hook/started") this.activeHooks.set(run.id, { threadId, turnId });
|
|
149
|
+
else this.activeHooks.delete(run.id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
verifyNativeHook(input) {
|
|
153
|
+
const { rootSessionId, sessionId, toolCallId, turnId } = input;
|
|
154
|
+
const home = this.options.env?.CODEX_HOME;
|
|
155
|
+
const id = `pre-tool-use:0:${path.join(home ?? "", "hooks.json")}:${toolCallId}`;
|
|
156
|
+
const active = this.activeHooks?.get(id);
|
|
157
|
+
let current = sessionId;
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
while (current && current !== rootSessionId && !seen.has(current)) {
|
|
160
|
+
seen.add(current); current = this.descendants.get(current)?.parent_provider_session_id;
|
|
161
|
+
}
|
|
162
|
+
if (!active || active.threadId !== sessionId || active.turnId !== turnId || !toolCallId
|
|
163
|
+
|| current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)) {
|
|
164
|
+
fail("No matching active managed native hook", "native_hook_unproven", { provider_session_id: sessionId, tool_call_id: toolCallId });
|
|
165
|
+
}
|
|
166
|
+
return { rootSessionId, sessionId, toolCallId, parentSessionId: sessionId === rootSessionId ? null : this.descendants.get(sessionId)?.parent_provider_session_id };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
observeHookFailure(message) {
|
|
170
|
+
const { threadId, turnId, run } = message.params ?? {};
|
|
171
|
+
const home = this.options.env?.CODEX_HOME;
|
|
172
|
+
if (message.method !== "hook/completed" || run?.status !== "failed" ||
|
|
173
|
+
run.eventName !== "preToolUse" || run.handlerType !== "command" ||
|
|
174
|
+
run.statusMessage !== "dd-flow runtime observation" || !home ||
|
|
175
|
+
run.sourcePath !== path.join(home, "hooks.json")) return;
|
|
176
|
+
const ancestors = new Set();
|
|
177
|
+
let root;
|
|
178
|
+
let current = threadId;
|
|
179
|
+
while (current && !ancestors.has(current)) {
|
|
180
|
+
ancestors.add(current);
|
|
181
|
+
if (this.requestedProfiles.has(current)) root = current;
|
|
182
|
+
current = this.descendants.get(current)?.parent_provider_session_id;
|
|
183
|
+
}
|
|
184
|
+
if (!root) return;
|
|
185
|
+
this.hookFailures ??= new Map();
|
|
186
|
+
if (this.hookFailures.has(root)) return;
|
|
187
|
+
const entries = Array.isArray(run.entries) ? run.entries : [];
|
|
188
|
+
const reason = entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("; ");
|
|
189
|
+
const error = Object.assign(new Error(reason || "Managed Codex hook failed"), {
|
|
190
|
+
code: /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed",
|
|
191
|
+
details: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
|
|
192
|
+
hook_run_id: run.id, effect: "unknown", duration_ms: run.durationMs }
|
|
193
|
+
});
|
|
194
|
+
this.hookFailures.set(root, error);
|
|
195
|
+
this.journal.write("native_hook_failure", { code: error.code, message: error.message, ...error.details });
|
|
196
|
+
for (const [id, pending] of this.pending) {
|
|
197
|
+
if (ancestors.has(pending.threadId) && pending.method !== "turn/interrupt") {
|
|
198
|
+
clearTimeout(pending.timer); this.pending.delete(id); pending.reject(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
assertHookHealthy(sessionId) {
|
|
204
|
+
const visited = new Set();
|
|
205
|
+
while (sessionId && !visited.has(sessionId)) {
|
|
206
|
+
visited.add(sessionId);
|
|
207
|
+
const error = this.hookFailures?.get(sessionId);
|
|
208
|
+
if (error) throw error;
|
|
209
|
+
sessionId = this.descendants.get(sessionId)?.parent_provider_session_id;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Collaboration child Threads are provider facts. Keep only objects that
|
|
214
|
+
// explicitly carry a parent relationship, including native spawn receipts.
|
|
215
|
+
// A wait/send receiver is not necessarily a child of its sender.
|
|
216
|
+
observeTopology(value, depth = 0) {
|
|
217
|
+
if (!value || typeof value !== "object" || depth > 8) return;
|
|
218
|
+
if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
|
|
219
|
+
const activity = value.params?.item;
|
|
220
|
+
const activityParent = value.params?.threadId;
|
|
221
|
+
if (activity?.type === "subAgentActivity" && typeof activity.agentThreadId === "string" && activity.agentThreadId &&
|
|
222
|
+
typeof activityParent === "string" && activityParent && activity.agentThreadId !== activityParent) {
|
|
223
|
+
const previous = this.descendants.get(activity.agentThreadId);
|
|
224
|
+
const observed = activity.kind === "completed" ? "completed" : activity.kind === "interrupted" ? "interrupted" : "running";
|
|
225
|
+
const status = previous?.status === "completed" ? "completed" : observed;
|
|
226
|
+
this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId,
|
|
227
|
+
parent_provider_session_id: activityParent, status, agent_path: activity.agentPath ?? previous?.agent_path ?? null,
|
|
228
|
+
source: "codex.app_server.subAgentActivity" });
|
|
229
|
+
}
|
|
230
|
+
if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
|
|
231
|
+
typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
|
|
232
|
+
for (const child of value.receiverThreadIds) {
|
|
233
|
+
if (typeof child !== "string" || !child || child === value.senderThreadId) continue;
|
|
234
|
+
const previous = this.descendants.get(child);
|
|
235
|
+
this.descendants.set(child, { ...previous, provider_session_id: child, parent_provider_session_id: value.senderThreadId,
|
|
236
|
+
status: previous?.status === "completed" ? "completed" : value.agentsStates?.[child]?.status ?? "unknown", source: "codex.app_server" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const id = value.threadId ?? value.id ?? null;
|
|
240
|
+
const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
|
|
241
|
+
if (typeof id === "string" && id && typeof parent === "string" && parent) {
|
|
242
|
+
const previous = this.descendants.get(id);
|
|
243
|
+
this.descendants.set(id, { ...previous, provider_session_id: id, parent_provider_session_id: parent,
|
|
244
|
+
status: previous?.status === "completed" ? "completed" : value.status?.type ?? value.status ?? "unknown", source: "codex.app_server" });
|
|
245
|
+
}
|
|
246
|
+
for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
directChildren(sessionId) {
|
|
250
|
+
return [...this.descendants.values()].filter((child) => child.parent_provider_session_id === sessionId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async answerRequest(message) {
|
|
254
|
+
// Eval runs are non-interactive. A request is evidence for the runner, not
|
|
255
|
+
// permission to continue. Do not silently approve a tool or elicitation.
|
|
256
|
+
// A JSON-RPC error is deliberately used instead of inventing a generic
|
|
257
|
+
// `{ decision: "deny" }` payload: app-server request methods have distinct
|
|
258
|
+
// result schemas, while every one must fail closed in a non-interactive
|
|
259
|
+
// eval. The runner can classify this journalled request as infrastructure
|
|
260
|
+
// or unexpected interaction without corrupting the provider protocol.
|
|
261
|
+
const error = { code: -32001, message: `dd-codex refuses interactive request: ${message.method}` };
|
|
262
|
+
this.journal.write("request_refused", { method: message.method, params: message.params, error });
|
|
263
|
+
this.send({ jsonrpc: "2.0", id: message.id, error });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
request(method, params = {}, timeoutMs = this.options.timeoutMs ?? 120_000) {
|
|
267
|
+
const id = this.nextId++;
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const timer = observedTimeout(() => {
|
|
270
|
+
const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
|
|
271
|
+
pending.timedOut = true;
|
|
272
|
+
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 } });
|
|
273
|
+
this.journal.write("request_deadline", timeoutError.details);
|
|
274
|
+
pending.timer = observedTimeout(() => {
|
|
275
|
+
if (this.pending.get(id) !== pending) return;
|
|
276
|
+
this.pending.delete(id); reject(timeoutError);
|
|
277
|
+
}, LATE_RESPONSE_GRACE_MS);
|
|
278
|
+
}, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
279
|
+
this.pending.set(id, { resolve, reject, timer, method, threadId: params.threadId }); this.send({ jsonrpc: "2.0", id, method, params });
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
send(value) {
|
|
283
|
+
this.journal.write("outbound", value);
|
|
284
|
+
const stdin = this.child?.stdin;
|
|
285
|
+
if (!stdin || stdin.destroyed) {
|
|
286
|
+
const error = Object.assign(new Error("Codex app-server stdin is closed"), { code: "bridge_pipe_broken" });
|
|
287
|
+
this.rejectAll(error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
try { stdin.write(`${JSON.stringify(value)}\n`); }
|
|
291
|
+
catch (error) { this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })); throw error; }
|
|
292
|
+
}
|
|
293
|
+
rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); }
|
|
294
|
+
async close() {
|
|
295
|
+
if (!this.child) return;
|
|
296
|
+
this.child.stdin.end();
|
|
297
|
+
await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
298
|
+
await stopProcessGroup(this.child, 2_000);
|
|
299
|
+
await this.modelObservations;
|
|
300
|
+
await this.journal.flush();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function withBridge(options, action) { const bridge = new CodexBridge(options); try { await bridge.start(); return await action(bridge); } finally { await bridge.close(); } }
|
|
305
|
+
function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
|
|
306
|
+
function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
|
|
307
|
+
function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
|
|
308
|
+
function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
|
|
309
|
+
|
|
310
|
+
async function requestConfiguredThread(bridge, method, params, timeoutMs) {
|
|
311
|
+
try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
|
|
312
|
+
catch (error) {
|
|
313
|
+
const data = error?.details?.data;
|
|
314
|
+
const key = data?.configKey ?? data?.config_key ?? null;
|
|
315
|
+
if (error?.code === "rpc_error" && (data?.reason === "config" || String(key ?? "").startsWith("features.multi_agent_v2"))) {
|
|
316
|
+
throw Object.assign(new Error("Codex runtime rejected the required multi-agent v2 session configuration"), {
|
|
317
|
+
code: "codex_multi_agent_v2_unavailable", cause: error, details: { method, provider_error: error.details }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function threadReceipt(thread) {
|
|
325
|
+
// Thread reads can include the whole transcript even with includeTurns:false.
|
|
326
|
+
// Item evidence lives in the native history/journal, not each command reply.
|
|
327
|
+
return { ...thread, ...(Array.isArray(thread.turns) ? { turns: thread.turns.map(turn => ({
|
|
328
|
+
id: turn?.id ?? null, status: turn?.status ?? null,
|
|
329
|
+
startedAt: turn?.startedAt ?? null, completedAt: turn?.completedAt ?? null
|
|
330
|
+
})) } : {}) };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function doctor(options = {}) {
|
|
334
|
+
const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"]));
|
|
335
|
+
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() }));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function observeCodexProfile(bridge, options, id, observed, source) {
|
|
339
|
+
bridge.requestedProfiles?.set(id, profile(options));
|
|
340
|
+
await bridge.modelObservations;
|
|
341
|
+
if (bridge.modelObservationError) throw bridge.modelObservationError;
|
|
342
|
+
await observeModel({ journal: options.journal ?? bridge.options?.journal, harness: "codex-desktop", sessionId: id,
|
|
343
|
+
parentSessionId: bridge.descendants?.get(id)?.parent_provider_session_id ?? null, requested: profile(options), observed, source });
|
|
344
|
+
return observed;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export async function createSession(options) {
|
|
348
|
+
return await withBridge(options, async (bridge) => await createSessionWithBridge(bridge, options));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function createSessionWithBridge(bridge, options) {
|
|
352
|
+
const cwd = absolute(options.cwd, "--cwd");
|
|
353
|
+
// dd-codex is the eval-only adapter. Its CODEX_HOME is generated by
|
|
354
|
+
// dd-flow, so trust only that managed PreToolUse hook for this thread and
|
|
355
|
+
// do not load user plugin hooks into the isolated evaluator.
|
|
356
|
+
const result = await requestConfiguredThread(bridge, "thread/start", {
|
|
357
|
+
cwd,
|
|
358
|
+
model: options.model ?? null,
|
|
359
|
+
approvalPolicy: "never",
|
|
360
|
+
sandbox: "danger-full-access",
|
|
361
|
+
ephemeral: false
|
|
362
|
+
});
|
|
363
|
+
// ThreadStartParams has no reasoning-effort field. The first turn applies
|
|
364
|
+
// it, so reporting the Desktop default here would be a false profile drift.
|
|
365
|
+
return { harness: "codex-desktop", provider_session_id: threadId(result), adapter_session_id: threadId(result), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, threadId(result), { model: result.model ?? null, reasoning: result.reasoningEffort ?? null, provider: result.modelProvider ?? null }, "thread/start.response"), cwd: result.cwd ?? cwd };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function promptSession(options) {
|
|
369
|
+
return await withBridge(options, async (bridge) => await promptSessionWithBridge(bridge, options));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function startSession(options) {
|
|
373
|
+
return await withBridge(options, async (bridge) => await startSessionWithBridge(bridge, options));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export async function startSessionWithBridge(bridge, options) {
|
|
377
|
+
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
378
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
379
|
+
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
380
|
+
bridge.finalMessages?.delete(sessionId);
|
|
381
|
+
options.assertDispatch?.();
|
|
382
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
383
|
+
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);
|
|
384
|
+
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
385
|
+
if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
|
|
386
|
+
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 } };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function promptSessionWithBridge(bridge, options) {
|
|
390
|
+
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
391
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
392
|
+
// A final-message fallback belongs only to the Turn that produced it. A
|
|
393
|
+
// new Turn may start before app-server emits its first item notification;
|
|
394
|
+
// clearing here prevents the previous receipt from completing the new Turn.
|
|
395
|
+
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
396
|
+
bridge.finalMessages?.delete(sessionId);
|
|
397
|
+
options.assertDispatch?.();
|
|
398
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
399
|
+
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);
|
|
400
|
+
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
401
|
+
const completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd);
|
|
402
|
+
const thread = await readCompletedThread(bridge, sessionId);
|
|
403
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
404
|
+
const assistantText = latestAssistantText(thread) ?? latestAssistantText(completed);
|
|
405
|
+
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: directChildren(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") };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
|
|
409
|
+
const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
|
|
410
|
+
const thread = value?.thread ?? value;
|
|
411
|
+
if (thread?.status?.type !== "notLoaded") return;
|
|
412
|
+
await requestConfiguredThread(bridge, "thread/resume", {
|
|
413
|
+
threadId: sessionId,
|
|
414
|
+
cwd,
|
|
415
|
+
model: options.model ?? null,
|
|
416
|
+
approvalPolicy: "never",
|
|
417
|
+
sandbox: "danger-full-access"
|
|
418
|
+
}, 30_000);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
|
|
422
|
+
const clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) });
|
|
423
|
+
// app-server can briefly expose the new Turn as `interrupted` between the
|
|
424
|
+
// turn/start response and its turn/started notification. Give that snapshot
|
|
425
|
+
// one event-loop grace window before treating failure as authoritative.
|
|
426
|
+
const failureNotBefore = Date.now() + 1_000;
|
|
427
|
+
let nextStoredReadAt = Date.now();
|
|
428
|
+
while (!clock.sample(bridge.activity?.get(sessionId))) {
|
|
429
|
+
bridge.assertHookHealthy?.(sessionId);
|
|
430
|
+
const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed") return terminalTurn(known.value, turnId, bridge);
|
|
431
|
+
if (Date.now() >= nextStoredReadAt) {
|
|
432
|
+
const stored = await readTurn(bridge, sessionId, turnId, false);
|
|
433
|
+
if (stored?.turn && isAuthoritativeTerminal(bridge, turnId, stored.turn.status, stored.threadStatus, failureNotBefore)) return terminalTurn({ turn: stored.turn }, turnId, bridge);
|
|
434
|
+
// A loaded app-server can retain a stale `active` Thread state after a
|
|
435
|
+
// provider-side interruption. The persisted turn page is independent of
|
|
436
|
+
// that live subscription and is the compact authority for its terminal
|
|
437
|
+
// status; unlike `includeTurns`, it never hydrates the whole history.
|
|
438
|
+
const persisted = await readPersistedTurn(bridge, sessionId, turnId);
|
|
439
|
+
if (persisted && isAuthoritativeTerminal(bridge, turnId, persisted.status, stored?.threadStatus, failureNotBefore)) return terminalTurn({ turn: persisted }, turnId, bridge);
|
|
440
|
+
// Desktop may unload an idle Thread immediately after an interrupted
|
|
441
|
+
// Turn. Reload it once before polling again: otherwise the compact
|
|
442
|
+
// status remains `notLoaded` forever and hides the terminal Turn.
|
|
443
|
+
if (stored?.threadStatus === "notLoaded") {
|
|
444
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
445
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
446
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
447
|
+
}
|
|
448
|
+
// Some app-server versions correctly omit turns for a compact read.
|
|
449
|
+
// Once the thread is idle, hydrate exactly once to recover the terminal
|
|
450
|
+
// turn instead of waiting for the global timeout.
|
|
451
|
+
if (stored?.threadStatus === "idle") {
|
|
452
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
453
|
+
// "idle" only describes the Thread. Some app-server builds retain
|
|
454
|
+
// an inProgress Turn after emitting the final agent message; treating
|
|
455
|
+
// that stale Turn as terminal incorrectly aborts an otherwise usable
|
|
456
|
+
// worker session.
|
|
457
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
458
|
+
}
|
|
459
|
+
// Some app-server builds emit a final agent message but omit both the
|
|
460
|
+
// terminal event and the compact turn record. Wait briefly, hydrate once,
|
|
461
|
+
// then preserve that message as the terminal receipt if still omitted.
|
|
462
|
+
const final = bridge.finalMessages?.get(sessionId);
|
|
463
|
+
// Agent messages are streamed as completed items while a Turn is still
|
|
464
|
+
// reasoning and calling tools. They are terminal evidence only after
|
|
465
|
+
// app-server reports the Thread idle.
|
|
466
|
+
if (final && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
|
|
467
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
468
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
469
|
+
return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, turnId, bridge);
|
|
470
|
+
}
|
|
471
|
+
nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
|
|
472
|
+
}
|
|
473
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
474
|
+
}
|
|
475
|
+
fail("Codex turn timed out", "turn_timeout");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function terminalTurn(value, turnId, bridge) {
|
|
479
|
+
// A terminal event may arrive while an older snapshot RPC is in flight.
|
|
480
|
+
const observed = bridge.turns.get(turnId);
|
|
481
|
+
if (observed?.status === "completed") value = observed.value;
|
|
482
|
+
const turn = value?.turn ?? value;
|
|
483
|
+
if (turn?.status && turn.status !== "completed") fail(`Codex turn ${turnId ?? "unknown"} ended as ${turn.status}`, "turn_interrupted", { turn_id: turn.id ?? turnId ?? null, terminal_status: turn.status, provider_error: turn.error ?? turn.codexErrorInfo ?? null });
|
|
484
|
+
return value;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isTerminalTurnStatus(status) { return ["completed", "failed", "interrupted", "cancelled"].includes(status); }
|
|
488
|
+
function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNotBefore) {
|
|
489
|
+
if (!isTerminalTurnStatus(status)) return false;
|
|
490
|
+
if (status === "completed") return true;
|
|
491
|
+
if (Date.now() < failureNotBefore) return false;
|
|
492
|
+
return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function readTurn(bridge, sessionId, turnId, includeTurns) {
|
|
496
|
+
if (!turnId) return null;
|
|
497
|
+
try {
|
|
498
|
+
const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
|
|
499
|
+
const thread = result?.thread ?? result;
|
|
500
|
+
return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
|
|
501
|
+
} catch { bridge.assertHookHealthy?.(sessionId); return null; }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function readPersistedTurn(bridge, sessionId, turnId) {
|
|
505
|
+
try {
|
|
506
|
+
const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
|
|
507
|
+
return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
|
|
508
|
+
} catch { bridge.assertHookHealthy?.(sessionId); return null; }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function readCompletedThread(bridge, sessionId) {
|
|
512
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
513
|
+
try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
|
|
514
|
+
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)); }
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function latestAssistantText(value) {
|
|
520
|
+
const messages = value?.messages ?? value?.items ?? value?.turn?.items ?? [];
|
|
521
|
+
if (!Array.isArray(messages)) return null;
|
|
522
|
+
for (const message of [...messages].reverse()) {
|
|
523
|
+
if (message?.role !== "assistant" && message?.type !== "agentMessage") continue;
|
|
524
|
+
const parts = message.content ?? message.parts ?? [];
|
|
525
|
+
if (typeof message.text === "string") return message.text;
|
|
526
|
+
if (Array.isArray(parts)) {
|
|
527
|
+
const text = parts.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").filter(Boolean).join("\n");
|
|
528
|
+
if (text) return text;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function inspectSession(options) {
|
|
535
|
+
return await withBridge(options, async (bridge) => await inspectSessionWithBridge(bridge, options));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function inspectSessionWithBridge(bridge, options) {
|
|
539
|
+
const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
|
|
540
|
+
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, thread: threadReceipt(thread), descendants: directChildren(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") };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export async function cancelSession(options) {
|
|
544
|
+
return await withBridge(options, async (bridge) => await cancelSessionWithBridge(bridge, options));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export async function cancelSessionWithBridge(bridge, options) {
|
|
548
|
+
const sessionId = required(options.sessionId, "--session-id");
|
|
549
|
+
let turnId = options.turnId ?? null;
|
|
550
|
+
if (!turnId) {
|
|
551
|
+
const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000);
|
|
552
|
+
const thread = result?.thread ?? result;
|
|
553
|
+
turnId = [...(thread?.turns ?? [])].reverse().find((turn) => turn?.status === "inProgress")?.id ?? null;
|
|
554
|
+
}
|
|
555
|
+
if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
|
|
556
|
+
await bridge.request("turn/interrupt", { threadId: sessionId, turnId });
|
|
557
|
+
// Interrupt acknowledgement is not a provider terminal receipt. Reconcile
|
|
558
|
+
// observes the Thread before the runner records terminal cancellation.
|
|
559
|
+
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, cancelled: true, settled: false };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
|