@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.74
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 +10 -10
- package/dist/cli/help.js +81 -9
- package/dist/cli/run-cli.js +442 -46
- 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.mjs +141 -0
- package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
- package/dist/harness-runtime/lib/dd-droid.mjs +470 -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/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.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/runtime/context.js +3 -1
- 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/harness-config.schema.json +23 -0
- 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/cleanup.js +8 -5
- package/dist/services/cli-operation-classifier.js +11 -1
- package/dist/services/code-checks.js +543 -96
- package/dist/services/controller-fanout.js +120 -0
- package/dist/services/dashboard.js +9 -0
- package/dist/services/engines.js +50 -19
- package/dist/services/eval-snapshots.js +639 -60
- package/dist/services/execution-policy.js +177 -0
- package/dist/services/external-work-launch.js +110 -0
- package/dist/services/harness-adapter.js +147 -24
- package/dist/services/harness-config.js +68 -0
- package/dist/services/hooks.js +413 -168
- package/dist/services/lanes.js +1 -1
- package/dist/services/lifecycle-command.js +74 -6
- package/dist/services/lifecycle-invocations.js +637 -0
- package/dist/services/managed-daemon-binding.js +38 -0
- package/dist/services/managed-processes.js +138 -22
- package/dist/services/merge-queue.js +6 -6
- package/dist/services/merge-server.js +75 -29
- package/dist/services/migrations.js +1 -1
- package/dist/services/native-daemon-history.js +49 -0
- package/dist/services/native-session-control.js +39 -0
- package/dist/services/projects.js +7 -3
- package/dist/services/prompts.js +4 -2
- package/dist/services/protocols.js +11 -2
- 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 +211 -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 +31 -0
- package/dist/services/run-controller.js +733 -0
- package/dist/services/run-engine-bindings.js +20 -62
- package/dist/services/run-fork.js +124 -0
- package/dist/services/run-observations.js +112 -0
- package/dist/services/run-recovery-runtime.js +69 -0
- package/dist/services/run-recovery.js +327 -0
- package/dist/services/runs.js +280 -40
- package/dist/services/runtime-budget.js +362 -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 +543 -0
- package/dist/services/runtime-scope-stop.js +99 -0
- package/dist/services/runtime-scope-worker.js +210 -0
- package/dist/services/runtime-service.js +96 -0
- package/dist/services/schema-validation.js +12 -12
- package/dist/services/stage-context.js +2 -2
- package/dist/services/stage-lifecycle.js +32 -10
- package/dist/services/stage-pause.js +25 -13
- package/dist/services/usage.js +37 -93
- package/dist/services/vnext-code-review.js +149 -89
- package/dist/services/vnext-code.js +223 -215
- package/dist/services/vnext-execution-profile.js +5 -3
- package/dist/services/vnext-fanout.js +135 -21
- package/dist/services/vnext-merge.js +258 -89
- package/dist/services/vnext-plan-review.js +75 -64
- package/dist/services/vnext-plan.js +77 -29
- package/dist/services/vnext-protocolize.js +21 -14
- package/dist/services/vnext-specify.js +26 -16
- package/dist/services/work-registry.js +592 -154
- package/dist/services/workspace-bootstrap.js +76 -0
- package/dist/shared/errors.js +12 -0
- package/dist/storage/database.js +341 -31
- package/dist/storage/writer-contract.js +77 -0
- package/dist/storage/writer-migration.js +102 -0
- package/package.json +20 -13
- package/tools/repair-paused-run-status.mjs +59 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { durableDaemonDispatch } from "./daemon-operations.mjs";
|
|
6
|
+
import { observedTimeout } from "./observation-clock.mjs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { waitForSettlement } from "./session-settlement.mjs";
|
|
10
|
+
import { errorRecord } from "./operation-errors.mjs";
|
|
11
|
+
import { CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
|
|
12
|
+
import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
|
|
13
|
+
|
|
14
|
+
const requestSchema = "dd-codex/daemon-request@1";
|
|
15
|
+
const responseSchema = "dd-codex/daemon-response@1";
|
|
16
|
+
const stateSchema = "dd-codex/daemon-state@1";
|
|
17
|
+
const absolute = (value, label) => { if (!value || !path.isAbsolute(value)) throw Object.assign(new Error(`${label} must be an absolute path`), { code: "invalid_path" }); return path.resolve(value); };
|
|
18
|
+
function locations(stateDir) { const dir = absolute(stateDir, "--state-dir"); const local = path.join(dir, "daemon.sock"); return { dir, socket: Buffer.byteLength(local) < 100 ? local : `/tmp/dd-codex-${createHash("sha256").update(dir).digest("hex").slice(0, 24)}.sock`, state: path.join(dir, "daemon.json"), log: path.join(dir, "daemon.log") }; }
|
|
19
|
+
async function readState(file) { try { return JSON.parse(await readFile(file, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
|
|
20
|
+
async function writeState(file, value) { const temp = `${file}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temp, file); }
|
|
21
|
+
async function removeSocket(socket) { try { const info = await lstat(socket); if (!info.isSocket()) throw Object.assign(new Error("refusing to remove non-socket daemon path"), { code: "unsafe_socket" }); await unlink(socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
|
|
22
|
+
const errorPayload = errorRecord;
|
|
23
|
+
const terminalTurn = turn => ["completed", "interrupted", "failed", "cancelled"].includes(turn?.status);
|
|
24
|
+
export function settledCodexSession(status, latestTurn, active) {
|
|
25
|
+
if (status === "idle") return true;
|
|
26
|
+
if (status === "notLoaded") return !latestTurn || terminalTurn(latestTurn);
|
|
27
|
+
// `systemError` alone is not evidence that the provider stopped. A current
|
|
28
|
+
// terminal Turn plus no daemon operation is the narrow settled case.
|
|
29
|
+
return status === "systemError" && !active && terminalTurn(latestTurn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function observeCodexSessions(bridge, options, sessions, active, remember = () => {}) {
|
|
33
|
+
// Expand trusted native topology before profile/history diagnostics: an
|
|
34
|
+
// unavailable root must not hide its already discovered siblings/children.
|
|
35
|
+
for (const id of sessions) for (const child of bridge.directChildren?.(id) ?? []) {
|
|
36
|
+
if (typeof child.provider_session_id !== "string" || !child.provider_session_id.trim()) throw Object.assign(new Error("Codex child has no native identity"), { code: "invalid_session_topology" });
|
|
37
|
+
sessions.add(child.provider_session_id); remember(child.provider_session_id);
|
|
38
|
+
}
|
|
39
|
+
const unsettled = [], observations = [];
|
|
40
|
+
for (const sessionId of sessions) {
|
|
41
|
+
const observed = await inspectSessionWithBridge(bridge, { ...options, sessionId });
|
|
42
|
+
for (const child of observed.descendants ?? []) { sessions.add(child.provider_session_id); remember(child.provider_session_id); }
|
|
43
|
+
const status = observed.status?.type ?? observed.status;
|
|
44
|
+
const stored = ["notLoaded", "systemError"].includes(status) ? await bridge.request("thread/turns/list", { threadId: sessionId, limit: 1, sortDirection: "desc", itemsView: "summary" }, 5_000) : null;
|
|
45
|
+
const settled = settledCodexSession(status, stored?.data?.[0] ?? null, active());
|
|
46
|
+
if (!settled) unsettled.push(sessionId);
|
|
47
|
+
observations.push({ ...observed, settled });
|
|
48
|
+
}
|
|
49
|
+
return { sessions: unsettled.reverse(), active: Boolean(active()), observations };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function inspectCodexTree(bridge, options, { remember = () => {}, active = () => null } = {}) {
|
|
53
|
+
const sessions = new Set([options.sessionId]);
|
|
54
|
+
const observed = await observeCodexSessions(bridge, options, sessions, active, remember);
|
|
55
|
+
return { ...observed.observations[0], settled: !observed.active && !observed.sessions.length, unsettled_sessions: observed.sessions, tree_session_ids: [...sessions], tree_observations: observed.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function cancelCodexTree(bridge, options, { remember = () => {}, active = () => null, timeoutMs = 5_000 } = {}) {
|
|
59
|
+
const sessions = new Set([options.sessionId]);
|
|
60
|
+
const settlement = await waitForSettlement({
|
|
61
|
+
timeoutMs, knownSessions: () => [...sessions],
|
|
62
|
+
cancel: async sessionId => {
|
|
63
|
+
try { await cancelSessionWithBridge(bridge, { ...options, sessionId, turnId: null }); }
|
|
64
|
+
catch (error) { if (error.code !== "turn_not_active") throw error; }
|
|
65
|
+
},
|
|
66
|
+
observe: () => observeCodexSessions(bridge, options, sessions, active, remember)
|
|
67
|
+
});
|
|
68
|
+
return { harness: "codex-desktop", provider_session_id: options.sessionId, adapter_session_id: options.sessionId, cancelled: true, settled: true, tree_session_ids: [...sessions], cleanup_errors: settlement.cleanup_errors, tree_observations: settlement.observations.map(item => ({ provider_session_id: item.provider_session_id, status: item.status, settled: item.settled })) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
|
|
72
|
+
const target = locations(stateDir); const request = { schema_id: requestSchema, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
|
|
73
|
+
return await new Promise((resolve, reject) => {
|
|
74
|
+
const client = net.createConnection(target.socket); let buffer = ""; let settled = false; const timer = operation === "session.prompt" ? null : observedTimeout(() => finish(Object.assign(new Error(`${operation} timed out`), { code: "daemon_timeout", retryable: true })), timeoutMs);
|
|
75
|
+
const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value); };
|
|
76
|
+
client.setEncoding("utf8"); client.on("connect", () => client.write(`${JSON.stringify(request)}\n`)); client.on("error", (error) => finish(Object.assign(error, { code: "daemon_not_running", retryable: true })));
|
|
77
|
+
client.on("end", () => finish(Object.assign(new Error("daemon closed the request without a response"), { code: "daemon_connection_closed", retryable: true })));
|
|
78
|
+
client.on("close", () => finish(Object.assign(new Error("daemon connection closed without a response"), { code: "daemon_connection_closed", retryable: true })));
|
|
79
|
+
client.on("data", (chunk) => { buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; try { const response = JSON.parse(buffer.slice(0, at)); if (response.schema_id !== responseSchema || response.id !== request.id) throw new Error("invalid daemon response"); response.ok ? finish(null, response.result) : finish(Object.assign(new Error(response.error?.message ?? "daemon error"), response.error)); } catch (error) { finish(Object.assign(error, { code: "daemon_protocol" })); } });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function startDaemon(options) {
|
|
84
|
+
const paths = locations(options.stateDir); const cwd = absolute(options.cwd, "--cwd"); const config = { cwd, journal: absolute(options.journal, "--journal"), bin: options.bin ?? null, ddFlowBin: options.ddFlowBin ? absolute(options.ddFlowBin, "--dd-flow-bin") : null, ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null, projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : cwd, resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {} };
|
|
85
|
+
await prepareRuntimeOwner(config, paths.dir);
|
|
86
|
+
await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
|
|
87
|
+
try { const status = await callDaemon(paths.dir, "daemon.status", {}, 1_000); if (JSON.stringify(status.config) !== JSON.stringify(config)) throw Object.assign(new Error("running daemon has a different configuration"), { code: "daemon_config_mismatch" }); return { ...status, already_running: true }; } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; }
|
|
88
|
+
const previous = await readState(paths.state); if (previous?.shutdown_state === "running") throw Object.assign(new Error("previous dd-codex daemon died; do not reuse its state directory"), { code: "invalid_harness_crash" });
|
|
89
|
+
if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
|
|
90
|
+
await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
|
|
91
|
+
const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
|
|
92
|
+
try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed" }); }
|
|
93
|
+
catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
|
|
94
|
+
}
|
|
95
|
+
export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
|
|
96
|
+
|
|
97
|
+
export async function serveDaemon(stateDir) {
|
|
98
|
+
const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
|
|
99
|
+
const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, DD_FLOW_DAEMON_ID: state.daemon_id } }); await bridge.start(); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
|
|
100
|
+
const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.then(() => writeState(paths.state, snapshot)).then(() => heartbeatDaemonProcess(snapshot.config, snapshot.resource_process).catch(() => {})); return persist; };
|
|
101
|
+
const dispatch = async (operation, params) => {
|
|
102
|
+
const assertDispatch = captureDispatchGuard(bridge);
|
|
103
|
+
if (operation === "daemon.status") return { daemon_id: state.daemon_id, pid: process.pid, cwd: state.cwd, journal: state.journal, config: state.config, sessions: [...sessions], active_operation: active, shutdown_state: state.shutdown_state };
|
|
104
|
+
// The runner may need to retire an orphaned daemon after its caller was
|
|
105
|
+
// interrupted. `daemon.stop` is the one control operation that remains
|
|
106
|
+
// available while a request is marked active; callers must first prove the
|
|
107
|
+
// provider Session itself is idle.
|
|
108
|
+
if (operation === "daemon.stop") {
|
|
109
|
+
if (params.cancelTree) cancelPendingDispatch(bridge);
|
|
110
|
+
if (active && !params.cancelTree) throw Object.assign(new Error("productive operation is running"), { code: "tree_not_settled" });
|
|
111
|
+
await waitForSettlement({
|
|
112
|
+
knownSessions: () => [...sessions],
|
|
113
|
+
observe: () => observeCodexSessions(bridge, state.config, sessions, () => active),
|
|
114
|
+
...(params.cancelTree ? { cancel: async sessionId => { try { await cancelSessionWithBridge(bridge, { ...state.config, sessionId }); } catch (error) { if (error.code !== "turn_not_active") throw error; } } } : {})
|
|
115
|
+
});
|
|
116
|
+
await save({ active_tree: false });
|
|
117
|
+
return { stopped: true, clean: true, _shutdown: true };
|
|
118
|
+
}
|
|
119
|
+
if (active && !["session.inspect", "session.cancel"].includes(operation)) throw Object.assign(new Error(`${active} is in progress`), { code: "operation_busy", retryable: true });
|
|
120
|
+
if (operation === "session.create") { active = operation; try { await save({ active_operation: active }); const result = await createSessionWithBridge(bridge, { ...state.config, ...params }); sessions.add(result.provider_session_id); return result; } finally { active = null; await save({ active_operation: null }); } }
|
|
121
|
+
if (operation === "session.prompt") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active }); return await promptSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch }); } finally { active = null; await save({ active_operation: null }); } }
|
|
122
|
+
if (operation === "session.start") { sessions.add(params.sessionId); active = operation; try { await save({ active_operation: active }); return await startSessionWithBridge(bridge, { ...state.config, ...params, assertDispatch }); } finally { active = null; await save({ active_operation: null }); } }
|
|
123
|
+
if (operation === "session.inspect" || operation === "session.resume") {
|
|
124
|
+
const owned = new Set(sessions);
|
|
125
|
+
for (const id of owned) for (const child of bridge.directChildren?.(id) ?? []) owned.add(child.provider_session_id);
|
|
126
|
+
if (!owned.has(params.sessionId)) throw Object.assign(new Error("inspection Session is not owned by this daemon"), { code: "session_identity_mismatch" });
|
|
127
|
+
try { return await inspectCodexTree(bridge, { ...state.config, ...params }, { remember: id => sessions.add(id), active: () => active }); }
|
|
128
|
+
finally { await save(); }
|
|
129
|
+
}
|
|
130
|
+
if (operation === "session.cancel") {
|
|
131
|
+
if (!sessions.has(params.sessionId)) throw Object.assign(new Error("cancellation Session is not owned by this daemon"), { code: "session_identity_mismatch" });
|
|
132
|
+
cancelPendingDispatch(bridge);
|
|
133
|
+
try { return await cancelCodexTree(bridge, { ...state.config, ...params }, { remember: id => sessions.add(id), active: () => active }); }
|
|
134
|
+
finally { await save(); }
|
|
135
|
+
}
|
|
136
|
+
throw Object.assign(new Error(`unknown operation: ${operation}`), { code: "unknown_operation" });
|
|
137
|
+
};
|
|
138
|
+
await removeSocket(paths.socket); server = net.createServer((connection) => { connection.setEncoding("utf8"); let buffer = ""; connection.on("data", (chunk) => { buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); void (async () => { let request; try { request = JSON.parse(line); if (request.schema_id !== requestSchema || !request.id || !request.operation) throw Object.assign(new Error("invalid daemon request"), { code: "daemon_protocol" }); const result = await durableDaemonDispatch(paths.dir, request, () => dispatch(request.operation, request.params ?? {}), () => dispatch("session.inspect", request.params ?? {})); connection.end(`${JSON.stringify({ schema_id: responseSchema, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`); if (result._shutdown) { await bridge.close(); await save({ shutdown_state: "clean", stopped_at: new Date().toISOString() }); await finishDaemonProcess(state.config, state.resource_process); server.close(() => process.exit(0)); await removeSocket(paths.socket); } } catch (error) { if (connection.writableEnded) { await save({ shutdown_state: "cleanup_failed", cleanup_error: errorPayload(error) }); return; } connection.end(`${JSON.stringify({ schema_id: responseSchema, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`); } })(); }); });
|
|
139
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600); await save({ shutdown_state: "running", active_operation: null }); return await new Promise(() => {});
|
|
140
|
+
}
|
|
141
|
+
import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
|
|
@@ -0,0 +1,438 @@
|
|
|
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
|
+
|
|
11
|
+
// Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
|
|
12
|
+
// intentionally one-shot: the provider owns durable Threads, while the caller
|
|
13
|
+
// owns the runner journal and can safely resume after a process restart.
|
|
14
|
+
function fail(message, code, details) { const error = new Error(message); if (code) error.code = code; if (details) error.details = details; throw error; }
|
|
15
|
+
function required(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
|
|
16
|
+
function absolute(value, label) { const resolved = path.resolve(required(value, label)); if (!path.isAbsolute(resolved)) fail(`${label} must be absolute`); return resolved; }
|
|
17
|
+
function now() { return new Date().toISOString(); }
|
|
18
|
+
function responseSummary(message) {
|
|
19
|
+
const result = message?.result;
|
|
20
|
+
return { request_id: message?.id ?? null, has_result: result !== undefined,
|
|
21
|
+
result_keys: result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).slice(0, 32) : [], error: message?.error ?? null };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseCodexCliVersion(value) {
|
|
25
|
+
const match = String(value).trim().match(/^codex-cli\s+(\S+)$/m);
|
|
26
|
+
if (!match) fail("Codex CLI did not report its version", "codex_version_unavailable");
|
|
27
|
+
return match[1];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function commandText(bin, args) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
33
|
+
let stdout = ""; let stderr = "";
|
|
34
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
35
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
36
|
+
child.on("error", reject);
|
|
37
|
+
child.on("exit", (code) => code === 0 ? resolve(stdout) : reject(Object.assign(new Error(`Codex CLI version failed: ${stderr.trim() || code}`), { code: "codex_version_unavailable" })));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class Journal {
|
|
42
|
+
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; }
|
|
43
|
+
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`)); }
|
|
44
|
+
flush() { return this.pending; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class CodexBridge {
|
|
48
|
+
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(); }
|
|
49
|
+
|
|
50
|
+
async start() {
|
|
51
|
+
const bin = this.options.bin ?? process.env.DD_CODEX_BIN ?? "codex";
|
|
52
|
+
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"] });
|
|
53
|
+
this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.stderr += String(chunk); this.journal.write("stderr", { text: String(chunk) }); });
|
|
54
|
+
this.child.stdin?.on("error", (error) => this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })));
|
|
55
|
+
this.child.on("error", (error) => this.rejectAll(error));
|
|
56
|
+
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" })));
|
|
57
|
+
readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
|
|
58
|
+
await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
receive(line) {
|
|
62
|
+
let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
|
|
63
|
+
this.observeTopology(message);
|
|
64
|
+
if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
|
|
65
|
+
const params = message.params ?? {}, id = params.threadId;
|
|
66
|
+
let root = id; const seen = new Set();
|
|
67
|
+
while (root && !this.requestedProfiles.has(root) && !seen.has(root)) { seen.add(root); root = this.descendants.get(root)?.parent_provider_session_id; }
|
|
68
|
+
if (root && this.requestedProfiles.has(root)) {
|
|
69
|
+
const settings = params.threadSettings ?? {};
|
|
70
|
+
const rerouted = message.method === "model/rerouted";
|
|
71
|
+
this.modelObservations = this.modelObservations.then(() => observeModel({ journal: this.options.journal, harness: "codex-desktop", sessionId: id,
|
|
72
|
+
parentSessionId: this.descendants.get(id)?.parent_provider_session_id ?? null, requested: this.requestedProfiles.get(root),
|
|
73
|
+
observed: { model: rerouted ? params.toModel : settings.model, provider: settings.modelProvider, reasoning: settings.effort },
|
|
74
|
+
source: message.method, evidence: rerouted ? "response" : "configured", reason: params.reason ?? null, previousModel: params.fromModel ?? null
|
|
75
|
+
})).catch(error => { this.modelObservationError = error; this.rejectAll(error); });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (message.id === undefined && (message.method?.startsWith("item/") || message.method === "turn/started" || message.method === "thread/tokenUsage/updated")) {
|
|
79
|
+
this.activity ??= new Map();
|
|
80
|
+
const visited = new Set();
|
|
81
|
+
let id = message.params?.threadId;
|
|
82
|
+
while (id && !visited.has(id)) {
|
|
83
|
+
visited.add(id); this.activity.set(id, (this.activity.get(id) ?? 0) + 1);
|
|
84
|
+
id = this.descendants.get(id)?.parent_provider_session_id;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// app-server currently returns complete turns even for `includeTurns:
|
|
88
|
+
// false`. The individual item notifications are already journalled, so
|
|
89
|
+
// retain a compact read receipt instead of writing the same transcript on
|
|
90
|
+
// every status poll.
|
|
91
|
+
const request = message.id !== undefined ? this.pending.get(message.id) : null;
|
|
92
|
+
if (request?.method === "thread/read") {
|
|
93
|
+
const thread = message.result?.thread ?? message.result ?? {};
|
|
94
|
+
this.journal.write("inbound", { jsonrpc: message.jsonrpc, id: message.id, result: { thread: {
|
|
95
|
+
id: thread.id ?? null, status: thread.status ?? null, model: thread.model ?? null, modelProvider: thread.modelProvider ?? null, reasoningEffort: thread.reasoningEffort ?? null, updatedAt: thread.updatedAt ?? null,
|
|
96
|
+
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 })) : []
|
|
97
|
+
} } });
|
|
98
|
+
} else this.journal.write("inbound", message);
|
|
99
|
+
if (message.id !== undefined && message.method) { void this.answerRequest(message); return; }
|
|
100
|
+
if (message.id !== undefined) {
|
|
101
|
+
const pending = this.pending.get(message.id);
|
|
102
|
+
if (!pending) {
|
|
103
|
+
this.lateResponses.set(message.id, message);
|
|
104
|
+
while (this.lateResponses.size > 128) this.lateResponses.delete(this.lateResponses.keys().next().value);
|
|
105
|
+
this.journal.write("late_response", responseSummary(message));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
clearTimeout(pending.timer); this.pending.delete(message.id);
|
|
109
|
+
if (pending.timedOut) this.journal.write("late_response", { ...responseSummary(message), method: pending.method, grace_ms: LATE_RESPONSE_GRACE_MS });
|
|
110
|
+
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 ?? {});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (message.method === "turn/completed") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "completed", value: message.params });
|
|
114
|
+
if (message.method === "turn/started") this.turns.set(message.params?.turn?.id ?? message.params?.threadId, { status: "running", value: message.params });
|
|
115
|
+
const threadId = message.params?.threadId;
|
|
116
|
+
if (threadId && message.method === "item/started") this.finalMessages.delete(threadId);
|
|
117
|
+
if (threadId && message.method === "item/completed" && message.params?.item?.type === "agentMessage") this.finalMessages.set(threadId, { item: message.params.item, completedAt: Date.now() });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Collaboration child Threads are provider facts. Keep only objects that
|
|
121
|
+
// explicitly carry a parent relationship, including native spawn receipts.
|
|
122
|
+
// A wait/send receiver is not necessarily a child of its sender.
|
|
123
|
+
observeTopology(value, depth = 0) {
|
|
124
|
+
if (!value || typeof value !== "object" || depth > 8) return;
|
|
125
|
+
if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
|
|
126
|
+
if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
|
|
127
|
+
typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
|
|
128
|
+
for (const child of value.receiverThreadIds) {
|
|
129
|
+
if (typeof child !== "string" || !child || child === value.senderThreadId) continue;
|
|
130
|
+
this.descendants.set(child, { provider_session_id: child, parent_provider_session_id: value.senderThreadId,
|
|
131
|
+
status: value.agentsStates?.[child]?.status ?? "unknown", source: "codex.app_server" });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const id = value.threadId ?? value.id ?? null;
|
|
135
|
+
const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
|
|
136
|
+
if (typeof id === "string" && id && typeof parent === "string" && parent) {
|
|
137
|
+
this.descendants.set(id, { provider_session_id: id, parent_provider_session_id: parent, status: value.status?.type ?? value.status ?? "unknown", source: "codex.app_server" });
|
|
138
|
+
}
|
|
139
|
+
for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
directChildren(sessionId) {
|
|
143
|
+
return [...this.descendants.values()].filter((child) => child.parent_provider_session_id === sessionId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async answerRequest(message) {
|
|
147
|
+
// Eval runs are non-interactive. A request is evidence for the runner, not
|
|
148
|
+
// permission to continue. Do not silently approve a tool or elicitation.
|
|
149
|
+
// A JSON-RPC error is deliberately used instead of inventing a generic
|
|
150
|
+
// `{ decision: "deny" }` payload: app-server request methods have distinct
|
|
151
|
+
// result schemas, while every one must fail closed in a non-interactive
|
|
152
|
+
// eval. The runner can classify this journalled request as infrastructure
|
|
153
|
+
// or unexpected interaction without corrupting the provider protocol.
|
|
154
|
+
const error = { code: -32001, message: `dd-codex refuses interactive request: ${message.method}` };
|
|
155
|
+
this.journal.write("request_refused", { method: message.method, params: message.params, error });
|
|
156
|
+
this.send({ jsonrpc: "2.0", id: message.id, error });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
request(method, params = {}, timeoutMs = this.options.timeoutMs ?? 120_000) {
|
|
160
|
+
const id = this.nextId++;
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const timer = observedTimeout(() => {
|
|
163
|
+
const pending = this.pending.get(id); if (!pending || pending.timedOut) return;
|
|
164
|
+
pending.timedOut = true;
|
|
165
|
+
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 } });
|
|
166
|
+
this.journal.write("request_deadline", timeoutError.details);
|
|
167
|
+
pending.timer = observedTimeout(() => {
|
|
168
|
+
if (this.pending.get(id) !== pending) return;
|
|
169
|
+
this.pending.delete(id); reject(timeoutError);
|
|
170
|
+
}, LATE_RESPONSE_GRACE_MS);
|
|
171
|
+
}, timeoutMs, { onGap: gap => this.journal.write("observation_gap", { ...gap, method, request_id: id }) });
|
|
172
|
+
this.pending.set(id, { resolve, reject, timer, method }); this.send({ jsonrpc: "2.0", id, method, params });
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
send(value) {
|
|
176
|
+
this.journal.write("outbound", value);
|
|
177
|
+
const stdin = this.child?.stdin;
|
|
178
|
+
if (!stdin || stdin.destroyed) {
|
|
179
|
+
const error = Object.assign(new Error("Codex app-server stdin is closed"), { code: "bridge_pipe_broken" });
|
|
180
|
+
this.rejectAll(error);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
try { stdin.write(`${JSON.stringify(value)}\n`); }
|
|
184
|
+
catch (error) { this.rejectAll(Object.assign(error, { code: error.code ?? "bridge_pipe_broken" })); throw error; }
|
|
185
|
+
}
|
|
186
|
+
rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); } this.pending.clear(); }
|
|
187
|
+
async close() {
|
|
188
|
+
if (!this.child) return;
|
|
189
|
+
this.child.stdin.end();
|
|
190
|
+
await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2_000))]);
|
|
191
|
+
await stopProcessGroup(this.child, 2_000);
|
|
192
|
+
await this.modelObservations;
|
|
193
|
+
await this.journal.flush();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function withBridge(options, action) { const bridge = new CodexBridge(options); try { await bridge.start(); return await action(bridge); } finally { await bridge.close(); } }
|
|
198
|
+
function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
|
|
199
|
+
function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
|
|
200
|
+
function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
|
|
201
|
+
|
|
202
|
+
function threadReceipt(thread) {
|
|
203
|
+
// Thread reads can include the whole transcript even with includeTurns:false.
|
|
204
|
+
// Item evidence lives in the native history/journal, not each command reply.
|
|
205
|
+
return { ...thread, ...(Array.isArray(thread.turns) ? { turns: thread.turns.map(turn => ({
|
|
206
|
+
id: turn?.id ?? null, status: turn?.status ?? null,
|
|
207
|
+
startedAt: turn?.startedAt ?? null, completedAt: turn?.completedAt ?? null
|
|
208
|
+
})) } : {}) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function doctor(options = {}) {
|
|
212
|
+
const codex_cli = parseCodexCliVersion(await commandText(options.bin ?? process.env.DD_CODEX_BIN ?? "codex", ["--version"]));
|
|
213
|
+
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() }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function observeCodexProfile(bridge, options, id, observed, source) {
|
|
217
|
+
bridge.requestedProfiles?.set(id, profile(options));
|
|
218
|
+
await bridge.modelObservations;
|
|
219
|
+
if (bridge.modelObservationError) throw bridge.modelObservationError;
|
|
220
|
+
await observeModel({ journal: options.journal ?? bridge.options?.journal, harness: "codex-desktop", sessionId: id,
|
|
221
|
+
parentSessionId: bridge.descendants?.get(id)?.parent_provider_session_id ?? null, requested: profile(options), observed, source });
|
|
222
|
+
return observed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function createSession(options) {
|
|
226
|
+
return await withBridge(options, async (bridge) => await createSessionWithBridge(bridge, options));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function createSessionWithBridge(bridge, options) {
|
|
230
|
+
const cwd = absolute(options.cwd, "--cwd");
|
|
231
|
+
// dd-codex is the eval-only adapter. Its CODEX_HOME is generated by
|
|
232
|
+
// dd-flow, so trust only that managed PreToolUse hook for this thread and
|
|
233
|
+
// do not load user plugin hooks into the isolated evaluator.
|
|
234
|
+
const result = await bridge.request("thread/start", {
|
|
235
|
+
cwd,
|
|
236
|
+
model: options.model ?? null,
|
|
237
|
+
approvalPolicy: "never",
|
|
238
|
+
sandbox: "danger-full-access",
|
|
239
|
+
ephemeral: false,
|
|
240
|
+
config: { bypass_hook_trust: true, "features.plugins": false }
|
|
241
|
+
});
|
|
242
|
+
// ThreadStartParams has no reasoning-effort field. The first turn applies
|
|
243
|
+
// it, so reporting the Desktop default here would be a false profile drift.
|
|
244
|
+
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 };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function promptSession(options) {
|
|
248
|
+
return await withBridge(options, async (bridge) => await promptSessionWithBridge(bridge, options));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function startSession(options) {
|
|
252
|
+
return await withBridge(options, async (bridge) => await startSessionWithBridge(bridge, options));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function startSessionWithBridge(bridge, options) {
|
|
256
|
+
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
257
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
258
|
+
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
259
|
+
bridge.finalMessages?.delete(sessionId);
|
|
260
|
+
options.assertDispatch?.();
|
|
261
|
+
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);
|
|
262
|
+
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
263
|
+
if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
|
|
264
|
+
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 } };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function promptSessionWithBridge(bridge, options) {
|
|
268
|
+
const sessionId = required(options.sessionId, "--session-id"); const cwd = absolute(options.cwd, "--cwd"); const prompt = required(options.prompt, "--prompt");
|
|
269
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
270
|
+
// A final-message fallback belongs only to the Turn that produced it. A
|
|
271
|
+
// new Turn may start before app-server emits its first item notification;
|
|
272
|
+
// clearing here prevents the previous receipt from completing the new Turn.
|
|
273
|
+
bridge.requestedProfiles?.set(sessionId, profile(options));
|
|
274
|
+
bridge.finalMessages?.delete(sessionId);
|
|
275
|
+
options.assertDispatch?.();
|
|
276
|
+
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);
|
|
277
|
+
const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
|
|
278
|
+
const completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd);
|
|
279
|
+
const thread = await readCompletedThread(bridge, sessionId);
|
|
280
|
+
const assistantText = latestAssistantText(thread) ?? latestAssistantText(completed);
|
|
281
|
+
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") };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
|
|
285
|
+
const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
|
|
286
|
+
const thread = value?.thread ?? value;
|
|
287
|
+
if (thread?.status?.type !== "notLoaded") return;
|
|
288
|
+
await bridge.request("thread/resume", {
|
|
289
|
+
threadId: sessionId,
|
|
290
|
+
cwd,
|
|
291
|
+
model: options.model ?? null,
|
|
292
|
+
approvalPolicy: "never",
|
|
293
|
+
sandbox: "danger-full-access",
|
|
294
|
+
config: { bypass_hook_trust: true, "features.plugins": false }
|
|
295
|
+
}, 30_000);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
|
|
299
|
+
const clock = new ObservationClock({ timeoutMs, onGap: gap => bridge.journal?.write("observation_gap", { ...gap, session_id: sessionId, turn_id: turnId }) });
|
|
300
|
+
// app-server can briefly expose the new Turn as `interrupted` between the
|
|
301
|
+
// turn/start response and its turn/started notification. Give that snapshot
|
|
302
|
+
// one event-loop grace window before treating failure as authoritative.
|
|
303
|
+
const failureNotBefore = Date.now() + 1_000;
|
|
304
|
+
let nextStoredReadAt = Date.now();
|
|
305
|
+
while (!clock.sample(bridge.activity?.get(sessionId))) {
|
|
306
|
+
const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed") return terminalTurn(known.value, turnId, bridge);
|
|
307
|
+
if (Date.now() >= nextStoredReadAt) {
|
|
308
|
+
const stored = await readTurn(bridge, sessionId, turnId, false);
|
|
309
|
+
if (stored?.turn && isAuthoritativeTerminal(bridge, turnId, stored.turn.status, stored.threadStatus, failureNotBefore)) return terminalTurn({ turn: stored.turn }, turnId, bridge);
|
|
310
|
+
// A loaded app-server can retain a stale `active` Thread state after a
|
|
311
|
+
// provider-side interruption. The persisted turn page is independent of
|
|
312
|
+
// that live subscription and is the compact authority for its terminal
|
|
313
|
+
// status; unlike `includeTurns`, it never hydrates the whole history.
|
|
314
|
+
const persisted = await readPersistedTurn(bridge, sessionId, turnId);
|
|
315
|
+
if (persisted && isAuthoritativeTerminal(bridge, turnId, persisted.status, stored?.threadStatus, failureNotBefore)) return terminalTurn({ turn: persisted }, turnId, bridge);
|
|
316
|
+
// Desktop may unload an idle Thread immediately after an interrupted
|
|
317
|
+
// Turn. Reload it once before polling again: otherwise the compact
|
|
318
|
+
// status remains `notLoaded` forever and hides the terminal Turn.
|
|
319
|
+
if (stored?.threadStatus === "notLoaded") {
|
|
320
|
+
await ensureThreadLoaded(bridge, sessionId, options, cwd);
|
|
321
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
322
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
323
|
+
}
|
|
324
|
+
// Some app-server versions correctly omit turns for a compact read.
|
|
325
|
+
// Once the thread is idle, hydrate exactly once to recover the terminal
|
|
326
|
+
// turn instead of waiting for the global timeout.
|
|
327
|
+
if (stored?.threadStatus === "idle") {
|
|
328
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
329
|
+
// "idle" only describes the Thread. Some app-server builds retain
|
|
330
|
+
// an inProgress Turn after emitting the final agent message; treating
|
|
331
|
+
// that stale Turn as terminal incorrectly aborts an otherwise usable
|
|
332
|
+
// worker session.
|
|
333
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
334
|
+
}
|
|
335
|
+
// Some app-server builds emit a final agent message but omit both the
|
|
336
|
+
// terminal event and the compact turn record. Wait briefly, hydrate once,
|
|
337
|
+
// then preserve that message as the terminal receipt if still omitted.
|
|
338
|
+
const final = bridge.finalMessages?.get(sessionId);
|
|
339
|
+
// Agent messages are streamed as completed items while a Turn is still
|
|
340
|
+
// reasoning and calling tools. They are terminal evidence only after
|
|
341
|
+
// app-server reports the Thread idle.
|
|
342
|
+
if (final && stored?.threadStatus === "idle" && Date.now() - final.completedAt >= 1_000) {
|
|
343
|
+
const hydrated = await readTurn(bridge, sessionId, turnId, true);
|
|
344
|
+
if (hydrated?.turn && isAuthoritativeTerminal(bridge, turnId, hydrated.turn.status, hydrated.threadStatus, failureNotBefore)) return terminalTurn({ turn: hydrated.turn }, turnId, bridge);
|
|
345
|
+
return terminalTurn({ turn: { id: turnId, status: "completed", synthetic: true, final_message: final.item } }, turnId, bridge);
|
|
346
|
+
}
|
|
347
|
+
nextStoredReadAt = Date.now() < failureNotBefore ? failureNotBefore : Date.now() + 15_000;
|
|
348
|
+
}
|
|
349
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
350
|
+
}
|
|
351
|
+
fail("Codex turn timed out", "turn_timeout");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function terminalTurn(value, turnId, bridge) {
|
|
355
|
+
// A terminal event may arrive while an older snapshot RPC is in flight.
|
|
356
|
+
const observed = bridge.turns.get(turnId);
|
|
357
|
+
if (observed?.status === "completed") value = observed.value;
|
|
358
|
+
const turn = value?.turn ?? value;
|
|
359
|
+
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 });
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isTerminalTurnStatus(status) { return ["completed", "failed", "interrupted", "cancelled"].includes(status); }
|
|
364
|
+
function isAuthoritativeTerminal(bridge, turnId, status, threadStatus, failureNotBefore) {
|
|
365
|
+
if (!isTerminalTurnStatus(status)) return false;
|
|
366
|
+
if (status === "completed") return true;
|
|
367
|
+
if (Date.now() < failureNotBefore) return false;
|
|
368
|
+
return !(bridge.turns.get(turnId)?.status === "running" && threadStatus === "active");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function readTurn(bridge, sessionId, turnId, includeTurns) {
|
|
372
|
+
if (!turnId) return null;
|
|
373
|
+
try {
|
|
374
|
+
const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
|
|
375
|
+
const thread = result?.thread ?? result;
|
|
376
|
+
return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
|
|
377
|
+
} catch { return null; }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function readPersistedTurn(bridge, sessionId, turnId) {
|
|
381
|
+
try {
|
|
382
|
+
const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
|
|
383
|
+
return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
|
|
384
|
+
} catch { return null; }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function readCompletedThread(bridge, sessionId) {
|
|
388
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
389
|
+
try { const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000); return result?.thread ?? result; }
|
|
390
|
+
catch (error) { bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function latestAssistantText(value) {
|
|
396
|
+
const messages = value?.messages ?? value?.items ?? value?.turn?.items ?? [];
|
|
397
|
+
if (!Array.isArray(messages)) return null;
|
|
398
|
+
for (const message of [...messages].reverse()) {
|
|
399
|
+
if (message?.role !== "assistant" && message?.type !== "agentMessage") continue;
|
|
400
|
+
const parts = message.content ?? message.parts ?? [];
|
|
401
|
+
if (typeof message.text === "string") return message.text;
|
|
402
|
+
if (Array.isArray(parts)) {
|
|
403
|
+
const text = parts.map((part) => typeof part?.text === "string" ? part.text : typeof part?.content === "string" ? part.content : "").filter(Boolean).join("\n");
|
|
404
|
+
if (text) return text;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function inspectSession(options) {
|
|
411
|
+
return await withBridge(options, async (bridge) => await inspectSessionWithBridge(bridge, options));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function inspectSessionWithBridge(bridge, options) {
|
|
415
|
+
const sessionId = required(options.sessionId, "--session-id"); const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }); const thread = value?.thread ?? value;
|
|
416
|
+
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") };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function cancelSession(options) {
|
|
420
|
+
return await withBridge(options, async (bridge) => await cancelSessionWithBridge(bridge, options));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function cancelSessionWithBridge(bridge, options) {
|
|
424
|
+
const sessionId = required(options.sessionId, "--session-id");
|
|
425
|
+
let turnId = options.turnId ?? null;
|
|
426
|
+
if (!turnId) {
|
|
427
|
+
const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns: true }, 10_000);
|
|
428
|
+
const thread = result?.thread ?? result;
|
|
429
|
+
turnId = [...(thread?.turns ?? [])].reverse().find((turn) => turn?.status === "inProgress")?.id ?? null;
|
|
430
|
+
}
|
|
431
|
+
if (!turnId) fail("Codex Session has no active Turn to interrupt", "turn_not_active");
|
|
432
|
+
await bridge.request("turn/interrupt", { threadId: sessionId, turnId });
|
|
433
|
+
// Interrupt acknowledgement is not a provider terminal receipt. Reconcile
|
|
434
|
+
// observes the Thread before the runner records terminal cancellation.
|
|
435
|
+
return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, cancelled: true, settled: false };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
|