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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/build-info.json +6 -6
  2. package/dist/cli/command-inputs.js +300 -0
  3. package/dist/cli/help.js +1 -1
  4. package/dist/cli/hook-ingress.js +82 -0
  5. package/dist/cli/input-preparation.js +115 -0
  6. package/dist/cli/run-cli.js +1015 -423
  7. package/dist/cli.js +6 -2
  8. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  9. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -2
  10. package/dist/harness-runtime/lib/dd-codex.mjs +137 -13
  11. package/dist/harness-runtime/lib/dd-droid.mjs +2 -2
  12. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  13. package/dist/harness-runtime/lib/delegation-instructions.mjs +13 -4
  14. package/dist/harness-runtime/lib/native-hook-command.d.mts +1 -0
  15. package/dist/services/canon.js +11 -8
  16. package/dist/services/cleanup.js +49 -8
  17. package/dist/services/cli-operation-classifier.js +12 -24
  18. package/dist/services/codex-hook-delivery.js +28 -0
  19. package/dist/services/config.js +11 -8
  20. package/dist/services/controller-fanout.js +2 -2
  21. package/dist/services/dashboard.js +94 -31
  22. package/dist/services/engines.js +75 -61
  23. package/dist/services/eval-snapshots.js +138 -38
  24. package/dist/services/execution-policy.js +5 -12
  25. package/dist/services/hooks.js +226 -203
  26. package/dist/services/lanes.js +60 -52
  27. package/dist/services/lifecycle-command.js +13 -1
  28. package/dist/services/lifecycle-invocations.js +285 -94
  29. package/dist/services/managed-processes.js +158 -18
  30. package/dist/services/merge-queue.js +173 -98
  31. package/dist/services/migrations.js +13 -8
  32. package/dist/services/plan-runtime.js +19 -8
  33. package/dist/services/plans.js +28 -25
  34. package/dist/services/projects.js +11 -2
  35. package/dist/services/prompts.js +23 -16
  36. package/dist/services/protocols.js +54 -19
  37. package/dist/services/recovery-observation-budget.js +1 -1
  38. package/dist/services/run-controller-adapter.js +31 -20
  39. package/dist/services/run-controller-state.js +5 -0
  40. package/dist/services/run-controller.js +102 -56
  41. package/dist/services/run-fork.js +36 -22
  42. package/dist/services/run-recovery.js +32 -17
  43. package/dist/services/runs.js +110 -38
  44. package/dist/services/runtime-budget.js +120 -48
  45. package/dist/services/runtime-scope-capture.js +2 -2
  46. package/dist/services/runtime-scope-control.js +2 -2
  47. package/dist/services/runtime-scope-resume.js +34 -12
  48. package/dist/services/runtime-scope-worker.js +49 -16
  49. package/dist/services/runtime-service.js +8 -7
  50. package/dist/services/schema-validation.js +9 -7
  51. package/dist/services/sessions.js +35 -69
  52. package/dist/services/stage-blocker.js +17 -7
  53. package/dist/services/stage-context.js +42 -16
  54. package/dist/services/stage-lifecycle.js +82 -100
  55. package/dist/services/stage-pause.js +54 -24
  56. package/dist/services/vnext-code-review.js +82 -32
  57. package/dist/services/vnext-code.js +78 -45
  58. package/dist/services/vnext-fanout.js +13 -12
  59. package/dist/services/vnext-merge.js +68 -35
  60. package/dist/services/vnext-plan-review.js +84 -41
  61. package/dist/services/vnext-plan.js +91 -70
  62. package/dist/services/vnext-protocolize.js +116 -50
  63. package/dist/services/vnext-specify.js +70 -57
  64. package/dist/services/work-registry.js +195 -92
  65. package/dist/services/worktrees.js +85 -35
  66. package/dist/storage/database.js +104 -10
  67. package/dist/storage/paths.js +17 -4
  68. package/package.json +1 -1
@@ -2,17 +2,18 @@ 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
12
  import { assertRecoveryHookCurrent } from "./run-recovery.js";
12
- import { observeLifecycleInvocation, settleLifecycleNoEffectRejection } from "./lifecycle-invocations.js";
13
- import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, unwrapShellCommand } from "./lifecycle-command.js";
13
+ import { observeLifecycleInvocation } from "./lifecycle-invocations.js";
14
+ import { commandHasOption, commandOption, commandPosition, shellSuffixOffset, parseLifecycleCommand, lifecycleCommandFromPayload } from "./lifecycle-command.js";
14
15
  import { registerProject, requireProjectByRoot } from "./projects.js";
15
- import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
16
+ import { activeFlowSessionsForProject, recordFlowSessionObservation } from "./sessions.js";
16
17
  const defaultProfileName = "default";
17
18
  const maxSanitizedSummaryLength = 1600;
18
19
  export const codexHomeSharedEntries = [
@@ -57,17 +58,23 @@ export function planCodexHome(context, input) {
57
58
  codex_env: { CODEX_HOME: paths.targetHome }
58
59
  };
59
60
  }
60
- export function initCodexHome(context, input) {
61
+ export function prepareCodexHome(context, input) {
61
62
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
62
63
  const profile = resolveProfileName(input.profile);
63
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;
64
72
  const now = context.now();
65
73
  ensureDir(paths.targetHome);
66
74
  ensureDir(path.join(paths.targetHome, "log"));
67
75
  ensureDir(path.join(paths.targetHome, ".tmp"));
68
76
  ensureDir(path.join(paths.targetHome, "tmp"));
69
77
  const linked = createSharedLinks(paths.sourceHome, paths.targetHome);
70
- const config = readTomlIfExists(path.join(paths.sourceHome, "config.toml"));
71
78
  fs.writeFileSync(paths.configPath, stringify(withManagedConfig(config, paths.sourceHome, paths.targetHome)));
72
79
  writeJsonFile(paths.hooksPath, expectedHooksConfig(project.root));
73
80
  writeJsonFile(path.join(paths.targetHome, ".dd-flow-home.json"), {
@@ -138,7 +145,7 @@ export function printCodexHomeEnv(context, input) {
138
145
  command_prefix: `CODEX_HOME=${JSON.stringify(record.target_home)}`
139
146
  };
140
147
  }
141
- export function removeCodexHome(context, input) {
148
+ export function prepareCodexHomeRemoval(context, input) {
142
149
  if (!["keep-shared", "remove-owned"].includes(input.mode)) {
143
150
  throw new AppError("validation", "--mode must be keep-shared or remove-owned", 2);
144
151
  }
@@ -146,9 +153,30 @@ export function removeCodexHome(context, input) {
146
153
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
147
154
  const profile = resolveProfileName(input.profile);
148
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);
149
175
  if (!record) {
150
176
  return { ok: true, removed: false, profile, status: "not_initialized" };
151
177
  }
178
+ if (record.status === "removed")
179
+ return { ok: true, removed: false, profile, status: "already_removed" };
152
180
  const removedEntries = [];
153
181
  for (const relative of ["config.toml", "hooks.json", ".dd-flow-home.json", "log", ".tmp", "tmp"]) {
154
182
  const target = path.join(record.target_home, relative);
@@ -209,7 +237,7 @@ export function getCodexHooksStatus(context, input) {
209
237
  recorded
210
238
  };
211
239
  }
212
- export function installCodexHooks(context, input) {
240
+ export function prepareCodexHooks(context, input) {
213
241
  const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
214
242
  const target = resolveHookTarget(input.target);
215
243
  const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
@@ -219,9 +247,16 @@ export function installCodexHooks(context, input) {
219
247
  hooks_path: location.hooksPath
220
248
  });
221
249
  }
222
- const existing = readJsonIfExists(location.hooksPath) ?? {};
223
- 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;
224
258
  const nextConfig = mergeHooksConfig(existing, project.root);
259
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
225
260
  ensureDir(path.dirname(location.hooksPath));
226
261
  writeJsonFile(location.hooksPath, nextConfig);
227
262
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, true, "none");
@@ -240,20 +275,11 @@ export function installCodexHooks(context, input) {
240
275
  expected_hash: hashObject(expectedHooksConfig(project.root))
241
276
  };
242
277
  }
243
- export function removeCodexHooks(context, input) {
244
- const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
245
- const target = resolveHookTarget(input.target);
246
- const location = requireHookLocation(context, project, target, resolveProfileName(input.profile));
247
- if (target === "default" && !input.yes) {
248
- throw new AppError("confirmation_required", "Direct default Codex home mutation requires --yes", 1, {
249
- target,
250
- hooks_path: location.hooksPath
251
- });
252
- }
253
- const existing = readJsonIfExists(location.hooksPath) ?? {};
278
+ export function removeCodexHooks(context, input, prepared = prepareCodexHooks(context, input)) {
279
+ const { project, target, location, existing, text } = prepared;
254
280
  const hadManaged = hasManagedHooks(existing, project.root);
255
- const backup = target === "default" && fs.existsSync(location.hooksPath) ? backupFile(location.hooksPath, context.now()) : null;
256
281
  const nextConfig = removeManagedHooks(existing, project.root);
282
+ const backup = target === "default" && text !== null ? backupFile(location.hooksPath, context.now(), text) : null;
257
283
  ensureDir(path.dirname(location.hooksPath));
258
284
  writeJsonFile(location.hooksPath, nextConfig);
259
285
  recordHookInstallation(context, project.id, installationScope(target, location.profile, location.hooksPath), location.hooksPath, false, "not_installed");
@@ -276,7 +302,6 @@ export function handleCodexHook(context, input) {
276
302
  // therefore the only valid identity for a fresh Work.
277
303
  const rawChildProviderSessionId = stringValue(payload.agent_id) ?? null;
278
304
  const providerSessionId = rawChildProviderSessionId ?? rootProviderSessionId;
279
- const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
280
305
  const turnId = stringValue(payload.turn_id);
281
306
  const toolName = stringValue(payload.tool_name) ?? toolNameFromPayload(payload);
282
307
  const command = lifecycleCommandFromPayload(payload);
@@ -285,108 +310,131 @@ export function handleCodexHook(context, input) {
285
310
  const lifecycle = lifecycleFacts(command);
286
311
  if (!lifecycle)
287
312
  return { ok: true, observed: false, reason: "event_not_participating", event: eventName };
313
+ const parentProviderSessionId = rawChildProviderSessionId ? codexNativeParentId(payload, rawChildProviderSessionId) : null;
288
314
  // Resume legitimately receives an answer through stdin. It is matched by
289
315
  // immutable lifecycle arguments below; never reject or rewrite that pipe.
290
- if (lifecycle.analysis.kind === "compound") {
291
- return {
292
- ok: false,
293
- observed: false,
294
- reason: "compound_lifecycle_command",
295
- message: "Run the dd-flow lifecycle invocation as the first technical action and as one standalone Bash command in this same Session.",
296
- standalone_command: lifecycle.invocation.command,
297
- hookSpecificOutput: {
298
- hookEventName: "PreToolUse",
299
- permissionDecision: "deny",
300
- permissionDecisionReason: `dd-flow lifecycle commands must be standalone. Retry in this same Session: ${lifecycle.invocation.command}`
301
- }
302
- };
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
+ }
303
325
  }
304
- const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
305
- const matchKey = commandProjectRoot ? lifecycleMatchKey(lifecycle, commandProjectRoot) : null;
326
+ const nativeProjectRoot = context.codexHookProof?.projectRoot ?? input.projectRoot;
306
327
  // A bootstrap stage is the first stateful command for a new materialized project.
307
328
  // Its PreToolUse event arrives before stage start can register that project.
308
- if (bootstrapStageStart && commandProjectRoot) {
309
- registerProject(context, { root: commandProjectRoot });
310
- }
311
- let project;
312
- try {
313
- project = projectForHook(context, input.projectRoot ?? commandProjectRoot, stringValue(payload.cwd));
314
- }
315
- catch (error) {
316
- // A terminal command outside a registered flow gets no trusted receipt;
317
- // the CLI will fail closed if it is actually executed. This keeps hooks
318
- // inert for unrelated shell-parser probes and stale project directories.
319
- if (stageFinish || stagePause || workFinish || workFail)
320
- return { ok: true, observed: false, reason: "project_not_registered" };
321
- throw error;
322
- }
323
- if (!project)
329
+ const bootstrapRoot = nativeProjectRoot ?? stringValue(payload.cwd);
330
+ if (bootstrapStageStart && bootstrapRoot) {
331
+ registerProject(context, { root: bootstrapRoot });
332
+ }
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 });
324
337
  return { ok: true, observed: false, reason: "unrelated_cwd" };
338
+ }
339
+ let matchKey = lifecycleMatchKey(lifecycle, project.root);
325
340
  // Codex retains the root session_id for a thread-spawned child, while the
326
341
  // payload transcript belongs to the current agent_id. Keep lineage separate
327
342
  // from ownership: otherwise a child overwrites the root transcript binding.
328
343
  const eventKey = hookEventKey(payload, eventName, toolName, command);
329
344
  const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
330
345
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
331
- 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: stringValue(payload.cwd) ?? 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 });
332
348
  const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
333
- const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
334
- const observedSession = flowPayload
335
- ? bindObservedFlowSession(context, project, {
336
- ...flowPayload,
337
- harness: "codex-desktop",
338
- provider_session_id: providerSessionId ?? flowPayload.provider_session_id ?? null,
339
- parent_session_id: parentSessionId
340
- }, storageId ?? undefined)
341
- : undefined;
342
- const effectiveSessionId = observedSession?.session_id ?? storageId;
343
- const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
344
- const inserted = recordHookEvent(context, {
345
- projectId: project.id,
346
- protocolId,
347
- harness: "codex-desktop",
348
- providerSessionId: providerSessionId ?? null,
349
- parentSessionId,
350
- // Pinned by the owning app-server process, never derived from current RUN
351
- // state or an agent-supplied shell command/payload.
352
- daemonId: stringValue(context.env.DD_FLOW_DAEMON_ID) ?? null,
353
- sessionId: effectiveSessionId,
354
- agentId: rawChildProviderSessionId ?? null,
355
- turnId: turnId ?? null,
356
- eventName,
357
- toolName: toolName ?? null,
358
- status: "observed",
359
- payload,
360
- eventKey,
361
- matchKey,
362
- transcriptPath: binding?.transcript_path ?? null,
363
- cwd: stringValue(payload.cwd) ?? null
364
- });
365
- if (effectiveSessionId) {
366
- recordFlowSessionObservation(context, {
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, {
367
359
  projectId: project.id,
368
- sessionId: effectiveSessionId,
369
- runId: observedSession?.run_id ?? null,
370
360
  protocolId,
371
- 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,
372
371
  toolName: toolName ?? null,
373
- eventKey
372
+ status: "observed",
373
+ payload,
374
+ eventKey,
375
+ matchKey,
376
+ transcriptPath: binding?.transcript_path ?? null,
377
+ cwd: stringValue(payload.cwd) ?? project.root
374
378
  });
375
- }
376
- 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 = {
377
392
  ok: true,
378
- observed: inserted,
379
- duplicate: !inserted,
380
393
  event_key: eventKey,
381
394
  session: providerSessionId ? nativeSessionIdentity("codex-desktop", providerSessionId) : null,
382
395
  ...(parentProviderSessionId ? { parent_session: nativeSessionIdentity("codex-desktop", parentProviderSessionId) } : {}),
383
- protocol_id: protocolId,
384
- ...(providerSessionId && (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
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
385
433
  ? {
386
434
  hookSpecificOutput: {
387
435
  hookEventName: "PreToolUse",
388
436
  permissionDecision: "allow",
389
- updatedInput: { command: commandWithHookEvent(command, eventKey) }
437
+ updatedInput: { ...objectRecord(payload.tool_input), command: commandWithHookEvent(command, eventKey) }
390
438
  }
391
439
  }
392
440
  : {})
@@ -398,7 +446,24 @@ function codexNativeParentId(payload, childId) {
398
446
  if (!transcript || !path.isAbsolute(transcript))
399
447
  return null;
400
448
  try {
401
- const firstLine = fs.readFileSync(transcript, "utf8").split("\n", 1)[0];
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");
402
467
  if (!firstLine)
403
468
  return null;
404
469
  const record = JSON.parse(firstLine);
@@ -456,9 +521,6 @@ function recordZcodeLifecycle(context, input, invocationId) {
456
521
  if (!invocationId && commandOption(lifecycle.invocation, "invocation-id"))
457
522
  return { ok: true, observed: false, reason: "cli_invocation_owns_admission" };
458
523
  const expectedRoot = resolveProjectRoot(input.projectRoot);
459
- if (lifecycle.commandProjectRoot && resolveProjectRoot(lifecycle.commandProjectRoot) !== expectedRoot) {
460
- throw new AppError("project_mismatch", "ZCode lifecycle command project root does not match the controlled workspace", 1, { expected: expectedRoot, actual: lifecycle.commandProjectRoot });
461
- }
462
524
  const meta = objectRecord(hook._meta);
463
525
  const ddZcode = objectRecord(meta.ddZcode);
464
526
  const providerSessionId = stringValue(hook.session_id) ?? stringValue(hook.sessionId);
@@ -474,56 +536,46 @@ function recordZcodeLifecycle(context, input, invocationId) {
474
536
  const toolUseId = stringValue(hook.tool_use_id) ?? stringValue(hook.toolUseId) ?? stringValue(hook.event_id) ?? stringValue(hook.eventId);
475
537
  if (!toolUseId)
476
538
  throw new AppError("zcode_hook_event_missing", "ZCode native hook has no tool-use ID", 1);
477
- if (lifecycle.analysis.kind === "compound") {
478
- if (lifecycle.analysis.reason !== "shell_composition")
479
- throw new AppError("compound_lifecycle_command", "Ambiguous shell cannot authorize a lifecycle replacement", 1, { reason: lifecycle.analysis.reason });
480
- if (invocationId) {
481
- const error = { code: "compound_lifecycle_command", message: "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", exitCode: 1,
482
- details: { standalone_command: lifecycle.invocation.command } };
483
- settleLifecycleNoEffectRejection(context, invocationId, lifecycle.invocation.command, error, { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId });
484
- return { ok: true, observed: false, invocation_id: invocationId, no_effect: true, error };
485
- }
486
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone ZCode Bash tool call", 1, { standalone_command: lifecycle.invocation.command });
487
- }
488
539
  const eventKey = crypto.createHash("sha256").update(JSON.stringify(["zcode-acp", daemonId, providerSessionId, toolUseId])).digest("hex");
489
540
  const rewritten = invocationId ? command : commandWithHookEvent(command, eventKey);
490
- if (!invocationId && rewritten === command && commandOption(lifecycle.invocation, "hook-event-id") !== eventKey)
491
- throw new AppError("zcode_hook_rewrite_unsupported", "ZCode requires a direct lifecycle command whose exact receipt can be delivered", 1);
492
541
  if (lifecycle.bootstrapStageStart)
493
542
  registerProject(context, { root: expectedRoot });
494
543
  const project = requireProjectByRoot(context, expectedRoot);
495
544
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
496
545
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", parentProviderSessionId)) : null;
497
546
  const observedProfile = objectRecord(ddZcode.observedProfile);
498
- const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
547
+ let matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
499
548
  const eventNameForReceipt = invocationId ? "ToolCallObserved" : "PreToolUse";
500
- const persist = () => {
549
+ const persist = (scope) => {
550
+ if (scope)
551
+ matchKey = lifecycleMatchKey(lifecycle, expectedRoot, scope);
501
552
  assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId, sessionId, turnId: null, eventName: eventNameForReceipt, toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
502
- const observedSession = lifecycle.flowPayload ? bindObservedFlowSession(context, project, {
503
- ...lifecycle.flowPayload, harness: "zcode-acp", provider_session_id: providerSessionId,
504
- parent_session_id: parentSessionId, provider: stringValue(observedProfile.provider) ?? null,
505
- model: stringValue(observedProfile.model) ?? null, reasoning: stringValue(observedProfile.reasoning) ?? null,
506
- mode: stringValue(observedProfile.mode) ?? null, agent_id: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null
507
- }, sessionId) : undefined;
508
553
  const payload = { ...hook, session_id: providerSessionId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
509
554
  command, provider: stringValue(observedProfile.provider), model: stringValue(observedProfile.model), reasoning: stringValue(observedProfile.reasoning), mode: stringValue(observedProfile.mode) };
510
555
  const inserted = recordHookEvent(context, {
511
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "zcode-acp", providerSessionId, parentSessionId,
556
+ projectId: project.id, protocolId: null, harness: "zcode-acp", providerSessionId, parentSessionId,
512
557
  daemonId, sessionId, agentId: stringValue(hook.agent_id) ?? stringValue(hook.agentId) ?? null, turnId: null,
513
558
  eventName: eventNameForReceipt, toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey,
514
559
  transcriptPath: stringValue(hook.transcript_path) ?? null, cwd: expectedRoot
515
560
  });
516
- recordFlowSessionObservation(context, { projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
561
+ recordFlowSessionObservation(context, { projectId: project.id, sessionId, cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey });
517
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 };
518
563
  return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: rewritten } } };
519
564
  };
520
- if (!invocationId)
521
- return persist();
565
+ let persistedResult;
522
566
  const receipt = observeLifecycleInvocation(context, {
523
- id: invocationId, command, identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
524
- recordReceipt: () => { persist(); return eventKey; }
567
+ ...(invocationId ? { id: invocationId } : {}), command,
568
+ harness: "zcode-acp",
569
+ identity: { daemonId, rootSessionId: rootProviderSessionId, sessionId: providerSessionId, parentSessionId: parentProviderSessionId ?? null, toolCallId: toolUseId },
570
+ recordReceipt: scope => { persistedResult = persist(scope); return eventKey; }
525
571
  });
526
- return { ok: true, observed: !receipt.duplicate, duplicate: receipt.duplicate, event_key: receipt.eventKey, invocation_id: invocationId };
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();
527
579
  }
528
580
  /** Convert a Grok Build PreToolUse hook into a trusted lifecycle receipt. */
529
581
  export function handleGrokEvent(context, input) {
@@ -541,18 +593,8 @@ export function handleGrokEvent(context, input) {
541
593
  const lifecycle = lifecycleFacts(command);
542
594
  if (!lifecycle)
543
595
  return { ok: true, observed: false, reason: "event_not_participating" };
544
- if (lifecycle.analysis.kind === "compound") {
545
- throw new AppError("compound_lifecycle_command", "dd-flow lifecycle commands must be a standalone Grok Build tool call", 1, {
546
- standalone_command: lifecycle.invocation.command
547
- });
548
- }
549
- const { flowPayload, stageStart, stageFinish, stagePause, stageResume, workStart, workFinish, workFail, bootstrapStageStart, commandProjectRoot } = lifecycle;
596
+ const { bootstrapStageStart } = lifecycle;
550
597
  const expectedRoot = resolveProjectRoot(input.projectRoot);
551
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
552
- throw new AppError("project_mismatch", "Grok Build lifecycle command project root does not match the controlled workspace", 1, {
553
- expected: expectedRoot, actual: commandProjectRoot
554
- });
555
- }
556
598
  const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
557
599
  if (bootstrapStageStart)
558
600
  registerProject(context, { root: expectedRoot });
@@ -573,27 +615,22 @@ export function handleGrokEvent(context, input) {
573
615
  harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
574
616
  })).digest("hex");
575
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 });
576
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
577
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
578
- }, sessionId) : undefined;
579
618
  const payload = {
580
619
  ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
581
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)
582
621
  };
583
622
  const inserted = recordHookEvent(context, {
584
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness: "grok-acp", providerSessionId, parentSessionId,
623
+ projectId: project.id, protocolId: null, harness: "grok-acp", providerSessionId, parentSessionId,
585
624
  daemonId: daemonId ?? null, sessionId, agentId: agentId ?? null, turnId: stringValue(hook.turn_id) ?? stringValue(hook.turnId) ?? null,
586
625
  eventName: "PreToolUse", toolName: toolName ?? "Bash", status: "observed", payload, eventKey, matchKey, transcriptPath: null, cwd: expectedRoot
587
626
  });
588
627
  recordFlowSessionObservation(context, {
589
- projectId: project.id, sessionId, runId: observedSession?.run_id ?? null, protocolId: observedSession?.protocol_id ?? null,
628
+ projectId: project.id, sessionId,
590
629
  cwd: expectedRoot, toolName: toolName ?? "Bash", eventKey
591
630
  });
592
631
  const result = { ok: true, observed: inserted, duplicate: !inserted, event_key: eventKey, session: nativeSessionIdentity("grok-acp", providerSessionId),
593
632
  ...(isChild ? { parent_session: nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId) } : {}), daemon_id: daemonId ?? null };
594
- return (flowPayload || stageStart || stageFinish || stagePause || stageResume || workStart || workFinish || workFail || lifecycle.invocation.operation === "recovery_accept")
595
- ? { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...(rawInput ?? {}), command: commandWithHookEvent(command, eventKey) } } }
596
- : result;
633
+ return { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, eventKey) } } };
597
634
  }
598
635
  /** Convert a controlled OpenCode tool hook into the shared lifecycle receipt. */
599
636
  export function handleOpenCodeEvent(context, input) {
@@ -656,13 +693,7 @@ function handleControlledToolEvent(context, input, event, harness) {
656
693
  const lifecycle = lifecycleFacts(command);
657
694
  if (!lifecycle)
658
695
  return { ok: true, observed: false, reason: "event_not_participating" };
659
- if (lifecycle.analysis.kind === "compound") {
660
- throw new AppError("compound_lifecycle_command", `dd-flow lifecycle commands must be a standalone ${label} shell tool call`, 1, { standalone_command: lifecycle.invocation.command });
661
- }
662
- const { flowPayload, bootstrapStageStart, commandProjectRoot } = lifecycle;
663
- if (commandProjectRoot && resolveProjectRoot(commandProjectRoot) !== expectedRoot) {
664
- throw new AppError("project_mismatch", `${label} lifecycle command project root does not match the controlled workspace`, 1, { expected: expectedRoot, actual: commandProjectRoot });
665
- }
696
+ const { bootstrapStageStart } = lifecycle;
666
697
  const matchKey = lifecycleMatchKey(lifecycle, expectedRoot);
667
698
  if (bootstrapStageStart)
668
699
  registerProject(context, { root: expectedRoot });
@@ -675,23 +706,17 @@ function handleControlledToolEvent(context, input, event, harness) {
675
706
  }
676
707
  const agentId = stringValue(session.agent_id);
677
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 });
678
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
679
- ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
680
- parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
681
- reasoning: stringValue(droid ? profile.reasoning : profile.variant) ?? null, mode: stringValue(droid ? profile.mode : profile.agent) ?? null, agent_type: stringValue(droid ? profile.agent_type : profile.agent) ?? null,
682
- metadata: droid ? { droid_version: stringValue(event.runtime_version) ?? null } : { 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 }
683
- }, sessionId) : undefined;
684
709
  const payload = {
685
710
  session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName, tool_input: rawInput, command,
686
711
  provider: stringValue(profile.provider), model: stringValue(profile.model), reasoning: stringValue(droid ? profile.reasoning : profile.variant),
687
712
  mode: stringValue(droid ? profile.mode : profile.agent), agent_type: stringValue(droid ? profile.agent_type : profile.agent)
688
713
  };
689
714
  const inserted = recordHookEvent(context, {
690
- projectId: project.id, protocolId: observedSession?.protocol_id ?? null, harness, providerSessionId, parentSessionId,
715
+ projectId: project.id, protocolId: null, harness, providerSessionId, parentSessionId,
691
716
  daemonId, sessionId, agentId: agentId ?? null, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName,
692
717
  status: "observed", payload, eventKey: baseKey, matchKey, transcriptPath: droid ? stringValue(event.transcript_path) ?? null : null, cwd: expectedRoot
693
718
  });
694
- 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 });
695
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 };
696
721
  return agy ? result : { ...result, hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { ...rawInput, command: commandWithHookEvent(command, baseKey) } } };
697
722
  }
@@ -740,10 +765,6 @@ export function isolatedDdFlowHomeFromHookStdin(stdin) {
740
765
  * hook contract intentionally narrow: unwrap only that whole-command form,
741
766
  * then apply the same lifecycle parser to its actual shell payload.
742
767
  */
743
- function lifecycleCommandFromPayload(payload) {
744
- const command = commandFromPayload(payload);
745
- return command ? unwrapShellCommand(command) : undefined;
746
- }
747
768
  /**
748
769
  * Adds the receipt to the command that the shell will execute. Heredoc input
749
770
  * belongs to that command too, but an option appended after its delimiter is a
@@ -754,8 +775,6 @@ export function commandWithHookEvent(command, eventKey) {
754
775
  if (analysis.kind !== "standalone")
755
776
  return command;
756
777
  if (commandHasOption(analysis.invocation, "hook-event-id")) {
757
- if (commandOption(analysis.invocation, "hook-event-id") !== eventKey)
758
- throw new AppError("hook_event_mismatch", "The command specifies a receipt from another tool invocation", 1);
759
778
  return command;
760
779
  }
761
780
  const option = `--hook-event-id '${eventKey.replaceAll("'", "'\\''")}'`;
@@ -825,7 +844,6 @@ function lifecycleFacts(command) {
825
844
  return {
826
845
  analysis,
827
846
  invocation,
828
- flowPayload: flowSessionPayloadFromRegisterCommand(invocation),
829
847
  stageStart,
830
848
  stageFinish,
831
849
  stagePause,
@@ -850,11 +868,17 @@ function allowsStdinLifecycleCompound(facts) {
850
868
  || (operation === "stage_pause" && commandHasOption(facts.invocation, "question-stdin"))
851
869
  || (operation === "stage_resume" && commandHasOption(facts.invocation, "answer-stdin")));
852
870
  }
853
- function lifecycleMatchKey(facts, projectRoot) {
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;
854
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;
855
879
  if (invocation.operation === "recovery_accept") {
856
880
  const run = commandPosition(invocation, 0), recovery = commandOption(invocation, "recovery-id");
857
- return run && recovery ? recoveryAcceptMatchKey(run, recovery, projectRoot) : null;
881
+ return (scope?.runId ?? run) && recovery ? recoveryAcceptMatchKey((scope?.runId ?? run), recovery, effectiveRoot) : null;
858
882
  }
859
883
  if (bootstrapStageStart)
860
884
  return bootstrapMatchKey({
@@ -863,20 +887,20 @@ function lifecycleMatchKey(facts, projectRoot) {
863
887
  intakeMode: contextSha256 ? "context" : commandHasOption(invocation, "intake-stdin") ? "stdin" : "file",
864
888
  ...(contextSha256 ? { contextSha256 } : {})
865
889
  });
866
- if (stageStart && runId && stageName)
867
- return stageStartMatchKey(runId, stageName, projectRoot, contextSha256);
868
- if (stageFinish && runId && stageName)
869
- return stageLifecycleMatchKey("stage_finish", runId, stageName, projectRoot);
870
- if (stagePause && runId && stageName && workId)
871
- return stageLifecycleMatchKey("stage_pause", runId, stageName, projectRoot, workId);
872
- if (stageResume && runId && stageName && workId)
873
- 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);
874
898
  if (workStart && workId)
875
- return workStartMatchKey(workId, projectRoot, commandOption(invocation, "recovery-id"));
899
+ return workStartMatchKey(workId, effectiveRoot, commandOption(invocation, "recovery-id"));
876
900
  if (workFinish && workId)
877
- return workLifecycleMatchKey("work_finish", workId, projectRoot);
901
+ return workLifecycleMatchKey("work_finish", workId, effectiveRoot);
878
902
  if (workFail && workId)
879
- return workLifecycleMatchKey("work_fail", workId, projectRoot);
903
+ return workLifecycleMatchKey("work_fail", workId, effectiveRoot);
880
904
  return null;
881
905
  }
882
906
  /** Observe exact immutable command attribution before reading model-supplied
@@ -892,6 +916,8 @@ export function observeCommandHook(context, command) {
892
916
  if (!matchKey)
893
917
  return;
894
918
  let key = commandOption(facts.invocation, "hook-event-id");
919
+ if (!key && commandOption(facts.invocation, "invocation-id"))
920
+ return;
895
921
  if (!key) {
896
922
  try {
897
923
  key = findRecentMatchingHookEvent(context, { projectId: project.id, matchKey, errorCode: "trusted_session_binding_required", operation: facts.invocation.operation }).eventKey;
@@ -1011,7 +1037,7 @@ export function findRecentMatchingHookEvent(context, input) {
1011
1037
  events = find();
1012
1038
  }
1013
1039
  if (events.length === 0 || !events[0]?.event_key) {
1014
- throw new AppError(input.errorCode, `${input.operation} requires a trusted lifecycle receipt. Check native hook delivery and updatedInput; managed ZCode requires its issued --invocation-id.`, 1);
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);
1015
1041
  }
1016
1042
  if (events.length > 1) {
1017
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) });
@@ -1147,6 +1173,8 @@ export function codexHookEventsForProject(context, projectId) {
1147
1173
  FROM hook_events WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 20`, [projectId]);
1148
1174
  }
1149
1175
  function resolveProfileName(profile) {
1176
+ if (profile !== undefined && (typeof profile !== "string" || !profile.trim()))
1177
+ throw new AppError("validation", "--profile must be nonempty text", 2);
1150
1178
  return sanitizeProfile(profile ?? defaultProfileName);
1151
1179
  }
1152
1180
  function sanitizeProfile(value) {
@@ -1174,11 +1202,12 @@ function readTomlIfExists(file) {
1174
1202
  if (!fs.existsSync(file)) {
1175
1203
  return {};
1176
1204
  }
1205
+ const { text } = prepareTextFile(file, "Codex config");
1177
1206
  try {
1178
- return parse(fs.readFileSync(file, "utf8"));
1207
+ return parse(text);
1179
1208
  }
1180
1209
  catch (error) {
1181
- 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 } : {}) });
1182
1211
  }
1183
1212
  }
1184
1213
  function withManagedConfig(config, sourceHome, targetHome) {
@@ -1254,7 +1283,10 @@ function hookEntry(matcher, command, statusMessage) {
1254
1283
  return { matcher, hooks: [commandHook(command, statusMessage)] };
1255
1284
  }
1256
1285
  function commandHook(command, statusMessage) {
1257
- 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 };
1258
1290
  }
1259
1291
  function hookCommand(event) {
1260
1292
  const args = `codex hook handle --event ${event} --json`;
@@ -1434,11 +1466,10 @@ function recordHookInstallation(context, projectId, scope, configPath, installed
1434
1466
  function installationScope(target, profile, hooksPath) {
1435
1467
  return target === "isolated" ? `isolated:${profile}` : `default:${path.dirname(hooksPath)}`;
1436
1468
  }
1437
- function upsertSessionBindingFromPayload(context, project, sessionId, payload) {
1469
+ function upsertSessionBindingFromPayload(context, project, sessionId, payload, transcriptPath) {
1438
1470
  const existing = sessionBinding(context, project.id, sessionId);
1439
1471
  const now = context.now();
1440
1472
  const cwd = stringValue(payload.cwd) ?? existing?.cwd ?? null;
1441
- const transcriptPath = stringValue(payload.transcript_path) ?? existing?.transcript_path ?? locateCodexTranscript(sessionId, context.env.CODEX_HOME);
1442
1473
  context.db.run(`INSERT INTO codex_session_bindings
1443
1474
  (session_id, project_id, protocol_id, handshake_id, cwd, transcript_path, status, created_at, updated_at)
1444
1475
  VALUES (?, ?, NULL, NULL, ?, ?, 'active', ?, ?)
@@ -1566,14 +1597,6 @@ function hookEventKey(payload, eventName, toolName, command) {
1566
1597
  command
1567
1598
  })).digest("hex");
1568
1599
  }
1569
- function commandFromPayload(payload) {
1570
- const direct = stringValue(payload.command);
1571
- if (direct) {
1572
- return direct;
1573
- }
1574
- const toolInput = objectRecord(payload.tool_input);
1575
- return stringValue(toolInput.command) ?? stringValue(toolInput.cmd);
1576
- }
1577
1600
  function toolNameFromPayload(payload) {
1578
1601
  const toolInput = objectRecord(payload.tool_input);
1579
1602
  return stringValue(toolInput.name);
@@ -1616,10 +1639,10 @@ function writeJsonFile(file, value) {
1616
1639
  ensureDir(path.dirname(file));
1617
1640
  fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
1618
1641
  }
1619
- function backupFile(file, now) {
1642
+ function backupFile(file, now, text) {
1620
1643
  const suffix = now.replace(/[:.]/g, "-");
1621
1644
  const backup = `${file}.${suffix}.bak`;
1622
- fs.copyFileSync(file, backup);
1645
+ fs.writeFileSync(backup, text, { mode: 0o600 });
1623
1646
  return backup;
1624
1647
  }
1625
1648
  function hashObject(value) {