@deksden-com/dd-flow-cli 0.9.0-beta.75 → 0.9.0-beta.79

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 (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from "./cli/run-cli.js";
3
- const exitCode = await runCli(process.argv.slice(2));
2
+ const args = process.argv.slice(2);
3
+ const managedHook = Boolean(process.env.DD_FLOW_CODEX_STATE_DIR) && args.slice(0, 3).join(" ") === "codex hook handle" && !args.includes("--help") && !args.includes("-h");
4
+ const exitCode = managedHook
5
+ ? await (await import("./cli/hook-ingress.js")).runHookIngress(args)
6
+ : await (await import("./cli/run-cli.js")).runCli(args);
4
7
  process.exitCode = exitCode;
8
+ export {};
@@ -0,0 +1 @@
1
+ export function callDaemon(stateDir: string, operation: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<Record<string, unknown>>;
@@ -81,7 +81,7 @@ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 3
81
81
  }
82
82
 
83
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") } : {} };
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"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {} };
85
85
  await prepareRuntimeOwner(config, paths.dir);
86
86
  await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
87
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; }
@@ -96,10 +96,14 @@ export async function stopDaemon(options) { const timeoutMs = options.timeoutMs
96
96
 
97
97
  export async function serveDaemon(stateDir) {
98
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();
99
+ const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_DAEMON_ID: state.daemon_id, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await bridge.start(); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
100
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
101
  const dispatch = async (operation, params) => {
102
102
  const assertDispatch = captureDispatchGuard(bridge);
103
+ if (operation === "hook.verify") {
104
+ if (params.daemonId !== state.daemon_id) throw Object.assign(new Error("Hook daemon identity differs"), { code: "native_hook_unproven" });
105
+ return { ...bridge.verifyNativeHook(params), daemonId: state.daemon_id, ddFlowHome: state.config.ddFlowHome, projectRoot: state.config.projectRoot };
106
+ }
103
107
  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
108
  // The runner may need to retire an orphaned daemon after its caller was
105
109
  // interrupted. `daemon.stop` is the one control operation that remains
@@ -124,6 +128,7 @@ export async function serveDaemon(stateDir) {
124
128
  const owned = new Set(sessions);
125
129
  for (const id of owned) for (const child of bridge.directChildren?.(id) ?? []) owned.add(child.provider_session_id);
126
130
  if (!owned.has(params.sessionId)) throw Object.assign(new Error("inspection Session is not owned by this daemon"), { code: "session_identity_mismatch" });
131
+ bridge.assertHookHealthy(params.sessionId);
127
132
  try { return await inspectCodexTree(bridge, { ...state.config, ...params }, { remember: id => sessions.add(id), active: () => active }); }
128
133
  finally { await save(); }
129
134
  }
@@ -7,6 +7,13 @@ import { ObservationClock, observedTimeout } from "./observation-clock.mjs";
7
7
  import { observeModel } from "./model-observations.mjs";
8
8
 
9
9
  const LATE_RESPONSE_GRACE_MS = 5_000;
10
+ const CODEX_SESSION_CONFIG = Object.freeze({
11
+ bypass_hook_trust: true,
12
+ "features.plugins": false,
13
+ "features.multi_agent_v2.enabled": true,
14
+ "features.multi_agent_v2.tool_namespace": "collaboration",
15
+ "features.multi_agent_v2.wait_agent_enabled": true
16
+ });
10
17
 
11
18
  // Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
12
19
  // intentionally one-shot: the provider owns durable Threads, while the caller
@@ -61,6 +68,8 @@ export class CodexBridge {
61
68
  receive(line) {
62
69
  let message; try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
63
70
  this.observeTopology(message);
71
+ this.observeNativeHook(message);
72
+ this.observeHookFailure(message);
64
73
  if (["model/rerouted", "thread/settings/updated"].includes(message.method)) {
65
74
  const params = message.params ?? {}, id = params.threadId;
66
75
  let root = id; const seen = new Set();
@@ -117,24 +126,122 @@ export class CodexBridge {
117
126
  if (threadId && message.method === "item/completed" && message.params?.item?.type === "agentMessage") this.finalMessages.set(threadId, { item: message.params.item, completedAt: Date.now() });
118
127
  }
119
128
 
129
+ isManagedHook(run) {
130
+ const home = this.options.env?.CODEX_HOME;
131
+ return Boolean(home && run?.eventName === "preToolUse" && run.handlerType === "command"
132
+ && run.executionMode === "sync" && run.statusMessage === "dd-flow runtime observation"
133
+ && run.sourcePath === path.join(home, "hooks.json"));
134
+ }
135
+
136
+ observeNativeHook(message) {
137
+ if (message.method === "turn/completed") {
138
+ const { threadId, turn } = message.params ?? {};
139
+ for (const [id, hook] of this.activeHooks ?? []) {
140
+ if (hook.threadId === threadId && hook.turnId === turn?.id) this.activeHooks.delete(id);
141
+ }
142
+ return;
143
+ }
144
+ if (!["hook/started", "hook/completed"].includes(message.method)) return;
145
+ const { threadId, turnId, run } = message.params ?? {};
146
+ if (!this.isManagedHook(run) || typeof run.id !== "string" || !threadId) return;
147
+ this.activeHooks ??= new Map();
148
+ if (message.method === "hook/started") this.activeHooks.set(run.id, { threadId, turnId });
149
+ else this.activeHooks.delete(run.id);
150
+ }
151
+
152
+ verifyNativeHook(input) {
153
+ const { rootSessionId, sessionId, toolCallId, turnId } = input;
154
+ const home = this.options.env?.CODEX_HOME;
155
+ const id = `pre-tool-use:0:${path.join(home ?? "", "hooks.json")}:${toolCallId}`;
156
+ const active = this.activeHooks?.get(id);
157
+ let current = sessionId;
158
+ const seen = new Set();
159
+ while (current && current !== rootSessionId && !seen.has(current)) {
160
+ seen.add(current); current = this.descendants.get(current)?.parent_provider_session_id;
161
+ }
162
+ if (!active || active.threadId !== sessionId || active.turnId !== turnId || !toolCallId
163
+ || current !== rootSessionId || !this.requestedProfiles.has(rootSessionId)) {
164
+ fail("No matching active managed native hook", "native_hook_unproven", { provider_session_id: sessionId, tool_call_id: toolCallId });
165
+ }
166
+ return { rootSessionId, sessionId, toolCallId, parentSessionId: sessionId === rootSessionId ? null : this.descendants.get(sessionId)?.parent_provider_session_id };
167
+ }
168
+
169
+ observeHookFailure(message) {
170
+ const { threadId, turnId, run } = message.params ?? {};
171
+ const home = this.options.env?.CODEX_HOME;
172
+ if (message.method !== "hook/completed" || run?.status !== "failed" ||
173
+ run.eventName !== "preToolUse" || run.handlerType !== "command" ||
174
+ run.statusMessage !== "dd-flow runtime observation" || !home ||
175
+ run.sourcePath !== path.join(home, "hooks.json")) return;
176
+ const ancestors = new Set();
177
+ let root;
178
+ let current = threadId;
179
+ while (current && !ancestors.has(current)) {
180
+ ancestors.add(current);
181
+ if (this.requestedProfiles.has(current)) root = current;
182
+ current = this.descendants.get(current)?.parent_provider_session_id;
183
+ }
184
+ if (!root) return;
185
+ this.hookFailures ??= new Map();
186
+ if (this.hookFailures.has(root)) return;
187
+ const entries = Array.isArray(run.entries) ? run.entries : [];
188
+ const reason = entries.filter(entry => entry.kind === "error").map(entry => entry.text).join("; ");
189
+ const error = Object.assign(new Error(reason || "Managed Codex hook failed"), {
190
+ code: /timed out/i.test(reason) ? "native_hook_timeout" : "native_hook_failed",
191
+ details: { provider_session_id: threadId, root_provider_session_id: root, turn_id: turnId,
192
+ hook_run_id: run.id, effect: "unknown", duration_ms: run.durationMs }
193
+ });
194
+ this.hookFailures.set(root, error);
195
+ this.journal.write("native_hook_failure", { code: error.code, message: error.message, ...error.details });
196
+ for (const [id, pending] of this.pending) {
197
+ if (ancestors.has(pending.threadId) && pending.method !== "turn/interrupt") {
198
+ clearTimeout(pending.timer); this.pending.delete(id); pending.reject(error);
199
+ }
200
+ }
201
+ }
202
+
203
+ assertHookHealthy(sessionId) {
204
+ const visited = new Set();
205
+ while (sessionId && !visited.has(sessionId)) {
206
+ visited.add(sessionId);
207
+ const error = this.hookFailures?.get(sessionId);
208
+ if (error) throw error;
209
+ sessionId = this.descendants.get(sessionId)?.parent_provider_session_id;
210
+ }
211
+ }
212
+
120
213
  // Collaboration child Threads are provider facts. Keep only objects that
121
214
  // explicitly carry a parent relationship, including native spawn receipts.
122
215
  // A wait/send receiver is not necessarily a child of its sender.
123
216
  observeTopology(value, depth = 0) {
124
217
  if (!value || typeof value !== "object" || depth > 8) return;
125
218
  if (Array.isArray(value)) { for (const item of value) this.observeTopology(item, depth + 1); return; }
219
+ const activity = value.params?.item;
220
+ const activityParent = value.params?.threadId;
221
+ if (activity?.type === "subAgentActivity" && typeof activity.agentThreadId === "string" && activity.agentThreadId &&
222
+ typeof activityParent === "string" && activityParent && activity.agentThreadId !== activityParent) {
223
+ const previous = this.descendants.get(activity.agentThreadId);
224
+ const observed = activity.kind === "completed" ? "completed" : activity.kind === "interrupted" ? "interrupted" : "running";
225
+ const status = previous?.status === "completed" ? "completed" : observed;
226
+ this.descendants.set(activity.agentThreadId, { ...previous, provider_session_id: activity.agentThreadId,
227
+ parent_provider_session_id: activityParent, status, agent_path: activity.agentPath ?? previous?.agent_path ?? null,
228
+ source: "codex.app_server.subAgentActivity" });
229
+ }
126
230
  if (value.type === "collabAgentToolCall" && value.tool === "spawnAgent" &&
127
231
  typeof value.senderThreadId === "string" && value.senderThreadId && Array.isArray(value.receiverThreadIds)) {
128
232
  for (const child of value.receiverThreadIds) {
129
233
  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" });
234
+ const previous = this.descendants.get(child);
235
+ this.descendants.set(child, { ...previous, provider_session_id: child, parent_provider_session_id: value.senderThreadId,
236
+ status: previous?.status === "completed" ? "completed" : value.agentsStates?.[child]?.status ?? "unknown", source: "codex.app_server" });
132
237
  }
133
238
  }
134
239
  const id = value.threadId ?? value.id ?? null;
135
240
  const parent = value.parentThreadId ?? value.parent_thread_id ?? null;
136
241
  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" });
242
+ const previous = this.descendants.get(id);
243
+ this.descendants.set(id, { ...previous, provider_session_id: id, parent_provider_session_id: parent,
244
+ status: previous?.status === "completed" ? "completed" : value.status?.type ?? value.status ?? "unknown", source: "codex.app_server" });
138
245
  }
139
246
  for (const child of Object.values(value)) this.observeTopology(child, depth + 1);
140
247
  }
@@ -169,7 +276,7 @@ export class CodexBridge {
169
276
  this.pending.delete(id); reject(timeoutError);
170
277
  }, LATE_RESPONSE_GRACE_MS);
171
278
  }, 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 });
279
+ this.pending.set(id, { resolve, reject, timer, method, threadId: params.threadId }); this.send({ jsonrpc: "2.0", id, method, params });
173
280
  });
174
281
  }
175
282
  send(value) {
@@ -198,6 +305,21 @@ async function withBridge(options, action) { const bridge = new CodexBridge(opti
198
305
  function profile(options) { return { harness: "codex-desktop", model: options.model ?? null, reasoning: options.reasoning ?? null }; }
199
306
  function threadId(result) { const id = result?.thread?.id ?? result?.threadId; return required(id, "Codex thread id"); }
200
307
  function directChildren(bridge, sessionId) { return typeof bridge?.directChildren === "function" ? bridge.directChildren(sessionId) : []; }
308
+ function codexSessionConfig() { return { ...CODEX_SESSION_CONFIG }; }
309
+
310
+ async function requestConfiguredThread(bridge, method, params, timeoutMs) {
311
+ try { return await bridge.request(method, { ...params, config: codexSessionConfig() }, timeoutMs); }
312
+ catch (error) {
313
+ const data = error?.details?.data;
314
+ const key = data?.configKey ?? data?.config_key ?? null;
315
+ if (error?.code === "rpc_error" && (data?.reason === "config" || String(key ?? "").startsWith("features.multi_agent_v2"))) {
316
+ throw Object.assign(new Error("Codex runtime rejected the required multi-agent v2 session configuration"), {
317
+ code: "codex_multi_agent_v2_unavailable", cause: error, details: { method, provider_error: error.details }
318
+ });
319
+ }
320
+ throw error;
321
+ }
322
+ }
201
323
 
202
324
  function threadReceipt(thread) {
203
325
  // Thread reads can include the whole transcript even with includeTurns:false.
@@ -231,13 +353,12 @@ export async function createSessionWithBridge(bridge, options) {
231
353
  // dd-codex is the eval-only adapter. Its CODEX_HOME is generated by
232
354
  // dd-flow, so trust only that managed PreToolUse hook for this thread and
233
355
  // do not load user plugin hooks into the isolated evaluator.
234
- const result = await bridge.request("thread/start", {
356
+ const result = await requestConfiguredThread(bridge, "thread/start", {
235
357
  cwd,
236
358
  model: options.model ?? null,
237
359
  approvalPolicy: "never",
238
360
  sandbox: "danger-full-access",
239
- ephemeral: false,
240
- config: { bypass_hook_trust: true, "features.plugins": false }
361
+ ephemeral: false
241
362
  });
242
363
  // ThreadStartParams has no reasoning-effort field. The first turn applies
243
364
  // it, so reporting the Desktop default here would be a false profile drift.
@@ -258,6 +379,7 @@ export async function startSessionWithBridge(bridge, options) {
258
379
  bridge.requestedProfiles?.set(sessionId, profile(options));
259
380
  bridge.finalMessages?.delete(sessionId);
260
381
  options.assertDispatch?.();
382
+ bridge.assertHookHealthy?.(sessionId);
261
383
  const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 30_000);
262
384
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
263
385
  if (typeof turnId !== "string" || !turnId) throw new Error("Codex did not return a Turn id");
@@ -273,10 +395,12 @@ export async function promptSessionWithBridge(bridge, options) {
273
395
  bridge.requestedProfiles?.set(sessionId, profile(options));
274
396
  bridge.finalMessages?.delete(sessionId);
275
397
  options.assertDispatch?.();
398
+ bridge.assertHookHealthy?.(sessionId);
276
399
  const started = await bridge.request("turn/start", { threadId: sessionId, input: [{ type: "text", text: prompt }], cwd, model: options.model ?? null, effort: options.reasoning ?? null, approvalPolicy: "never", sandboxPolicy: { type: "dangerFullAccess" } }, options.timeoutMs ?? 1_800_000);
277
400
  const turn = started?.turn ?? started; const turnId = turn?.id ?? null;
278
401
  const completed = await waitForTurn(bridge, sessionId, turnId, options.timeoutMs ?? 1_800_000, options, cwd);
279
402
  const thread = await readCompletedThread(bridge, sessionId);
403
+ bridge.assertHookHealthy?.(sessionId);
280
404
  const assistantText = latestAssistantText(thread) ?? latestAssistantText(completed);
281
405
  return { harness: "codex-desktop", provider_session_id: sessionId, adapter_session_id: sessionId, turn_id: turnId, status: "completed", turn: completed, ...(thread ? { thread: threadReceipt(thread) } : {}), descendants: directChildren(bridge, sessionId), ...(assistantText ? { assistant_text: assistantText } : {}), requested_profile: profile(options), observed_profile: await observeCodexProfile(bridge, options, sessionId, { model: thread?.model ?? null, reasoning: thread?.reasoningEffort ?? null, provider: thread?.modelProvider ?? null }, "thread/read.after_turn") };
282
406
  }
@@ -285,13 +409,12 @@ async function ensureThreadLoaded(bridge, sessionId, options, cwd) {
285
409
  const value = await bridge.request("thread/read", { threadId: sessionId, includeTurns: false }, 10_000);
286
410
  const thread = value?.thread ?? value;
287
411
  if (thread?.status?.type !== "notLoaded") return;
288
- await bridge.request("thread/resume", {
412
+ await requestConfiguredThread(bridge, "thread/resume", {
289
413
  threadId: sessionId,
290
414
  cwd,
291
415
  model: options.model ?? null,
292
416
  approvalPolicy: "never",
293
- sandbox: "danger-full-access",
294
- config: { bypass_hook_trust: true, "features.plugins": false }
417
+ sandbox: "danger-full-access"
295
418
  }, 30_000);
296
419
  }
297
420
 
@@ -303,6 +426,7 @@ async function waitForTurn(bridge, sessionId, turnId, timeoutMs, options, cwd) {
303
426
  const failureNotBefore = Date.now() + 1_000;
304
427
  let nextStoredReadAt = Date.now();
305
428
  while (!clock.sample(bridge.activity?.get(sessionId))) {
429
+ bridge.assertHookHealthy?.(sessionId);
306
430
  const known = bridge.turns.get(turnId) ?? bridge.turns.get(sessionId); if (known?.status === "completed") return terminalTurn(known.value, turnId, bridge);
307
431
  if (Date.now() >= nextStoredReadAt) {
308
432
  const stored = await readTurn(bridge, sessionId, turnId, false);
@@ -374,20 +498,20 @@ async function readTurn(bridge, sessionId, turnId, includeTurns) {
374
498
  const result = await bridge.request("thread/read", { threadId: sessionId, includeTurns }, 10_000);
375
499
  const thread = result?.thread ?? result;
376
500
  return { turn: Array.isArray(thread?.turns) ? thread.turns.find((turn) => turn?.id === turnId) ?? null : null, threadStatus: thread?.status?.type ?? null };
377
- } catch { return null; }
501
+ } catch { bridge.assertHookHealthy?.(sessionId); return null; }
378
502
  }
379
503
 
380
504
  async function readPersistedTurn(bridge, sessionId, turnId) {
381
505
  try {
382
506
  const result = await bridge.request("thread/turns/list", { threadId: sessionId, limit: 20, sortDirection: "desc", itemsView: "summary" }, 10_000);
383
507
  return Array.isArray(result?.data) ? result.data.find((turn) => turn?.id === turnId) ?? null : null;
384
- } catch { return null; }
508
+ } catch { bridge.assertHookHealthy?.(sessionId); return null; }
385
509
  }
386
510
 
387
511
  async function readCompletedThread(bridge, sessionId) {
388
512
  for (let attempt = 0; attempt < 8; attempt += 1) {
389
513
  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)); }
514
+ catch (error) { bridge.assertHookHealthy?.(sessionId); bridge.journal.write("thread_read_retry", { session_id: sessionId, attempt, error: error.message }); await new Promise((resolve) => setTimeout(resolve, 150)); }
391
515
  }
392
516
  return null;
393
517
  }
@@ -16,7 +16,7 @@ import { DroidMetadataReader, readDroidRoutingLog } from "./droid-observation.mj
16
16
  const metadataReaders = new Map();
17
17
  function metadataReader(factory) { if (!metadataReaders.has(factory)) metadataReaders.set(factory, new DroidMetadataReader(factory)); return metadataReaders.get(factory); }
18
18
  import { confirmDaemonProcess, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
19
- import { renderAdapterPolicy } from "./delegation-instructions.mjs";
19
+ import { renderAdapterPolicy, lifecycleRetryInstruction } from "./delegation-instructions.mjs";
20
20
 
21
21
  const run = promisify(execFile);
22
22
  export const DROID_CONTRACT = "dd-droid-harness@1";
@@ -55,7 +55,7 @@ export async function prepareDroidHome(config) {
55
55
  const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --state-dir ${shell(paths.dir)}`;
56
56
  const hooks = Object.fromEntries(["SessionStart", "PreToolUse", "PostToolUse", "Stop", "SessionEnd", "SubagentStop"].map(event => [event, [{ matcher: "*", hooks: [{ type: "command", command, timeout: 30 }] }]]));
57
57
  await writeJsonAtomic(path.join(paths.factory, "hooks.json"), hooks);
58
- const worker = `---\nname: dd-flow-worker\ndescription: Execute one explicit dd-flow Work packet or a technical capacity marker\nmodel: ${config.model}\nreasoningEffort: ${config.reasoning}\nmcpServers: []\n---\nFor a Work assignment, your first technical action is the exact standalone work start command supplied by the coordinator. If that command is absent, report an incomplete packet and stop: do not run stage pause, stage finish, or another lifecycle command. Follow only the returned Work packet, invoke its exact work finish or fail command, then stop. A Task child never executes or finishes its coordinator's Stage Work. Do not create children or take another Work. For a technical capacity marker, only return the requested marker; do not read the project or call dd-flow.\n`;
58
+ const worker = `---\nname: dd-flow-worker\ndescription: Execute one explicit dd-flow Work packet or a technical capacity marker\nmodel: ${config.model}\nreasoningEffort: ${config.reasoning}\nmcpServers: []\n---\nFor a Work assignment, your first technical action is the exact standalone work start command supplied by the coordinator. If that command is absent, report an incomplete packet and stop: do not run stage pause, stage finish, or another lifecycle command. Execute the supplied operation and target exactly; lifecycle invocation IDs are internal runtime authority, so never add or invent them. Continue only after a successful start returns the Work packet. ${lifecycleRetryInstruction} Follow only the returned Work packet, invoke its exact work finish or fail command, then stop. A Task child never executes or finishes its coordinator's Stage Work. Do not create children or take another Work. For a technical capacity marker, only return the requested marker; do not read the project or call dd-flow.\n`;
59
59
  await writeFile(path.join(paths.factory, "droids", "dd-flow-worker.md"), worker, { mode: 0o600 });
60
60
  return { hooks_sha256: createHash("sha256").update(JSON.stringify(hooks)).digest("hex"), worker_sha256: createHash("sha256").update(worker).digest("hex") };
61
61
  }
@@ -145,7 +145,7 @@ export async function startDaemon(options) {
145
145
  ddFlowHome: options.ddFlowHome ? absolute(options.ddFlowHome, "--dd-flow-home") : null,
146
146
  projectRoot: options.projectRoot ? absolute(options.projectRoot, "--project-root") : null,
147
147
  resourceHome: process.env.DD_FLOW_RESOURCE_HOME ? absolute(process.env.DD_FLOW_RESOURCE_HOME, "DD_FLOW_RESOURCE_HOME") : null,
148
- env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {},
148
+ env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home"), ...(options.ddFlowBin ? { DD_FLOW_BIN: absolute(options.ddFlowBin, "--dd-flow-bin") } : {}) } : {},
149
149
  livenessTimeoutMs: positiveDuration(options.livenessTimeoutMs, "livenessTimeoutMs", DEFAULT_LIVENESS_TIMEOUT_MS),
150
150
  requestTimeoutMs: optionalDuration(options.requestTimeoutMs, "requestTimeoutMs", DEFAULT_REQUEST_TIMEOUT_MS),
151
151
  };
@@ -1,8 +1,9 @@
1
1
  const ADAPTERS = {
2
2
  codex: {
3
3
  name: "Codex",
4
- launch: "Call the native `spawn_agent` tool in its exposed namespace once per assignment. Use the schema available in this Session: v2 accepts `task_name`, `message`, and `fork_turns: \"none\"`; v1 accepts `message` and `fork_context: false` without task_name. Never mix these schemas. Use the assignment's task_name only with v2. Keep the current model and reasoning by omitting overrides. Do not create a root Session or run the child's command yourself.",
5
- wait: "The spawn result is a handle, not completion. Use the exposed native wait_agent (or wait in older schemas) and completion messages. V2 waits for mailbox updates; v1 waits on the returned agent IDs using the arguments in its tool schema. An update is not necessarily final: wait for each child's terminal result. A wait timeout leaves the same child running: continue waiting without relaunch or interruption. Release a settled child only when a release tool is exposed and its Work result has been recorded."
4
+ launch: "This Session uses the Codex multi-agent v2 API in the `collaboration` namespace. In code mode call `tools.collaboration__spawn_agent` once per assignment with exactly `task_name`, `message`, and `fork_turns: \"none\"` for fresh context. Keep the current model and reasoning by omitting overrides. Do not create a root Session or run the child's command yourself.",
5
+ wait: "The spawn result is a handle, not completion. In code mode use `tools.collaboration__wait_agent` and native completion messages until every launched child is terminal. A mailbox update is not necessarily final. A wait timeout leaves the same child running: continue waiting without relaunch or interruption. Retain terminal results and product Work receipts. Codex v2 owns idle-agent residency; do not interrupt a completed child as cleanup.",
6
+ partial: "If a spawn is refused, stop issuing new spawns and retain every successful child handle. Wait for every already-launched child to become terminal; silence is not completion. Do not interrupt a running child to free capacity, and do not interrupt completed children as cleanup. Report completed/launched assignments, assignments not launched and the exact refusal. Return after this partial wave; the controller will issue remaining Work when capacity is available. Do not replay an ambiguous spawn."
6
7
  },
7
8
  zcode: {
8
9
  name: "ZCode",
@@ -43,10 +44,13 @@ function requireAdapter(harness) {
43
44
  }
44
45
 
45
46
  export function delegationContract(harness) {
47
+ const key = delegationAdapter(harness);
46
48
  requireAdapter(harness);
47
- return `dd-flow/delegation/${delegationAdapter(harness)}@1`;
49
+ return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
48
50
  }
49
51
 
52
+ export const lifecycleRetryInstruction = "If the CLI rejects the call with effect=no_effect, recoverable=true and retry_command, execute that exact retry_command in this same Session after correcting the reported input. Never invent IDs. If the native shell tool rejects the launch before the CLI starts, correct only that tool-call error and repeat the same exact command; do not change its arguments. A hook/transport failure is an infrastructure error: stop and report it without retrying. For any other failure, stop and report the exact error; do not run status as a substitute, call a hook handler, retry blindly, or finish the Work.";
53
+
50
54
  export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
51
55
  if (!workId || !startCommand) throw new Error("ready fan-out Work lacks its exact start command");
52
56
  const context = launchPolicy === "fresh_agent_required"
@@ -57,7 +61,11 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
57
61
  context,
58
62
  "Your first technical action must be this exact standalone lifecycle command:",
59
63
  startCommand,
60
- "Use only the authoritative Work packet returned by that command. Complete the assigned Work, write its required result, invoke its exact standalone work finish command, then stop.",
64
+ "Execute the issued operation and target exactly. Lifecycle invocation IDs are internal runtime authority and are intentionally omitted; never add or invent them. Preserve any explicit native receipt handle that the command does contain.",
65
+ "Use the native shell's inherited working directory. Do not set or override a workdir/cwd field for this lifecycle command.",
66
+ "Continue only if it succeeds and returns the authoritative Work packet.",
67
+ lifecycleRetryInstruction,
68
+ "After a successful start, complete the assigned Work, write its required result, invoke its exact standalone work finish command, then stop.",
61
69
  "Do not start another Work, create a child, change dependencies, ask the user, or treat a quiet sibling as failed. You cannot ask the user or pause the parent Stage. Missing facts go through this Work's declared result/failure contract; only the coordinator handles Stage HITL."
62
70
  ].join("\n");
63
71
  }
@@ -77,6 +85,7 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
77
85
  "Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
78
86
  "You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
79
87
  "Every child must be a direct child of this current Session. Wait for all launched children to settle before returning. One failed child is evidence for the coordinator, not a reason to cancel its siblings.",
88
+ adapter.partial ?? "If a spawn is refused, stop issuing new spawns and retain every successful child handle. Wait for all already-launched children, then release completed children using the adapter's exposed native close/release mechanism when supported. A failed batch does not undo successful spawns. Report completed/launched assignments, assignments not launched and the exact refusal; never claim no children launched when any spawn succeeded. Return after this partial wave; the controller will issue remaining Work when capacity is available. Do not replay an ambiguous spawn.",
80
89
  "Do not finish this Stage, repair it, or start a successor in this Turn. Do not invent provider or internal session IDs; lifecycle evidence comes from the adapter.",
81
90
  adapter.wait,
82
91
  "Child assignments (JSON data):",
@@ -0,0 +1 @@
1
+ export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
@@ -1,27 +1,30 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { prepareError } from "../cli/input-preparation.js";
4
6
  const canonRootKey = "canon.root";
5
- export function registerCanonRoot(context, input) {
7
+ export function prepareCanonRegistration(context, input) {
8
+ if (typeof input.root !== "string" || !input.root.trim())
9
+ return prepareError(new AppError("validation", "Canon root must be a non-empty path", 2));
6
10
  const resolution = resolveCanonRoot(context, { explicitRoot: input.root, allowRegistered: false });
7
11
  if (!resolution.ok || !resolution.canon) {
8
- return {
9
- ...resolution,
10
- action: "register",
11
- exit_code: 1
12
- };
12
+ return prepareError(new AppError("validation", "Canon root cannot be registered", 2, { root: input.root, blockers: resolution.blockers, candidates: resolution.candidates, bootstrap: resolution.bootstrap }));
13
13
  }
14
+ return resolution.canon;
15
+ }
16
+ export function registerCanonRoot(context, input, canon = prepareCanonRegistration(context, input)) {
14
17
  const now = context.now();
15
18
  context.db.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at)
16
19
  VALUES (?, ?, ?, ?)
17
20
  ON CONFLICT(key) DO UPDATE SET
18
21
  value_json = excluded.value_json,
19
- updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root: resolution.canon.root }), now, now]);
22
+ updated_at = excluded.updated_at`, [canonRootKey, JSON.stringify({ root: canon.root }), now, now]);
20
23
  return {
21
24
  ok: true,
22
25
  action: "register",
23
26
  canon: {
24
- ...resolution.canon,
27
+ ...canon,
25
28
  source: "registered"
26
29
  },
27
30
  db_path: context.db.path
@@ -2,8 +2,7 @@ import fs from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import path from "node:path";
4
4
  import { AppError } from "../shared/errors.js";
5
- import { parseJsonObject } from "../shared/json.js";
6
- import { ensureReadableFile } from "../storage/database.js";
5
+ import { prepareJsonFile } from "../cli/input-preparation.js";
7
6
  import { projectCheckoutRoot, resolveProjectRoot, runtimeStateJsonPath } from "../storage/paths.js";
8
7
  import { appendAudit } from "./audit.js";
9
8
  import { requireProjectByRoot } from "./projects.js";
@@ -173,20 +172,29 @@ export function cleanupScan(context, input) {
173
172
  plan: { project_root: project.root, project_id: project.id, actions }
174
173
  };
175
174
  }
176
- export function cleanupApply(context, input) {
177
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
175
+ export function prepareCleanupApply(context, input) {
178
176
  const reason = input.reason.trim();
179
177
  if (!reason) {
180
178
  throw new AppError("validation", "cleanup apply requires --reason", 2);
181
179
  }
182
- ensureReadableFile(input.planFile);
183
- const plan = validateCleanupPlan(parseJsonObject(fs.readFileSync(input.planFile, "utf8"), input.planFile), project);
180
+ const value = prepareJsonFile(input.planFile, "cleanup plan").value;
181
+ if (!value || typeof value !== "object" || Array.isArray(value))
182
+ throw new AppError("validation", "cleanup plan must be an object", 2);
183
+ const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
184
+ const plan = validateCleanupPlan(value, project);
184
185
  validateNoDestructiveSurprises(context, plan, input.force);
186
+ return { project, reason, plan };
187
+ }
188
+ export function cleanupApply(context, input) {
189
+ const { project, reason, plan } = input.prepared ?? prepareCleanupApply(context, input);
185
190
  const now = context.now();
186
191
  const results = [];
187
192
  context.db.beginWriteTransaction();
193
+ let executing = false;
188
194
  try {
195
+ validateNoDestructiveSurprises(context, plan, input.force);
189
196
  for (const action of plan.actions) {
197
+ executing = true;
190
198
  results.push(applyAction(context, action, reason, input.force, now));
191
199
  }
192
200
  const changed = results.filter((result) => result.changed).length;
@@ -203,6 +211,8 @@ export function cleanupApply(context, input) {
203
211
  }
204
212
  catch (error) {
205
213
  context.db.exec("ROLLBACK");
214
+ if (executing)
215
+ throw new AppError("cleanup_apply_failed", "Cleanup failed after execution began; inspect retained artifacts before retrying", 1, { effect: "unknown", database_rolled_back: true, attempted_results: results, cause: error instanceof Error ? error.message : String(error), ...(error instanceof AppError ? { cause_code: error.code } : {}) });
206
216
  throw error;
207
217
  }
208
218
  return {
@@ -251,11 +261,29 @@ function validateCleanupPlan(value, project) {
251
261
  throw new AppError("validation", "close_flow_session requires a native session identity", 2);
252
262
  }
253
263
  }
264
+ const requiredField = action.kind === "discard_stale_run" ? "run_id"
265
+ : ["repair_protocol_state", "cancel_queue_job", "close_worktree_record"].includes(String(action.kind)) ? "protocol_id" : undefined;
266
+ if (requiredField && (typeof action[requiredField] !== "string" || !action[requiredField].trim()))
267
+ throw new AppError("validation", `cleanup ${String(action.kind)} requires ${requiredField}`, 2);
268
+ const numericField = action.kind === "expire_lane_lock" ? "lock_id" : action.kind === "expire_lane_waiter" ? "waiter_id" : undefined;
269
+ if (numericField && (!Number.isSafeInteger(action[numericField]) || action[numericField] < 1))
270
+ throw new AppError("validation", `cleanup ${String(action.kind)} requires a positive integer ${numericField}`, 2);
271
+ if (action.worktree_path !== undefined && (typeof action.worktree_path !== "string" || !path.isAbsolute(action.worktree_path)))
272
+ throw new AppError("validation", "cleanup worktree_path must be an absolute path", 2);
254
273
  return action;
255
274
  });
256
275
  return { project_root: project.root, project_id: project.id, actions };
257
276
  }
258
277
  function validateNoDestructiveSurprises(context, plan, force) {
278
+ for (const action of plan.actions) {
279
+ if (action.kind === "repair_protocol_state")
280
+ requireProtocol(context, action.protocol_id, action.project_id);
281
+ if (action.kind === "discard_stale_run") {
282
+ const run = context.db.get("SELECT index_json, status FROM runs WHERE project_id = ? AND id = ?", [action.project_id, action.run_id]);
283
+ if (run && ["running", "discarded"].includes(run.status))
284
+ cleanupRunIndex(run.index_json);
285
+ }
286
+ }
259
287
  const destructive = plan.actions.find((action) => action.kind === "close_worktree_record" && action.worktree_path && dirtyGit(action.worktree_path));
260
288
  if (destructive && !force) {
261
289
  throw new AppError("worktree_dirty", "cleanup apply refuses dirty worktree actions without --force --reason", 1, {
@@ -410,7 +438,7 @@ function applyAction(context, action, reason, force, now) {
410
438
  return { kind: action.kind, changed: false, skipped: true, reason: "invalid_action" };
411
439
  }
412
440
  function discardedRunIndex(indexJson, reason, now) {
413
- const index = JSON.parse(indexJson);
441
+ const index = cleanupRunIndex(indexJson);
414
442
  index.schema_id = "dd-flow/flow-run@3";
415
443
  index.status = "discarded";
416
444
  index.verdict = "discarded";
@@ -420,12 +448,25 @@ function discardedRunIndex(indexJson, reason, now) {
420
448
  index.finished_at ??= now;
421
449
  return index;
422
450
  }
451
+ function cleanupRunIndex(indexJson) {
452
+ try {
453
+ const index = JSON.parse(indexJson);
454
+ if (!index || typeof index !== "object" || Array.isArray(index))
455
+ throw new Error("RUN index must be an object");
456
+ return index;
457
+ }
458
+ catch (error) {
459
+ throw new AppError("runtime_state_invalid", "Cleanup cannot read retained RUN state", 1, { cause: String(error) });
460
+ }
461
+ }
423
462
  function dirtyGit(worktreePath) {
424
463
  if (!fs.existsSync(worktreePath)) {
425
464
  return false;
426
465
  }
427
466
  const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { encoding: "utf8" });
428
- return result.status === 0 && result.stdout.trim().length > 0;
467
+ if (result.error || result.status !== 0)
468
+ throw new AppError("workspace_status_failed", "Cannot establish worktree status before cleanup", 1, { path: worktreePath, cause: result.error?.message ?? result.stderr });
469
+ return result.stdout.trim().length > 0;
429
470
  }
430
471
  function safeCleanupWorktreePath(context, projectId, worktreePath) {
431
472
  if (!fs.existsSync(worktreePath)) {