@deksden-com/dd-flow-cli 0.9.0-beta.25 → 0.9.0-beta.27

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,18 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.27
4
+
5
+ ### Patch Changes
6
+
7
+ - Resume retained native sessions from their active coordinator WorkSession, preserving the root launch record and rejecting lifecycle receipts from retired daemons.
8
+ - Propagate Antigravity lifecycle hooks to direct native children while preserving workspace hooks, and settle confirmed Codex system-error turns without losing provider failure evidence.
9
+
10
+ ## 0.9.0-beta.26
11
+
12
+ ### Patch Changes
13
+
14
+ - Reject interactive terminal input for Work and Stage result submission before routing or lifecycle dispatch. Require a pipe, input redirection, or result file so long JSON cannot be truncated by terminal input limits while progress appears to run indefinitely.
15
+
3
16
  ## 0.9.0-beta.25
4
17
 
5
18
  ### Patch Changes
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.25",
4
- "cli_commit": "73e454ebdded3258c2c567e3495724b93ef528b6",
5
- "built_at": "2026-09-07T04:06:44.087Z",
3
+ "cli_version": "0.9.0-beta.27",
4
+ "cli_commit": "f619ffc1750da72ea36703933265005be43a5401",
5
+ "built_at": "2026-09-07T07:04:13.513Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.0.6",
8
- "commit": "2aafb30d23e3f646b736b7ead25438cab97b16bf",
8
+ "commit": "c6fc50cb3b5526ea0162ee4454d1ee36f17be2da",
9
9
  "flow_contract": "dd-flow-canonical-2026-08",
10
10
  "repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
11
11
  "memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
@@ -78,6 +78,10 @@ export async function runCli(args, io = defaultIo, env = process.env) {
78
78
  io.stdout.write(`${help}\n`);
79
79
  return 0;
80
80
  }
81
+ if (io.stdin?.isTTY && ["work", "stage"].includes(output.args[0] ?? "") && output.args[1] === "finish"
82
+ && hasOption(parseArgs(output.args.slice(2)), "result-stdin")) {
83
+ throw new AppError("usage", "--result-stdin requires non-interactive input. Pipe or redirect the complete JSON into this command, or use --result-file; interactive terminals can truncate long JSON and wait indefinitely for EOF.", 2);
84
+ }
81
85
  const classification = classifyCliOperation(output.args, env);
82
86
  if (requiresExplicitUpgradeAuthorization(output.args) && classification.mode !== "mb_upgrade") {
83
87
  throw new AppError("upgrade_authorization_required", "This mb-upgrade command requires the explicit mb-upgrade authorization marker and allowlisted target context", 1, {
@@ -45,8 +45,30 @@ async function prepare(paths, config) {
45
45
  PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command: command.replace("__EVENT__", "PostToolUse"), timeout: 30 }] }],
46
46
  Stop: [{ type: "command", command: command.replace("__EVENT__", "Stop"), timeout: 30 }]
47
47
  } };
48
+ // Antigravity's process-level configuration is not inherited by native
49
+ // subagents. Put the same handlers in the isolated workspace discovery
50
+ // root before the first Session is created, preserving unrelated hooks.
51
+ const workspaceConfig = path.join(config.projectRoot, ".agents");
52
+ await mkdir(workspaceConfig, { recursive: true, mode: 0o700 });
53
+ const workspaceHooks = path.join(workspaceConfig, "hooks.json");
54
+ let existing = {};
55
+ try {
56
+ const parsed = JSON.parse(await readFile(workspaceHooks, "utf8"));
57
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("not an object");
58
+ existing = parsed;
59
+ } catch (error) {
60
+ if (error.code !== "ENOENT") throw new DaemonError("workspace_hooks_invalid", `cannot install Antigravity lifecycle hooks: ${error.message}`);
61
+ }
62
+ const workspace = { ...existing };
63
+ const isManagedHandler = handler => typeof handler?.command === "string" && handler.command.includes(config.entryPath) && handler.command.includes("hook handle");
64
+ for (const [event, handlers] of Object.entries(hooks["dd-flow"])) {
65
+ const retained = Array.isArray(existing[event]) ? existing[event].filter(item => Array.isArray(item?.hooks) ? !item.hooks.some(isManagedHandler) : !isManagedHandler(item)) : [];
66
+ workspace[event] = [...retained, ...handlers];
67
+ }
68
+ await writeJson(workspaceHooks, workspace);
48
69
  await writeJson(path.join(paths.config, "hooks.json"), hooks);
49
70
  config.hooksSha256 = createHash("sha256").update(JSON.stringify(hooks)).digest("hex");
71
+ config.workspaceHooksPath = workspaceHooks;
50
72
  }
51
73
 
52
74
  export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
@@ -20,6 +20,14 @@ async function readState(file) { try { return JSON.parse(await readFile(file, "u
20
20
  async function writeState(file, value) { const temp = `${file}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temp, file); }
21
21
  async function removeSocket(socket) { try { const info = await lstat(socket); if (!info.isSocket()) throw Object.assign(new Error("refusing to remove non-socket daemon path"), { code: "unsafe_socket" }); await unlink(socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
22
22
  const errorPayload = errorRecord;
23
+ const terminalTurn = turn => ["completed", "interrupted", "failed", "cancelled"].includes(turn?.status);
24
+ export function settledCodexSession(status, latestTurn, active) {
25
+ if (status === "idle") return true;
26
+ if (status === "notLoaded") return !latestTurn || terminalTurn(latestTurn);
27
+ // `systemError` alone is not evidence that the provider stopped. A current
28
+ // terminal Turn plus no daemon operation is the narrow settled case.
29
+ return status === "systemError" && !active && terminalTurn(latestTurn);
30
+ }
23
31
 
24
32
  export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
25
33
  const target = locations(stateDir); const request = { schema_id: requestSchema, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
@@ -69,9 +77,8 @@ export async function serveDaemon(stateDir) {
69
77
  const observed = await inspectSessionWithBridge(bridge, { ...state.config, sessionId });
70
78
  for (const child of observed.descendants ?? []) sessions.add(child.provider_session_id);
71
79
  const status = observed.status?.type ?? observed.status;
72
- const stored = status === "notLoaded" ? await bridge.request("thread/turns/list", { threadId: sessionId, limit: 1, sortDirection: "desc", itemsView: "summary" }, 5_000) : null;
73
- const terminalUnloaded = status === "notLoaded" && Array.isArray(stored?.data) && (stored.data.length === 0 || ["completed", "interrupted", "failed", "cancelled"].includes(stored.data[0]?.status));
74
- if (status !== "idle" && !terminalUnloaded) unsettled.push(sessionId);
80
+ const stored = ["notLoaded", "systemError"].includes(status) ? await bridge.request("thread/turns/list", { threadId: sessionId, limit: 1, sortDirection: "desc", itemsView: "summary" }, 5_000) : null;
81
+ if (!settledCodexSession(status, stored?.data?.[0] ?? null, active)) unsettled.push(sessionId);
75
82
  }
76
83
  return { sessions: unsettled.reverse(), active: Boolean(active), active_operation: active };
77
84
  },
@@ -9,7 +9,7 @@ import { observeModel } from "./model-observations.mjs";
9
9
  // Minimal JSON-RPC wrapper around the public Codex app-server protocol. It is
10
10
  // intentionally one-shot: the provider owns durable Threads, while the caller
11
11
  // owns the runner journal and can safely resume after a process restart.
12
- function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
12
+ function fail(message, code, details) { const error = new Error(message); if (code) error.code = code; if (details) error.details = details; throw error; }
13
13
  function required(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
14
14
  function absolute(value, label) { const resolved = path.resolve(required(value, label)); if (!path.isAbsolute(resolved)) fail(`${label} must be absolute`); return resolved; }
15
15
  function now() { return new Date().toISOString(); }
@@ -305,7 +305,7 @@ function terminalTurn(value, turnId, bridge) {
305
305
  const observed = bridge.turns.get(turnId);
306
306
  if (observed?.status === "completed") value = observed.value;
307
307
  const turn = value?.turn ?? value;
308
- if (turn?.status && turn.status !== "completed") fail(`Codex turn ${turnId ?? "unknown"} ended as ${turn.status}`, "turn_interrupted");
308
+ if (turn?.status && turn.status !== "completed") fail(`Codex turn ${turnId ?? "unknown"} ended as ${turn.status}`, "turn_interrupted", { turn_id: turn.id ?? turnId ?? null, terminal_status: turn.status, provider_error: turn.error ?? turn.codexErrorInfo ?? null });
309
309
  return value;
310
310
  }
311
311
 
@@ -68,7 +68,10 @@ export function assertRecoveryHookCurrent(context, projectId, runId, identity) {
68
68
  const generation = assertRunMutationAllowed(context, projectId, runId);
69
69
  if (!generation)
70
70
  return;
71
- const retired = identity.daemonId && (context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN hook_events he ON he.project_id = w.project_id AND he.event_key = ws.hook_event_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'interrupted' AND he.daemon_id = ? LIMIT 1`, [projectId, runId, identity.daemonId]) || context.db.get("SELECT b.recovery_id 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 b.daemon_id = ? AND b.status = 'abandoned' LIMIT 1", [projectId, runId, identity.daemonId]));
71
+ const current = identity.daemonId && context.db.get("SELECT b.recovery_id 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.status = 'recovered' AND b.status = 'accepted' AND b.daemon_id = ? LIMIT 1", [projectId, runId, identity.daemonId]);
72
+ if (current)
73
+ return;
74
+ const retired = identity.daemonId && (context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN hook_events he ON he.project_id = w.project_id AND he.event_key = ws.hook_event_id WHERE w.project_id = ? AND w.run_id = ? AND he.daemon_id = ? LIMIT 1`, [projectId, runId, identity.daemonId]) || context.db.get("SELECT b.recovery_id 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 b.daemon_id = ? AND b.status = 'abandoned' LIMIT 1", [projectId, runId, identity.daemonId]));
72
75
  if (!identity.daemonId || retired)
73
76
  throw new AppError("recovery_daemon_stale", "Lifecycle receipt belongs to an unidentified or retired recovery daemon", 1, { run_id: runId, generation, daemon_id: identity.daemonId });
74
77
  }
@@ -155,9 +158,12 @@ export function resumeRunRecovery(context, input) {
155
158
  }
156
159
  if (guard.status !== "sealed" || !guard.capture_path)
157
160
  throw new AppError("recovery_capture_required", "Recovery must be sealed and captured before it can resume", 1, { recovery_id: guard.recovery_id, status: guard.status });
158
- // The root coordinator keeps its same native Session and receives a recovery
159
- // prompt from the eval runner. Only delegated Work is returned to the ready
160
- // graph and therefore gets a fresh, addressable recovery packet.
161
+ // The coordinator keeps its same native Session and receives a recovery
162
+ // prompt from the eval runner. A root Work may have recorded its launch as
163
+ // complete while its currently active Stage Work owns that Session, so bind
164
+ // the active WorkSession rather than assuming the root row is still running.
165
+ // Only delegated Work is returned to the ready graph and therefore gets a
166
+ // fresh, addressable recovery packet.
161
167
  verifyRecoverySnapshot(guard.capture_path, { runId: run.id, recoveryId: guard.recovery_id, projectRoot: run.project_root, workspaceRoot: run.workspace_root });
162
168
  const now = context.now();
163
169
  let running = [];
@@ -168,14 +174,14 @@ export function resumeRunRecovery(context, input) {
168
174
  throw new AppError("recovery_state_changed", "Recovery state changed before its resume transaction", 1, { recovery_id: guard.recovery_id });
169
175
  if (!input.daemonId?.trim() || !input.harness?.trim() || !input.nativeSessionId?.trim())
170
176
  throw new AppError("recovery_identity_required", "Recovery requires the replacement daemon and retained native Session identity", 1);
171
- const owner = context.db.get(`SELECT w.work_id, ws.id, ws.session_id FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND w.parent_work_id IS NULL AND w.status IN ('running', 'paused') AND ws.status = 'running' AND s.harness = ? AND s.provider_session_id = ? ORDER BY ws.created_at DESC LIMIT 1`, [project.id, run.id, input.harness, input.nativeSessionId]);
177
+ const owner = context.db.get(`SELECT w.work_id, ws.id, ws.session_id FROM works w JOIN work_sessions ws ON ws.work_id = w.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND w.status IN ('running', 'paused') AND ws.status = 'running' AND s.harness = ? AND s.provider_session_id = ? ORDER BY ws.created_at DESC LIMIT 1`, [project.id, run.id, input.harness, input.nativeSessionId]);
172
178
  if (!owner)
173
- throw new AppError("recovery_owner_missing", "Recovery must retain the root coordinator's native Session", 1, { run_id: run.id });
179
+ throw new AppError("recovery_owner_missing", "Recovery must retain an active coordinator WorkSession in its native Session", 1, { run_id: run.id });
174
180
  if (context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND daemon_id = ? LIMIT 1", [project.id, input.daemonId]) || context.db.get("SELECT b.recovery_id FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND b.daemon_id = ? LIMIT 1", [project.id, input.daemonId]))
175
181
  throw new AppError("recovery_daemon_stale", "Replacement daemon already has historical lifecycle authority in this project", 1);
176
182
  const packet = rootRecoveryPacket(context, run, guard, owner);
177
183
  context.db.run("INSERT INTO run_recovery_bindings (recovery_id, work_id, prior_work_session_id, session_id, harness, provider_session_id, daemon_id, status, created_at, packet_json, packet_sha256) VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?, ?)", [guard.recovery_id, owner.work_id, owner.id, owner.session_id, input.harness, input.nativeSessionId, input.daemonId, now, packet, crypto.createHash("sha256").update(packet).digest("hex")]);
178
- running = context.db.all("SELECT work_id, started_at, status FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status = 'running' OR (status = 'created' AND EXISTS (SELECT 1 FROM work_recovery_segments s WHERE s.work_id = works.work_id AND s.status = 'prepared'))) ORDER BY work_id", [project.id, run.id]);
184
+ running = context.db.all("SELECT work_id, started_at, status FROM works WHERE project_id = ? AND run_id = ? AND work_id <> ? AND parent_work_id IS NOT NULL AND (status = 'running' OR (status = 'created' AND EXISTS (SELECT 1 FROM work_recovery_segments s WHERE s.work_id = works.work_id AND s.status = 'prepared'))) ORDER BY work_id", [project.id, run.id, owner.work_id]);
179
185
  for (const work of running)
180
186
  context.db.run("UPDATE work_recovery_segments SET status = 'superseded', updated_at = ? WHERE work_id = ? AND status = 'prepared'", [now, work.work_id]);
181
187
  for (const work of running) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.25",
3
+ "version": "0.9.0-beta.27",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {