@deksden-com/dd-flow-cli 0.9.0-beta.8 → 0.9.0-beta.80

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