@deksden-com/dd-flow-cli 0.9.0-beta.107 → 0.9.0-beta.108

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,11 @@
1
1
  # @deksden-com/dd-flow-cli
2
2
 
3
+ ## 0.9.0-beta.108
4
+
5
+ ### Patch Changes
6
+
7
+ - Route native tool hooks through the prepared hook store and skip storage entirely for unrelated tools. Give ZCode daemon readiness a bounded 30-second window and clean up the exact registered provider process if any managed daemon fails to start, preserving the primary startup error.
8
+
3
9
  ## 0.9.0-beta.107
4
10
 
5
11
  ### Patch Changes
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.107",
4
- "cli_commit": "b1e4fba26b8d9dea7b9b4f6de05aec6be3e21dc2",
5
- "built_at": "2026-09-27T03:02:31.051Z",
3
+ "cli_version": "0.9.0-beta.108",
4
+ "cli_commit": "0df3cd1e7201f27baeeb07f1c4f2b69ed6e2399b",
5
+ "built_at": "2026-09-27T04:24:32.044Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -0,0 +1,48 @@
1
+ import { AppError, errorRecord } from "../shared/errors.js";
2
+ import { parseCommandArgs } from "../services/lifecycle-command.js";
3
+ /** These ingress commands consume a prepared store; they never prepare it. */
4
+ export function isNativeHookIngress(args) {
5
+ return args[2] === "handle" && ((args[1] === "event" && ["agy", "grok", "droid", "opencode", "zcode"].includes(args[0] ?? ""))
6
+ || args[0] === "zcode" && args[1] === "hook");
7
+ }
8
+ export async function dispatchNativeHook(args, stdin, env) {
9
+ const parsed = parseCommandArgs(args.slice(3));
10
+ const projectRoot = parsed.options.get("project-root")?.[0];
11
+ if (!isNativeHookIngress(args) || parsed.positional.length || !projectRoot || projectRoot === "__dd_flow_flag__")
12
+ throw new AppError("usage", "Native hook requires --project-root", 2);
13
+ for (const [name, values] of parsed.options)
14
+ if (!["project-root", "json"].includes(name) || values.length !== 1 || name === "json" && values[0] !== "__dd_flow_flag__")
15
+ throw new AppError("usage", `Invalid native hook option: ${name}`, 2);
16
+ const [hooks, { createContext }, { resolveDdFlowHome }] = await Promise.all([
17
+ import("../services/hooks.js"), import("../runtime/context.js"), import("../storage/paths.js")
18
+ ]);
19
+ let opened;
20
+ // Reuse the actual envelope validators and shell parser. They return for
21
+ // unrelated tools before their first DB access, without duplicating policy.
22
+ const context = {
23
+ env, ddFlowHome: resolveDdFlowHome(env), now: () => new Date().toISOString(),
24
+ get db() { return (opened ??= createContext(env, "hook")).db; }
25
+ };
26
+ const handler = args[0] === "agy" ? hooks.handleAgyEvent : args[0] === "grok" ? hooks.handleGrokEvent
27
+ : args[0] === "droid" ? hooks.handleDroidEvent : args[0] === "opencode" ? hooks.handleOpenCodeEvent
28
+ : args[1] === "hook" ? hooks.handleZcodeHook : hooks.handleZcodeEvent;
29
+ try {
30
+ return await handler(context, { projectRoot, stdin });
31
+ }
32
+ finally {
33
+ opened?.db.close?.();
34
+ }
35
+ }
36
+ export async function runNativeHookIngress(args) {
37
+ try {
38
+ const { readNativeHookInput, hookBudget } = await import("../harness-runtime/lib/native-hook-command.mjs");
39
+ const stdin = await readNativeHookInput(process.stdin, hookBudget());
40
+ process.stdout.write(`${JSON.stringify(await dispatchNativeHook(args, stdin, process.env))}\n`);
41
+ return 0;
42
+ }
43
+ catch (error) {
44
+ const record = errorRecord(error);
45
+ process.stderr.write(`${JSON.stringify({ ok: false, error: record })}\n`);
46
+ return record.exitCode;
47
+ }
48
+ }
@@ -8,6 +8,7 @@ import { observeLifecycleCommand, observedLifecycleNativeEvent } from "../servic
8
8
  import { verifyCodexHookDelivery } from "../services/codex-hook-delivery.js";
9
9
  import { assertLifecycleInvocationCurrent, awaitLifecycleInvocation, bindLifecyclePublishedPacket, expandLifecycleInvocationArgs, issueLifecycleInvocation, prepareLifecycleIssuance, lifecycleInvocationScope, observedLifecycleDiagnostic, observedLifecycleInvocation, resolveObservedLifecycleArgs, retainObservedLifecycleSemantics, settleLifecycleInvocation, settleLifecyclePreparationRejection, settleLifecycleRejection, validateLifecyclePathAliases } from "../services/lifecycle-invocations.js";
10
10
  import { createContext, createRouterContext } from "../runtime/context.js";
11
+ import { dispatchNativeHook, isNativeHookIngress } from "./native-hook-ingress.js";
11
12
  import { migrateStoreWriter } from "../storage/writer-migration.js";
12
13
  import { helpForArgs } from "./help.js";
13
14
  import { AppError, isAppError } from "../shared/errors.js";
@@ -129,6 +130,10 @@ export async function runCli(args, io = defaultIo, env = process.env) {
129
130
  return 0;
130
131
  }
131
132
  const suppliedInvocationId = optionalOption(parseArgs(output.args.slice(2)), "invocation-id");
133
+ if (isNativeHookIngress(output.args)) {
134
+ writeJson(io.stdout, await dispatchNativeHook(output.args, await readStdin(io.stdin), env));
135
+ return 0;
136
+ }
132
137
  if (output.args[0] === "run" && ["drive", "control"].includes(output.args[1] ?? "")) {
133
138
  const controlArgs = parseArgs(output.args.slice(2));
134
139
  requiredOption(controlArgs, "run");
@@ -411,6 +416,8 @@ function resolveProjectRootBeforeRouting(args, env) {
411
416
  }
412
417
  }
413
418
  function databaseModeForCommand(args, classification) {
419
+ if (isNativeHookIngress(args))
420
+ return "hook";
414
421
  const [family, command] = args;
415
422
  // Managed attempts are issued only from a prepared store. Opening that
416
423
  // existing writer must not run migrations before their input is admitted.
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import { isNativeHookIngress } from "./cli/native-hook-ingress.js";
2
3
  const args = process.argv.slice(2);
3
4
  const managedHook = Boolean(process.env.DD_FLOW_CODEX_STATE_DIR) && args.slice(0, 3).join(" ") === "codex hook handle" && !args.includes("--help") && !args.includes("-h");
4
5
  const exitCode = managedHook
5
6
  ? await (await import("./cli/hook-ingress.js")).runHookIngress(args)
6
- : await (await import("./cli/run-cli.js")).runCli(args);
7
+ : isNativeHookIngress(args) && !args.includes("--help") && !args.includes("-h")
8
+ ? await (await import("./cli/native-hook-ingress.js")).runNativeHookIngress(args)
9
+ : await (await import("./cli/run-cli.js")).runCli(args);
7
10
  process.exitCode = exitCode;
8
- export {};
@@ -12,7 +12,7 @@ import { durableDaemonDispatch } from "./daemon-operations.mjs";
12
12
  import path from "node:path";
13
13
  import { observeModel } from "./model-observations.mjs";
14
14
  import { AgyError, assertProfile, executable, observedProfile, runAgy, usageSnapshot } from "./dd-agy.mjs";
15
- import { prepareRuntimeOwner, cleanupFailedStart, runtimeProcess, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
15
+ import { prepareRuntimeOwner, cleanupFailedStart, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, stopProcessGroup } from "./managed-daemon.mjs";
16
16
 
17
17
  const REQUEST_SCHEMA = "dd-agy/daemon-request@1", RESPONSE_SCHEMA = "dd-agy/daemon-response@1", STATE_SCHEMA = "dd-agy/daemon-state@1";
18
18
  class DaemonError extends AgyError {}
@@ -206,12 +206,7 @@ export async function startDaemon(options) {
206
206
  }
207
207
  throw new DaemonError("daemon_start_failed", "Antigravity daemon did not become ready", true);
208
208
  } catch (error) {
209
- await cleanupFailedStart(config, resourceProcess, child, error);
210
- const persisted = await readState(paths.dir).catch(() => null);
211
- if (persisted?.provider_process) {
212
- try { await runtimeProcess(config, "stop", { id: persisted.provider_process.id, "lease-token": persisted.provider_process.lease_token }); }
213
- catch (cleanup) { error.details = { ...error.details, provider_cleanup_failed: errorPayload(cleanup), provider_process_id: persisted.provider_process.id }; }
214
- }
209
+ await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), config.daemonId);
215
210
  throw error;
216
211
  }
217
212
  }
@@ -251,7 +246,10 @@ export class Runtime {
251
246
  void this.readyPromise.catch(() => {});
252
247
  this.providerProcess = await registerDaemonProcess(this.config, { kind: "agy-provider", owner: `agy:${path.basename(this.paths.dir)}`, operation: `agy-provider:${path.basename(this.paths.dir)}`, stdout: this.paths.log, stderr: this.paths.log });
253
248
  const providerProcess = this.providerProcess;
254
- this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] });
249
+ try { await this.persist({ provider_process: providerProcess }); }
250
+ catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
251
+ try { this.child = spawn(target.command, target.args, { cwd: this.config.cwd, env: sanitizedEnv(this.config), detached: true, stdio: ["pipe", "pipe", "pipe"] }); }
252
+ catch (error) { await cleanupFailedStart(this.config, providerProcess, null, error); throw error; }
255
253
  const providerChild = this.child;
256
254
  let allowProviderFinalization;
257
255
  const providerRegistered = new Promise(resolve => { allowProviderFinalization = resolve; });
@@ -283,7 +281,7 @@ export class Runtime {
283
281
  });
284
282
  });
285
283
  void this.providerFinalization.catch(() => {});
286
- try { await confirmDaemonProcess(this.config, providerProcess, this.child); await heartbeatDaemonProcess(this.config, providerProcess); await this.persist({ provider_process: providerProcess, provider_pid: this.child.pid }); }
284
+ try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_pid: this.child.pid }); await heartbeatDaemonProcess(this.config, providerProcess); }
287
285
  catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
288
286
  finally { allowProviderFinalization(); }
289
287
  let readyTimer;
@@ -9,7 +9,7 @@ import { spawn } from "node:child_process";
9
9
  import { waitForSettlement } from "./session-settlement.mjs";
10
10
  import { errorRecord } from "./operation-errors.mjs";
11
11
  import { CodexBridge, cancelSessionWithBridge, createSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge, startSessionWithBridge } from "./dd-codex.mjs";
12
- import { prepareRuntimeOwner, cleanupFailedStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
12
+ import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
13
13
 
14
14
  const requestSchema = "dd-codex/daemon-request@1";
15
15
  const responseSchema = "dd-codex/daemon-response@1";
@@ -90,7 +90,7 @@ export async function startDaemon(options) {
90
90
  await removeSocket(paths.socket); const resourceProcess = await registerDaemonProcess(config, { kind: "codex-daemon", owner: `codex:${path.basename(paths.dir)}`, operation: `codex-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log }); const state = { schema_id: stateSchema, daemon_id: randomUUID(), pid: null, cwd: config.cwd, journal: config.journal, config, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", sessions: previous?.sessions ?? [], resource_process: resourceProcess }; await writeState(paths.state, state);
91
91
  const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [options.entryPath, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
92
92
  try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 60_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 5_000); } catch (error) { if (!["daemon_not_running", "daemon_timeout"].includes(error.code)) throw error; } } throw Object.assign(new Error("daemon did not become ready"), { code: "daemon_start_failed" }); }
93
- catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
93
+ catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.state), state.daemon_id); throw error; }
94
94
  }
95
95
  export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
96
96
 
@@ -12,7 +12,7 @@ import path from "node:path";
12
12
 
13
13
  import { AcpBridge } from "./dd-zcode.mjs";
14
14
  import { cancelSessionWithBridge, createSessionWithBridge, doctor, forkSessionWithBridge, inspectSessionWithBridge, promptSessionWithBridge } from "./dd-grok.mjs";
15
- import { prepareRuntimeOwner, cleanupFailedStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
15
+ import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
16
16
 
17
17
  const REQUEST_SCHEMA = "dd-grok/daemon-request@1";
18
18
  const RESPONSE_SCHEMA = "dd-grok/daemon-response@1";
@@ -171,7 +171,7 @@ export async function startDaemon(options) {
171
171
  const state = { descendants: previous?.descendants ?? [], schema_id: STATE_SCHEMA, daemon_id: daemonId, pid: null, socket: paths.socket, started_at: new Date().toISOString(), shutdown_state: "starting", active_tree: false, recovery_status: previous?.shutdown_state === "running" ? "recovered_idle" : "clean_start", auth_status: copiedAuth ? "copied" : "absent", ...(importedSession ? { imported_session: importedSession } : {}), versions: versions.versions, config_isolation: configIsolation, config: { ...config, daemonId }, sessions: importedSession ? [{ provider_session_id: importedSession.provider_session_id, adapter_session_id: importedSession.provider_session_id, parent_provider_session_id: null, root_provider_session_id: importedSession.provider_session_id, cwd: importedSession.source_cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess };
172
172
  await writeState(paths.state, state); const log = await open(paths.log, "a", 0o600); const child = spawn(process.execPath, [absolute(options.entryPath, "dd-grok entry"), "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(state.config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
173
173
  try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 30_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { const status = await callDaemon(paths.dir, "daemon.ready", {}, 5000); if (status.ready) return status; } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
174
- catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
174
+ catch (error) { await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), daemonId); throw error; }
175
175
  }
176
176
  export async function stopDaemon(options) { const timeoutMs = options.timeoutMs ?? 30_000; return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), locations(options.stateDir).socket, timeoutMs); }
177
177
 
@@ -5,7 +5,7 @@ import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "
5
5
  import net from "node:net";
6
6
  import { durableDaemonDispatch } from "./daemon-operations.mjs";
7
7
  import path from "node:path";
8
- import { prepareRuntimeOwner, cleanupFailedStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
8
+ import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, heartbeatDaemonProcess, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
9
9
 
10
10
  import {
11
11
  AcpBridge,
@@ -197,7 +197,7 @@ export async function startDaemon(options) {
197
197
  await log.close();
198
198
  try {
199
199
  await confirmDaemonProcess(config, resourceProcess, child);
200
- const deadline = Date.now() + 15_000;
200
+ const deadline = Date.now() + 30_000;
201
201
  while (Date.now() < deadline) {
202
202
  await new Promise((resolve) => setTimeout(resolve, 100));
203
203
  try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
@@ -205,7 +205,7 @@ export async function startDaemon(options) {
205
205
  }
206
206
  throw new DaemonError("daemon_start_failed", "daemon did not become ready", true);
207
207
  } catch (error) {
208
- await cleanupFailedStart(config, resourceProcess, child, error);
208
+ await cleanupFailedDaemonStart(config, resourceProcess, child, error, () => readState(paths.dir), state.daemon_id);
209
209
  throw error;
210
210
  }
211
211
  }
@@ -196,14 +196,17 @@ export async function startManagedBridge({ config, state, bridge, kind, publish
196
196
  state.pid = process.pid;
197
197
  await publish(state);
198
198
  const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
199
- bridge.options.onSpawn = async child => {
200
- await confirmDaemonProcess(config, provider, child);
201
- await heartbeatDaemonProcess(config, provider);
202
- state.provider_pid = child.pid;
199
+ try {
203
200
  state.provider_process = provider;
204
201
  await publish(state);
205
- };
206
- try { return await bridge.start(); }
202
+ bridge.options.onSpawn = async child => {
203
+ await confirmDaemonProcess(config, provider, child);
204
+ state.provider_pid = child.pid;
205
+ await publish(state);
206
+ await heartbeatDaemonProcess(config, provider);
207
+ };
208
+ return await bridge.start();
209
+ }
207
210
  catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
208
211
  }
209
212
 
@@ -260,6 +263,25 @@ export async function cleanupFailedStart(config, record, child, original) {
260
263
  }
261
264
  }
262
265
 
266
+ /** The provider has its own process group. A failed daemon start must settle
267
+ * that exact retained process as well as the daemon; PID-only cleanup is unsafe. */
268
+ export async function cleanupFailedDaemonStart(config, daemonRecord, daemonChild, original, readState, daemonId) {
269
+ await cleanupFailedStart(config, daemonRecord, daemonChild, original);
270
+ if (!daemonRecord) return;
271
+ try {
272
+ const state = await readState();
273
+ const owner = config.runtime_owner;
274
+ if (!state || state.daemon_id !== daemonId || state.resource_process?.id !== daemonRecord.id || state.resource_process?.lease_token !== daemonRecord.lease_token
275
+ || (owner && (state.config?.runtime_owner?.owner_id !== owner.owner_id || path.resolve(state.config?.runtime_owner?.state_dir ?? "") !== path.resolve(owner.state_dir)))) {
276
+ throw Object.assign(new Error("Failed daemon state no longer matches the startup owner"), { code: "daemon_cleanup_identity_mismatch" });
277
+ }
278
+ const provider = state.provider_process;
279
+ if (provider) await runtimeProcess(config, "stop", { id: provider.id, "lease-token": provider.lease_token });
280
+ } catch (cleanup) {
281
+ original.details = { ...original.details, provider_cleanup_failed: errorRecord(cleanup) };
282
+ }
283
+ }
284
+
263
285
  export async function stopProcessGroup(child, graceMs = 1_000) {
264
286
  if (!child?.pid) return;
265
287
  // Harness bridges are detached on Unix, so their own process group is the
@@ -1 +1,3 @@
1
1
  export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
+ export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { remaining(phase: string): number };
3
+ export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deksden-com/dd-flow-cli",
3
- "version": "0.9.0-beta.107",
3
+ "version": "0.9.0-beta.108",
4
4
  "description": "Mechanical runtime CLI for dd-flow workflows.",
5
5
  "type": "module",
6
6
  "bin": {