@deksden-com/dd-flow-cli 0.9.0-beta.1 → 0.9.0-beta.100

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 (153) hide show
  1. package/CHANGELOG.md +561 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +91 -15
  6. package/dist/cli/hook-ingress.js +82 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/run-cli.js +1506 -334
  9. package/dist/cli.js +6 -2
  10. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  11. package/dist/harness-runtime/bin/dd-agy.mjs +39 -0
  12. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  13. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  14. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  15. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  16. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  17. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  18. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +449 -0
  19. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  20. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  21. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +146 -0
  22. package/dist/harness-runtime/lib/dd-codex.mjs +562 -0
  23. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  24. package/dist/harness-runtime/lib/dd-droid.mjs +468 -0
  25. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +300 -0
  26. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  27. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +217 -0
  28. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  29. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  30. package/dist/harness-runtime/lib/dd-zcode.mjs +941 -0
  31. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  32. package/dist/harness-runtime/lib/delegation-instructions.mjs +130 -0
  33. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  34. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  35. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  36. package/dist/harness-runtime/lib/managed-daemon.mjs +267 -0
  37. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  38. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  39. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  40. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  41. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  42. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  43. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  44. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  45. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  46. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  47. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  48. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  49. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  50. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  51. package/dist/runtime/context.js +3 -1
  52. package/dist/schemas/agent-profile.schema.json +1 -1
  53. package/dist/schemas/code-review-result.schema.json +2 -2
  54. package/dist/schemas/code-work-batch.schema.json +4 -3
  55. package/dist/schemas/code-work-result.schema.json +4 -4
  56. package/dist/schemas/harness-config.schema.json +23 -0
  57. package/dist/schemas/plan-review-decision.schema.json +1 -1
  58. package/dist/schemas/plan-review-result.schema.json +2 -2
  59. package/dist/schemas/run-control-receipt.schema.json +99 -0
  60. package/dist/schemas/run-control-request.schema.json +36 -0
  61. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  62. package/dist/services/canon.js +11 -8
  63. package/dist/services/cleanup.js +75 -21
  64. package/dist/services/cli-operation-classifier.js +22 -24
  65. package/dist/services/code-checks.js +676 -79
  66. package/dist/services/codex-hook-delivery.js +28 -0
  67. package/dist/services/command-context.js +80 -0
  68. package/dist/services/config.js +11 -8
  69. package/dist/services/continuation-outcome.js +20 -0
  70. package/dist/services/controller-fanout.js +134 -0
  71. package/dist/services/dashboard.js +103 -31
  72. package/dist/services/engines.js +125 -80
  73. package/dist/services/eval-snapshots.js +838 -74
  74. package/dist/services/execution-policy.js +170 -0
  75. package/dist/services/external-work-launch.js +110 -0
  76. package/dist/services/harness-adapter.js +147 -24
  77. package/dist/services/harness-config.js +68 -0
  78. package/dist/services/hooks.js +609 -310
  79. package/dist/services/lanes.js +62 -53
  80. package/dist/services/lifecycle-command.js +103 -17
  81. package/dist/services/lifecycle-contract.js +30 -0
  82. package/dist/services/lifecycle-invocations.js +1128 -0
  83. package/dist/services/managed-daemon-binding.js +38 -0
  84. package/dist/services/managed-processes.js +425 -0
  85. package/dist/services/merge-queue.js +179 -104
  86. package/dist/services/merge-server.js +78 -27
  87. package/dist/services/migrations.js +14 -9
  88. package/dist/services/native-daemon-history.js +49 -0
  89. package/dist/services/native-session-control.js +39 -0
  90. package/dist/services/plan-runtime.js +19 -8
  91. package/dist/services/plans.js +28 -25
  92. package/dist/services/portable-refs.js +57 -0
  93. package/dist/services/projects.js +18 -5
  94. package/dist/services/prompts.js +27 -18
  95. package/dist/services/protocols.js +64 -20
  96. package/dist/services/recovery-observation-budget.js +61 -0
  97. package/dist/services/recovery-snapshot-database.js +107 -0
  98. package/dist/services/repair-intents.js +40 -0
  99. package/dist/services/run-control-receipt.js +56 -0
  100. package/dist/services/run-control-worker.js +363 -0
  101. package/dist/services/run-control.js +876 -0
  102. package/dist/services/run-controller-adapter.js +222 -0
  103. package/dist/services/run-controller-capture.js +136 -0
  104. package/dist/services/run-controller-process.js +189 -0
  105. package/dist/services/run-controller-recovery.js +222 -0
  106. package/dist/services/run-controller-state.js +36 -0
  107. package/dist/services/run-controller.js +803 -0
  108. package/dist/services/run-engine-bindings.js +20 -62
  109. package/dist/services/run-fork.js +138 -0
  110. package/dist/services/run-observations.js +112 -0
  111. package/dist/services/run-projection.js +10 -8
  112. package/dist/services/run-recovery-runtime.js +69 -0
  113. package/dist/services/run-recovery.js +343 -0
  114. package/dist/services/runs.js +437 -81
  115. package/dist/services/runtime-budget.js +439 -0
  116. package/dist/services/runtime-command.js +43 -0
  117. package/dist/services/runtime-scope-capture.js +50 -0
  118. package/dist/services/runtime-scope-control.js +450 -0
  119. package/dist/services/runtime-scope-resume.js +565 -0
  120. package/dist/services/runtime-scope-stop.js +99 -0
  121. package/dist/services/runtime-scope-worker.js +243 -0
  122. package/dist/services/runtime-service.js +97 -0
  123. package/dist/services/schema-validation.js +21 -19
  124. package/dist/services/session-identity.js +19 -0
  125. package/dist/services/sessions.js +61 -80
  126. package/dist/services/stage-blocker.js +17 -7
  127. package/dist/services/stage-context.js +44 -18
  128. package/dist/services/stage-lifecycle.js +112 -108
  129. package/dist/services/stage-pause.js +117 -73
  130. package/dist/services/stage-work-graph.js +35 -0
  131. package/dist/services/usage.js +108 -132
  132. package/dist/services/vnext-code-review.js +245 -98
  133. package/dist/services/vnext-code.js +355 -257
  134. package/dist/services/vnext-execution-profile.js +5 -3
  135. package/dist/services/vnext-fanout.js +166 -26
  136. package/dist/services/vnext-merge.js +413 -147
  137. package/dist/services/vnext-plan-review.js +182 -109
  138. package/dist/services/vnext-plan.js +199 -82
  139. package/dist/services/vnext-protocolize.js +133 -60
  140. package/dist/services/vnext-specify.js +94 -71
  141. package/dist/services/work-registry.js +813 -160
  142. package/dist/services/workspace-bootstrap.js +76 -0
  143. package/dist/services/worktrees.js +85 -35
  144. package/dist/shared/entity-references.js +8 -0
  145. package/dist/shared/errors.js +12 -0
  146. package/dist/storage/database.js +569 -16
  147. package/dist/storage/paths.js +17 -4
  148. package/dist/storage/work-references.js +23 -0
  149. package/dist/storage/writer-contract.js +77 -0
  150. package/dist/storage/writer-migration.js +102 -0
  151. package/package.json +24 -13
  152. package/tools/audit-runtime-fix-boundaries.mjs +96 -0
  153. package/tools/repair-paused-run-status.mjs +59 -0
@@ -2,17 +2,22 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { parse, stringify } from "smol-toml";
5
+ import { parse, stringify, TomlError } from "smol-toml";
6
6
  import { AppError } from "../shared/errors.js";
7
+ import { entityReferenceVariants } from "../shared/entity-references.js";
7
8
  import { parseJsonObject } from "../shared/json.js";
8
- import { ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
+ import { canonicalPath, ensureDir, resolveProjectRoot } from "../storage/paths.js";
9
10
  import { appendAudit } from "./audit.js";
10
- import { commandHasOption, commandOption, commandPosition, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
11
+ import { prepareTextFile, prepareJsonText, prepareError } from "../cli/input-preparation.js";
12
+ import { nativeSessionIdentity, storageSessionId } from "./session-identity.js";
13
+ import { assertRecoveryHookCurrent } from "./run-recovery.js";
14
+ import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
15
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload, continuationCommand } from "./lifecycle-command.js";
11
16
  import { registerProject, requireProjectByRoot } from "./projects.js";
12
- import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
17
+ import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
13
18
  const defaultProfileName = "default";
14
19
  const maxSanitizedSummaryLength = 1600;
15
- const sharedEntries = [
20
+ export const codexHomeSharedEntries = [
16
21
  "auth.json",
17
22
  "auth.bk",
18
23
  "sessions",
@@ -49,22 +54,28 @@ export function planCodexHome(context, input) {
49
54
  config_path: paths.configPath,
50
55
  hooks_path: paths.hooksPath,
51
56
  owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
52
- shared_entries: sharedEntries,
57
+ shared_entries: codexHomeSharedEntries,
53
58
  exists: fs.existsSync(paths.targetHome),
54
59
  codex_env: { CODEX_HOME: paths.targetHome }
55
60
  };
56
61
  }
57
- export function initCodexHome(context, input) {
62
+ export function prepareCodexHome(context, input) {
58
63
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
59
64
  const profile = resolveProfileName(input.profile);
60
65
  const paths = resolveHomePaths(context, project, profile, input);
66
+ if (canonicalPath(paths.sourceHome, false) === canonicalPath(paths.targetHome, false))
67
+ throw new AppError("validation", "Managed Codex home must differ from its source home", 2);
68
+ const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
69
+ return { project, profile, paths, config };
70
+ }
71
+ export function initCodexHome(context, input, prepared = prepareCodexHome(context, input)) {
72
+ const { project, profile, paths, config } = prepared;
61
73
  const now = context.now();
62
74
  ensureDir(paths.targetHome);
63
75
  ensureDir(path.join(paths.targetHome, "log"));
64
76
  ensureDir(path.join(paths.targetHome, ".tmp"));
65
77
  ensureDir(path.join(paths.targetHome, "tmp"));
66
78
  const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
67
- const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
68
79
  fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
69
80
  writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
70
81
  writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
@@ -135,7 +146,7 @@ export function printCodexHomeEnv(context, input) {
135
146
  command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
136
147
  };
137
148
  }
138
- export function removeCodexHome(context, input) {
149
+ export function prepareCodexHomeRemoval(context, input) {
139
150
  if (!["keep-shared", "remove-owned"].includes(input.mode)) {
140
151
  throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
141
152
  }
@@ -143,9 +154,30 @@ export function removeCodexHome(context, input) {
143
154
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
144
155
  const profile = resolveProfileName(input.profile);
145
156
  const record = homeProfile(context, project.id, profile);
157
+ if (record && record.status !== "removed" && fs.existsSync(record.target_home)) {
158
+ let marker;
159
+ try {
160
+ const value = JSON.parse(fs.readFileSync(path.join(record.target_home, ".dd-flow-home.json"), "utf8"));
161
+ if (!value || typeof value !== "object" || Array.isArray(value))
162
+ throw new Error("Invalid marker");
163
+ marker = value;
164
+ }
165
+ catch {
166
+ throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership marker is missing or invalid; refusing removal", 1, { target_home: record.target_home });
167
+ }
168
+ if (marker.managed_by !== "dd-flow-cli" || marker.project_id !== project.id || marker.profile !== profile || marker.target_home !== record.target_home || marker.source_home !== record.source_home || canonicalPath(record.target_home, false) === canonicalPath(record.source_home, false)) {
169
+ throw new AppError("runtime_artifact_invalid", "Managed Codex home ownership does not match its registration; refusing removal", 1, { target_home: record.target_home });
170
+ }
171
+ }
172
+ return { project, profile, record, mode };
173
+ }
174
+ export function removeCodexHome(context, input) {
175
+ const { project, profile, record, mode } = prepareCodexHomeRemoval(context, input);
146
176
  if (!record) {
147
177
  return { ok: true, removed: false, profile, status: "not_initialized" };
148
178
  }
179
+ if (record.status === "removed")
180
+ return { ok: true, removed: false, profile, status: "already_removed" };
149
181
  const removedEntries = [];
150
182
  for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
151
183
  const target = path.join(record.target_home, relative);
@@ -206,7 +238,7 @@ export function getCodexHooksStatus(context, input) {
206
238
  recorded
207
239
  };
208
240
  }
209
- export function installCodexHooks(context, input) {
241
+ export function prepareCodexHooks(context, input) {
210
242
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
211
243
  const target = resolveHookTarget(input.target);
212
244
  const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
@@ -216,9 +248,16 @@ export function installCodexHooks(context, input) {
216
248
  hooks_path: location.hooksPath
217
249
  });
218
250
  }
219
- const existing = readJsonIfExists(location.hooksPath) ?? {};
220
- const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
251
+ const text = fs.existsSync(location.hooksPath) ? prepareTextFile(location.hooksPath, "hooks configuration").text : null;
252
+ const existing = text === null ? {} : prepareJsonText(text, { file: location.hooksPath }).value;
253
+ if (!existing || typeof existing !== "object" || Array.isArray(existing) || (existing.hooks !== undefined && (!existing.hooks || typeof existing.hooks !== "object" || Array.isArray(existing.hooks) || Object.values(existing.hooks).some(entries => !Array.isArray(entries)))))
254
+ return prepareError(new AppError("validation", "Hooks configuration requires an object with event arrays", 2), { file: location.hooksPath });
255
+ return { project, target, location, existing, text };
256
+ }
257
+ export function installCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
258
+ const { project, target, location, existing, text } = prepared;
221
259
  const nextConfig = mergeHooksConfig(existing, project.root);
260
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
222
261
  ensureDir(path.dirname(location.hooksPath));
223
262
  writeJsonFile(location.hooksPath, nextConfig);
224
263
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
@@ -237,20 +276,11 @@ export function installCodexHooks(context, input) {
237
276
  expected_hash: hashObject(expectedHooksConfig(project.root))
238
277
  };
239
278
  }
240
- export function removeCodexHooks(context, input) {
241
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
242
- const target = resolveHookTarget(input.target);
243
- const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
244
- if (target === "default" && !input.yes) {
245
- throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
246
- target,
247
- hooks_path: location.hooksPath
248
- });
249
- }
250
- const existing = readJsonIfExists(location.hooksPath) ?? {};
279
+ export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
280
+ const { project, target, location, existing, text } = prepared;
251
281
  const hadManaged = hasManagedHooks(existing, project.root);
252
- const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
253
282
  const nextConfig = removeManagedHooks(existing, project.root);
283
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
254
284
  ensureDir(path.dirname(location.hooksPath));
255
285
  writeJsonFile(location.hooksPath, nextConfig);
256
286
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
@@ -267,207 +297,292 @@ export function handleCodexHook(context, input) {
267
297
  if (eventName !== "PreToolUse") {
268
298
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
269
299
  }
270
- const sessionId = stringValue(payload.session_id);
300
+ const rootProviderSessionId = stringValue(payload.session_id) ?? null;
301
+ // Codex deliberately retains its legacy root session_id inside a
302
+ // thread-spawned subagent. `agent_id` is the current child Thread id and
303
+ // therefore the only valid identity for a fresh Work.
304
+ const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
305
+ const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
271
306
  const turnId = stringValue(payload.turn_id);
272
307
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
273
308
  const command = lifecycleCommandFromPayload(payload);
274
309
  if (!command)
275
310
  return { ok: true, observed: false, reason: "non_bash_tool" };
276
311
  const lifecycle = lifecycleFacts(command);
277
- if (!lifecycle)
312
+ if (!lifecycle && !continuationCommand(command))
278
313
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
314
+ const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
279
315
  // Resume legitimately receives an answer through stdin. It is matched by
280
316
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
281
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
282
- return {
283
- ok: false,
284
- observed: false,
285
- reason: "compound_lifecycle_command",
286
- message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
287
- standalone_command: lifecycle.invocation.command,
288
- hookSpecificOutput: {
289
- hookEventName: "PreToolUse",
290
- permissionDecision: "deny",
291
- permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
292
- }
293
- };
317
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
318
+ const invocationId = lifecycle ? commandOption(lifecycle.invocation, "invocation-id") : undefined;
319
+ if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
320
+ const proof = context.codexHookProof;
321
+ if (!proof || proof.daemonId !== context.env.DD_FLOW_DAEMON_ID || proof.rootSessionId !== rootProviderSessionId
322
+ || proof.sessionId !== providerSessionId || proof.parentSessionId !== parentProviderSessionId
323
+ || proof.toolCallId !== payload.tool_use_id || proof.command !== command) {
324
+ throw new AppError("native_hook_unproven", "Managed Codex admission requires its active native hook delivery, not a manual handler call", 1, { ...(invocationId ? { invocation_id: invocationId } : {}), effect: "no_effect" });
325
+ }
294
326
  }
295
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
296
- const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
327
+ const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
297
328
  // A bootstrap stage is the first stateful command for a new materialized project.
298
329
  // Its PreToolUse event arrives before stage start can register that project.
299
- if (bootstrapStageStart && commandProjectRoot) {
300
- registerProject(context, { root: commandProjectRoot });
330
+ const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
331
+ if (bootstrapStageStart && bootstrapRoot) {
332
+ registerProject(context, { root: bootstrapRoot });
301
333
  }
302
- const project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
303
- if (!project)
334
+ const project = projectForHook(context, nativeProjectRoot, stringValue(payload.cwd));
335
+ if (!project) {
336
+ if (invocationId)
337
+ throw new AppError("project_not_registered", "Managed lifecycle invocation has no registered project", 1, { project_root: nativeProjectRoot ?? null });
304
338
  return { ok: true, observed: false, reason: "unrelated_cwd" };
305
- const binding = sessionId ? upsertSessionBindingFromPayload(context, project, sessionId, payload) : undefined;
306
- const observedSession = flowPayload
307
- ? bindObservedFlowSession(context, project, flowPayload, sessionId ?? flowPayload.session_id ?? undefined)
308
- : undefined;
309
- const effectiveSessionId = observedSession?.session_id ?? sessionId ?? null;
310
- const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
339
+ }
340
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, project.root) : null;
341
+ // Codex retains the root session_id for a thread-spawned child, while the
342
+ // payload transcript belongs to the current agent_id. Keep lineage separate
343
+ // from ownership: otherwise a child overwrites the root transcript binding.
311
344
  const eventKey = hookEventKey(payload, eventName, toolName, command);
312
- const inserted = recordHookEvent(context, {
313
- projectId: project.id,
314
- protocolId,
315
- harness: "codex-desktop",
316
- providerSessionId: sessionId ?? null,
317
- parentSessionId: null,
318
- sessionId: effectiveSessionId,
319
- agentId: stringValue(payload.agent_id) ?? null,
320
- turnId: turnId ?? null,
321
- eventName,
322
- toolName: toolName ?? null,
323
- status: "observed",
324
- payload,
325
- eventKey,
326
- matchKey,
327
- transcriptPath: binding?.transcript_path ?? null,
328
- cwd: stringValue(payload.cwd) ?? null
329
- });
330
- if (effectiveSessionId) {
331
- recordFlowSessionObservation(context, {
345
+ const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
346
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
347
+ const eventCwd = stringValue(payload.cwd) ?? project.root;
348
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "codex-desktop", providerSessionId: providerSessionId ?? null, parentSessionId, daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null, sessionId: storageId, turnId: turnId ?? null, eventName, toolName: toolName ?? null, matchKey, cwd: eventCwd });
349
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
350
+ // Resolve legacy transcript discovery before acquiring the receipt write lock.
351
+ const transcriptPath = stringValue(payload.transcript_path)
352
+ ?? (bindingOwnerId ? sessionBinding(context, project.id, bindingOwnerId)?.transcript_path ?? locateCodexTranscript(bindingOwnerId, context.env.CODEX_HOME) : null);
353
+ const persist = (scope) => {
354
+ if (scope)
355
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), project.root, scope);
356
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload, transcriptPath) : undefined;
357
+ const effectiveSessionId = storageId;
358
+ const protocolId = binding?.protocol_id ?? null;
359
+ const inserted = recordHookEvent(context, {
332
360
  projectId: project.id,
333
- sessionId: effectiveSessionId,
334
- runId: observedSession?.run_id ?? null,
335
361
  protocolId,
336
- cwd: stringValue(payload.cwd) ?? null,
362
+ harness: "codex-desktop",
363
+ providerSessionId: providerSessionId ?? null,
364
+ parentSessionId,
365
+ // Pinned by the owning app-server process, never derived from current RUN
366
+ // state or an agent-supplied shell command/payload.
367
+ daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
368
+ sessionId: effectiveSessionId,
369
+ agentId: rawChildProviderSessionId ?? null,
370
+ turnId: turnId ?? null,
371
+ eventName,
337
372
  toolName: toolName ?? null,
338
- eventKey
373
+ status: "observed",
374
+ payload,
375
+ eventKey,
376
+ matchKey,
377
+ transcriptPath: binding?.transcript_path ?? null,
378
+ cwd: stringValue(payload.cwd) ?? project.root
339
379
  });
340
- }
341
- return {
380
+ if (effectiveSessionId) {
381
+ recordFlowSessionObservation(context, {
382
+ projectId: project.id,
383
+ sessionId: effectiveSessionId,
384
+ protocolId,
385
+ cwd: eventCwd,
386
+ toolName: toolName ?? null,
387
+ eventKey
388
+ });
389
+ }
390
+ return { inserted, protocolId };
391
+ };
392
+ const common = {
342
393
  ok: true,
343
- observed: inserted,
344
- duplicate: !inserted,
345
394
  event_key: eventKey,
346
- session_id: effectiveSessionId,
347
- protocol_id: protocolId,
348
- ...(sessionId && (flowPayload || stageStart || stageResume || workStart)
395
+ session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
396
+ ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
397
+ protocol_id: null
398
+ };
399
+ if (!lifecycle) {
400
+ const persisted = context.db.writeTransaction(() => persist());
401
+ return { ...common, observed: persisted.inserted, duplicate: !persisted.inserted };
402
+ }
403
+ if (invocationId || context.env.DD_FLOW_DAEMON_ID) {
404
+ const daemonId = stringValue(context.env.DD_FLOW_DAEMON_ID);
405
+ const toolCallId = stringValue(payload.tool_use_id) ?? stringValue(payload.event_id) ?? stringValue(payload.delivery_id) ?? stringValue(payload.id);
406
+ if (!daemonId || !rootProviderSessionId || !providerSessionId || !toolCallId)
407
+ throw new AppError("codex_identity_missing", "Managed Codex lifecycle hook has no complete native invocation identity", 1, { invocation_id: invocationId });
408
+ const receipt = observeLifecycleInvocation(context, {
409
+ ...(invocationId ? { id: invocationId } : {}),
410
+ command,
411
+ harness: "codex-desktop",
412
+ identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId, toolCallId },
413
+ recordReceipt: scope => { persist(scope); return eventKey; }
414
+ });
415
+ const stored = context.db.get("SELECT protocol_id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, receipt.eventKey]);
416
+ const admittedInvocationId = receipt.invocationId ?? invocationId;
417
+ if (admittedInvocationId)
418
+ return { ...common, protocol_id: stored?.protocol_id ?? null, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
419
+ return {
420
+ ...common,
421
+ protocol_id: stored?.protocol_id ?? null,
422
+ observed: !receipt.duplicate,
423
+ duplicate: receipt.duplicate,
424
+ hookSpecificOutput: {
425
+ hookEventName: "PreToolUse",
426
+ permissionDecision: "allow",
427
+ updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
428
+ }
429
+ };
430
+ }
431
+ const persisted = persist();
432
+ return {
433
+ ...common,
434
+ protocol_id: persisted.protocolId,
435
+ observed: persisted.inserted,
436
+ duplicate: !persisted.inserted,
437
+ ...(providerSessionId
349
438
  ? {
350
439
  hookSpecificOutput: {
351
440
  hookEventName: "PreToolUse",
352
441
  permissionDecision: "allow",
353
- updatedInput: { command: commandWithHookEvent(command, eventKey) }
442
+ updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
354
443
  }
355
444
  }
356
445
  : {})
357
446
  };
358
447
  }
359
- /** Convert a zcode-acp Bash tool notification into the same trusted lifecycle receipt used by Codex hooks. */
448
+ /** The hook protocol does not carry the immediate parent. Recover it only from this child's own native transcript. */
449
+ function codexNativeParentId(payload, childId) {
450
+ const transcript = stringValue(payload.transcript_path);
451
+ if (!transcript || !path.isAbsolute(transcript))
452
+ return null;
453
+ try {
454
+ const descriptor = fs.openSync(transcript, "r");
455
+ const chunks = [];
456
+ try {
457
+ for (;;) {
458
+ const buffer = Buffer.alloc(4096);
459
+ const length = fs.readSync(descriptor, buffer, 0, buffer.length, null);
460
+ if (!length)
461
+ break;
462
+ const newline = buffer.subarray(0, length).indexOf(10);
463
+ chunks.push(buffer.subarray(0, newline < 0 ? length : newline));
464
+ if (newline >= 0)
465
+ break;
466
+ }
467
+ }
468
+ finally {
469
+ fs.closeSync(descriptor);
470
+ }
471
+ const firstLine = Buffer.concat(chunks).toString("utf8");
472
+ if (!firstLine)
473
+ return null;
474
+ const record = JSON.parse(firstLine);
475
+ return record.type === "session_meta" && record.payload?.id === childId && typeof record.payload.parent_thread_id === "string"
476
+ ? record.payload.parent_thread_id
477
+ : null;
478
+ }
479
+ catch {
480
+ return null;
481
+ }
482
+ }
483
+ /** ACP is asynchronous evidence. Only an explicitly issued invocation may use
484
+ * it as the receipt that its CLI waits for; this is not a PreToolUse hook. */
360
485
  export function handleZcodeEvent(context, input) {
361
486
  const notification = parseJsonObject(input.stdin || "{}", "zcode ACP notification");
362
- if (notification.method !== "session/update")
363
- return { ok: true, observed: false, reason: "event_not_participating" };
364
- const params = objectRecord(notification.params);
365
- const update = objectRecord(params.update);
366
- if (update.sessionUpdate !== "tool_call")
487
+ const params = objectRecord(notification.params), update = objectRecord(params.update);
488
+ if (notification.method !== "session/update" || update.sessionUpdate !== "tool_call")
367
489
  return { ok: true, observed: false, reason: "event_not_participating" };
368
- const updateMeta = objectRecord(update._meta);
369
- const claudeCode = objectRecord(updateMeta.claudeCode);
370
- const runtime = objectRecord(updateMeta.zcodeRuntime);
371
- const toolName = stringValue(claudeCode.toolName) ?? stringValue(update.kind);
372
- const rawInput = objectRecord(update.rawInput);
490
+ const rawInput = objectRecord(update.rawInput), meta = objectRecord(update._meta), native = objectRecord(meta.zcodeRuntime);
491
+ const toolName = stringValue(objectRecord(meta.claudeCode).toolName);
492
+ if (toolName !== "Bash" && toolName !== "bash" && toolName !== "execute")
493
+ return { ok: true, observed: false, reason: "non_bash_tool" };
373
494
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd);
374
- if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && update.kind !== "execute")) {
495
+ const parsed = command ? parseLifecycleCommand(command) : { kind: "none" };
496
+ if (parsed.kind === "none")
497
+ return { ok: true, observed: false, reason: "acp_evidence_only" };
498
+ const controlled = objectRecord(objectRecord(notification._meta).ddZcode);
499
+ const providerSessionId = stringValue(native.childSessionId) ?? stringValue(controlled.rootProviderSessionId);
500
+ return recordZcodeLifecycle(context, { projectRoot: input.projectRoot, stdin: JSON.stringify({
501
+ session_id: providerSessionId, tool_use_id: update.toolCallId, tool_name: toolName, tool_input: rawInput,
502
+ agent_id: native.agentId, _meta: { ddZcode: controlled }
503
+ }) }, commandOption(parsed.invocation, "invocation-id"));
504
+ }
505
+ /**
506
+ * Accept the synchronous native ZCode PreToolUse callback. Invocation-ID
507
+ * commands instead rendezvous with their ACP event while the CLI awaits
508
+ * admission; the native hook must not create a competing receipt for them.
509
+ */
510
+ export function handleZcodeHook(context, input) {
511
+ return recordZcodeLifecycle(context, input);
512
+ }
513
+ function recordZcodeLifecycle(context, input, invocationId) {
514
+ const hook = parseJsonObject(input.stdin || "{}", "ZCode hook event");
515
+ const eventName = stringValue(hook.hook_event_name) ?? stringValue(hook.hookEventName);
516
+ if (eventName && eventName !== "PreToolUse" && eventName !== "pre_tool_use")
517
+ return { ok: true, observed: false, reason: "event_not_participating" };
518
+ const toolName = stringValue(hook.tool_name) ?? stringValue(hook.toolName);
519
+ const rawInput = objectRecord(hook.tool_input ?? hook.toolInput);
520
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(hook.command);
521
+ if (!command || (toolName && toolName !== "Bash" && toolName !== "bash" && toolName !== "execute"))
375
522
  return { ok: true, observed: false, reason: "non_bash_tool" };
376
- }
377
523
  const lifecycle = lifecycleFacts(command);
378
- if (!lifecycle)
524
+ if (!lifecycle && !continuationCommand(command))
379
525
  return { ok: true, observed: false, reason: "event_not_participating" };
380
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
381
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, {
382
- standalone_command: lifecycle.invocation.command
383
- });
384
- }
385
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
526
+ if (!invocationId && lifecycle && commandOption(lifecycle.invocation, "invocation-id"))
527
+ return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
386
528
  const expectedRoot = resolveProjectRoot(input.projectRoot);
387
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
388
- throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, {
389
- expected: expectedRoot,
390
- actual: commandProjectRoot
391
- });
529
+ const meta = objectRecord(hook._meta);
530
+ const ddZcode = objectRecord(meta.ddZcode);
531
+ const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
532
+ const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId);
533
+ const parentProviderSessionId = stringValue(ddZcode.parentProviderSessionId);
534
+ const daemonId = stringValue(ddZcode.daemonId);
535
+ if (!providerSessionId || !rootProviderSessionId || !daemonId) {
536
+ throw new AppError("zcode_identity_missing", "ZCode native hook has no controlled Session identity", 1);
392
537
  }
393
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
394
- if (bootstrapStageStart)
538
+ if (providerSessionId === rootProviderSessionId ? parentProviderSessionId : !parentProviderSessionId) {
539
+ throw new AppError("zcode_identity_missing", "ZCode native hook has inconsistent Session ancestry", 1);
540
+ }
541
+ const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
542
+ if (!toolUseId)
543
+ throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
544
+ const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
545
+ const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
546
+ if (lifecycle?.bootstrapStageStart)
395
547
  registerProject(context, { root: expectedRoot });
396
548
  const project = requireProjectByRoot(context, expectedRoot);
397
- const envelopeMeta = objectRecord(notification._meta);
398
- const ddZcode = objectRecord(envelopeMeta.ddZcode);
549
+ const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
550
+ const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
399
551
  const observedProfile = objectRecord(ddZcode.observedProfile);
400
- const rootProviderSessionId = stringValue(ddZcode.rootProviderSessionId) ?? stringValue(params.sessionId);
401
- const daemonId = stringValue(ddZcode.daemonId);
402
- if (!rootProviderSessionId)
403
- throw new AppError("zcode_identity_missing", "ZCode ACP event has no root provider Session ID", 1);
404
- const childProviderSessionId = stringValue(runtime.childSessionId);
405
- const providerSessionId = childProviderSessionId ?? rootProviderSessionId;
406
- const sessionId = `zcode-acp:${providerSessionId}`;
407
- const parentSessionId = childProviderSessionId ? `zcode-acp:${rootProviderSessionId}` : null;
408
- const agentId = stringValue(runtime.agentId);
409
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
410
- ...flowPayload,
411
- harness: "zcode-acp",
412
- provider_session_id: providerSessionId,
413
- agent_id: agentId ?? null,
414
- parent_session_id: parentSessionId
415
- }, sessionId) : undefined;
416
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
417
- harness: "zcode-acp", providerSessionId, command
418
- })).digest("hex");
419
- const payload = {
420
- session_id: providerSessionId,
421
- agent_id: agentId,
422
- cwd: expectedRoot,
423
- tool_name: toolName ?? "Bash",
424
- tool_input: rawInput,
425
- command,
426
- provider: stringValue(observedProfile.provider),
427
- model: stringValue(observedProfile.model),
428
- reasoning: stringValue(observedProfile.reasoning),
429
- mode: stringValue(observedProfile.mode),
430
- agent_type: stringValue(runtime.source)
552
+ let matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
553
+ const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
554
+ const persist = (scope) => {
555
+ if (scope)
556
+ matchKey = lifecycleMatchKey(lifecycleFacts(scope.command), expectedRoot, scope);
557
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
558
+ const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
559
+ command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
560
+ const inserted = recordHookEvent(context, {
561
+ projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
562
+ daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
563
+ eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
564
+ transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
565
+ });
566
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
567
+ 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 };
568
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
431
569
  };
432
- const inserted = recordHookEvent(context, {
433
- projectId: project.id,
434
- protocolId: observedSession?.protocol_id ?? null,
570
+ if (!lifecycle)
571
+ return context.db.writeTransaction(() => persist());
572
+ let persistedResult;
573
+ const receipt = observeLifecycleInvocation(context, {
574
+ ...(invocationId ? { id: invocationId } : {}), command,
435
575
  harness: "zcode-acp",
436
- providerSessionId,
437
- parentSessionId,
438
- daemonId: daemonId ?? null,
439
- sessionId,
440
- agentId: agentId ?? null,
441
- turnId: null,
442
- eventName: "PreToolUse",
443
- toolName: toolName ?? "Bash",
444
- status: "observed",
445
- payload,
446
- eventKey,
447
- matchKey,
448
- transcriptPath: null,
449
- cwd: expectedRoot
576
+ identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
577
+ recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
450
578
  });
451
- recordFlowSessionObservation(context, {
452
- projectId: project.id,
453
- sessionId,
454
- runId: observedSession?.run_id ?? null,
455
- protocolId: observedSession?.protocol_id ?? null,
456
- cwd: expectedRoot,
457
- toolName: toolName ?? "Bash",
458
- eventKey
459
- });
460
- return {
461
- ok: true,
462
- observed: inserted,
463
- duplicate: !inserted,
464
- event_key: eventKey,
465
- harness: "zcode-acp",
466
- session_id: sessionId,
467
- provider_session_id: providerSessionId,
468
- parent_session_id: parentSessionId,
469
- daemon_id: daemonId ?? null
470
- };
579
+ const admittedInvocationId = receipt.invocationId ?? invocationId;
580
+ if (admittedInvocationId)
581
+ return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: admittedInvocationId };
582
+ // Without an issued managed attempt this is still ordinary hook evidence.
583
+ // Preserve persist()'s replay result instead of overwriting it with the
584
+ // admission layer's non-authoritative duplicate flag.
585
+ return persistedResult ?? persist();
471
586
  }
472
587
  /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
473
588
  export function handleGrokEvent(context, input) {
@@ -483,21 +598,11 @@ export function handleGrokEvent(context, input) {
483
598
  return { ok: true, observed: false, reason: "non_bash_tool" };
484
599
  }
485
600
  const lifecycle = lifecycleFacts(command);
486
- if (!lifecycle)
601
+ if (!lifecycle && !continuationCommand(command))
487
602
  return { ok: true, observed: false, reason: "event_not_participating" };
488
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
489
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
490
- standalone_command: lifecycle.invocation.command
491
- });
492
- }
493
- const { flowPayload, stageStart, stageResume, workStart, bootstrapStageStart, commandProjectRoot } = lifecycle;
603
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
494
604
  const expectedRoot = resolveProjectRoot(input.projectRoot);
495
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
496
- throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
497
- expected: expectedRoot, actual: commandProjectRoot
498
- });
499
- }
500
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
605
+ const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
501
606
  if (bootstrapStageStart)
502
607
  registerProject(context, { root: expectedRoot });
503
608
  const project = requireProjectByRoot(context, expectedRoot);
@@ -509,45 +614,54 @@ export function handleGrokEvent(context, input) {
509
614
  if (!rootProviderSessionId || !providerSessionId)
510
615
  throw new AppError("grok_identity_missing", "Grok Build hook has no trusted Session ID", 1);
511
616
  const isChild = providerSessionId !== rootProviderSessionId;
512
- const sessionId = `grok-acp:${providerSessionId}`;
513
- const parentSessionId = isChild ? `grok-acp:${stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId}` : null;
617
+ const sessionId = storageSessionId(nativeSessionIdentity("grok-acp", providerSessionId));
618
+ const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
514
619
  const daemonId = stringValue(ddGrok.daemonId);
515
620
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
516
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
517
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
518
- }, sessionId) : undefined;
519
621
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
520
- harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
622
+ harness: "grok-acp", providerSessionId, toolUseId: hook.tool_use_id ?? hook.toolUseId ?? null,
623
+ promptId: hook.prompt_id ?? hook.promptId ?? null, command, turn: hook.turn_id ?? hook.turnId ?? null
521
624
  })).digest("hex");
625
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "grok-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
522
626
  const payload = {
523
627
  ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
524
628
  command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode), agent_type: stringValue(hook.subagent_type) ?? stringValue(hook.subagentType)
525
629
  };
526
630
  const inserted = recordHookEvent(context, {
527
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "grok-acp", providerSessionId, parentSessionId,
631
+ projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
528
632
  daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
529
633
  eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
530
634
  });
531
635
  recordFlowSessionObservation(context, {
532
- projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
636
+ projectId: project.id, sessionId,
533
637
  cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
534
638
  });
535
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, harness: "grok-acp", session_id: sessionId,
536
- provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId ?? null };
537
- return (flowPayload || stageStart || stageResume || workStart)
538
- ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
539
- : result;
639
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
640
+ ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
641
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
540
642
  }
541
643
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
542
644
  export function handleOpenCodeEvent(context, input) {
543
645
  const event = parseJsonObject(input.stdin || "{}", "OpenCode tool event");
544
- const agy = event.source_harness === "antigravity-cli";
545
- const harness = agy ? "antigravity-cli" : "opencode-server";
546
646
  if (event.schema_id !== "dd-flow/opencode-tool-event@1")
547
647
  throw new AppError("unsupported_opencode_event_schema", "Unsupported OpenCode event schema", 1);
648
+ return handleControlledToolEvent(context, input, event, event.source_harness === "antigravity-cli" ? "antigravity-cli" : "opencode-server");
649
+ }
650
+ /** Accept adapter-observed Factory identity, never a model-supplied Session ID. */
651
+ export function handleDroidEvent(context, input) {
652
+ const event = parseJsonObject(input.stdin || "{}", "Droid tool event");
653
+ if (event.schema_id !== "dd-flow/droid-tool-event@1")
654
+ throw new AppError("unsupported_droid_event_schema", "Unsupported Droid event schema", 1);
655
+ return handleControlledToolEvent(context, input, event, "droid-cli");
656
+ }
657
+ function handleControlledToolEvent(context, input, event, harness) {
658
+ const agy = harness === "antigravity-cli";
659
+ const droid = harness === "droid-cli";
660
+ const label = droid ? "Droid" : agy ? "Antigravity" : "OpenCode";
661
+ const errorPrefix = droid ? "droid" : agy ? "agy" : "opencode";
548
662
  const phase = stringValue(event.phase);
549
663
  if (phase !== "before" && phase !== "after")
550
- throw new AppError("unsupported_opencode_event_schema", "OpenCode event phase must be before or after", 1);
664
+ throw new AppError(`unsupported_${errorPrefix}_event_schema`, `${label} event phase must be before or after`, 1);
551
665
  const session = objectRecord(event.session);
552
666
  const profile = objectRecord(event.profile);
553
667
  const rawInput = objectRecord(event.input);
@@ -555,18 +669,20 @@ export function handleOpenCodeEvent(context, input) {
555
669
  const nativeParentId = stringValue(session.parent_provider_session_id);
556
670
  const daemonId = stringValue(event.daemon_id);
557
671
  const toolCallId = stringValue(event.tool_call_id);
558
- const eventId = stringValue(event.event_id) ?? toolCallId;
672
+ const eventId = stringValue(event.event_id) ?? (droid ? undefined : toolCallId);
559
673
  const toolName = stringValue(event.tool);
560
674
  const directory = stringValue(session.directory);
561
- if (!providerSessionId || !daemonId || !toolCallId || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
562
- throw new AppError("opencode_session_identity_invalid", "OpenCode event is missing trusted physical identity", 1);
675
+ if (!providerSessionId || !daemonId || (!droid && !toolCallId) || !eventId || !toolName || !directory || !path.isAbsolute(directory)) {
676
+ throw new AppError(`${errorPrefix}_session_identity_invalid`, `${label} event is missing trusted physical identity`, 1);
563
677
  }
564
678
  const expectedRoot = resolveProjectRoot(input.projectRoot);
565
679
  if (resolveProjectRoot(directory) !== expectedRoot) {
566
- throw new AppError(agy ? "agy_directory_mismatch" : "opencode_directory_mismatch", `${agy ? "Antigravity" : "OpenCode"} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
680
+ throw new AppError(`${errorPrefix}_directory_mismatch`, `${label} Session directory does not match the controlled workspace`, 1, { expected: expectedRoot, actual: directory });
567
681
  }
568
- const sessionId = `${harness}:${providerSessionId}`;
569
- const parentSessionId = nativeParentId ? `${harness}:${nativeParentId}` : null;
682
+ if (droid && nativeParentId === providerSessionId)
683
+ throw new AppError("droid_session_identity_invalid", "A Droid Session cannot parent itself", 1);
684
+ const sessionId = storageSessionId(nativeSessionIdentity(harness, providerSessionId));
685
+ const parentSessionId = nativeParentId ? storageSessionId(nativeSessionIdentity(harness, nativeParentId)) : null;
570
686
  const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
571
687
  const baseKey = `${eventId}:${phase}`;
572
688
  if (phase === "after") {
@@ -575,46 +691,41 @@ export function handleOpenCodeEvent(context, input) {
575
691
  projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
576
692
  daemonId, sessionId, agentId: stringValue(session.agent_id) ?? null, turnId: stringValue(event.message_id) ?? null,
577
693
  eventName: "PostToolUse", toolName, status: "observed", payload: { session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName, status: objectRecord(event.outcome).status },
578
- eventKey: baseKey, matchKey: null, transcriptPath: null, cwd: expectedRoot
694
+ eventKey: baseKey, matchKey: null, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
579
695
  });
580
- return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId };
696
+ return { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}) };
581
697
  }
582
- if (!command || !["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"].includes(toolName)) {
698
+ if (!command || !(droid ? ["Execute"] : ["bash", "Bash", "run_command", "run_terminal_command", "RunTerminalCommand"]).includes(toolName)) {
583
699
  return { ok: true, observed: false, reason: command ? "non_bash_tool" : "event_not_participating" };
584
700
  }
585
701
  const lifecycle = lifecycleFacts(command);
586
- if (!lifecycle)
702
+ if (!lifecycle && !continuationCommand(command))
587
703
  return { ok: true, observed: false, reason: "event_not_participating" };
588
- if (lifecycle.analysis.kind === "compound" && !lifecycle.stageResume) {
589
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${agy ? "Antigravity" : "OpenCode"} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
590
- }
591
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
592
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
593
- throw new AppError("project_mismatch", "OpenCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: commandProjectRoot });
594
- }
595
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
704
+ const bootstrapStageStart = lifecycle?.bootstrapStageStart;
705
+ const matchKey = lifecycle ? lifecycleMatchKey(lifecycle, expectedRoot) : null;
596
706
  if (bootstrapStageStart)
597
707
  registerProject(context, { root: expectedRoot });
598
708
  const project = requireProjectByRoot(context, expectedRoot);
709
+ if (droid) {
710
+ const existing = context.db.get("SELECT harness, provider_session_id, parent_session_id, daemon_id, match_key, tool_name FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, baseKey]);
711
+ if (existing && (existing.harness !== harness || existing.provider_session_id !== providerSessionId || existing.parent_session_id !== parentSessionId || existing.daemon_id !== daemonId || existing.match_key !== matchKey || existing.tool_name !== toolName)) {
712
+ throw new AppError("droid_event_replay_mismatch", "Droid event ID was already observed with different lifecycle identity", 1);
713
+ }
714
+ }
599
715
  const agentId = stringValue(session.agent_id);
600
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
601
- ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
602
- parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
603
- reasoning: stringValue(profile.variant) ?? null, mode: stringValue(profile.agent) ?? null, agent_type: stringValue(profile.agent) ?? null,
604
- metadata: { opencode_version: stringValue(event.server_version) ?? null, api_fingerprint: stringValue(event.api_fingerprint) ?? null, plugin_sha256: stringValue(event.plugin_sha256) ?? null, native_project_id: stringValue(session.project_id) ?? null }
605
- }, sessionId) : undefined;
716
+ assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
606
717
  const payload = {
607
718
  session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
608
- provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(profile.variant),
609
- mode: stringValue(profile.agent), agent_type: stringValue(profile.agent)
719
+ provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
720
+ mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
610
721
  };
611
722
  const inserted = recordHookEvent(context, {
612
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
723
+ projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
613
724
  daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
614
- status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: null, cwd: expectedRoot
725
+ status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
615
726
  });
616
- recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName, eventKey: baseKey });
617
- const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session_id: sessionId, provider_session_id: providerSessionId, parent_session_id: parentSessionId, daemon_id: daemonId };
727
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName, eventKey: baseKey });
728
+ const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: baseKey, phase, harness, session: nativeSessionIdentity(harness, providerSessionId), ...(nativeParentId ? { parent_session: nativeSessionIdentity(harness, nativeParentId) } : {}), daemon_id: daemonId };
618
729
  return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
619
730
  }
620
731
  /** Convert an Antigravity CLI tool hook into the shared lifecycle receipt. */
@@ -630,6 +741,13 @@ export function handleAgyEvent(context, input) {
630
741
  const toolName = stringValue(event.tool);
631
742
  if (!directory || !providerSessionId || !daemonId || !eventId || !toolName)
632
743
  throw new AppError("agy_session_identity_invalid", "Antigravity event is missing trusted physical identity", 1);
744
+ const rawInput = objectRecord(event.input);
745
+ const command = stringValue(rawInput.command) ?? stringValue(rawInput.cmd) ?? stringValue(rawInput.CommandLine);
746
+ const invalidStep = !Number.isSafeInteger(event.step_index) || Number(event.step_index) < 0;
747
+ const invalidExecution = event.execution_num !== undefined && (!Number.isSafeInteger(event.execution_num) || Number(event.execution_num) < 0);
748
+ if (event.phase === "before" && command && (lifecycleFacts(command) || continuationCommand(command)) && (invalidStep || invalidExecution)) {
749
+ throw new AppError("agy_native_call_identity_missing", "Participating Antigravity lifecycle calls require a native step identity and a valid execution identity when provided", 1, { phase: "prepare", effect: "no_effect", recoverable: false });
750
+ }
633
751
  const translated = {
634
752
  schema_id: "dd-flow/opencode-tool-event@1", source_harness: "antigravity-cli", phase: event.phase, event_id: eventId,
635
753
  daemon_id: daemonId, tool_call_id: eventId, tool: toolName, input: event.input,
@@ -662,15 +780,56 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
662
780
  * hook contract intentionally narrow: unwrap only that whole-command form,
663
781
  * then apply the same lifecycle parser to its actual shell payload.
664
782
  */
665
- function lifecycleCommandFromPayload(payload) {
666
- const command = commandFromPayload(payload);
667
- return command ? unwrapShellCommand(command) : undefined;
668
- }
669
- function commandWithHookEvent(command, eventKey) {
783
+ /**
784
+ * Adds the receipt to the command that the shell will execute. Heredoc input
785
+ * belongs to that command too, but an option appended after its delimiter is a
786
+ * second shell command rather than an argument, so insert it before `<<`.
787
+ */
788
+ export function commandWithHookEvent(command, eventKey) {
670
789
  const analysis = parseLifecycleCommand(command);
671
- if (analysis.kind !== "standalone" || !analysis.invocation.rewriteSafe || commandHasOption(analysis.invocation, "hook-event-id"))
790
+ if (analysis.kind !== "standalone")
672
791
  return command;
673
- return `${command.trim()} --hook-event-id ${JSON.stringify(eventKey)}`;
792
+ if (commandHasOption(analysis.invocation, "hook-event-id")) {
793
+ return command;
794
+ }
795
+ const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
796
+ if (analysis.invocation.rewriteSafe) {
797
+ const index = shellSuffixOffset(command);
798
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`.trimEnd();
799
+ }
800
+ if (!analysis.invocation.wrapped && analysis.invocation.heredoc && allowsStdinLifecycleCompound(lifecycleFacts(command))) {
801
+ const index = firstHeredoc(command);
802
+ if (index >= 0)
803
+ return `${command.slice(0, index).trimEnd()} ${option} ${command.slice(index)}`;
804
+ }
805
+ return command;
806
+ }
807
+ function firstHeredoc(command) {
808
+ let quoteState = null;
809
+ let escaped = false;
810
+ for (let index = 0; index < command.length - 1; index += 1) {
811
+ const char = command[index];
812
+ if (escaped) {
813
+ escaped = false;
814
+ continue;
815
+ }
816
+ if (char === "\\" && quoteState !== "'") {
817
+ escaped = true;
818
+ continue;
819
+ }
820
+ if (quoteState) {
821
+ if (char === quoteState)
822
+ quoteState = null;
823
+ continue;
824
+ }
825
+ if (char === "'" || char === '"') {
826
+ quoteState = char;
827
+ continue;
828
+ }
829
+ if (char === "<" && command[index + 1] === "<" && command[index + 2] !== "<")
830
+ return index;
831
+ }
832
+ return -1;
674
833
  }
675
834
  /**
676
835
  * Recognise the small shell surface used by generated dd-flow lifecycle
@@ -691,25 +850,51 @@ function lifecycleFacts(command) {
691
850
  return null;
692
851
  const invocation = analysis.invocation;
693
852
  const stageStart = invocation.operation === "stage_start";
853
+ const stageFinish = invocation.operation === "stage_finish";
854
+ const stagePause = invocation.operation === "stage_pause";
694
855
  const stageResume = invocation.operation === "stage_resume";
695
856
  const workStart = invocation.operation === "work_start";
857
+ const workFinish = invocation.operation === "work_finish";
858
+ const workFail = invocation.operation === "work_fail";
696
859
  return {
697
860
  analysis,
698
861
  invocation,
699
- flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
700
862
  stageStart,
863
+ stageFinish,
864
+ stagePause,
701
865
  stageResume,
702
- workStart,
866
+ workStart, workFinish, workFail,
703
867
  bootstrapStageStart: stageStart && commandHasOption(invocation, "bootstrap"),
704
868
  commandProjectRoot: commandOption(invocation, "project-root"),
705
- stageName: stageStart || stageResume ? commandOption(invocation, "stage") : undefined,
706
- runId: stageStart || stageResume ? commandPosition(invocation, 0) : undefined,
707
- workId: stageResume ? commandOption(invocation, "work") : workStart ? commandPosition(invocation, 0) : undefined,
869
+ stageName: stageStart || stageFinish || stagePause || stageResume ? commandOption(invocation, "stage") : undefined,
870
+ runId: stageStart || stageFinish || stagePause || stageResume ? commandPosition(invocation, 0) : undefined,
871
+ workId: stagePause || stageResume ? commandOption(invocation, "work") : workStart || workFinish || workFail ? commandPosition(invocation, 0) : undefined,
708
872
  contextSha256: commandOption(invocation, "context-sha256")
709
873
  };
710
874
  }
711
- function lifecycleMatchKey(facts, projectRoot) {
712
- const { invocation, bootstrapStageStart, stageStart, stageResume, workStart, stageName, runId, workId, contextSha256 } = facts;
875
+ // Worker completion and the two HITL commands intentionally take their
876
+ // payload from stdin. Their generated pipe/heredoc is one lifecycle command,
877
+ // not a second authority. Everything else remains standalone-only.
878
+ function allowsStdinLifecycleCompound(facts) {
879
+ const operation = facts.invocation.operation;
880
+ return facts.analysis.kind === "standalone" && facts.invocation.heredoc && !facts.invocation.wrapped
881
+ && ((operation === "work_finish" && commandHasOption(facts.invocation, "result-stdin"))
882
+ || (operation === "stage_start" && commandHasOption(facts.invocation, "intake-stdin"))
883
+ || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
884
+ || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
885
+ }
886
+ function lifecycleMatchKey(facts, projectRoot, scope) {
887
+ // Observe the actual argument without validating it. A mistyped project
888
+ // must not leave a receipt matching a later, corrected non-rewriting call.
889
+ if (!scope && facts.commandProjectRoot && canonicalPath(facts.commandProjectRoot, false) !== canonicalPath(projectRoot, false))
890
+ return null;
891
+ const { invocation, bootstrapStageStart, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, stageName, runId, workId, contextSha256 } = facts;
892
+ const effectiveRoot = scope?.projectRoot ?? projectRoot;
893
+ const effectiveRunId = scope?.runId ?? runId;
894
+ if (invocation.operation === "recovery_accept") {
895
+ const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
896
+ return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
897
+ }
713
898
  if (bootstrapStageStart)
714
899
  return bootstrapMatchKey({
715
900
  projectRoot,
@@ -717,14 +902,100 @@ function lifecycleMatchKey(facts, projectRoot) {
717
902
  intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
718
903
  ...(contextSha256 ? { contextSha256 } : {})
719
904
  });
720
- if (stageStart && runId && stageName)
721
- return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
722
- if (stageResume && runId && stageName && workId)
723
- return stageResumeMatchKey(runId, stageName, workId, projectRoot);
905
+ if (stageStart && effectiveRunId && stageName)
906
+ return stageStartMatchKey(effectiveRunId, stageName, effectiveRoot, contextSha256);
907
+ if (stageFinish && effectiveRunId && stageName)
908
+ return stageLifecycleMatchKey("stage_finish", effectiveRunId, stageName, effectiveRoot);
909
+ if (stagePause && effectiveRunId && stageName && workId)
910
+ return stageLifecycleMatchKey("stage_pause", effectiveRunId, stageName, effectiveRoot, workId);
911
+ if (stageResume && effectiveRunId && stageName && workId)
912
+ return stageResumeMatchKey(effectiveRunId, stageName, workId, effectiveRoot);
724
913
  if (workStart && workId)
725
- return workStartMatchKey(workId, projectRoot);
914
+ return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
915
+ if (workFinish && workId)
916
+ return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
917
+ if (workFail && workId)
918
+ return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
726
919
  return null;
727
920
  }
921
+ /** Observe exact immutable command attribution before reading model-supplied
922
+ * result files. This does not claim or authorize the lifecycle mutation. */
923
+ export function observeCommandHook(context, command) {
924
+ const facts = lifecycleFacts(command);
925
+ if (!facts || facts.analysis.kind !== "standalone" || !facts.commandProjectRoot)
926
+ return;
927
+ const project = context.db.get("SELECT id FROM projects WHERE root = ?", [resolveProjectRoot(facts.commandProjectRoot)]);
928
+ if (!project)
929
+ return; // Bootstrap has not registered a project yet.
930
+ const matchKey = lifecycleMatchKey(facts, facts.commandProjectRoot);
931
+ if (!matchKey)
932
+ return;
933
+ let key = commandOption(facts.invocation, "hook-event-id");
934
+ if (!key && commandOption(facts.invocation, "invocation-id"))
935
+ return;
936
+ if (!key) {
937
+ try {
938
+ key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
939
+ }
940
+ catch {
941
+ return;
942
+ } // Dispatch still owns its mandatory admission error.
943
+ }
944
+ const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ? AND match_key = ? AND status = 'observed'", [project.id, key, matchKey]);
945
+ if (event)
946
+ context.observeLifecycleHook?.(project.id, event.id);
947
+ }
948
+ export function stageLifecycleMatchKey(operation, runId, stage, projectRoot, workId) {
949
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, run_id: runId, stage, project_root: resolveProjectRoot(projectRoot), ...(workId ? { work_id: workId } : {}) })).digest("hex");
950
+ }
951
+ export function recoveryAcceptMatchKey(runId, recoveryId, projectRoot) {
952
+ return crypto.createHash("sha256").update(JSON.stringify({ operation: "recovery_accept", run_id: runId, recovery_id: recoveryId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
953
+ }
954
+ export function workLifecycleMatchKey(operation, workId, projectRoot) {
955
+ return crypto.createHash("sha256").update(JSON.stringify({ operation, work_id: workId, project_root: resolveProjectRoot(projectRoot) })).digest("hex");
956
+ }
957
+ function workLifecycleMatchKeys(operation, workId, projectRoot) {
958
+ return entityReferenceVariants(workId).map((reference) => workLifecycleMatchKey(operation, reference, projectRoot));
959
+ }
960
+ function stageLifecycleMatchKeys(operation, runId, stage, projectRoot, workId) {
961
+ const runReferences = entityReferenceVariants(runId);
962
+ const workReferences = workId ? entityReferenceVariants(workId) : [undefined];
963
+ return [...new Set(runReferences.flatMap((runReference) => workReferences.map((workReference) => stageLifecycleMatchKey(operation, runReference, stage, projectRoot, workReference))))];
964
+ }
965
+ /** Claims a Work terminal command only after its exact hook receipt has proved
966
+ * the physical Session that issued it. Work code performs the owner check. */
967
+ export function claimWorkLifecycleHookEvent(context, input) {
968
+ const matchKeys = workLifecycleMatchKeys(input.operation, input.workId, input.projectRoot);
969
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
970
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
971
+ }).eventKey;
972
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
973
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
974
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
975
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { work_id: input.workId, event_key: eventKey, reason, observed_status: event?.status ?? null });
976
+ }
977
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
978
+ if (claimed.changes !== 1)
979
+ throw new AppError("trusted_session_binding_required", "matching Work lifecycle hook event was already claimed", 1, { work_id: input.workId });
980
+ return hookSessionIdentity(context, input.projectId, eventKey);
981
+ }
982
+ /** Claims a lifecycle command only after its exact hook receipt has proved the
983
+ * physical Session that issued it. Stage code performs the Work-owner check. */
984
+ export function claimStageLifecycleHookEvent(context, input) {
985
+ const matchKeys = stageLifecycleMatchKeys(input.operation, input.runId, input.stage, input.projectRoot, input.workId);
986
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, {
987
+ projectId: input.projectId, matchKey: matchKeys, errorCode: "trusted_session_binding_required", operation: input.operation.replace("_", " ")
988
+ }).eventKey;
989
+ const event = context.db.get("SELECT id, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
990
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !matchKeys.includes(event.match_key)) {
991
+ const reason = !event ? "event_missing" : !event.session_id ? "identity_missing" : event.status !== "observed" ? "event_consumed" : "target_mismatch";
992
+ throw new AppError("trusted_session_binding_required", `${input.operation.replace("_", " ")} requires one fresh matching PreToolUse hook event`, 1, { run_id: input.runId, stage: input.stage, event_key: eventKey, reason, observed_status: event?.status ?? null });
993
+ }
994
+ const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
995
+ if (claimed.changes !== 1)
996
+ throw new AppError("trusted_session_binding_required", "matching lifecycle hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
997
+ return hookSessionIdentity(context, input.projectId, eventKey);
998
+ }
728
999
  /** Trusted session identity is created by PreToolUse, never supplied by an agent. */
729
1000
  export function sessionIdForHookEvent(context, projectId, eventKey) {
730
1001
  const event = context.db.get("SELECT session_id FROM hook_events WHERE project_id = ? AND event_key = ?", [projectId, eventKey]);
@@ -736,10 +1007,11 @@ export function sessionIdForHookEvent(context, projectId, eventKey) {
736
1007
  /** Reads immutable identity facts already captured by PreToolUse. */
737
1008
  export function hookSessionIdentity(context, projectId, eventKey) {
738
1009
  const event = context.db.get(`SELECT he.id, he.harness, he.provider_session_id, he.parent_session_id, he.daemon_id, he.session_id, he.agent_id, he.turn_id, he.transcript_path, he.provider, he.model, he.reasoning, he.mode, he.agent_type, COALESCE(he.cwd, csb.cwd) AS cwd
739
- FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = he.session_id
1010
+ FROM hook_events he LEFT JOIN codex_session_bindings csb ON csb.project_id = he.project_id AND csb.session_id = COALESCE(he.provider_session_id, he.session_id)
740
1011
  WHERE he.project_id = ? AND he.event_key = ?`, [projectId, eventKey]);
741
1012
  if (!event?.session_id)
742
1013
  throw new AppError("hook_event_not_found", "stage start requires a trusted PreToolUse hook event", 1, { event_key: eventKey });
1014
+ context.observeLifecycleHook?.(projectId, event.id);
743
1015
  return {
744
1016
  hookEventId: event.id,
745
1017
  harness: event.harness,
@@ -747,7 +1019,9 @@ export function hookSessionIdentity(context, projectId, eventKey) {
747
1019
  parentSessionId: event.parent_session_id,
748
1020
  daemonId: event.daemon_id,
749
1021
  agentId: event.agent_id,
750
- sessionId: event.harness === "codex-desktop" ? event.agent_id ?? event.session_id : event.session_id,
1022
+ // agent_id is an auxiliary provider fact, never a replacement Session ID.
1023
+ sessionId: event.session_id,
1024
+ nativeSessionId: event.provider_session_id ?? event.session_id,
751
1025
  turnId: event.turn_id,
752
1026
  transcriptPath: event.transcript_path,
753
1027
  provider: event.provider,
@@ -774,21 +1048,30 @@ export function claimRecentMatchingHookEvent(context, input) {
774
1048
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
775
1049
  export function findRecentMatchingHookEvent(context, input) {
776
1050
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
777
- const find = () => context.db.get(`SELECT id, event_key FROM hook_events
778
- WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
779
- ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
780
- let event = find();
781
- // Some harnesses publish nested-agent tool events concurrently with command
782
- // execution. Give the trusted event a short bounded window to reach SQLite.
1051
+ const matchKeys = Array.isArray(input.matchKey) ? [...new Set(input.matchKey)] : [input.matchKey];
1052
+ const placeholders = matchKeys.map(() => "?").join(", ");
1053
+ const find = () => context.db.all(`SELECT id, event_key FROM hook_events
1054
+ WHERE project_id = ? AND match_key IN (${placeholders}) AND status = 'observed' AND created_at >= ?
1055
+ AND NOT (COALESCE(json_extract(outcome_json, '$.effect'), '') = 'no_effect'
1056
+ AND COALESCE(json_extract(outcome_json, '$.disposition'), '') IN ('correctable', 'fatal'))
1057
+ AND harness IN ('codex-desktop', 'antigravity-cli')
1058
+ AND (? IS NULL OR session_id = ?)
1059
+ ORDER BY id DESC LIMIT 2`, [input.projectId, ...matchKeys, freshAfter, input.sessionId ?? null, input.sessionId ?? null]);
1060
+ let events = find();
1061
+ // Only the qualified non-rewriting transports may correlate the original
1062
+ // command: Codex Desktop and Antigravity. New harnesses fail closed by default.
783
1063
  const deadline = Date.now() + 250;
784
- while (!event?.event_key && Date.now() < deadline) {
1064
+ while (events.length === 0 && Date.now() < deadline) {
785
1065
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
786
- event = find();
1066
+ events = find();
1067
+ }
1068
+ if (events.length === 0 || !events[0]?.event_key) {
1069
+ throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery; managed commands are admitted from committed native identity, not from model-supplied invocation IDs or updatedInput.`, 1, { reason: "event_missing" });
787
1070
  }
788
- if (!event?.event_key) {
789
- throw new AppError(input.errorCode, `${input.operation} requires one matching harness lifecycle event. Stay in this Session and retry the exact standalone lifecycle command; do not create a new Session.`, 1);
1071
+ if (events.length > 1) {
1072
+ throw new AppError("ambiguous_lifecycle_receipt", `${input.operation} has more than one fresh matching lifecycle event; stop and let the controller reconcile native delivery`, 1, { reason: "multiple_live_events", match_keys: matchKeys, event_keys: events.map((event) => event.event_key) });
790
1073
  }
791
- return { id: event.id, eventKey: event.event_key };
1074
+ return { id: events[0].id, eventKey: events[0].event_key };
792
1075
  }
793
1076
  /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
794
1077
  export function claimBootstrapHookEvent(context, input) {
@@ -811,9 +1094,9 @@ export function bootstrapMatchKey(input) {
811
1094
  })).digest("hex");
812
1095
  }
813
1096
  export function claimWorkStartHookEvent(context, input) {
814
- const expected = new Set([workStartMatchKey(input.workId, input.projectRoot), workStartMatchKey(shortWorkReference(input.workId), input.projectRoot)]);
1097
+ const expected = entityReferenceVariants(input.workId).map((reference) => workStartMatchKey(reference, input.projectRoot, input.recoveryId));
815
1098
  const event = context.db.get("SELECT id, session_id, agent_id, turn_id, transcript_path, model, agent_type, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
816
- if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.has(event.match_key)) {
1099
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
817
1100
  throw new AppError("trusted_work_launch_required", "work start requires one fresh matching PreToolUse event", 1, { work_id: input.workId, event_key: input.eventKey });
818
1101
  }
819
1102
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
@@ -821,15 +1104,16 @@ export function claimWorkStartHookEvent(context, input) {
821
1104
  throw new AppError("trusted_work_launch_required", "matching work-start hook event was already claimed", 1, { work_id: input.workId });
822
1105
  return hookSessionIdentity(context, input.projectId, input.eventKey);
823
1106
  }
824
- function shortWorkReference(id) { return /^WRK-\d{3,}(?:-|$)/.exec(id)?.[0]?.replace(/-$/, "") ?? id; }
825
1107
  /** Claims the exact non-bootstrap stage entry which starts a coordinator Work. */
826
1108
  export function assertStageStartHookEvent(context, input) {
827
- const expected = stageStartMatchKey(input.runId, input.stage, input.projectRoot, input.contextSha256);
1109
+ const expected = entityReferenceVariants(input.runId).map((reference) => stageStartMatchKey(reference, input.stage, input.projectRoot, input.contextSha256));
828
1110
  const event = context.db.get("SELECT session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
829
- if (!event?.session_id || event.status !== "observed" || event.match_key !== expected) {
830
- throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_key: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
1111
+ if (!event?.session_id || event.status !== "observed" || !event.match_key || !expected.includes(event.match_key)) {
1112
+ throw new AppError("trusted_stage_launch_required", "stage start requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, event_key: input.eventKey, expected_match_keys: expected, observed_match_key: event?.match_key ?? null, observed_status: event?.status ?? null });
831
1113
  }
832
- return hookSessionIdentity(context, input.projectId, input.eventKey);
1114
+ const identity = hookSessionIdentity(context, input.projectId, input.eventKey);
1115
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
1116
+ return identity;
833
1117
  }
834
1118
  export function claimStageStartHookEvent(context, input) {
835
1119
  assertStageStartHookEvent(context, input);
@@ -859,24 +1143,25 @@ export function stageResumeMatchKey(runId, stage, workId, projectRoot) {
859
1143
  }
860
1144
  /** Claims the resume command observed in the Session that received the user answer. */
861
1145
  export function claimStageResumeHookEvent(context, input) {
862
- const matchKey = stageResumeMatchKey(input.runId, input.stage, input.workId, input.projectRoot);
863
- const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
864
- const event = input.eventKey
865
- ? context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey])
866
- : context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND session_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ? ORDER BY id DESC LIMIT 1", [input.projectId, input.sessionId ?? "", matchKey, freshAfter]);
867
- if (!event?.event_key || !event.session_id || event.match_key !== matchKey || event.status !== "observed") {
1146
+ const matchKeys = entityReferenceVariants(input.runId).flatMap((runReference) => entityReferenceVariants(input.workId).map((workReference) => stageResumeMatchKey(runReference, input.stage, workReference, input.projectRoot)));
1147
+ const eventKey = input.eventKey ?? findRecentMatchingHookEvent(context, { projectId: input.projectId, matchKey: matchKeys, sessionId: input.sessionId ?? "", errorCode: "trusted_stage_resume_required", operation: "stage resume" }).eventKey;
1148
+ const event = context.db.get("SELECT id, event_key, session_id, match_key, status FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, eventKey]);
1149
+ if (!event?.event_key || !event.session_id || !event.match_key || !matchKeys.includes(event.match_key) || event.status !== "observed") {
868
1150
  throw new AppError("trusted_stage_resume_required", "stage resume requires one fresh matching PreToolUse event", 1, { run_id: input.runId, stage: input.stage, work_id: input.workId });
869
1151
  }
1152
+ const identity = hookSessionIdentity(context, input.projectId, event.event_key);
1153
+ assertRecoveryHookCurrent(context, input.projectId, input.runId, identity);
870
1154
  const claimed = context.db.run("UPDATE hook_events SET status = 'claimed', claimed_at = ? WHERE id = ? AND status = 'observed'", [context.now(), event.id]);
871
1155
  if (claimed.changes !== 1)
872
1156
  throw new AppError("trusted_stage_resume_required", "matching stage-resume hook event was already claimed", 1, { run_id: input.runId, stage: input.stage });
873
- return hookSessionIdentity(context, input.projectId, event.event_key);
1157
+ return identity;
874
1158
  }
875
- export function workStartMatchKey(workId, projectRoot) {
1159
+ export function workStartMatchKey(workId, projectRoot, recoveryId) {
876
1160
  return crypto.createHash("sha256").update(JSON.stringify({
877
1161
  operation: "work_start",
878
1162
  work_id: workId,
879
- project_root: resolveProjectRoot(projectRoot)
1163
+ project_root: resolveProjectRoot(projectRoot),
1164
+ ...(recoveryId ? { recovery_id: recoveryId } : {})
880
1165
  })).digest("hex");
881
1166
  }
882
1167
  export function hookStatusForProject(context, projectId) {
@@ -916,6 +1201,8 @@ export function codexHookEventsForProject(context, projectId) {
916
1201
  FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
917
1202
  }
918
1203
  function resolveProfileName(profile) {
1204
+ if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
1205
+ throw new AppError("validation", "--profile must be nonempty text", 2);
919
1206
  return sanitizeProfile(profile ?? defaultProfileName);
920
1207
  }
921
1208
  function sanitizeProfile(value) {
@@ -943,11 +1230,12 @@ function readTomlIfExists(file) {
943
1230
  if (!fs.existsSync(file)) {
944
1231
  return {};
945
1232
  }
1233
+ const { text } = prepareTextFile(file, "Codex config");
946
1234
  try {
947
- return parse(fs.readFileSync(file, "utf8"));
1235
+ return parse(text);
948
1236
  }
949
1237
  catch (error) {
950
- throw new AppError("validation", `Invalid TOML in ${file}: ${String(error)}`, 2);
1238
+ throw new AppError("validation", `Invalid TOML in ${file}`, 2, { file, ...(error instanceof TomlError ? { line: error.line, column: error.column } : {}) });
951
1239
  }
952
1240
  }
953
1241
  function withManagedConfig(config, sourceHome, targetHome) {
@@ -966,7 +1254,7 @@ function objectTable(value) {
966
1254
  function createSharedLinks(sourceHome, targetHome) {
967
1255
  const linked = [];
968
1256
  const skipped = [];
969
- for (const entry of sharedEntries) {
1257
+ for (const entry of codexHomeSharedEntries) {
970
1258
  const source = path.join(sourceHome, entry);
971
1259
  const target = path.join(targetHome, entry);
972
1260
  if (!fs.existsSync(source)) {
@@ -989,7 +1277,7 @@ function createSharedLinks(sourceHome, targetHome) {
989
1277
  return { linked, skipped };
990
1278
  }
991
1279
  function removeSharedSymlinks(targetHome) {
992
- for (const entry of sharedEntries) {
1280
+ for (const entry of codexHomeSharedEntries) {
993
1281
  const target = path.join(targetHome, entry);
994
1282
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
995
1283
  fs.rmSync(target);
@@ -1002,7 +1290,7 @@ function removeDirIfEmpty(dir) {
1002
1290
  }
1003
1291
  }
1004
1292
  function sharedEntryStatus(sourceHome, targetHome) {
1005
- return sharedEntries.map((entry) => {
1293
+ return codexHomeSharedEntries.map((entry) => {
1006
1294
  const source = path.join(sourceHome, entry);
1007
1295
  const target = path.join(targetHome, entry);
1008
1296
  return {
@@ -1023,10 +1311,14 @@ function hookEntry(matcher, command, statusMessage) {
1023
1311
  return { matcher, hooks: [commandHook(command, statusMessage)] };
1024
1312
  }
1025
1313
  function commandHook(command, statusMessage) {
1026
- return { type: "command", command, timeout: 5, statusMessage };
1314
+ // Hook admission is synchronous (the CLI must see a committed receipt), but
1315
+ // the old 5s budget was shorter than a cold CLI/store open under fan-out.
1316
+ // Startup owns migrations; this budget only covers the bounded hot path.
1317
+ return { type: "command", command, timeout: 30, statusMessage };
1027
1318
  }
1028
1319
  function hookCommand(event) {
1029
- const handler = `PATH="\${HOME}/Library/pnpm:/opt/homebrew/bin:/usr/local/bin:\${PATH}" dd-flow codex hook handle --event ${event} --json`;
1320
+ const args = `codex hook handle --event ${event} --json`;
1321
+ 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`;
1030
1322
  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"'`;
1031
1323
  }
1032
1324
  function resolveHookTarget(target) {
@@ -1202,11 +1494,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
1202
1494
  function installationScope(target, profile, hooksPath) {
1203
1495
  return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
1204
1496
  }
1205
- function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
1497
+ function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
1206
1498
  const existing = sessionBinding(context, project.id, sessionId);
1207
1499
  const now = context.now();
1208
1500
  const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
1209
- const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
1210
1501
  context.db.run(`INSERT INTO codex_session_bindings
1211
1502
  (session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
1212
1503
  VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
@@ -1260,12 +1551,11 @@ function sessionBinding(context, projectId, sessionId) {
1260
1551
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
1261
1552
  }
1262
1553
  function recordHookEvent(context, input) {
1263
- const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1264
- if (existing)
1554
+ if (assertHookEventReplay(context, input))
1265
1555
  return false;
1266
1556
  context.db.run(`INSERT INTO hook_events
1267
- (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1268
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1557
+ (project_id, protocol_id, harness, provider_session_id, parent_session_id, daemon_id, session_id, agent_id, turn_id, transcript_path, provider, model, reasoning, mode, agent_type, cwd, event_key, match_key, event_name, tool_name, status, sanitized_summary, created_at)
1558
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1269
1559
  input.projectId,
1270
1560
  input.protocolId,
1271
1561
  input.harness,
@@ -1292,6 +1582,23 @@ function recordHookEvent(context, input) {
1292
1582
  ]);
1293
1583
  return true;
1294
1584
  }
1585
+ /** A duplicate delivery is harmless only when all durable identity facts match. */
1586
+ function assertHookEventReplay(context, input) {
1587
+ const existing = context.db.get(`SELECT harness, provider_session_id, parent_session_id, daemon_id, session_id, turn_id, event_name, tool_name, match_key, cwd
1588
+ FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
1589
+ if (existing) {
1590
+ const observed = {
1591
+ harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
1592
+ daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
1593
+ event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
1594
+ };
1595
+ if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
1596
+ throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
1597
+ }
1598
+ return true;
1599
+ }
1600
+ return false;
1601
+ }
1295
1602
  function projectForHook(context, explicitRoot, cwd) {
1296
1603
  if (explicitRoot)
1297
1604
  return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
@@ -1318,14 +1625,6 @@ function hookEventKey(payload, eventName, toolName, command) {
1318
1625
  command
1319
1626
  })).digest("hex");
1320
1627
  }
1321
- function commandFromPayload(payload) {
1322
- const direct = stringValue(payload.command);
1323
- if (direct) {
1324
- return direct;
1325
- }
1326
- const toolInput = objectRecord(payload.tool_input);
1327
- return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
1328
- }
1329
1628
  function toolNameFromPayload(payload) {
1330
1629
  const toolInput = objectRecord(payload.tool_input);
1331
1630
  return stringValue(toolInput.name);
@@ -1368,10 +1667,10 @@ function writeJsonFile(file, value) {
1368
1667
  ensureDir(path.dirname(file));
1369
1668
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
1370
1669
  }
1371
- function backupFile(file, now) {
1670
+ function backupFile(file, now, text) {
1372
1671
  const suffix = now.replace(/[:.]/g, "-");
1373
1672
  const backup = `${file}.${suffix}.bak`;
1374
- fs.copyFileSync(file, backup);
1673
+ fs.writeFileSync(backup, text, { mode: 0o600 });
1375
1674
  return backup;
1376
1675
  }
1377
1676
  function hashObject(value) {