@deksden-com/dd-flow-cli 0.9.0-beta.21 → 0.9.0-beta.22

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.22
4
+
5
+ ### Patch Changes
6
+
7
+ - Reject conflicting or ambiguous lifecycle receipts before any harness can
8
+ mutate a Session binding. Restore a Work-start receipt when its binding
9
+ fails, so the same verified invocation remains safely retryable.
10
+
3
11
  ## 0.9.0-beta.21
4
12
 
5
13
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.21",
4
- "cli_commit": "2350e9cbeddba31e283d27384163b2bb44690d46",
5
- "built_at": "2026-09-06T23:59:54.226Z",
3
+ "cli_version": "0.9.0-beta.22",
4
+ "cli_commit": "7a08ac0550694b06cec5438d5ed938d19f772418",
5
+ "built_at": "2026-09-07T02:03:13.276Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.6",
8
8
  "commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
@@ -324,10 +324,12 @@ export function handleCodexHook(context, input) {
324
324
  // Codex retains the root session_id for a thread-spawned child, while the
325
325
  // payload transcript belongs to the current agent_id. Keep lineage separate
326
326
  // from ownership: otherwise a child overwrites the root transcript binding.
327
- const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
328
- const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
327
+ const eventKey = hookEventKey(payload, eventName, toolName, command);
329
328
  const storageId = providerSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", providerSessionId)) : null;
330
329
  const parentSessionId = parentProviderSessionId ? storageSessionId(nativeSessionIdentity("codex-desktop", parentProviderSessionId)) : null;
330
+ 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 });
331
+ const bindingOwnerId = providerSessionId ?? rootProviderSessionId;
332
+ const binding = bindingOwnerId ? upsertSessionBindingFromPayload(context, project, bindingOwnerId, payload) : undefined;
331
333
  const observedSession = flowPayload
332
334
  ? bindObservedFlowSession(context, project, {
333
335
  ...flowPayload,
@@ -338,7 +340,6 @@ export function handleCodexHook(context, input) {
338
340
  : undefined;
339
341
  const effectiveSessionId = observedSession?.session_id ?? storageId;
340
342
  const protocolId = observedSession?.protocol_id ?? binding?.protocol_id ?? null;
341
- const eventKey = hookEventKey(payload, eventName, toolName, command);
342
343
  const inserted = recordHookEvent(context, {
343
344
  projectId: project.id,
344
345
  protocolId,
@@ -458,6 +459,10 @@ export function handleZcodeEvent(context, input) {
458
459
  const sessionId = storageSessionId(nativeSessionIdentity("zcode-acp", providerSessionId));
459
460
  const parentSessionId = childProviderSessionId ? storageSessionId(nativeSessionIdentity("zcode-acp", rootProviderSessionId)) : null;
460
461
  const agentId = stringValue(runtime.agentId);
462
+ const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
463
+ harness: "zcode-acp", providerSessionId, command
464
+ })).digest("hex");
465
+ assertHookEventReplay(context, { projectId: project.id, eventKey, harness: "zcode-acp", providerSessionId, parentSessionId, daemonId: daemonId ?? null, sessionId, turnId: null, eventName: "PreToolUse", toolName: toolName ?? "Bash", matchKey, cwd: expectedRoot });
461
466
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
462
467
  ...flowPayload,
463
468
  harness: "zcode-acp",
@@ -465,9 +470,6 @@ export function handleZcodeEvent(context, input) {
465
470
  agent_id: agentId ?? null,
466
471
  parent_session_id: parentSessionId
467
472
  }, sessionId) : undefined;
468
- const eventKey = stringValue(update.toolCallId) ?? crypto.createHash("sha256").update(JSON.stringify({
469
- harness: "zcode-acp", providerSessionId, command
470
- })).digest("hex");
471
473
  const payload = {
472
474
  session_id: providerSessionId,
473
475
  agent_id: agentId,
@@ -564,12 +566,13 @@ export function handleGrokEvent(context, input) {
564
566
  const parentSessionId = isChild ? storageSessionId(nativeSessionIdentity("grok-acp", stringValue(ddGrok.parentProviderSessionId) ?? rootProviderSessionId)) : null;
565
567
  const daemonId = stringValue(ddGrok.daemonId);
566
568
  const agentId = stringValue(hook.agent_id) ?? stringValue(hook.agentId);
567
- const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
568
- ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
569
- }, sessionId) : undefined;
570
569
  const eventKey = stringValue(hook.event_id) ?? stringValue(hook.eventId) ?? crypto.createHash("sha256").update(JSON.stringify({
571
570
  harness: "grok-acp", providerSessionId, command, turn: hook.turn_id ?? hook.turnId ?? null
572
571
  })).digest("hex");
572
+ 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 });
573
+ const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
574
+ ...flowPayload, harness: "grok-acp", provider_session_id: providerSessionId, agent_id: agentId ?? null, parent_session_id: parentSessionId
575
+ }, sessionId) : undefined;
573
576
  const payload = {
574
577
  ...hook, session_id: providerSessionId, agent_id: agentId, cwd: expectedRoot, tool_name: toolName ?? "Bash", tool_input: rawInput,
575
578
  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)
@@ -668,6 +671,7 @@ function handleControlledToolEvent(context, input, event, harness) {
668
671
  }
669
672
  }
670
673
  const agentId = stringValue(session.agent_id);
674
+ assertHookEventReplay(context, { projectId: project.id, eventKey: baseKey, harness, providerSessionId, parentSessionId, daemonId, sessionId, turnId: stringValue(event.message_id) ?? null, eventName: "PreToolUse", toolName, matchKey, cwd: expectedRoot });
671
675
  const observedSession = flowPayload ? bindObservedFlowSession(context, project, {
672
676
  ...flowPayload, harness, provider_session_id: providerSessionId, agent_id: agentId ?? null,
673
677
  parent_session_id: parentSessionId, provider: stringValue(profile.provider) ?? null, model: stringValue(profile.model) ?? null,
@@ -914,21 +918,24 @@ export function claimRecentMatchingHookEvent(context, input) {
914
918
  /** Finds the trusted event that a stage- or Work-specific claimant will atomically claim. */
915
919
  export function findRecentMatchingHookEvent(context, input) {
916
920
  const freshAfter = new Date(Date.parse(context.now()) - 60_000).toISOString();
917
- const find = () => context.db.get(`SELECT id, event_key FROM hook_events
921
+ const find = () => context.db.all(`SELECT id, event_key FROM hook_events
918
922
  WHERE project_id = ? AND match_key = ? AND status = 'observed' AND created_at >= ?
919
- ORDER BY id DESC LIMIT 1`, [input.projectId, input.matchKey, freshAfter]);
920
- let event = find();
923
+ ORDER BY id DESC LIMIT 2`, [input.projectId, input.matchKey, freshAfter]);
924
+ let events = find();
921
925
  // Some harnesses publish nested-agent tool events concurrently with command
922
926
  // execution. Give the trusted event a short bounded window to reach SQLite.
923
927
  const deadline = Date.now() + 250;
924
- while (!event?.event_key && Date.now() < deadline) {
928
+ while (events.length === 0 && Date.now() < deadline) {
925
929
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
926
- event = find();
930
+ events = find();
927
931
  }
928
- if (!event?.event_key) {
932
+ if (events.length === 0 || !events[0]?.event_key) {
929
933
  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);
930
934
  }
931
- return { id: event.id, eventKey: event.event_key };
935
+ if (events.length > 1) {
936
+ 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) });
937
+ }
938
+ return { id: events[0].id, eventKey: events[0].event_key };
932
939
  }
933
940
  /** Fallback for Desktop paths that execute the original command after a hook rewrite. */
934
941
  export function claimBootstrapHookEvent(context, input) {
@@ -1405,12 +1412,11 @@ function sessionBinding(context, projectId, sessionId) {
1405
1412
  return context.db.get("SELECT * FROM codex_session_bindings WHERE project_id = ? AND session_id = ?", [projectId, sessionId]);
1406
1413
  }
1407
1414
  function recordHookEvent(context, input) {
1408
- const existing = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [input.projectId, input.eventKey]);
1409
- if (existing)
1415
+ if (assertHookEventReplay(context, input))
1410
1416
  return false;
1411
1417
  context.db.run(`INSERT INTO hook_events
1412
- (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)
1413
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1418
+ (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)
1419
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1414
1420
  input.projectId,
1415
1421
  input.protocolId,
1416
1422
  input.harness,
@@ -1437,6 +1443,23 @@ function recordHookEvent(context, input) {
1437
1443
  ]);
1438
1444
  return true;
1439
1445
  }
1446
+ /** A duplicate delivery is harmless only when all durable identity facts match. */
1447
+ function assertHookEventReplay(context, input) {
1448
+ 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
1449
+ FROM hook_events WHERE project_id = ? AND event_key = ?`, [input.projectId, input.eventKey]);
1450
+ if (existing) {
1451
+ const observed = {
1452
+ harness: input.harness, provider_session_id: input.providerSessionId, parent_session_id: input.parentSessionId,
1453
+ daemon_id: input.daemonId ?? null, session_id: input.sessionId, turn_id: input.turnId,
1454
+ event_name: input.eventName, tool_name: input.toolName, match_key: input.matchKey, cwd: input.cwd
1455
+ };
1456
+ if (Object.entries(observed).some(([key, value]) => existing[key] !== value)) {
1457
+ throw new AppError("hook_event_replay_mismatch", "Lifecycle event ID was already observed with different immutable identity", 1, { event_key: input.eventKey });
1458
+ }
1459
+ return true;
1460
+ }
1461
+ return false;
1462
+ }
1440
1463
  function projectForHook(context, explicitRoot, cwd) {
1441
1464
  if (explicitRoot)
1442
1465
  return requireProjectByRoot(context, resolveProjectRoot(explicitRoot));
@@ -245,7 +245,14 @@ export function startWork(context, id, input) {
245
245
  }
246
246
  }
247
247
  const identity = claimWorkStartHookEvent(context, { projectId: work.project_id, eventKey: hookEventId, workId: work.work_id, projectRoot: run.project_root, ...(recoveryId ? { recoveryId } : {}) });
248
- const started = startBoundWork(context, work, run, identity, hookEventId);
248
+ let started;
249
+ try {
250
+ started = startBoundWork(context, work, run, identity, hookEventId);
251
+ }
252
+ catch (error) {
253
+ context.db.run("UPDATE hook_events SET status = 'observed', claimed_at = NULL WHERE project_id = ? AND event_key = ? AND status = 'claimed'", [work.project_id, hookEventId]);
254
+ throw error;
255
+ }
249
256
  appendFlowRunTimelineEvent(context, work.project_id, work.run_id, { type: "work_started", work_id: work.work_id, session_id: identity.sessionId, agent_id: identity.agentId });
250
257
  return started;
251
258
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.21",
3
+ "version": "0.9.0-beta.22",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dd-flow": "./dist/cli.js"
7
+ "dd-flow": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
10
  "dist",
@@ -16,16 +16,6 @@
16
16
  "node": ">=26.0.0",
17
17
  "pnpm": ">=10.0.0"
18
18
  },
19
- "scripts": {
20
- "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
21
- "typecheck": "tsc --noEmit",
22
- "lint": "eslint . --max-warnings=0",
23
- "test": "vitest run",
24
- "changeset": "changeset",
25
- "version-packages": "changeset version",
26
- "release": "pnpm run build && changeset publish",
27
- "prepublishOnly": "node scripts/verify-release-build.mjs"
28
- },
29
19
  "devDependencies": {
30
20
  "@changesets/cli": "^2.31.0",
31
21
  "@eslint/js": "^9.39.1",
@@ -44,5 +34,14 @@
44
34
  "publishConfig": {
45
35
  "access": "public"
46
36
  },
47
- "license": "MIT"
48
- }
37
+ "license": "MIT",
38
+ "scripts": {
39
+ "build": "tsc -p tsconfig.build.json && node scripts/generate-build-info.mjs && node scripts/copy-assets.mjs",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "eslint . --max-warnings=0",
42
+ "test": "vitest run",
43
+ "changeset": "changeset",
44
+ "version-packages": "changeset version",
45
+ "release": "pnpm run build && changeset publish"
46
+ }
47
+ }