@deksden-com/dd-flow-cli 0.9.0-beta.22 → 0.9.0-beta.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/help.js +1 -0
  5. package/dist/cli/run-cli.js +3 -0
  6. package/dist/cli.js +0 -0
  7. package/dist/harness-runtime/bin/dd-agy.mjs +34 -0
  8. package/dist/harness-runtime/bin/dd-codex.mjs +22 -0
  9. package/dist/harness-runtime/bin/dd-droid.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-grok.mjs +28 -0
  11. package/dist/harness-runtime/bin/dd-opencode.mjs +18 -0
  12. package/dist/harness-runtime/bin/dd-zcode.mjs +62 -0
  13. package/dist/harness-runtime/lib/daemon-operations.mjs +78 -0
  14. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +259 -0
  15. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  16. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +93 -0
  17. package/dist/harness-runtime/lib/dd-codex.mjs +387 -0
  18. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +123 -0
  19. package/dist/harness-runtime/lib/dd-droid.mjs +435 -0
  20. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +282 -0
  21. package/dist/harness-runtime/lib/dd-grok.mjs +168 -0
  22. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +157 -0
  23. package/dist/harness-runtime/lib/dd-opencode.mjs +80 -0
  24. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +419 -0
  25. package/dist/harness-runtime/lib/dd-zcode.mjs +598 -0
  26. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  27. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  28. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  29. package/dist/harness-runtime/lib/managed-daemon.mjs +119 -0
  30. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +33 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +61 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +28 -0
  39. package/dist/services/code-checks.js +25 -11
  40. package/dist/services/execution-policy.js +61 -0
  41. package/dist/services/external-work-launch.js +76 -0
  42. package/dist/services/harness-adapter.js +31 -14
  43. package/dist/services/runs.js +2 -1
  44. package/dist/services/stage-context.js +2 -2
  45. package/dist/services/vnext-execution-profile.js +38 -3
  46. package/dist/storage/database.js +21 -0
  47. package/package.json +20 -13
@@ -0,0 +1,387 @@
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
+ // Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
10
+ // intentionally one-shot: the provider owns durable Threads, while the caller
11
+ // owns the runner journal and can safely resume after a process restart.
12
+ function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
13
+ function required(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
14
+ function absolute(value, label) { const resolved = path.resolve(required(value, label)); if (!path.isAbsolute(resolved)) fail(`${label} must be absolute`); return resolved; }
15
+ function now() { return new Date().toISOString(); }
16
+
17
+ export function parseCodexCliVersion(value) {
18
+ const match = String(value).trim().match(/^codex-cli\s+(\S+)$/m);
19
+ if (!match) fail("Codex CLI did not report its version", "codex_version_unavailable");
20
+ return match[1];
21
+ }
22
+
23
+ function commandText(bin, args) {
24
+ return new Promise((resolve, reject) => {
25
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
26
+ let stdout = ""; let stderr = "";
27
+ child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
28
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
29
+ child.on("error", reject);
30
+ child.on("exit", (code) => code === 0 ? resolve(stdout) : reject(Object.assign(new Error(`Codex CLI version failed: ${stderr.trim() || code}`), { code: "codex_version_unavailable" })));
31
+ });
32
+ }
33
+
34
+ class Journal {
35
+ 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; }
36
+ 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`)); }
37
+ flush() { return this.pending; }
38
+ }
39
+
40
+ export class CodexBridge {
41
+ constructor(options = {}) { this.options = options; this.nextId = 1; this.pending = 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(); }
42
+
43
+ async start() {
44
+ const bin = this.options.bin ?? process.env.DD_CODEX_BIN ?? "codex";
45
+ 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"] });
46
+ this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.stderr += String(chunk); this.journal.write("stderr", { text: String(chunk) }); });
47
+ this.child.on("error", (error) => this.rejectAll(error));
48
+ 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" })));
49
+ readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
50
+ await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
51
+ }
52
+
53
+ receive(line) {
54
+ let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
55
+ this.observeTopology(message);
56
+ if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
57
+ const params = message.params ?? {}, id = params.threadId;
58
+ let root = id; const seen = new Set();
59
+ while (root && !this.requestedProfiles.has(root) && !seen.has(root)) { seen.add(root); root = this.descendants.get(root)?.parent_provider_session_id; }
60
+ if (root && this.requestedProfiles.has(root)) {
61
+ const settings = params.threadSettings ?? {};
62
+ const rerouted = message.method === "model/rerouted";
63
+ this.modelObservations = this.modelObservations.then(() => observeModel({ journal: this.options.journal, harness: "codex-desktop", sessionId: id,
64
+ parentSessionId: this.descendants.get(id)?.parent_provider_session_id ?? null, requested: this.requestedProfiles.get(root),
65
+ observed: { model: rerouted ? params.toModel : settings.model, provider: settings.modelProvider, reasoning: settings.effort },
66
+ source: message.method, evidence: rerouted ? "response" : "configured", reason: params.reason ?? null, previousModel: params.fromModel ?? null
67
+ })).catch(error => { this.modelObservationError = error; this.rejectAll(error); });
68
+ }
69
+ }
70
+ if (message.id === undefined && (message.method?.startsWith("item/") || message.method === "turn/started" || message.method === "thread/tokenUsage/updated")) {
71
+ this.activity ??= new Map();
72
+ const visited = new Set();
73
+ let id = message.params?.threadId;
74
+ while (id && !visited.has(id)) {
75
+ visited.add(id); this.activity.set(id, (this.activity.get(id) ?? 0) + 1);
76
+ id = this.descendants.get(id)?.parent_provider_session_id;
77
+ }
78
+ }
79
+ // app-server currently returns complete turns even for `includeTurns:
80
+ // false`. The individual item notifications are already journalled, so
81
+ // retain a compact read receipt instead of writing the same transcript on
82
+ // every status poll.
83
+ const request = message.id !== undefined ? this.pending.get(message.id) : null;
84
+ if (request?.method === "thread/read") {
85
+ const thread = message.result?.thread ?? message.result ?? {};
86
+ this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
87
+ id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
88
+ 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 })) : []
89
+ } } });
90
+ } else this.journal.write("inbound", message);
91
+ if (message.id !== undefined && message.method) { void this.answerRequest(message); return; }
92
+ if (message.id !== undefined) {
93
+ const pending = this.pending.get(message.id); if (!pending) return;
94
+ clearTimeout(pending.timer); this.pending.delete(message.id);
95
+ 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 ?? {});
96
+ return;
97
+ }
98
+ if (message.method === "turn/completed") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "completed", value: message.params });
99
+ if (message.method === "turn/started") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "running", value: message.params });
100
+ const threadId = message.params?.threadId;
101
+ if (threadId && message.method === "item/started") this.finalMessages.delete(threadId);
102
+ if (threadId && message.method === "item/completed" && message.params?.item?.type === "agentMessage") this.finalMessages.set(threadId, { item: message.params.item, completedAt: Date.now() });
103
+ }
104
+
105
+ // Collaboration child Threads are provider facts. Keep only objects that
106
+ // explicitly carry both a thread id and parent-thread id; this deliberately
107
+ // ignores model prose and unrelated turn/item identifiers.
108
+ observeTopology(value, depth = 0) {
109
+ if (!value || typeof value !== "object" || depth > 8) return;
110
+ if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
111
+ const id = value.threadId ?? value.id ?? null;
112
+ const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
113
+ if (typeof id === "string" && id && typeof parent === "string" && parent) {
114
+ this.descendants.set(id, { provider_session_id: id, parent_provider_session_id: parent, status: value.status?.type ?? value.status ?? "unknown", source: "codex.app_server" });
115
+ }
116
+ for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
117
+ }
118
+
119
+ directChildren(sessionId) {
120
+ return [...this.descendants.values()].filter((child) => child.parent_provider_session_id === sessionId);
121
+ }
122
+
123
+ async answerRequest(message) {
124
+ // Eval runs are non-interactive. A request is evidence for the runner, not
125
+ // permission to continue. Do not silently approve a tool or elicitation.
126
+ // A JSON-RPC error is deliberately used instead of inventing a generic
127
+ // `{ decision: "deny" }` payload: app-server request methods have distinct
128
+ // result schemas, while every one must fail closed in a non-interactive
129
+ // eval. The runner can classify this journalled request as infrastructure
130
+ // or unexpected interaction without corrupting the provider protocol.
131
+ const error = { code: -32001, message: `dd-codex refuses interactive request: ${message.method}` };
132
+ this.journal.write("request_refused", { method: message.method, params: message.params, error });
133
+ this.send({ jsonrpc: "2.0", id: message.id, error });
134
+ }
135
+
136
+ request(method, params = {}, timeoutMs = this.options.timeoutMs ?? 120_000) {
137
+ const id = this.nextId++;
138
+ return new Promise((resolve, reject) => {
139
+ const timer = observedTimeout(() => { this.pending.delete(id); reject(Object.assign(new Error(`${method} timed out`), { code: "rpc_timeout" })); }, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
140
+ this.pending.set(id, { resolve, reject, timer, method }); this.send({ jsonrpc: "2.0", id, method, params });
141
+ });
142
+ }
143
+ send(value) { this.journal.write("outbound", value); this.child.stdin.write(`${JSON.stringify(value)}\n`); }
144
+ rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); }
145
+ async close() {
146
+ if (!this.child) return;
147
+ this.child.stdin.end();
148
+ await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2_000))]);
149
+ await stopProcessGroup(this.child, 2_000);
150
+ await this.modelObservations;
151
+ await this.journal.flush();
152
+ }
153
+ }
154
+
155
+ async function withBridge(options, action) { const bridge = new CodexBridge(options); try { await bridge.start(); return await action(bridge); } finally { await bridge.close(); } }
156
+ function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
157
+ function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
158
+ function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
159
+
160
+ export async function doctor(options = {}) {
161
+ const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"]));
162
+ 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() }));
163
+ }
164
+
165
+ async function observeCodexProfile(bridge, options, id, observed, source) {
166
+ bridge.requestedProfiles?.set(id, profile(options));
167
+ await bridge.modelObservations;
168
+ if (bridge.modelObservationError) throw bridge.modelObservationError;
169
+ await observeModel({ journal: options.journal ?? bridge.options?.journal, harness: "codex-desktop", sessionId: id,
170
+ parentSessionId: bridge.descendants?.get(id)?.parent_provider_session_id ?? null, requested: profile(options), observed, source });
171
+ return observed;
172
+ }
173
+
174
+ export async function createSession(options) {
175
+ return await withBridge(options, async (bridge) => await createSessionWithBridge(bridge, options));
176
+ }
177
+
178
+ export async function createSessionWithBridge(bridge, options) {
179
+ const cwd = absolute(options.cwd, "--cwd");
180
+ // dd-codex is the eval-only adapter. Its CODEX_HOME is generated by
181
+ // dd-flow, so trust only that managed PreToolUse hook for this thread and
182
+ // do not load user plugin hooks into the isolated evaluator.
183
+ const result = await bridge.request("thread/start", {
184
+ cwd,
185
+ model: options.model ?? null,
186
+ approvalPolicy: "never",
187
+ sandbox: "danger-full-access",
188
+ ephemeral: false,
189
+ config: { bypass_hook_trust: true, "features.plugins": false }
190
+ });
191
+ // ThreadStartParams has no reasoning-effort field. The first turn applies
192
+ // it, so reporting the Desktop default here would be a false profile drift.
193
+ 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 };
194
+ }
195
+
196
+ export async function promptSession(options) {
197
+ return await withBridge(options, async (bridge) => await promptSessionWithBridge(bridge, options));
198
+ }
199
+
200
+ export async function startSession(options) {
201
+ return await withBridge(options, async (bridge) => await startSessionWithBridge(bridge, options));
202
+ }
203
+
204
+ export async function startSessionWithBridge(bridge, options) {
205
+ const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
206
+ await ensureThreadLoaded(bridge, sessionId, options, cwd);
207
+ bridge.requestedProfiles?.set(sessionId, profile(options));
208
+ bridge.finalMessages?.delete(sessionId);
209
+ options.assertDispatch?.();
210
+ 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);
211
+ const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
212
+ if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
213
+ 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 } };
214
+ }
215
+
216
+ export async function promptSessionWithBridge(bridge, options) {
217
+ const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
218
+ await ensureThreadLoaded(bridge, sessionId, options, cwd);
219
+ // A final-message fallback belongs only to the Turn that produced it. A
220
+ // new Turn may start before app-server emits its first item notification;
221
+ // clearing here prevents the previous receipt from completing the new Turn.
222
+ bridge.requestedProfiles?.set(sessionId, profile(options));
223
+ bridge.finalMessages?.delete(sessionId);
224
+ options.assertDispatch?.();
225
+ 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);
226
+ const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
227
+ const completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd);
228
+ const thread = await readCompletedThread(bridge, sessionId);
229
+ const assistantText = latestAssistantText(thread) ?? latestAssistantText(completed);
230
+ return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed, ...(thread ? { 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") };
231
+ }
232
+
233
+ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
234
+ const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
235
+ const thread = value?.thread ?? value;
236
+ if (thread?.status?.type !== "notLoaded") return;
237
+ await bridge.request("thread/resume", {
238
+ threadId: sessionId,
239
+ cwd,
240
+ model: options.model ?? null,
241
+ approvalPolicy: "never",
242
+ sandbox: "danger-full-access",
243
+ config: { bypass_hook_trust: true, "features.plugins": false }
244
+ }, 30_000);
245
+ }
246
+
247
+ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
248
+ const clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) });
249
+ // app-server can briefly expose the new Turn as `interrupted` between the
250
+ // turn/start response and its turn/started notification. Give that snapshot
251
+ // one event-loop grace window before treating failure as authoritative.
252
+ const failureNotBefore = Date.now() + 1_000;
253
+ let nextStoredReadAt = Date.now();
254
+ while (!clock.sample(bridge.activity?.get(sessionId))) {
255
+ const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed") return terminalTurn(known.value, turnId, bridge);
256
+ if (Date.now() >= nextStoredReadAt) {
257
+ const stored = await readTurn(bridge, sessionId, turnId, false);
258
+ if (stored?.turn && isAuthoritativeTerminal(bridge, turnId, stored.turn.status, stored.threadStatus, failureNotBefore)) return terminalTurn({ turn: stored.turn }, turnId, bridge);
259
+ // A loaded app-server can retain a stale `active` Thread state after a
260
+ // provider-side interruption. The persisted turn page is independent of
261
+ // that live subscription and is the compact authority for its terminal
262
+ // status; unlike `includeTurns`, it never hydrates the whole history.
263
+ const persisted = await readPersistedTurn(bridge, sessionId, turnId);
264
+ if (persisted && isAuthoritativeTerminal(bridge, turnId, persisted.status, stored?.threadStatus, failureNotBefore)) return terminalTurn({ turn: persisted }, turnId, bridge);
265
+ // Desktop may unload an idle Thread immediately after an interrupted
266
+ // Turn. Reload it once before polling again: otherwise the compact
267
+ // status remains `notLoaded` forever and hides the terminal Turn.
268
+ if (stored?.threadStatus === "notLoaded") {
269
+ await ensureThreadLoaded(bridge, sessionId, options, cwd);
270
+ const hydrated = await readTurn(bridge, sessionId, turnId, true);
271
+ if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
272
+ }
273
+ // Some app-server versions correctly omit turns for a compact read.
274
+ // Once the thread is idle, hydrate exactly once to recover the terminal
275
+ // turn instead of waiting for the global timeout.
276
+ if (stored?.threadStatus === "idle") {
277
+ const hydrated = await readTurn(bridge, sessionId, turnId, true);
278
+ // "idle" only describes the Thread. Some app-server builds retain
279
+ // an inProgress Turn after emitting the final agent message; treating
280
+ // that stale Turn as terminal incorrectly aborts an otherwise usable
281
+ // worker session.
282
+ if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
283
+ }
284
+ // Some app-server builds emit a final agent message but omit both the
285
+ // terminal event and the compact turn record. Wait briefly, hydrate once,
286
+ // then preserve that message as the terminal receipt if still omitted.
287
+ const final = bridge.finalMessages?.get(sessionId);
288
+ // Agent messages are streamed as completed items while a Turn is still
289
+ // reasoning and calling tools. They are terminal evidence only after
290
+ // app-server reports the Thread idle.
291
+ if (final && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
292
+ const hydrated = await readTurn(bridge, sessionId, turnId, true);
293
+ if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
294
+ return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, turnId, bridge);
295
+ }
296
+ nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
297
+ }
298
+ await new Promise((resolve) => setTimeout(resolve, 250));
299
+ }
300
+ fail("Codex turn timed out", "turn_timeout");
301
+ }
302
+
303
+ function terminalTurn(value, turnId, bridge) {
304
+ // A terminal event may arrive while an older snapshot RPC is in flight.
305
+ const observed = bridge.turns.get(turnId);
306
+ if (observed?.status === "completed") value = observed.value;
307
+ const turn = value?.turn ?? value;
308
+ if (turn?.status && turn.status !== "completed") fail(`Codex turn ${turnId ?? "unknown"} ended as ${turn.status}`, "turn_interrupted");
309
+ return value;
310
+ }
311
+
312
+ function isTerminalTurnStatus(status) { return ["completed", "failed", "interrupted", "cancelled"].includes(status); }
313
+ function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNotBefore) {
314
+ if (!isTerminalTurnStatus(status)) return false;
315
+ if (status === "completed") return true;
316
+ if (Date.now() < failureNotBefore) return false;
317
+ return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
318
+ }
319
+
320
+ async function readTurn(bridge, sessionId, turnId, includeTurns) {
321
+ if (!turnId) return null;
322
+ try {
323
+ const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
324
+ const thread = result?.thread ?? result;
325
+ return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
326
+ } catch { return null; }
327
+ }
328
+
329
+ async function readPersistedTurn(bridge, sessionId, turnId) {
330
+ try {
331
+ const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
332
+ return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
333
+ } catch { return null; }
334
+ }
335
+
336
+ async function readCompletedThread(bridge, sessionId) {
337
+ for (let attempt = 0; attempt < 8; attempt += 1) {
338
+ try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
339
+ catch (error) { bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function latestAssistantText(value) {
345
+ const messages = value?.messages ?? value?.items ?? value?.turn?.items ?? [];
346
+ if (!Array.isArray(messages)) return null;
347
+ for (const message of [...messages].reverse()) {
348
+ if (message?.role !== "assistant" && message?.type !== "agentMessage") continue;
349
+ const parts = message.content ?? message.parts ?? [];
350
+ if (typeof message.text === "string") return message.text;
351
+ if (Array.isArray(parts)) {
352
+ const text = parts.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").filter(Boolean).join("\n");
353
+ if (text) return text;
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
359
+ export async function inspectSession(options) {
360
+ return await withBridge(options, async (bridge) => await inspectSessionWithBridge(bridge, options));
361
+ }
362
+
363
+ export async function inspectSessionWithBridge(bridge, options) {
364
+ const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
365
+ return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, status: thread.status ?? null, 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") };
366
+ }
367
+
368
+ export async function cancelSession(options) {
369
+ return await withBridge(options, async (bridge) => await cancelSessionWithBridge(bridge, options));
370
+ }
371
+
372
+ export async function cancelSessionWithBridge(bridge, options) {
373
+ const sessionId = required(options.sessionId, "--session-id");
374
+ let turnId = options.turnId ?? null;
375
+ if (!turnId) {
376
+ const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000);
377
+ const thread = result?.thread ?? result;
378
+ turnId = [...(thread?.turns ?? [])].reverse().find((turn) => turn?.status === "inProgress")?.id ?? null;
379
+ }
380
+ if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
381
+ await bridge.request("turn/interrupt", { threadId: sessionId, turnId });
382
+ // Interrupt acknowledgement is not a provider terminal receipt. Reconcile
383
+ // observes the Thread before the runner records terminal cancellation.
384
+ return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, cancelled: true, settled: false };
385
+ }
386
+
387
+ export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
@@ -0,0 +1,123 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, unlink, realpath } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import net from "node:net";
5
+ import os from "node:os";
6
+ import { DroidError, DroidRuntime, absolute, droidPaths, droidVersion, prepareDroidHome, readJson, removeDroidAuth, processSnapshot } from "./dd-droid.mjs";
7
+ import { durableDaemonDispatch, inspectDaemonOperation } from "./daemon-operations.mjs";
8
+ import { observedTimeout } from "./observation-clock.mjs";
9
+ import { writeJsonAtomic } from "./runner-events.mjs";
10
+ import { errorRecord } from "./operation-errors.mjs";
11
+ import { cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess } from "./managed-daemon.mjs";
12
+
13
+ const REQUEST = "dd-droid/daemon-request@1", RESPONSE = "dd-droid/daemon-response@1";
14
+ async function removeSocket(file) { try { if (!(await lstat(file)).isSocket()) throw new DroidError("unsafe_socket", "Refusing to replace non-socket daemon path"); await unlink(file); } catch (error) { if (error.code !== "ENOENT") throw error; } }
15
+ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000, operationId = process.env.DD_EVAL_OPERATION_ID ?? randomUUID()) {
16
+ const paths = droidPaths(stateDir), request = { schema_id: REQUEST, id: operationId, operation, params };
17
+ return await new Promise((resolve, reject) => {
18
+ const client = net.createConnection(paths.socket); let buffer = "", settled = false;
19
+ const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value); };
20
+ const timer = operation === "session.prompt" ? null : observedTimeout(() => finish(new DroidError("daemon_timeout", `${operation} response was not observed`, { operation_id: operationId })), timeoutMs);
21
+ client.setEncoding("utf8"); client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
22
+ client.on("error", error => finish(new DroidError("daemon_not_running", error.message)));
23
+ client.on("end", () => finish(new DroidError("daemon_connection_closed", "Droid daemon closed the connection before its reply")));
24
+ client.on("close", () => finish(new DroidError("daemon_connection_closed", "Droid daemon connection closed")));
25
+ client.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; try { const response = JSON.parse(buffer.slice(0, index)); if (response.schema_id !== RESPONSE || response.id !== operationId) throw new DroidError("daemon_protocol", "Droid daemon returned an unrelated response"); response.ok ? finish(null, response.result) : finish(Object.assign(new Error(response.error.message), response.error)); } catch (error) { finish(error); } });
26
+ });
27
+ }
28
+ function sameConfig(a, b) { return ["cwd", "bin", "model", "reasoning", "mode", "provider", "ddFlowBin", "ddFlowHome", "resourceHome", "projectRoot", "noFlow"].every(key => a?.[key] === b[key]); }
29
+ export async function startDaemon(options) {
30
+ const paths = droidPaths(options.stateDir), noFlow = options.noFlow === true || (!options.ddFlowBin && !options.ddFlowHome);
31
+ const config = { stateDir: paths.dir, home: paths.home, bin: options.bin ?? process.env.DD_DROID_BIN ?? "droid", cwd: absolute(options.cwd, "--cwd"), projectRoot: absolute(options.projectRoot ?? options.cwd, "--project-root"), journal: absolute(options.journal ?? `${paths.dir}/events.jsonl`, "--journal"), entryPath: absolute(options.entryPath, "adapter entry"), authHome: absolute(options.authHome ?? os.homedir(), "auth home"), model: options.model ?? "gpt-5.6-sol", reasoning: options.reasoning ?? "high", mode: options.mode ?? "auto-high", provider: options.provider ?? "openai", noFlow, ddFlowBin: noFlow ? null : absolute(options.ddFlowBin, "--dd-flow-bin"), ddFlowHome: noFlow ? null : absolute(options.ddFlowHome, "--dd-flow-home"), resourceHome: noFlow ? null : process.env.DD_FLOW_RESOURCE_HOME ?? null, inactivityMs: options.timeoutMs ?? 600_000 };
32
+ config.cwd = await realpath(config.cwd); config.projectRoot = await realpath(config.projectRoot);
33
+ if (config.mode !== "auto-high") throw new DroidError("droid_mode_unsupported", "The qualified Droid profile requires auto-high");
34
+ await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
35
+ try { const live = await callDaemon(paths.dir, "daemon.status", {}, 1_000); if (!sameConfig(live.config, config)) throw new DroidError("daemon_config_mismatch", "Droid daemon already uses another configuration"); return { ...live, already_running: true }; } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; }
36
+ const previous = await readJson(paths.state);
37
+ if (previous) {
38
+ const rows = await processSnapshot();
39
+ if (rows.some(row => !row.zombie && ([previous.pid, previous.provider_pid].includes(row.pid) || previous.owned_processes?.some(old => old.pid === row.pid && old.started === row.started)))) throw new DroidError("invalid_harness_crash", "Previous daemon or provider ownership remains; refusing another owner");
40
+ }
41
+ if (["clean", "unclean"].includes(previous?.shutdown_state)) await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
42
+ if (previous && !["failed", "starting", "clean"].includes(previous.shutdown_state)) throw new DroidError("invalid_harness_crash", "Previous Droid ownership must be reconciled before another daemon starts");
43
+ config.daemonId = randomUUID(); config.version = await droidVersion(config.bin);
44
+ await removeSocket(paths.socket); const home = await prepareDroidHome(config); Object.assign(config, home);
45
+ const resource = await registerDaemonProcess(config, { kind: "droid-daemon", owner: `droid:${config.daemonId}`, operation: `droid-daemon:${config.daemonId}`, stdout: paths.log, stderr: paths.log });
46
+ await writeJsonAtomic(paths.state, { schema_id: "dd-droid/daemon-state@1", config, daemon_id: config.daemonId, shutdown_state: "starting", sessions: previous?.sessions ?? [], root_session_id: previous?.root_session_id ?? null, descendants: previous?.descendants ?? [], last_result: previous?.last_result ?? null, resource_process: resource });
47
+ const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [config.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
48
+ try {
49
+ await confirmDaemonProcess(config, resource, child);
50
+ const deadline = Date.now() + 30_000;
51
+ while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 100)); const saved = await readJson(paths.state); if (saved?.startup_error) throw Object.assign(new Error(saved.startup_error.message), saved.startup_error); try { return await callDaemon(paths.dir, "daemon.status", {}, 1_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } }
52
+ throw new DroidError("daemon_start_failed", "Droid daemon did not become ready");
53
+ } catch (error) { await cleanupFailedStart(config, resource, child, error); throw error; }
54
+ }
55
+ export async function stopDaemon(options) { return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, 30_000), droidPaths(options.stateDir).socket, 30_000); }
56
+ export async function serveDaemon(stateDir) {
57
+ const paths = droidPaths(stateDir), state = await readJson(paths.state); if (!state?.config) throw new DroidError("daemon_state_missing", "Missing Droid daemon state");
58
+ let saving = Promise.resolve(), busy = null, stopping = false;
59
+ const save = patch => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString() }); const snapshot = structuredClone(state); saving = saving.then(() => writeJsonAtomic(paths.state, snapshot)); return saving; };
60
+ const runtime = new DroidRuntime(state.config, state, save);
61
+ const dispatch = async request => {
62
+ const params = request.params ?? {}, operation = request.operation;
63
+ if (operation === "daemon.status") { let observationError; try { await runtime.refreshTopology(); } catch (error) { observationError = errorRecord(error); } return { observation_error: observationError ?? null, daemon_id: state.daemon_id, pid: process.pid, config: state.config, shutdown_state: state.shutdown_state, provider_ready: Boolean(runtime.child && !runtime.closed), active_operation: busy, active_tree: Boolean(runtime.active || runtime.workingState !== "idle" || runtime.topologyIncomplete || [...runtime.topology.values()].some(child => !["completed", "cancelled", "failed"].includes(child.status))), sessions: runtime.rootId ? [runtime.rootId] : [], provider_pid: runtime.closed ? null : runtime.child?.pid }; }
64
+ if (operation === "hook.observe") return await runtime.observeHook(params.payload, params.eventId);
65
+ if (operation === "session.inspect") return await runtime.inspect(params.sessionId);
66
+ if (operation === "session.cancel") {
67
+ runtime.requireIdentity(params.sessionId); const cleanup = await runtime.closeTree(true); let inspected;
68
+ try { inspected = await runtime.inspect(params.sessionId, { ignoreActive: true }); }
69
+ catch (error) { inspected = { harness: "droid-cli", provider_session_id: params.sessionId, descendants: [...runtime.topology.values()], inspection_error: errorRecord(error) }; }
70
+ return { ...inspected, cancelled: true, settled: cleanup.settled, cleanup };
71
+ }
72
+ if (operation === "daemon.stop") {
73
+ if (stopping) throw new DroidError("operation_busy", "Droid shutdown is already in progress");
74
+ if (!params.cancelTree && (busy || (runtime.rootId && !(await runtime.inspect(runtime.rootId)).settled))) throw new DroidError("tree_not_settled", "Droid still owns unfinished work");
75
+ stopping = true;
76
+ try {
77
+ const cleanup = await runtime.closeTree(params.cancelTree === true);
78
+ if (runtime.rootId) try { await runtime.inspect(runtime.rootId, { ignoreActive: true }); } catch (error) { cleanup.inspection_error = errorRecord(error); }
79
+ // Native evidence failures remain visible, but cannot strand copied
80
+ // credentials or the daemon after the owned process tree is gone.
81
+ await removeDroidAuth(state.config); const clean = !cleanup.forced && !cleanup.topology_error && !cleanup.inspection_error;
82
+ await save({ shutdown_state: clean ? "clean" : "unclean", active_tree: false, cleanup }); await finishDaemonProcess(state.config, state.resource_process);
83
+ return { stopped: true, clean, settled: true, cleanup, _shutdown: true };
84
+ }
85
+ catch (error) { stopping = false; await save({ shutdown_state: "cleanup_failed", cleanup_error: errorRecord(error) }); throw error; }
86
+ }
87
+ if (busy || stopping) throw new DroidError("operation_busy", "Another Droid operation is active");
88
+ busy = operation; await save({ active_operation: busy });
89
+ try {
90
+ if (operation === "session.create") { if (params.prompt) throw new DroidError("invalid_create", "create does not execute a prompt"); return await runtime.create(request.id); }
91
+ if (operation === "session.resume") return await runtime.resume(params.sessionId);
92
+ if (["session.prompt", "session.start"].includes(operation)) return await runtime.prompt(params.sessionId, params.prompt, request.id);
93
+ throw new DroidError("unknown_operation", `Unknown Droid operation: ${operation}`);
94
+ } finally { busy = null; await save({ active_operation: null }); }
95
+ };
96
+ await removeSocket(paths.socket);
97
+ const server = net.createServer(socket => { socket.setEncoding("utf8"); let buffer = "", consumed = false; socket.on("error", () => {}); socket.on("data", chunk => { if (consumed) return; buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
98
+ void (async () => { let request; try { request = JSON.parse(buffer.slice(0, at)); if (request.schema_id !== REQUEST || typeof request.id !== "string" || typeof request.operation !== "string") throw new DroidError("daemon_protocol", "Malformed Droid daemon request"); let result;
99
+ if (request.operation === "operation.inspect") {
100
+ result = await inspectDaemonOperation(paths.dir, request.params?.operationId);
101
+ if (!["completed", "failed"].includes(result.state)) {
102
+ const recovered = await runtime.recoverOperation(request.params.operationId);
103
+ if (recovered) {
104
+ const terminal = { state: "completed", finished_at: new Date().toISOString(), result: recovered };
105
+ await writeJsonAtomic(`${paths.dir}/operations/${createHash("sha256").update(request.params.operationId).digest("hex")}/result.json`, terminal);
106
+ result = { ...result, ...terminal };
107
+ }
108
+ }
109
+ } else result = await durableDaemonDispatch(paths.dir, request, () => dispatch(request)); const { _shutdown, ...receipt } = result;
110
+ // Close the listening endpoint before replying. `server.close()` keeps
111
+ // this already accepted connection alive, so the caller receives its
112
+ // durable stop receipt while the Unix pathname is atomically retired.
113
+ // Unlinking it from the request handler races that final response and
114
+ // can leave a stop caller observing the socket for its whole timeout.
115
+ if (_shutdown) server.close();
116
+ socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request.id, ok: true, result: receipt })}\n`);
117
+ } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE, id: request?.id, ok: false, error: errorRecord(error) })}\n`); } })();
118
+ }); });
119
+ try { await runtime.start(); await heartbeatDaemonProcess(state.config, state.resource_process); await save({ shutdown_state: "running" }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); }
120
+ catch (error) { let cleanup; try { cleanup = await runtime.closeTree(true); await removeDroidAuth(state.config); } catch (failure) { await save({ shutdown_state: "cleanup_failed", startup_error: errorRecord(error), cleanup_error: errorRecord(failure) }); throw error; } await save({ shutdown_state: "failed", startup_error: errorRecord(error), cleanup }); await finishDaemonProcess(state.config, state.resource_process, "failed", "startup_failed"); throw error; }
121
+ await new Promise(resolve => server.once("close", resolve));
122
+ }
123
+ import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";