@deksden-com/dd-flow-cli 0.9.0-beta.39 → 0.9.0-beta.40

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,17 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.40
4
+
5
+ ### Patch Changes
6
+
7
+ - 2b4b714: Admit ordinary managed HITL answers through their exact generation-bound native
8
+ prompt, preserving pause, Session, raw-answer and prompt integrity. Keep accepted
9
+ answer intent separate from physical delivery so stop drains native operations
10
+ and recovery can deliver an unanswered pause without replaying completed work.
11
+ - c5eeb6f: Capture owned Codex home configuration and evidence without copying shared host
12
+ history or ephemeral helper links. Reuse the provisioned shared-entry contract
13
+ and retain strict rejection of unrelated external recovery artifact links.
14
+
3
15
  ## 0.9.0-beta.39
4
16
 
5
17
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.39",
4
- "cli_commit": "adabaee64d3de604908351aa1ee048208c1dd249",
5
- "built_at": "2026-09-09T16:08:31.531Z",
3
+ "cli_version": "0.9.0-beta.40",
4
+ "cli_commit": "652524c6f370aa17c75474de5ebb7b3b9bf0b3a2",
5
+ "built_at": "2026-09-09T17:31:05.981Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.0",
8
8
  "commit": "ef349bf47cba1c987468e51d73a0dbadbd48dc1f",
@@ -59,6 +59,7 @@ import { fenceRuntimeBudget, runtimeBudgetStatus, admitRuntimeCheck, parseRuntim
59
59
  import { stopRuntimeScope } from "../services/runtime-scope-stop.js";
60
60
  import { launchRuntimeScopeControl, launchRuntimeScopeResume, serveRuntimeScopeControl } from "../services/runtime-scope-worker.js";
61
61
  import { launchRunController, resumeRunController, serveRunController, runControllerStatus, answerRunController, supplyRunControllerContext } from "../services/run-controller.js";
62
+ import { controllerHitlAdmission } from "../services/run-controller-adapter.js";
62
63
  import { controlLifecycleReply, runControlStatus } from "../services/run-control.js";
63
64
  import { requestRunControl, serveRunControl } from "../services/run-control-worker.js";
64
65
  import { startRuntimeService } from "../services/runtime-service.js";
@@ -1810,7 +1811,8 @@ async function dispatchRuntime(context, command, parsed, progress) {
1810
1811
  }
1811
1812
  const run = context.db.get("SELECT status FROM runs WHERE project_id = ? AND id = ?", [record.project_id, record.run_id]);
1812
1813
  const retainedHitlPrompt = recovering && ["paused", "waiting_for_user"].includes(run?.status ?? "") && ["session.prompt", "session.resume"].includes(optionalOption(parsed, "operation") ?? "") && context.db.get("SELECT 1 FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND g.generation = ? AND b.daemon_id = ? AND b.provider_session_id = ?", [record.project_id, record.run_id, metadata.run_generation, optionalOption(parsed, "daemon-id"), optionalOption(parsed, "native-session-id")]);
1813
- if (!run || run.status !== "running" && !retainedHitlPrompt && !["judge", "probe"].includes(metadata.role ?? ""))
1814
+ const acceptedHitlPrompt = ["paused", "waiting_for_user"].includes(run?.status ?? "") && controllerHitlAdmission(context, record, { stateDir: requiredOption(parsed, "state-dir"), operationId: optionalOption(parsed, "operation-id"), operation: optionalOption(parsed, "operation"), nativeSessionId: optionalOption(parsed, "native-session-id"), promptSha256: optionalOption(parsed, "prompt-sha256") });
1815
+ if (!run || run.status !== "running" && !retainedHitlPrompt && !acceptedHitlPrompt && !["judge", "probe"].includes(metadata.role ?? ""))
1814
1816
  throw new AppError("invalid_run_state", "Subject dispatch requires a running RUN or its retained recovery/HITL prompt", 1);
1815
1817
  }
1816
1818
  const budget = reserveProviderTurn(context, record, { operationId: optionalOption(parsed, "operation-id") ?? "", operation: optionalOption(parsed, "operation") ?? "", sessionId: optionalOption(parsed, "native-session-id"), daemonId: optionalOption(parsed, "daemon-id") });
@@ -14,6 +14,7 @@ import { readVnextFlowDefinition } from "./vnext-specify.js";
14
14
  import { assertRunWorkResultArtifacts, runWorkResultArtifactIssues } from "./work-registry.js";
15
15
  import { captureRecoveryDatabase, readRecoveryResourceEvidence } from "./recovery-snapshot-database.js";
16
16
  import { resourceHome } from "./managed-processes.js";
17
+ import { codexHomeSharedEntries } from "./hooks.js";
17
18
  /** Capture the only flow boundary which exists before `stage start --bootstrap` creates a RUN. */
18
19
  export function createEvalBootstrapSnapshot(context, input) {
19
20
  const projectRoot = resolveProjectRoot(input.projectRoot);
@@ -134,16 +135,17 @@ function captureEvalRunSnapshot(context, input) {
134
135
  fs.mkdirSync(output, { recursive: true, mode: 0o700 });
135
136
  const resourceDatabase = path.join(context.ddFlowHome, "runtime.sqlite");
136
137
  const databaseFiles = [context.db.path, resourceDatabase].flatMap(file => [file, `${file}-wal`, `${file}-shm`]);
138
+ const codexExclusions = codexHomeSnapshotExclusions(context, project.id, input.runId);
137
139
  if (recovery) {
138
140
  if (typeof status.run.run_root !== "string" || !isInside(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)) || samePath(physicalPath(context.ddFlowHome), physicalPath(status.run.run_root)))
139
141
  throw new AppError("snapshot_run_scope_invalid", "Recovery requires a RUN artifact root within its runtime", 1);
140
142
  for (const source of new Set([status.run.run_root, runtimeRunDir(context.ddFlowHome, project.id, input.runId)])) {
141
143
  if (fs.existsSync(source))
142
- copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)));
144
+ copyRuntimeTree(source, path.join(output, "runtime", path.relative(context.ddFlowHome, source)), true, codexExclusions);
143
145
  }
144
146
  }
145
147
  else
146
- copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, databaseFiles);
148
+ copyRuntimeTree(context.ddFlowHome, path.join(output, "runtime"), false, [...databaseFiles, ...codexExclusions]);
147
149
  // VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
148
150
  // pages. Copying the live database and its WAL separately is not atomic.
149
151
  const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
@@ -734,6 +736,35 @@ function snapshotSourceVersion(context, input) {
734
736
  ...roots.map(root => [treeHash(root, true), treeModesHash(root, true), git(root, ["symbolic-ref", "--quiet", "HEAD"]), gitBytes(root, ["show-ref", "--head"]).toString("base64"), indexEntries(root).toString("base64")])
735
737
  ])).digest("hex");
736
738
  }
739
+ /** Managed Codex homes share host configuration, not owned snapshot payload. */
740
+ function codexHomeSnapshotExclusions(context, projectId, runId) {
741
+ const homes = new Set(context.db.all("SELECT target_home FROM codex_home_profiles WHERE project_id = ?", [projectId]).map(row => row.target_home));
742
+ if (context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'")) {
743
+ for (const { home } of context.db.all("SELECT json_extract(value, '$.codex_home') AS home FROM run_controllers, json_each(state_json, '$.sessions') WHERE project_id = ? AND run_id = ?", [projectId, runId]))
744
+ if (home)
745
+ homes.add(home);
746
+ }
747
+ const excluded = [];
748
+ for (const home of homes) {
749
+ if (!path.isAbsolute(home) || !fs.existsSync(home) || fs.lstatSync(home).isSymbolicLink())
750
+ continue;
751
+ const marker = path.join(home, ".dd-flow-home.json");
752
+ if (!fs.existsSync(marker) || fs.lstatSync(marker).isSymbolicLink())
753
+ continue;
754
+ const identity = JSON.parse(fs.readFileSync(marker, "utf8"));
755
+ if (identity.managed_by !== "dd-flow-cli" || identity.project_id !== projectId || identity.target_home !== home || !identity.source_home || !path.isAbsolute(identity.source_home))
756
+ continue;
757
+ for (const entry of codexHomeSharedEntries) {
758
+ const file = path.join(home, entry);
759
+ // Skip only the exact links installed by initCodexHome, never arbitrary
760
+ // external links or owned local files with the same convenient name.
761
+ if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink() && fs.readlinkSync(file) === path.join(identity.source_home, entry))
762
+ excluded.push(file);
763
+ }
764
+ excluded.push(path.join(home, "tmp"), path.join(home, ".tmp"));
765
+ }
766
+ return excluded;
767
+ }
737
768
  /** Copy a dedicated home or scoped recovery tree, preserving SQLite WAL commits. */
738
769
  function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
739
770
  if (fs.lstatSync(source).isSymbolicLink())
@@ -794,6 +825,7 @@ function copyRuntimeTree(source, destination, scoped = true, excluded = []) {
794
825
  }
795
826
  function captureRecoveryAuxiliary(context, captured, input) {
796
827
  const runtime = path.join(input.output, "runtime");
828
+ const codexExclusions = codexHomeSnapshotExclusions(context, input.projectId, input.runId);
797
829
  const run = captured.db.get("SELECT run_root, project_root, workspace_root FROM runs WHERE project_id = ? AND id = ?", [input.projectId, input.runId]);
798
830
  const roots = [run.run_root, runtimeRunDir(context.ddFlowHome, input.projectId, input.runId)];
799
831
  for (const protocol of captured.db.all("SELECT id, state_path, plan_path FROM protocols")) {
@@ -801,7 +833,7 @@ function captureRecoveryAuxiliary(context, captured, input) {
801
833
  if (!isInside(physicalPath(context.ddFlowHome), physicalPath(directory)))
802
834
  throw new AppError("snapshot_protocol_scope_invalid", "Protocol runtime leaves DD_FLOW_HOME", 1);
803
835
  if (fs.existsSync(directory)) {
804
- copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)));
836
+ copyRuntimeTree(directory, path.join(runtime, path.relative(context.ddFlowHome, directory)), true, codexExclusions);
805
837
  roots.push(directory);
806
838
  }
807
839
  for (const file of [protocol.state_path, protocol.plan_path]) {
@@ -848,7 +880,7 @@ function captureRecoveryAuxiliary(context, captured, input) {
848
880
  if (!owner || owner.project_id !== input.projectId || owner.run_id !== input.runId || owner.owner_id !== process.owner_id || owner.dd_flow_home !== context.ddFlowHome || owner.resource_home !== home || owner.state_dir !== directory)
849
881
  throw new AppError("snapshot_native_scope_invalid", "External native artifact root has no matching RUN owner", 1, { state_dir: directory });
850
882
  const relative = path.join("native-evidence", crypto.createHash("sha256").update(directory).digest("hex"));
851
- copyRuntimeTree(directory, path.join(runtime, relative));
883
+ copyRuntimeTree(directory, path.join(runtime, relative), true, codexExclusions);
852
884
  nativeCaptures.set(directory, relative);
853
885
  }
854
886
  const relative = path.join("resource-evidence", `${crypto.createHash("sha256").update(home).digest("hex")}.json`);
@@ -14,7 +14,7 @@ import { registerProject, requireProjectByRoot } from "./projects.js";
14
14
  import { activeFlowSessionsForProject, bindObservedFlowSession, flowSessionPayloadFromRegisterCommand, recordFlowSessionObservation } from "./sessions.js";
15
15
  const defaultProfileName = "default";
16
16
  const maxSanitizedSummaryLength = 1600;
17
- const sharedEntries = [
17
+ export const codexHomeSharedEntries = [
18
18
  "auth.json",
19
19
  "auth.bk",
20
20
  "sessions",
@@ -51,7 +51,7 @@ export function planCodexHome(context, input) {
51
51
  config_path: paths.configPath,
52
52
  hooks_path: paths.hooksPath,
53
53
  owned_files: ["config.toml", "hooks.json", ".dd-flow-home.json", "log/", ".tmp/", "tmp/"],
54
- shared_entries: sharedEntries,
54
+ shared_entries: codexHomeSharedEntries,
55
55
  exists: fs.existsSync(paths.targetHome),
56
56
  codex_env: { CODEX_HOME: paths.targetHome }
57
57
  };
@@ -1118,7 +1118,7 @@ function objectTable(value) {
1118
1118
  function createSharedLinks(sourceHome, targetHome) {
1119
1119
  const linked = [];
1120
1120
  const skipped = [];
1121
- for (const entry of sharedEntries) {
1121
+ for (const entry of codexHomeSharedEntries) {
1122
1122
  const source = path.join(sourceHome, entry);
1123
1123
  const target = path.join(targetHome, entry);
1124
1124
  if (!fs.existsSync(source)) {
@@ -1141,7 +1141,7 @@ function createSharedLinks(sourceHome, targetHome) {
1141
1141
  return { linked, skipped };
1142
1142
  }
1143
1143
  function removeSharedSymlinks(targetHome) {
1144
- for (const entry of sharedEntries) {
1144
+ for (const entry of codexHomeSharedEntries) {
1145
1145
  const target = path.join(targetHome, entry);
1146
1146
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) {
1147
1147
  fs.rmSync(target);
@@ -1154,7 +1154,7 @@ function removeDirIfEmpty(dir) {
1154
1154
  }
1155
1155
  }
1156
1156
  function sharedEntryStatus(sourceHome, targetHome) {
1157
- return sharedEntries.map((entry) => {
1157
+ return codexHomeSharedEntries.map((entry) => {
1158
1158
  const source = path.join(sourceHome, entry);
1159
1159
  const target = path.join(targetHome, entry);
1160
1160
  return {
@@ -1,8 +1,43 @@
1
1
  import crypto from "node:crypto";
2
+ import fs from "node:fs";
2
3
  import { AppError } from "../shared/errors.js";
3
4
  import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
4
5
  import { runtimeHarness } from "./execution-policy.js";
5
- import { appendEvent, digest, errorRecord } from "./run-controller-state.js";
6
+ import { appendEvent, digest, digestBytes, errorRecord } from "./run-controller-state.js";
7
+ /** A paused RUN admits only its controller's exact accepted answer Turn. */
8
+ export function controllerHitlAdmission(context, record, input) {
9
+ if (input.operation !== "session.prompt" || !input.operationId || !input.promptSha256)
10
+ return false;
11
+ if (!context.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controller_operations'"))
12
+ return false;
13
+ const row = context.db.get("SELECT * FROM run_controllers WHERE controller_id = ? AND project_id = ? AND run_id = ? AND status = 'waiting_for_user'", [record.owner_id, record.project_id, record.run_id]);
14
+ if (!row || row.generation !== JSON.parse(record.metadata_json).run_generation)
15
+ return false;
16
+ const suffix = `:prompt:${row.generation}`;
17
+ if (!input.operationId.endsWith(suffix))
18
+ return false;
19
+ const state = JSON.parse(row.state_json);
20
+ const session = state.current_session === null ? undefined : state.sessions[state.current_session];
21
+ if (!session || session.stopped || session.id !== input.nativeSessionId || session.state_dir !== input.stateDir || session.owner.owner_id !== record.owner_id)
22
+ return false;
23
+ const operation = (id) => context.db.get("SELECT input_json, input_hash FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation = ? AND status = 'dispatching'", [id, row.controller_id, row.generation]);
24
+ const answer = context.db.get("SELECT input_json, input_hash FROM run_controller_operations WHERE operation_id = ? AND controller_id = ? AND generation <= ? AND status = 'accepted'", [input.operationId.slice(0, -suffix.length), row.controller_id, row.generation]);
25
+ const prompt = operation(input.operationId);
26
+ if (!answer || !prompt)
27
+ return false;
28
+ const accepted = JSON.parse(answer.input_json);
29
+ const dispatch = JSON.parse(prompt.input_json);
30
+ const run = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [record.project_id, record.run_id]);
31
+ const stage = run && JSON.parse(run.index_json).stage_runs.find(item => item.stage === accepted.stage);
32
+ if (accepted.action !== "user_answer" || state.stage !== accepted.stage || stage?.status !== "paused" || !stage.pause || stage.pause.answer_path || stage.pause.id !== accepted.pause_id || stage.pause.work_id !== accepted.work_id)
33
+ return false;
34
+ if (answer.input_hash !== digest({ pause_id: accepted.pause_id, answer_sha256: accepted.sha256 }) || prompt.input_hash !== digest(dispatch))
35
+ return false;
36
+ const value = (option) => dispatch.args.includes(option) ? dispatch.args[dispatch.args.indexOf(option) + 1] : undefined;
37
+ if (dispatch.owner !== record.owner_id || dispatch.state_dir !== input.stateDir || dispatch.args[0] !== "session" || dispatch.args[1] !== "prompt" || value("--session-id") !== input.nativeSessionId || dispatch.prompt_sha256 !== input.promptSha256 || !value("--prompt-file"))
38
+ return false;
39
+ return digestBytes(fs.readFileSync(accepted.file)) === accepted.sha256 && digestBytes(fs.readFileSync(value("--prompt-file"))) === input.promptSha256;
40
+ }
6
41
  export function sessionArguments(context, manifest, session) {
7
42
  const profile = session.profile;
8
43
  return ["--dd-flow-bin", manifest.flow_executable, "--dd-flow-home", context.ddFlowHome, "--project-root", manifest.request.project_root, "--state-dir", session.state_dir, "--journal", session.journal, "--cwd", session.cwd, "--provider", profile.provider, "--model", profile.model, "--reasoning", profile.reasoning, "--mode", profile.mode, "--permission", profile.permission, ...manifest.adapters[runtimeHarness(profile)].runtime_args, "--json"];
@@ -19,7 +54,7 @@ export async function controllerAdapter(context, row, manifest, session, args, a
19
54
  assertOwner?.();
20
55
  const operationId = retainedOperationId ?? `${row.controller_id}:${crypto.randomUUID()}`;
21
56
  const executable = manifest.adapters[runtimeHarness(session.profile)].executable;
22
- const input = { executable, args, owner: session.owner.owner_id, state_dir: session.state_dir };
57
+ const input = { executable, args, owner: session.owner.owner_id, state_dir: session.state_dir, ...(args[0] === "session" && args[1] === "prompt" ? { prompt_sha256: digestBytes(fs.readFileSync(args[args.indexOf("--prompt-file") + 1])) } : {}) };
23
58
  const now = context.now();
24
59
  context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'dispatching', ?, ?)", [operationId, row.controller_id, row.generation, digest(input), JSON.stringify(input), now, now]);
25
60
  appendEvent(context, row.controller_id, "operation_requested", { operation_id: operationId, operation: args.slice(0, 2), session_id: session.id || null });
@@ -342,12 +342,12 @@ async function executeController(context, row, manifest, state, assertOwner, ass
342
342
  if (failure)
343
343
  throw failure.reason;
344
344
  };
345
- const prompt = async (session, text) => {
345
+ const prompt = async (session, text, operationId) => {
346
346
  const file = path.join(row.state_dir, `turn-${String(state.turns + 1).padStart(5, "0")}.md`);
347
347
  fs.writeFileSync(file, `${text}\n`, { flag: "wx", mode: 0o600 });
348
348
  state.turns += 1;
349
349
  saveState(context, row, state);
350
- const receipt = await call(session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)]);
350
+ const receipt = await controllerAdapter(executionContext, row, manifest, session, ["session", "prompt", "--session-id", session.id, "--prompt-file", file, ...sessionArguments(context, manifest, session)], assertOwner, operationId);
351
351
  state.last_receipt = receipt;
352
352
  saveState(context, row, state);
353
353
  return receipt;
@@ -391,9 +391,10 @@ async function executeController(context, row, manifest, state, assertOwner, ass
391
391
  if (digestBytes(fs.readFileSync(input.file)) !== input.sha256)
392
392
  throw new AppError("context_checksum_mismatch", "Accepted HITL answer bytes changed", 1);
393
393
  assertOwner();
394
- context.db.run("UPDATE run_controller_operations SET status = 'dispatching', updated_at = ? WHERE operation_id = ? AND status = 'accepted'", [context.now(), answer.operation_id]);
394
+ // The accepted answer is logical intent; only its generation-bound
395
+ // adapter operation represents a physical dispatch that stop must drain.
395
396
  const command = `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${JSON.stringify(run.project_root)} --answer-stdin --json < ${JSON.stringify(input.file)}`;
396
- const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`);
397
+ const receipt = await prompt(state.sessions[state.current_session], `The user supplied the raw answer for pause ${pause.id}. Your first tool call must be this exact standalone command:\n\n${command}\n\nDo not alter the answer. Follow the returned packet, then stop at this Stage boundary.`, `${answer.operation_id}:prompt:${row.generation}`);
397
398
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify({ delivered: true, session_id: adapterSessionId(receipt) }), context.now(), answer.operation_id]);
398
399
  state.last_continuation = null;
399
400
  saveState(context, row, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.39",
3
+ "version": "0.9.0-beta.40",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {