@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127

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 (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -93,10 +93,16 @@ export async function launchExternalWork(context, input) {
93
93
  const common = ["--dd-flow-bin", flowExecutable, "--state-dir", root, "--journal", journal, "--cwd", isolatedWorkspace ?? run.workspace_root, "--provider", resolved.worker.provider, "--model", resolved.worker.model, "--reasoning", resolved.worker.reasoning, "--mode", resolved.worker.mode, "--permission", resolved.worker.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, runtimeHarness(resolved.worker)), "--json"];
94
94
  const evidence = (value) => fs.appendFileSync(journal, `${JSON.stringify({ type: "adapter_call", at: context.now(), ...value })}\n`);
95
95
  try {
96
- const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs ? { timeoutMs } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence });
96
+ const call = (args, timeoutMs, id = `${operationId}:${args[0]}.${args[1]}`) => runHarnessAdapter({ executable, args, harness: runtimeHarness(resolved.worker), env: runtimeContext.env, operationId: id, runtimeOwner, ...(timeoutMs !== undefined ? { timeoutMs } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(input.progress ? { progress: input.progress } : {}), onEvidence: evidence, onNativeOutcome: value => {
97
+ if (args[0] !== "session" || args[1] !== "create")
98
+ return;
99
+ const nativeId = adapterSessionId(value);
100
+ if (nativeId)
101
+ context.db.run("UPDATE work_launches SET provider_session_id = ?, adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ?", [nativeId, JSON.stringify({ create: value, settlement_pending: true }), context.now(), operationId]);
102
+ } });
97
103
  await call(["daemon", "start", ...common]);
98
104
  assertRunMutationAllowed(context, work.project_id, work.run_id, generation);
99
- const created = await call(["session", "create", ...common], runtimeHarness(resolved.worker) === "zcode-acp" ? 180_000 : undefined);
105
+ const created = await call(["session", "create", ...common]);
100
106
  const sessionId = adapterSessionId(created);
101
107
  if (!sessionId)
102
108
  throw new AppError("harness_adapter_invalid", "Harness adapter did not return a typed Session id", 1, { work_id: work.work_id, harness: runtimeHarness(resolved.worker) });
@@ -111,20 +117,28 @@ export async function launchExternalWork(context, input) {
111
117
  const promptFile = path.join(root, "launch.md");
112
118
  fs.writeFileSync(promptFile, `Start the assigned Work now. Your first tool call must be this exact standalone command:\n\n${workStartCommand(runtimeContext, work)}\n\nUse the packet returned by that command. Do not claim another Work or run this command more than once.\n\n${lifecycleRetryInstruction}${isolatedWorkspace ? `\n\nThe review copy has no .git. Read the captured status/diff or gitless note in ${path.join(isolatedWorkspace, ".dd-flow", "review-evidence")}; do not run Git inside the copy.\n` : "\n"}`);
113
119
  const promptOperationId = `${operationId}:session.prompt`;
114
- const promptDeadline = Date.now() + 45 * 60_000;
120
+ context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, progress_policy: "native-inactivity@1" }), context.now(), operationId]);
115
121
  let prompted;
116
122
  try {
117
- prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60_000);
123
+ prompted = await call(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], null);
118
124
  }
119
125
  catch (error) {
120
126
  if (runtimeHarness(resolved.worker) !== "codex-desktop")
121
127
  throw error;
122
128
  const retained = context.db.get("SELECT adapter_receipt_json FROM work_launches WHERE operation_id = ?", [operationId]);
123
129
  const capacityIntents = retained?.adapter_receipt_json ? JSON.parse(retained.adapter_receipt_json).capacity_intents ?? [] : [];
130
+ const capacityFailures = retained?.adapter_receipt_json ? JSON.parse(retained.adapter_receipt_json).capacity_failures ?? [] : [];
131
+ const retainCapacity = () => {
132
+ const changed = context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, progress_policy: "native-inactivity@1", capacity_intents: capacityIntents, capacity_failures: capacityFailures }), context.now(), operationId]);
133
+ if (changed.changes !== 1)
134
+ throw new AppError("capacity_continuation_blocked", "External Work launch owner changed", 1);
135
+ };
124
136
  const resumed = await continueCodexCapacity({ error, sessionId, operationId: promptOperationId, allowSettledDescendants: true,
125
- ...(input.signal ? { signal: input.signal } : {}), deadline: promptDeadline,
137
+ ...(input.signal ? { signal: input.signal } : {}),
126
138
  continuationText: `Continue only Work ${work.work_id} in RUN ${work.run_id}, Stage ${stage ?? "current"}, in the same Session. Inspect its current durable state and the retained Work packet. Never repeat Work start or a completed tool, accepted answer, check or lifecycle command. Use only the current exact issued successor and finish at the original Work boundary.`,
127
139
  retainedIntents: () => capacityIntents,
140
+ retainedFailures: () => capacityFailures,
141
+ retainFailure: (_id, refusal) => { capacityFailures.push(refusal); retainCapacity(); },
128
142
  inspectOperation: id => call(["daemon", "operation", "--state-dir", root, "--operation-id", id, "--json"], 30_000, `${id}:capacity-observe`),
129
143
  inspectSession: turnId => call(["session", "inspect", "--session-id", sessionId, ...(turnId ? ["--turn-id", turnId] : []), ...common], 30_000, `${operationId}:capacity-inspect`),
130
144
  assertCurrent: () => {
@@ -141,9 +155,7 @@ export async function launchExternalWork(context, input) {
141
155
  },
142
156
  retainIntent: intent => {
143
157
  capacityIntents.push(intent);
144
- const changed = context.db.run("UPDATE work_launches SET adapter_receipt_json = ?, updated_at = ? WHERE operation_id = ? AND status = 'launching'", [JSON.stringify({ create: created, capacity_intent: intent, capacity_intents: capacityIntents }), context.now(), operationId]);
145
- if (changed.changes !== 1)
146
- throw new AppError("capacity_continuation_blocked", "External Work launch owner changed", 1);
158
+ retainCapacity();
147
159
  },
148
160
  prompt: (id, text, ordinal) => {
149
161
  const file = path.join(root, `capacity-${ordinal}.md`);
@@ -153,7 +165,7 @@ export async function launchExternalWork(context, input) {
153
165
  }
154
166
  else
155
167
  fs.writeFileSync(file, text, { flag: "wx", mode: 0o600 });
156
- return call(["session", "prompt", "--session-id", sessionId, "--prompt-file", file, ...common], Math.max(1, promptDeadline - Date.now()), id);
168
+ return call(["session", "prompt", "--session-id", sessionId, "--prompt-file", file, ...common], null, id);
157
169
  } });
158
170
  if (!resumed)
159
171
  throw error;
@@ -2,8 +2,14 @@ import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
- import { AppError } from "../shared/errors.js";
6
- import { recoveryRpcTimeout } from "./recovery-observation-budget.js";
5
+ import { AppError, errorRecord } from "../shared/errors.js";
6
+ import { stopProcessGroup } from "../harness-runtime/lib/managed-daemon.mjs";
7
+ import { recoveryObservationBudget, recoveryRpcTimeout, withRecoveryObservation } from "./recovery-observation-budget.js";
8
+ import { ObservationClock, validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
9
+ import { canonicalNativeOperation, nativeOperationWait } from "../harness-runtime/lib/adapter-timeouts.mjs";
10
+ import { readOperationProgress, readOperationRecord, readOperationWaiting, suspendOperationClock } from "../harness-runtime/lib/operation-progress.mjs";
11
+ import { recoverDaemonReply } from "../harness-runtime/lib/daemon-observation.mjs";
12
+ import { isObservationLoss } from "../harness-runtime/lib/operation-errors.mjs";
7
13
  const maxAdapterOutputBytes = 1_000_000;
8
14
  /** Native hooks need a real executable, not a PATH-dependent shell expression. */
9
15
  export function prepareHarnessFlowExecutable(context, stateDir) {
@@ -36,11 +42,50 @@ export function prepareHarnessFlowExecutable(context, stateDir) {
36
42
  }
37
43
  return executable;
38
44
  }
39
- /** Runs one harness command with the lifecycle guarantees shared by agent services. */
40
45
  export function runHarnessAdapter(input) {
46
+ const index = input.args.indexOf("--state-dir"), stateDir = index >= 0 ? input.args[index + 1] : undefined;
47
+ const operation = canonicalNativeOperation(input.harness ?? "", `${input.args[0]}.${input.args[1]}`);
48
+ const boundOperation = ["session.create", "session.fork", "session.start", "session.resume"].includes(operation);
49
+ const binding = { operation, ...(input.runtimeOwner ? { owner_id: input.runtimeOwner.owner_id } : {}) };
50
+ let retainedOutcome;
51
+ const onOutcome = (value) => {
52
+ const fingerprint = JSON.stringify(value);
53
+ if (fingerprint !== retainedOutcome) {
54
+ input.onNativeOutcome?.(value);
55
+ retainedOutcome = fingerprint;
56
+ }
57
+ };
58
+ const dispatched = runAdapterTransport(input, stateDir, binding);
59
+ return dispatched.catch(async (error) => {
60
+ if (!stateDir || !boundOperation || input.signal?.aborted || !(isObservationLoss(error) || isObservationLoss(error?.details?.cause)))
61
+ throw error;
62
+ try {
63
+ return await recoverDaemonReply(stateDir, input.operationId, { timeoutMs: recoveryRpcTimeout(30_000), ...(input.signal ? { signal: input.signal } : {}), ...(error?.observationClock ? { observationClock: error.observationClock } : {}), binding, onOutcome,
64
+ // Reconcile only original bookkeeping. Work/MERGE share this path and
65
+ // must not replay creation merely because its release receipt is pending.
66
+ onPendingSettlement: async (retained) => {
67
+ input.signal?.throwIfAborted();
68
+ await withRecoveryObservation(recoveryObservationBudget(retained), () => runAdapterTransport({ ...input, args: ["daemon", "operation", "settle", "--state-dir", stateDir, "--operation-id", input.operationId, "--json"], operationId: `${input.operationId}:settlement`, timeoutMs: recoveryRpcTimeout(30_000) }, stateDir, {}), true);
69
+ }
70
+ });
71
+ }
72
+ catch (recovery) {
73
+ if (recovery && typeof recovery === "object")
74
+ Object.assign(recovery, { cause: error });
75
+ throw recovery;
76
+ }
77
+ });
78
+ }
79
+ function runAdapterTransport(input, stateDir, binding = {}) {
41
80
  // `null` is deliberate: a productive Turn has no adapter-wall-clock limit.
42
81
  // Preserve it instead of treating it like an omitted short control request.
43
- const timeoutMs = recoveryRpcTimeout(input.timeoutMs === undefined ? 120_000 : input.timeoutMs);
82
+ if (input.timeoutMs != null)
83
+ validateObservationDuration(input.timeoutMs);
84
+ const operation = canonicalNativeOperation(input.harness ?? "", `${input.args[0]}.${input.args[1]}`);
85
+ const productive = input.harness !== undefined && nativeOperationWait(input.harness, operation) === "native-work";
86
+ const timeoutMs = recoveryRpcTimeout(productive ? null : input.timeoutMs === undefined ? 120_000 : input.timeoutMs);
87
+ if (timeoutMs !== null)
88
+ validateObservationDuration(timeoutMs);
44
89
  if (!input.operationId.trim())
45
90
  throw new AppError("operation_id_required", "Harness calls require a scoped operation id", 2);
46
91
  // The current adapter transport uses this name; its identity is runtime-owned,
@@ -64,18 +109,33 @@ export function runHarnessAdapter(input) {
64
109
  let outputTooLarge = false;
65
110
  let timedOut = false;
66
111
  let aborted = false;
67
- let killTimer;
112
+ let cleanup;
113
+ let pendingDiagnostic = "", droppingDiagnostic = false, progressCursor = 0;
114
+ let prettyDiagnostic = "", retainedError;
115
+ const phases = new Set();
116
+ let operationSequence = 0;
117
+ let diagnosticReads = Promise.resolve();
118
+ const clock = timeoutMs === null ? null : new ObservationClock({ timeoutMs });
119
+ clock?.sample(progressCursor);
68
120
  let settled = false;
69
121
  let callbackError;
70
122
  let spawnError;
71
- const terminate = (signal) => { try {
72
- if (detached && child.pid)
73
- process.kill(-child.pid, signal);
74
- else
75
- child.kill(signal);
76
- }
77
- catch { /* The process already settled. */ } };
78
- const stopTransport = () => { terminate("SIGTERM"); killTimer ??= setTimeout(() => terminate("SIGKILL"), 5_000); };
123
+ const stopTransport = () => {
124
+ cleanup ??= stopProcessGroup(child, 5_000, { detached }).catch(error => {
125
+ if (timer)
126
+ clearInterval(timer);
127
+ if (heartbeat)
128
+ clearInterval(heartbeat);
129
+ input.signal?.removeEventListener("abort", abort);
130
+ const details = { cleanup_error: errorRecord(error) };
131
+ if (callbackError instanceof Error) {
132
+ Object.assign(callbackError, { cleanup_error: details.cleanup_error, details: { ...callbackError.details, ...details } });
133
+ reject(callbackError);
134
+ }
135
+ else
136
+ reject(new AppError(outputTooLarge ? "harness_adapter_output_limit" : timedOut ? "harness_adapter_timeout" : aborted ? "harness_adapter_aborted" : "harness_adapter_cleanup_unconfirmed", "Adapter transport cleanup could not be confirmed", 1, details));
137
+ });
138
+ };
79
139
  const deliver = (callback) => {
80
140
  try {
81
141
  const result = callback();
@@ -91,7 +151,43 @@ export function runHarnessAdapter(input) {
91
151
  }
92
152
  };
93
153
  const heartbeat = input.progress && input.progressMessage ? setInterval(() => deliver(() => input.progress?.(`${input.progressMessage}; waiting for adapter completion`)), 15_000) : undefined;
94
- const timer = timeoutMs === null ? undefined : setTimeout(() => { timedOut = true; stopTransport(); }, timeoutMs);
154
+ let observing = false;
155
+ const timer = timeoutMs === null ? undefined : setInterval(async () => {
156
+ if (settled || cleanup)
157
+ return;
158
+ if (observing)
159
+ return;
160
+ observing = true;
161
+ // Helper phase progress must never replenish its owner's independent
162
+ // physical-settlement episode. ALS retains that existing budget here.
163
+ try {
164
+ recoveryRpcTimeout(timeoutMs);
165
+ }
166
+ catch (error) {
167
+ callbackError ??= error;
168
+ stopTransport();
169
+ observing = false;
170
+ return;
171
+ }
172
+ try {
173
+ const waiting = stateDir && await readOperationWaiting(stateDir, input.operationId, binding);
174
+ if (settled || cleanup)
175
+ return;
176
+ if (waiting)
177
+ suspendOperationClock(clock);
178
+ else if (clock.sample(progressCursor)) {
179
+ timedOut = true;
180
+ stopTransport();
181
+ }
182
+ }
183
+ catch (error) {
184
+ callbackError ??= error;
185
+ stopTransport();
186
+ }
187
+ finally {
188
+ observing = false;
189
+ }
190
+ }, Math.max(1, Math.min(1000, timeoutMs / 4)));
95
191
  const abort = () => { aborted = true; stopTransport(); };
96
192
  if (input.signal?.aborted)
97
193
  abort();
@@ -106,22 +202,96 @@ export function runHarnessAdapter(input) {
106
202
  }
107
203
  return current + chunk;
108
204
  };
205
+ const observeDiagnostic = (line) => {
206
+ if (prettyDiagnostic === null) {
207
+ if (line.trimEnd() === "}")
208
+ prettyDiagnostic = "";
209
+ return;
210
+ }
211
+ const candidate = prettyDiagnostic ? `${prettyDiagnostic}\n${line}` : line;
212
+ if (candidate.length > 64_000) {
213
+ prettyDiagnostic = null;
214
+ return;
215
+ }
216
+ let value;
217
+ try {
218
+ value = JSON.parse(candidate);
219
+ prettyDiagnostic = "";
220
+ }
221
+ catch {
222
+ prettyDiagnostic = prettyDiagnostic || line.trimStart().startsWith("{") ? candidate : "";
223
+ return;
224
+ }
225
+ if (!value || typeof value !== "object" || Array.isArray(value))
226
+ return;
227
+ if (value.kind === "operation_progress" && stateDir && value.operation_id === input.operationId) {
228
+ // The diagnostic is only a wake-up: durable, bound evidence owns liveness.
229
+ diagnosticReads = diagnosticReads.then(async () => {
230
+ const record = await readOperationProgress(stateDir, input.operationId, binding);
231
+ if (!record || record.sequence <= operationSequence)
232
+ return;
233
+ const requested = await readOperationRecord(stateDir, input.operationId, "requested.json");
234
+ if (!requested)
235
+ throw new AppError("operation_progress_invalid", "Operation request disappeared while observing progress", 1);
236
+ for (const key of ["daemon_id", "params_sha256", "generation"])
237
+ binding[key] ??= requested[key] ?? null;
238
+ operationSequence = record.sequence;
239
+ clock?.sample(++progressCursor, Date.parse(record.observed_at));
240
+ }).catch(error => { callbackError ??= error; if (!settled)
241
+ stopTransport(); });
242
+ return;
243
+ }
244
+ const cause = adapterCause(candidate);
245
+ if (typeof cause.code === "string")
246
+ retainedError = cause;
247
+ if (clock && !(stateDir && ["session.create", "session.fork", "session.start", "session.resume", "session.prompt"].includes(operation))
248
+ && ["native_hook_phase", "process_maintenance_phase"].includes(String(value.kind))
249
+ && typeof value.phase === "string" && /^[a-z_]{1,48}$/.test(value.phase)
250
+ && !["heartbeat", "poll", "elapsed", "wait", "waiting", "status"].includes(value.phase)) {
251
+ const marker = JSON.stringify([value.kind, value.phase, value.operation_id ?? value.process_id]);
252
+ if (!phases.has(marker)) {
253
+ phases.add(marker);
254
+ clock.sample(++progressCursor);
255
+ }
256
+ }
257
+ };
109
258
  child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout = append(stdout, chunk); });
110
- child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr = append(stderr, chunk); });
259
+ // Diagnostics have no lifetime quota. Stream to the existing evidence sink
260
+ // and carry only a bounded tail in the disposable RPC receipt.
261
+ child.stderr.setEncoding("utf8").on("data", (chunk) => {
262
+ stderr = (stderr + chunk).slice(-64_000);
263
+ for (const piece of chunk.split(/(?<=\n)/)) {
264
+ if (!droppingDiagnostic)
265
+ pendingDiagnostic += piece;
266
+ if (pendingDiagnostic.length > 64_000) {
267
+ prettyDiagnostic = prettyDiagnostic || pendingDiagnostic.trimStart().startsWith("{") ? null : "";
268
+ pendingDiagnostic = "";
269
+ droppingDiagnostic = true;
270
+ }
271
+ if (!piece.endsWith("\n"))
272
+ continue;
273
+ if (!droppingDiagnostic)
274
+ observeDiagnostic(pendingDiagnostic);
275
+ pendingDiagnostic = "";
276
+ droppingDiagnostic = false;
277
+ }
278
+ if (input.onEvidence)
279
+ deliver(() => evidence({ type: "adapter_diagnostic", stderr: chunk }));
280
+ });
111
281
  child.once("error", (error) => { spawnError = error; });
112
- child.once("close", (status, signal) => {
282
+ child.once("close", async (status, signal) => {
113
283
  if (settled)
114
284
  return;
115
285
  settled = true;
116
286
  if (timer)
117
- clearTimeout(timer);
287
+ clearInterval(timer);
118
288
  input.signal?.removeEventListener("abort", abort);
119
289
  if (heartbeat)
120
290
  clearInterval(heartbeat);
121
- if (killTimer) {
122
- clearTimeout(killTimer);
123
- terminate("SIGKILL");
124
- }
291
+ await cleanup;
292
+ if (!droppingDiagnostic && pendingDiagnostic)
293
+ observeDiagnostic(pendingDiagnostic);
294
+ await diagnosticReads;
125
295
  const progressFailure = callbackError;
126
296
  deliver(() => evidence({ status, signal, timed_out: timedOut, aborted, stdout, stderr, ...(spawnError ? { error: spawnError.message } : {}) }));
127
297
  if (spawnError)
@@ -129,11 +299,11 @@ export function runHarnessAdapter(input) {
129
299
  if (outputTooLarge)
130
300
  return reject(new AppError("harness_adapter_output_limit", "Harness adapter exceeded the bounded reply limit", 1, { executable: input.executable, args: input.args.slice(0, 3), max_output_bytes: maxAdapterOutputBytes }));
131
301
  if (timedOut)
132
- return reject(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }));
302
+ return reject(Object.assign(new AppError("harness_adapter_timeout", "Harness adapter call timed out", 1, { executable: input.executable, args: input.args.slice(0, 3), timeout_ms: timeoutMs }), { observationClock: clock }));
133
303
  if (aborted)
134
304
  return reject(new AppError("harness_adapter_aborted", "Harness adapter call was cancelled", 1, { executable: input.executable, args: input.args.slice(0, 3) }));
135
305
  if (status !== 0)
136
- return reject(progressFailure ?? new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr, cause: adapterCause(stdout, stderr), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
306
+ return reject(progressFailure ?? new AppError("harness_adapter_failed", "Harness adapter call failed", 1, { executable: input.executable, args: input.args.slice(0, 3), status, signal, stderr, cause: adapterCause(stdout, stderr, retainedError ? JSON.stringify(retainedError) : ""), ...(callbackError ? { evidence_error: String(callbackError) } : {}) }));
137
307
  if (callbackError)
138
308
  return reject(callbackError);
139
309
  try {
@@ -158,9 +328,9 @@ function adapterCause(...outputs) {
158
328
  const value = JSON.parse(line);
159
329
  const nested = value.error && typeof value.error === "object" && !Array.isArray(value.error) ? value.error : null;
160
330
  const code = typeof nested?.code === "string" ? nested.code : typeof value.code === "string" ? value.code : null;
161
- const message = typeof nested?.message === "string" ? nested.message : typeof value.error === "string" ? value.error : code;
331
+ const message = typeof nested?.message === "string" ? nested.message : typeof value.error === "string" ? value.error : typeof value.message === "string" ? value.message : code;
162
332
  if (code)
163
- return { code, message, ...(nested?.details ?? value.details ? { details: nested?.details ?? value.details } : {}), ...((nested?.retryable ?? value.retryable) === true ? { retryable: true } : {}) };
333
+ return errorRecord({ ...(nested ?? value), code, message });
164
334
  }
165
335
  catch { /* Adapter diagnostics need not be JSON. */ }
166
336
  }
@@ -12,7 +12,7 @@ import { appendAudit } from "./audit.js";
12
12
  import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
13
13
  import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
14
14
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
15
- import { observeLifecycleInvocation, observedLifecycleInvocation } from "./lifecycle-invocations.js";
15
+ import { observeLifecycleInvocation, observedLifecycleInvocation, managedNativeInvocationScope } from "./lifecycle-invocations.js";
16
16
  import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
17
17
  import { registerProject, requireProjectByRoot } from "./projects.js";
18
18
  import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
@@ -398,6 +398,8 @@ export function handleCodexHook(context, input) {
398
398
  protocol_id: null
399
399
  };
400
400
  if (!lifecycle) {
401
+ if (providerSessionId)
402
+ assignedLifecycleCwd(context, project.root, null, providerSessionId, command, stringValue(payload.cwd));
401
403
  const persisted = context.db.writeTransaction(() => persist());
402
404
  return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
403
405
  }
@@ -406,10 +408,13 @@ export function handleCodexHook(context, input) {
406
408
  const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
407
409
  if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
408
410
  throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
411
+ const executionCwd = assignedLifecycleCwd(context, project.root, lifecycle, providerSessionId, command, stringValue(payload.cwd), { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId }, "codex-desktop");
409
412
  const receipt = observeLifecycleInvocation(context, {
410
413
  ...(invocationId ? { id: invocationId } : {}),
411
414
  command,
412
415
  harness: "codex-desktop",
416
+ projectId: project.id,
417
+ ...(executionCwd ? { executionCwd } : {}),
413
418
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
414
419
  recordReceipt: scope => { persist(scope); return eventKey; }
415
420
  });
@@ -493,17 +498,17 @@ export function handleZcodeEvent(context, input) {
493
498
  if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
494
499
  return { ok: true, observed: false, reason: "non_bash_tool" };
495
500
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
496
- const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
497
- if (parsed.kind === "none" && !parsed.unresolved)
501
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none", participation: "none" };
502
+ if (parsed.participation === "none" || parsed.participation === "unproven")
498
503
  return { ok: true, observed: false, reason: "acp_evidence_only" };
499
- if (parsed.kind === "none")
500
- throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
504
+ if (parsed.kind === "none" && !continuationCommand(command))
505
+ throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle shell execution is unsupported", 1, { reason: parsed.reason, effect: "unknown", recoverable: false });
501
506
  const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
502
507
  const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
503
508
  return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
504
509
  session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
505
510
  agent_id: native.agentId, _meta: { ddZcode: controlled }
506
- }) }, commandOption(parsed.invocation, "invocation-id"));
511
+ }) }, parsed.kind !== "none" ? commandOption(parsed.invocation, "invocation-id") : undefined);
507
512
  }
508
513
  /**
509
514
  * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
@@ -576,12 +581,17 @@ function recordZcodeLifecycle(context, input, invocationId, nativePreTool = fals
576
581
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "zcode-acp", session: nativeSessionIdentity("zcode-acp", providerSessionId), ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("zcode-acp", parentProviderSessionId) } : {}), daemon_id: daemonId };
577
582
  return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
578
583
  };
579
- if (!lifecycle)
584
+ if (!lifecycle) {
585
+ assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
580
586
  return context.db.writeTransaction(() => persist());
587
+ }
581
588
  let persistedResult;
589
+ const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId }, "zcode-acp");
582
590
  const receipt = observeLifecycleInvocation(context, {
583
591
  ...(invocationId ? { id: invocationId } : {}), command,
584
592
  harness: "zcode-acp",
593
+ projectId: project.id,
594
+ ...(executionCwd ? { executionCwd } : {}),
585
595
  identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
586
596
  recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
587
597
  });
@@ -668,11 +678,15 @@ export function handleGrokEvent(context, input) {
668
678
  };
669
679
  const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
670
680
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null });
671
- if (!lifecycle)
681
+ if (!lifecycle) {
682
+ assignedLifecycleCwd(context, expectedRoot, null, providerSessionId, command, observedCwd ?? undefined);
672
683
  return { ...result(context.db.writeTransaction(() => persist())), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
684
+ }
673
685
  let inserted = false;
686
+ const executionCwd = assignedLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, command, observedCwd ?? undefined, { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey }, "grok-acp");
674
687
  const receipt = observeLifecycleInvocation(context, {
675
688
  command, harness: "grok-acp", projectId: project.id,
689
+ ...(executionCwd ? { executionCwd } : {}),
676
690
  identity: { daemonId: daemonId ?? "", rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId, toolCallId: eventKey },
677
691
  recordReceipt: scope => { inserted = persist(scope); return eventKey; }
678
692
  });
@@ -695,12 +709,35 @@ export function handleDroidEvent(context, input) {
695
709
  return handleControlledToolEvent(context, input, event, "droid-cli");
696
710
  }
697
711
  /** Stable project identity is not the native Session's execution workspace. */
712
+ function assignedLifecycleCwd(context, projectRoot, facts, providerSessionId, command, observedCwd, identity, harness) {
713
+ const auxiliary = continuationCommand(command);
714
+ if ((!facts && !auxiliary) || facts?.analysis.supported === false)
715
+ return undefined;
716
+ const proven = identity && harness ? managedNativeInvocationScope(context, projectRoot, identity, harness) : null;
717
+ const constraint = proven ?? (identity ? { projectRoot, daemonId: identity.daemonId, rootSessionId: identity.rootSessionId } : undefined);
718
+ const issuedRunId = facts && !facts.runId && !facts.workId && !facts.bootstrapStageStart && constraint ? observedLifecycleInvocation(context, command, constraint, identity ? { identity } : undefined)?.scope.runId : null;
719
+ if (facts?.invocation.operation.startsWith("stage_") && !facts.bootstrapStageStart && !facts.runId && !issuedRunId)
720
+ throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
721
+ const expected = controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId);
722
+ // Unknown CLI targets have no assigned workspace. Recording their native
723
+ // observation must not turn the stable identity root into cwd authority.
724
+ if (!expected)
725
+ return undefined;
726
+ const route = facts?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
727
+ if (route && resolveProjectRoot(route) !== expected)
728
+ throw new AppError("invocation_directory_mismatch", "Literal lifecycle route differs from the assigned workspace", 1, { expected, actual: route, actual_provenance: "shell_literal_route", phase: "prepare", effect: "no_effect", recoverable: false });
729
+ if (!route && observedCwd && resolveProjectRoot(observedCwd) !== expected)
730
+ throw new AppError("invocation_directory_mismatch", "Native lifecycle directory differs from the assigned workspace", 1, { expected, actual: observedCwd, actual_provenance: "native_tool_cwd", phase: "prepare", effect: "no_effect", recoverable: false });
731
+ // ACP without native cwd still binds assigned authority; CLI confirms the
732
+ // physical process directory, rather than inventing native observation.
733
+ return expected;
734
+ }
698
735
  function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId, auxiliary, issuedRunId) {
699
736
  if (facts?.bootstrapStageStart)
700
737
  return projectRoot;
701
738
  const project = context.db.get("SELECT id FROM projects WHERE root = ?", [projectRoot]);
702
739
  if (!project)
703
- return projectRoot;
740
+ return undefined;
704
741
  if (facts?.invocation.operation === "merge_apply" || facts?.invocation.operation === "merge_repair") {
705
742
  const requestId = commandPosition(facts.invocation, 0);
706
743
  const request = requestId && context.db.get("SELECT target_workspace FROM merge_requests WHERE project_id = ? AND merge_request_id = ?", [project.id, requestId]);
@@ -716,13 +753,15 @@ function controlledLifecycleCwd(context, projectRoot, facts, providerSessionId,
716
753
  if (!(error instanceof AppError) || !["not_found", "ambiguous_work_alias"].includes(error.code))
717
754
  throw error;
718
755
  }
756
+ if (!work)
757
+ return undefined;
719
758
  }
720
759
  const reference = work?.run_id ?? facts?.runId ?? auxiliary?.args.options.get("run")?.[0] ?? auxiliary?.args.positional[2] ?? issuedRunId;
721
760
  if (!reference)
722
- return projectRoot;
761
+ return undefined;
723
762
  const run = context.db.get("SELECT id, run_root, workspace_root FROM runs WHERE project_id = ? AND (id = ? OR short_id = ?)", [project.id, reference, reference]);
724
763
  if (!run)
725
- return projectRoot; // Hook ingress does not validate a mistyped CLI target.
764
+ return undefined; // Hook ingress does not validate a mistyped CLI target.
726
765
  if (work) {
727
766
  // PROTOCOLIZE provisions the feature checkout after its coordinator
728
767
  // Session has launched in the stable project root. Its transition Work
@@ -809,13 +848,17 @@ function handleControlledToolEvent(context, input, event, harness) {
809
848
  // Syntax refusal precedes assignment/cwd lookup: an unsupported AST cannot
810
849
  // supply a route or execution authority, including a short public Stage call.
811
850
  const shellRefusal = lifecycle?.analysis.supported === false;
851
+ const nativeIdentity = { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId };
852
+ const provenScope = lifecycle && !shellRefusal ? managedNativeInvocationScope(context, expectedRoot, nativeIdentity, harness) : null;
812
853
  const issuedRunId = lifecycle && !lifecycle.runId && !lifecycle.workId && !lifecycle.bootstrapStageStart
813
- ? observedLifecycleInvocation(context, command, daemonId)?.scope.runId : null;
854
+ ? observedLifecycleInvocation(context, command, provenScope ?? { projectRoot: expectedRoot, daemonId, rootSessionId: nativeIdentity.rootSessionId }, { identity: nativeIdentity })?.scope.runId : null;
814
855
  if (!shellRefusal && lifecycle && lifecycle.invocation.operation.startsWith("stage_") && !lifecycle.bootstrapStageStart && !lifecycle.runId && !issuedRunId) {
815
856
  throw new AppError("invocation_assignment_missing", "Short lifecycle call has no unique issued RUN assignment; its workspace cannot be inferred", 1, { effect: "no_effect", recoverable: false });
816
857
  }
817
- const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId);
818
- const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd;
858
+ // These controlled platforms additionally qualify their native launch
859
+ // route against the stable project root when no CLI target is assigned.
860
+ const expectedCwd = shellRefusal ? observedCwd : controlledLifecycleCwd(context, expectedRoot, lifecycle, providerSessionId, auxiliary, issuedRunId) ?? expectedRoot;
861
+ const executionRoute = shellRefusal ? undefined : lifecycle?.invocation.expectedCwd ?? auxiliary?.expectedCwd;
819
862
  if (executionRoute && resolveProjectRoot(executionRoute) !== expectedCwd) {
820
863
  throw new AppError(`${errorPrefix}_directory_mismatch`, "Literal shell cd route does not match the assigned lifecycle workspace", 1, { expected: expectedCwd, actual: executionRoute, actual_provenance: "shell_literal_route", effect: "no_effect", recoverable: false });
821
864
  }
@@ -854,13 +897,14 @@ function handleControlledToolEvent(context, input, event, harness) {
854
897
  return inserted;
855
898
  };
856
899
  const result = (inserted) => ({ ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId });
857
- if ((agy || shellRefusal) && lifecycle) {
900
+ if (lifecycle) {
858
901
  let inserted = false;
859
902
  const receipt = observeLifecycleInvocation(context, {
860
- command, harness, projectId: project.id, ...(!shellRefusal && (agy || executionRoute) ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
903
+ command, harness, projectId: project.id, ...(!shellRefusal ? { executionCwd: expectedCwd } : {}), identity: { daemonId, rootSessionId: nativeParentId ?? providerSessionId, sessionId: providerSessionId, parentSessionId: nativeParentId ?? null, toolCallId: toolCallId },
861
904
  recordReceipt: scope => { inserted = persist(scope); return baseKey; }
862
905
  });
863
- return { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
906
+ const admitted = { ...result(inserted), ...(receipt.invocationId ? { invocation_id: receipt.invocationId } : {}) };
907
+ return agy || shellRefusal ? admitted : { ...admitted, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
864
908
  }
865
909
  const inserted = context.db.writeTransaction(() => persist());
866
910
  return agy ? result(inserted) : { ...result(inserted), hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
@@ -968,8 +1012,8 @@ export function analyzeLifecycleShellCommand(command, includeSessionRegister = f
968
1012
  }
969
1013
  function lifecycleFacts(command) {
970
1014
  const analysis = parseLifecycleCommand(command);
971
- if (analysis.kind === "none" && analysis.unresolved)
972
- throw new AppError("lifecycle_shell_syntax_invalid", "Shell participation could not be proven; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
1015
+ if (analysis.kind === "none" && analysis.unresolved && ["lifecycle", "auxiliary"].includes(analysis.participation))
1016
+ throw new AppError("lifecycle_shell_syntax_invalid", "Known lifecycle execution is unsupported; use a literal issued command", 1, { phase: "prepare", effect: "no_effect", recoverable: true, disposition: "pre_cli_shell_refusal", reason: analysis.reason });
973
1017
  if (analysis.kind === "none")
974
1018
  return null;
975
1019
  const invocation = analysis.invocation;
@@ -1431,8 +1475,8 @@ function commandHook(command, statusMessage) {
1431
1475
  }
1432
1476
  function hookCommand(event) {
1433
1477
  const args = `codex hook handle --event ${event} --json`;
1434
- const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin" dd-flow ${args}; fi`;
1435
- return `/bin/bash -c 'log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
1478
+ const handler = `if [ -n "\${DD_FLOW_BIN:-}" ]; then "$DD_FLOW_BIN" ${args}; else dd-flow ${args}; fi`;
1479
+ return `/bin/bash -c 'export PATH="\${DD_FLOW_HOME:+\${DD_FLOW_HOME}/bin:}\${PATH}:\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin"; log_dir="\${DD_FLOW_HOME:-\${HOME}/.dd-flow}/logs"; mkdir -p "$log_dir"; log="$log_dir/codex-hook.log"; printf "%s event=${event} phase=start pid=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" >>"$log"; ${handler} 2> >(tee -a "$log" >&2); status=$?; printf "%s event=${event} phase=finish pid=%s exit=%s\\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$$" "$status" >>"$log"; exit "$status"'`;
1436
1480
  }
1437
1481
  function resolveHookTarget(target) {
1438
1482
  if (!target || target === "isolated") {