@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.103

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 (46) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  4. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  5. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  6. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  7. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  8. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  9. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  10. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  11. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  12. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  13. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  14. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  15. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  16. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  17. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  18. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  19. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  20. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  21. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  22. package/dist/harness-runtime/lib/managed-daemon.mjs +32 -3
  23. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  24. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  25. package/dist/services/code-checks.js +53 -16
  26. package/dist/services/controller-fanout.js +37 -6
  27. package/dist/services/eval-snapshots.js +148 -38
  28. package/dist/services/external-work-launch.js +60 -5
  29. package/dist/services/prompts.js +2 -0
  30. package/dist/services/review-copy.js +136 -0
  31. package/dist/services/run-control-worker.js +16 -11
  32. package/dist/services/run-controller-adapter.js +9 -0
  33. package/dist/services/runs.js +3 -0
  34. package/dist/services/runtime-scope-stop.js +66 -6
  35. package/dist/services/vnext-code-review.js +6 -2
  36. package/dist/services/vnext-code.js +1 -0
  37. package/dist/services/vnext-fanout.js +9 -1
  38. package/dist/services/vnext-merge.js +8 -4
  39. package/dist/services/vnext-plan-review.js +4 -15
  40. package/dist/services/vnext-plan.js +4 -0
  41. package/dist/services/vnext-protocolize.js +4 -2
  42. package/dist/services/vnext-specify.js +7 -0
  43. package/dist/services/work-registry.js +208 -22
  44. package/dist/services/workspace-files.js +83 -0
  45. package/dist/services/worktrees.js +3 -1
  46. package/package.json +1 -1
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli_package": "@deksden-com/dd-flow-cli",
3
- "cli_version": "0.9.0-beta.100",
4
- "cli_commit": "d7700af4c6d5985d8d6fd762d6d515bf766d8604",
5
- "built_at": "2026-09-22T22:32:27.076Z",
3
+ "cli_version": "0.9.0-beta.103",
4
+ "cli_commit": "26ffa3e3ac8382c8a83bae2593a91a3e922f10a9",
5
+ "built_at": "2026-09-25T12:03:44.786Z",
6
6
  "built_with_canon": {
7
7
  "version": "4.1.1",
8
8
  "commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
@@ -5,6 +5,7 @@ import { errorRecord, AppError } from "../shared/errors.js";
5
5
  import { parseJsonObject } from "../shared/json.js";
6
6
  const workerFile = fileURLToPath(import.meta.url);
7
7
  const ingressBudgetMs = 20_000; // Native host timeout is 30s; this includes cold module/DB open.
8
+ const { hookBudget, readNativeHookInput } = await import("../harness-runtime/lib/native-hook-command.mjs");
8
9
  /** Lightweight entry: unrelated hooks never load the router or open storage. */
9
10
  export async function runHookIngress(args) {
10
11
  try {
@@ -20,12 +21,8 @@ export async function runHookIngress(args) {
20
21
  throw new AppError("usage", "--project-root requires a value", 2);
21
22
  if (parsed.options.has("json") && parsed.options.get("json")?.[0] !== "__dd_flow_flag__")
22
23
  throw new AppError("usage", "--json does not take a value", 2);
23
- let stdin = "";
24
- for await (const chunk of process.stdin) {
25
- stdin += String(chunk);
26
- if (Buffer.byteLength(stdin) > 1_048_576)
27
- throw new AppError("native_hook_response_invalid", "Hook input exceeds 1 MiB", 1);
28
- }
24
+ const budget = hookBudget(ingressBudgetMs);
25
+ const stdin = await readNativeHookInput(process.stdin, budget);
29
26
  const payload = parseJsonObject(stdin || "{}", "native hook stdin");
30
27
  const command = lifecycleCommandFromPayload(payload);
31
28
  if (event !== "PreToolUse" || !command || (parseLifecycleCommand(command).kind === "none" && !continuationCommand(command))) {
@@ -34,7 +31,7 @@ export async function runHookIngress(args) {
34
31
  }
35
32
  const started = performance.now();
36
33
  const { invokeNativeHook } = await import("../harness-runtime/lib/native-hook-command.mjs");
37
- const result = await invokeNativeHook({ bin: workerFile, args, payload, home: process.env.DD_FLOW_HOME, timeoutMs: ingressBudgetMs });
34
+ const result = await invokeNativeHook({ bin: workerFile, args, payload, home: process.env.DD_FLOW_HOME, timeoutMs: budget.remaining("admission") });
38
35
  const { timing, ...output } = result;
39
36
  process.stderr.write(`${JSON.stringify({ kind: "hook_ingress", total_ms: Math.round(performance.now() - started), ...timing })}\n`);
40
37
  process.stdout.write(`${JSON.stringify(output)}\n`);
@@ -49,9 +46,7 @@ export async function runHookIngress(args) {
49
46
  // A bounded CLI subprocess, not a detached writer: its successful reply follows COMMIT.
50
47
  if (process.argv[1] === workerFile) {
51
48
  try {
52
- let stdin = "";
53
- for await (const chunk of process.stdin)
54
- stdin += String(chunk);
49
+ const stdin = await readNativeHookInput(process.stdin, hookBudget(ingressBudgetMs));
55
50
  const payload = parseJsonObject(stdin, "native hook stdin");
56
51
  const started = performance.now();
57
52
  const { verifyCodexHookDelivery } = await import("../services/codex-hook-delivery.js");
@@ -1,30 +1,43 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
3
3
  import { readFile } from "node:fs/promises";
4
- import { invokeNativeHook } from "../lib/native-hook-command.mjs";
4
+ import { randomUUID } from "node:crypto";
5
+ import path from "node:path";
6
+ import { writeJsonAtomic } from "../lib/runner-events.mjs";
7
+ import { hookBudget, invokeNativeHook, readNativeHookInput } from "../lib/native-hook-command.mjs";
5
8
  import { doctor } from "../lib/dd-agy.mjs";
6
- import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-agy-daemon.mjs";
9
+ import { callDaemon, qualifyAgyHooks, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-agy-daemon.mjs";
7
10
 
8
11
  function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json","cancel-tree","tree","no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`--${key} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
9
12
  const prompt = async options => options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt;
10
- async function stdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
11
13
  async function hook(options) {
12
- const payload = JSON.parse(await stdin() || "{}"), event = options.event;
13
- const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload }, 10_000);
14
+ const budget = hookBudget(), requestId = randomUUID();
15
+ const payload = JSON.parse(await readNativeHookInput(process.stdin, budget) || "{}"), event = options.event;
16
+ try {
17
+ const observationMs = budget.remaining("observation");
18
+ const identity = await callDaemon(options["state-dir"], "hook.observe", { event, payload, daemonId: options["daemon-id"], deadlineAt: Date.now() + observationMs }, observationMs, requestId);
14
19
  if (identity.stale) return event === "PreToolUse" ? { decision: "deny", reason: "agy_stale_hook" } : {};
15
20
  if (event === "PreToolUse" || event === "PostToolUse") {
16
21
  const phase = event === "PreToolUse" ? "before" : "after", args = payload.toolCall?.args ?? {};
17
22
  const normalized = { schema_id: "dd-flow/agy-tool-event@1", event_id: identity.event_id, phase, daemon_id: identity.daemon_id, conversation_id: payload.conversationId, parent_conversation_id: identity.parent_conversation_id, execution_num: payload.executionNum, step_index: payload.stepIdx, tool: payload.toolCall?.name, input: args, workspace_paths: payload.workspacePaths, transcript_path: payload.transcriptPath, model: payload.modelName, profile: identity.profile, ...(phase === "after" ? { error: payload.error ?? "" } : {}) };
18
23
  await invokeNativeHook({ bin: options["dd-flow-bin"] ?? identity.dd_flow_bin,
19
24
  args: ["agy", "event", "handle", "--project-root", options["project-root"] ?? identity.project_root, "--json"],
20
- payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home });
25
+ payload: normalized, home: options["dd-flow-home"] ?? identity.dd_flow_home, timeoutMs: budget.remaining("admission", 15_000) });
21
26
  }
22
27
  return event === "PreToolUse" ? { decision: "allow" } : {};
28
+ } catch (error) {
29
+ // A failed socket cannot be used to report its own failure. The daemon
30
+ // reads only receipts matching request IDs it actually accepted this turn.
31
+ try { await writeJsonAtomic(path.join(options["state-dir"], "hook-failures", `${requestId}.json`), { request_id: requestId, observed_at: new Date().toISOString(), code: error.code ?? "agy_hook_rejected", message: error.message, details: { ...error.details, effect: "unknown" } }); }
32
+ catch (storage) { process.stderr.write(`${JSON.stringify({ code: error.code, receipt_error: storage.message })}\n`); }
33
+ throw error;
34
+ }
23
35
  }
24
36
 
25
37
  try {
26
38
  const { positional, options } = parse(process.argv.slice(2)), [family, command, modifier] = positional; const common = { journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, bin: options["agy-bin"], provider: options.provider, model: options.model, reasoning: options.reasoning, mode: options.mode, projectRoot: options["project-root"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; let result;
27
39
  if (family === "doctor") result = await doctor(common);
40
+ else if (family === "hooks" && ["install", "check"].includes(command)) result = await qualifyAgyHooks(common.projectRoot, { stateDir: common.stateDir, install: command === "install" });
28
41
  else if (family === "daemon" && command === "start") result = await durableDaemonStart({ ...common, sessionId: options["session-id"], entryPath: process.argv[1] }, startDaemon);
29
42
  else if (family === "daemon" && command === "serve") { await serveDaemon(common.stateDir); process.exit(0); }
30
43
  else if (family === "daemon" && command === "status") result = await callDaemon(common.stateDir, "daemon.status");
@@ -34,6 +47,6 @@ try {
34
47
  else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: common.stateDir, cancelTree: options["cancel-tree"], timeoutMs: common.timeoutMs });
35
48
  else if (family === "hook" && command === "handle") { process.stdout.write(`${JSON.stringify(await hook(options))}\n`); process.exit(0); }
36
49
  else if (family === "session") { if (command === "fork") throw Object.assign(new Error("Antigravity native headless fork is unsupported; use deterministic_replay"), { code: "agy_headless_fork_unsupported" }); const operation = command === "status" ? "inspect" : command; result = await callDaemon(common.stateDir, `session.${operation}`, { sessionId: options["session-id"], ...(command === "create" || command === "prompt" ? { prompt: await prompt(options) } : {}), tree: options.tree === true }, common.timeoutMs ?? 1_800_000); }
37
- else throw new Error("usage: dd-agy doctor | daemon start|status|stop | session create|prompt|inspect|resume|cancel|fork [options] --json");
50
+ else throw new Error("usage: dd-agy doctor | hooks install|check | daemon start|status|stop | session create|prompt|inspect|resume|cancel|fork [options] --json");
38
51
  process.stdout.write(`${JSON.stringify({ ok: true, ...result })}\n`);
39
52
  } catch (error) { if (process.argv.includes("hook") && process.argv.includes("handle")) { process.stdout.write(`${JSON.stringify({ decision: "deny", reason: error.code ?? "agy_hook_rejected" })}\n`); process.exit(0); } process.stderr.write(`${JSON.stringify({ ok: false, error: error.message, ...(error.code ? { code: error.code, retryable: error.retryable === true, details: error.details } : {}) })}\n`); process.exit(1); }
@@ -7,9 +7,9 @@ import path from "node:path";
7
7
  import { DROID_CONTRACT, DROID_PROTOCOL, DroidRuntime, droidVersion } from "../lib/dd-droid.mjs";
8
8
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-droid-daemon.mjs";
9
9
  import { errorRecord } from "../lib/operation-errors.mjs";
10
+ import { hookBudget, readNativeHookInput } from "../lib/native-hook-command.mjs";
10
11
 
11
12
  function parse(argv) { const positional = [], options = {}; for (let i = 0; i < argv.length; i++) { const word = argv[i]; if (!word.startsWith("--")) { positional.push(word); continue; } const key = word.slice(2); if (["json", "cancel-tree", "tree", "no-flow"].includes(key)) options[key] = true; else { if (argv[i + 1] === undefined) throw new Error(`${word} requires a value`); options[key] = argv[++i]; } } return { positional, options }; }
12
- async function input() { let text = ""; for await (const chunk of process.stdin) text += chunk; return text; }
13
13
  try {
14
14
  const { positional: [family, command, modifier], options } = parse(process.argv.slice(2));
15
15
  const config = { stateDir: options["state-dir"], sessionId: options["session-id"], cwd: options.cwd, projectRoot: options["project-root"], bin: options["droid-bin"] ?? "droid", entryPath: process.argv[1], journal: options.journal, authHome: options["auth-home"], model: options.model, provider: options.provider, reasoning: options.reasoning, mode: options.mode, ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1_000 : undefined };
@@ -26,7 +26,7 @@ try {
26
26
  else if (family === "daemon" && command === "operation" && modifier === "settle") result = await settleDaemonOperation(config.stateDir, options["operation-id"]);
27
27
  else if (family === "daemon" && command === "operation") result = await inspectDaemonOperation(config.stateDir, options["operation-id"]);
28
28
  else if (family === "daemon" && command === "stop") result = await stopDaemon({ stateDir: config.stateDir, cancelTree: options["cancel-tree"] });
29
- else if (family === "hook" && command === "handle") { const payload = JSON.parse(await input()); const eventId = randomUUID(); result = await callDaemon(config.stateDir, "hook.observe", { payload, eventId }, 25_000, eventId); }
29
+ else if (family === "hook" && command === "handle") { const budget = hookBudget(); const payload = JSON.parse(await readNativeHookInput(process.stdin, budget)); const eventId = randomUUID(); result = await callDaemon(config.stateDir, "hook.observe", { payload, eventId, deadlineAt: budget.deadlineAt }, budget.remaining("observation"), eventId); }
30
30
  else if (family === "session") { if (command === "fork") throw new Error("Droid fork is not qualified for this adapter"); const prompt = options["prompt-file"] ? await readFile(options["prompt-file"], "utf8") : options.prompt; result = await callDaemon(config.stateDir, `session.${command === "status" ? "inspect" : command}`, { sessionId: options["session-id"], ...(config.noFlow ? { noFlow: true } : {}), ...(prompt ? { prompt } : {}) }); }
31
31
  else throw new Error("usage: dd-droid doctor | daemon start|status|operation|stop | session create|resume|prompt|inspect|cancel");
32
32
  process.stdout.write(`${JSON.stringify(family === "hook" ? { ...(result.hookSpecificOutput ? { hookSpecificOutput: result.hookSpecificOutput } : {}), ...(typeof result.continue === "boolean" ? { continue: result.continue } : {}), ...(result.stopReason ? { stopReason: result.stopReason } : {}) } : { ok: true, ...result })}\n`);
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
3
- import { invokeNativeHook } from "../lib/native-hook-command.mjs";
3
+ import { hookBudget, invokeNativeHook, readNativeHookInput } from "../lib/native-hook-command.mjs";
4
4
  import { cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-grok.mjs";
5
5
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-grok-daemon.mjs";
6
6
 
7
7
  function parse(argv) { const positional = []; const options = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { positional.push(token); continue; } const key = token.slice(2); if (["json", "cancel-tree", "no-flow"].includes(key)) { options[key] = true; continue; } const value = argv[++index]; if (value === undefined) throw new Error(`--${key} requires a value`); options[key] = value; } return { positional, options }; }
8
8
  function common(options) { return { bin: options["grok-bin"], journal: options.journal, stateDir: options["state-dir"], cwd: options.cwd, sessionId: options["session-id"], model: options.model, reasoning: options.reasoning, mode: options.mode ?? "bypassPermissions", authPath: options["auth-path"], authMethodId: options["auth-method-id"], ddFlowBin: options["dd-flow-bin"], ddFlowHome: options["dd-flow-home"], projectRoot: options["project-root"], sessionArchive: options["session-archive"], noFlow: options["no-flow"] === true, timeoutMs: options.timeout ? Number(options.timeout) * 1000 : undefined }; }
9
- async function readStdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
10
9
  async function hook(options) {
11
- const stdin = await readStdin(); const payload = JSON.parse(stdin || "{}"); const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.sessionId ?? payload.session_id });
10
+ const budget = hookBudget();
11
+ const stdin = await readNativeHookInput(process.stdin, budget); const payload = JSON.parse(stdin || "{}"); const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.sessionId ?? payload.session_id }, budget.remaining("identity"));
12
12
  const enriched = { ...payload, _meta: { ...(payload._meta ?? {}), ddGrok: identity } }; const args = ["grok", "event", "handle", "--project-root", options["project-root"], "--json"]; const command = options["dd-flow-bin"];
13
- return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], payload: enriched });
13
+ return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], payload: enriched, timeoutMs: budget.remaining("admission", 15_000) });
14
14
  }
15
15
 
16
16
  try {
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { durableDaemonStart, inspectDaemonOperation, interruptDaemonOperation, settleDaemonOperation } from "../lib/daemon-operations.mjs";
3
- import { invokeNativeHook } from "../lib/native-hook-command.mjs";
3
+ import { hookBudget, invokeNativeHook, readNativeHookInput } from "../lib/native-hook-command.mjs";
4
4
  import { cancelChild, cancelSession, createSession, doctor, forkSession, inspectSession, promptFromFile, promptSession } from "../lib/dd-zcode.mjs";
5
5
  import { callDaemon, serveDaemon, startDaemon, stopDaemon } from "../lib/dd-zcode-daemon.mjs";
6
6
 
@@ -37,13 +37,13 @@ function common(options) {
37
37
  };
38
38
  }
39
39
 
40
- async function readStdin() { let value = ""; process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) value += chunk; return value; }
41
40
  async function hook(options) {
42
- const payload = JSON.parse((await readStdin()) || "{}");
43
- const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.session_id ?? payload.sessionId });
41
+ const budget = hookBudget();
42
+ const payload = JSON.parse((await readNativeHookInput(process.stdin, budget)) || "{}");
43
+ const identity = await callDaemon(options["state-dir"], "hook.resolve", { sessionId: payload.session_id ?? payload.sessionId }, budget.remaining("identity"));
44
44
  const command = options["dd-flow-bin"]; const args = ["zcode", "hook", "handle", "--project-root", options["project-root"], "--json"];
45
45
 
46
- return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], payload: { ...payload, _meta: { ...(payload._meta ?? {}), ddZcode: identity } } });
46
+ return invokeNativeHook({ bin: command, args, home: options["dd-flow-home"], timeoutMs: budget.remaining("admission", 15_000), payload: { ...payload, _meta: { ...(payload._meta ?? {}), ddZcode: identity } } });
47
47
  }
48
48
 
49
49
  try {
@@ -0,0 +1 @@
1
+ export function agyWorkspaceHooks(): Record<string, unknown>;
@@ -0,0 +1,9 @@
1
+ // Qualified hooks are stable; only the launching process selects its pinned
2
+ // entrypoint and daemon. Keep this document shared with product-file policy.
3
+ export function agyWorkspaceHooks() {
4
+ const command = '"${DD_AGY_HOOK_NODE:?missing AGY hook binding}" "${DD_AGY_HOOK_ENTRY:?missing AGY hook binding}" hook handle --event __EVENT__ --state-dir "${DD_AGY_STATE_DIR:?missing AGY hook binding}" --daemon-id "${DD_AGY_DAEMON_ID:?missing AGY hook binding}" --json';
5
+ return { "dd-flow": {
6
+ PreToolUse: [{ matcher: "^run_command$", hooks: [{ type: "command", command: command.replace("__EVENT__", "PreToolUse"), timeout: 30 }] }],
7
+ Stop: [{ type: "command", command: command.replace("__EVENT__", "Stop"), timeout: 30 }]
8
+ } };
9
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { mkdir, readFile, link, unlink, open } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, link, unlink, open } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { hashJson, writeJsonAtomic } from "./runner-events.mjs";
@@ -36,7 +36,18 @@ export async function inspectDaemonOperation(root, id) {
36
36
  const requested = await read(path.join(directory, "requested.json"));
37
37
  if (!requested) throw Object.assign(new Error("daemon operation is not recorded"), { code: "operation_not_found", details: { operation_id: id } });
38
38
  const settlement = await read(path.join(directory, "settlement.json"));
39
- return { ...requested, ...(await read(path.join(directory, "result.json")) ?? await read(path.join(directory, "interrupted.json")) ?? await read(path.join(directory, "observation-lost.json")) ?? { state: "running" }), ...(settlement ? { settlement } : {}) };
39
+ const hookFailures = [];
40
+ for (const name of await readdir(path.join(directory, "hooks")).catch(error => { if (error.code === "ENOENT") return []; throw error; })) {
41
+ if (!/^[A-Za-z0-9-]{1,128}\.json$/.test(name)) continue;
42
+ const binding = await read(path.join(directory, "hooks", name));
43
+ if (binding?.schema_id !== "dd-flow/hook-request@1" || binding.request_id !== name.slice(0, -5) || binding.operation_id !== id || binding.daemon_id !== requested.daemon_id) continue;
44
+ const failure = await read(path.join(root, "hook-failures", name));
45
+ if (failure?.request_id !== binding.request_id || typeof failure.code !== "string" || typeof failure.message !== "string") continue;
46
+ hookFailures.push({ code: failure.code, message: failure.message, observed_at: failure.observed_at ?? null, details: { ...failure.details, ...binding, effect: "unknown" } });
47
+ }
48
+ hookFailures.sort((a, b) => String(a.observed_at ?? "").localeCompare(String(b.observed_at ?? "")));
49
+ // Admission failure is causal evidence, not proof that the native tree stopped.
50
+ return { ...requested, ...(await read(path.join(directory, "result.json")) ?? await read(path.join(directory, "interrupted.json")) ?? await read(path.join(directory, "observation-lost.json")) ?? { state: "running" }), ...(settlement ? { settlement } : {}), ...(hookFailures[0] ? { primary_error: hookFailures[0] } : {}) };
40
51
  }
41
52
 
42
53
  /** Retry only the bookkeeping that follows a durable productive receipt.
@@ -0,0 +1,6 @@
1
+ export declare function qualifyAgyHooks(projectRoot: string, options?: { stateDir?: string | null; install?: boolean }): Promise<{
2
+ hooksSha256: string;
3
+ workspaceHooksPath: string;
4
+ workspaceHooksSha256: string;
5
+ workspaceHooksInstalled: string;
6
+ }>;
@@ -4,7 +4,10 @@ import { createHash, randomUUID } from "node:crypto";
4
4
  import { appendFile, chmod, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
5
5
  import { spawn } from "node:child_process";
6
6
  import net from "node:net";
7
+ import { isDeepStrictEqual } from "node:util";
7
8
  import { ObservationClock } from "./observation-clock.mjs";
9
+ import { hookBudget } from "./native-hook-command.mjs";
10
+ import { agyWorkspaceHooks } from "./agy-hooks.mjs";
8
11
  import { durableDaemonDispatch } from "./daemon-operations.mjs";
9
12
  import path from "node:path";
10
13
  import { observeModel } from "./model-observations.mjs";
@@ -25,7 +28,7 @@ async function writeJson(file, value) { const temporary = `${file}.${process.pid
25
28
  async function readState(dir) { try { return JSON.parse(await readFile(locations(dir).state, "utf8")); } catch (error) { if (error.code === "ENOENT") return null; throw error; } }
26
29
  async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace a non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
27
30
  const shell = value => `'${String(value).replaceAll("'", `'"'"'`)}'`;
28
- function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) delete env[key]; return env; }
31
+ function sanitizedEnv(config) { const env = { ...process.env, TMPDIR: config.temporary, DD_AGY_HOOK_NODE: process.execPath, DD_AGY_HOOK_ENTRY: config.entryPath, DD_AGY_STATE_DIR: config.stateDir, DD_AGY_DAEMON_ID: config.daemonId }; for (const key of ["GEMINI_API_KEY","GOOGLE_API_KEY","ANTHROPIC_API_KEY","OPENAI_API_KEY"]) delete env[key]; return env; }
29
32
  function errorPayload(error) { return { code: error.code ?? "operation_failed", message: error.message ?? String(error), retryable: error.retryable === true, ...(error.details === undefined ? {} : { details: error.details }) }; }
30
33
  async function forwardUsage(config, sessionId, result, tools, descendants) {
31
34
  if (!config.ddFlowBin || !config.projectRoot) return;
@@ -38,39 +41,52 @@ async function forwardUsage(config, sessionId, result, tools, descendants) {
38
41
  }
39
42
 
40
43
  export async function prepare(paths, config) {
44
+ config.stateDir = paths.dir;
45
+ config.hookBinding = "environment-v1";
46
+ const hooks = agyWorkspaceHooks();
41
47
  for (const dir of [paths.dir, paths.gemini, paths.runtime, paths.config, paths.temporary]) { await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); }
42
48
  if (config.noFlow) {
43
49
  // Technical probes still need native settlement, but must not install
44
50
  // workspace hooks or forward any lifecycle mutation to dd-flow.
45
- const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event Stop --state-dir ${shell(paths.dir)} --json`;
46
- await writeJson(path.join(paths.config, "hooks.json"), { "dd-flow-observer": { Stop: [{ type: "command", command, timeout: 30 }] } });
51
+ await writeJson(path.join(paths.config, "hooks.json"), { "dd-flow-observer": { Stop: hooks["dd-flow"].Stop } });
47
52
  return;
48
53
  }
49
- const command = `${shell(process.execPath)} ${shell(config.entryPath)} hook handle --event __EVENT__ --state-dir ${shell(paths.dir)} --project-root ${shell(config.projectRoot)} --dd-flow-bin ${shell(config.ddFlowBin)} --dd-flow-home ${shell(config.ddFlowHome)} --json`;
50
- const hooks = { "dd-flow": {
51
- // Only shell lifecycle commands require trusted pre-execution identity.
52
- // PostToolUse only recorded observations; native updates and Stop settle work.
53
- PreToolUse: [{ matcher: "^run_command$", hooks: [{ type: "command", command: command.replace("__EVENT__", "PreToolUse"), timeout: 30 }] }],
54
- Stop: [{ type: "command", command: command.replace("__EVENT__", "Stop"), timeout: 30 }]
55
- } };
54
+ // Native discovery follows cwd (feature worktree/reviewer clone), while
55
+ // projectRoot remains the stable lifecycle identity returned to dd-flow.
56
+ Object.assign(config, await qualifyAgyHooks(config.cwd ?? config.projectRoot, { stateDir: paths.dir }));
57
+ // Workspace discovery covers root and children without global duplicates.
58
+ await writeJson(path.join(paths.config, "hooks.json"), {});
59
+ }
60
+
61
+ export async function qualifyAgyHooks(projectRoot, { stateDir = null, install = false } = {}) {
62
+ const hooks = agyWorkspaceHooks();
56
63
  // Register in workspace discovery before creating any Session: native root
57
64
  // and children load these named hook definitions without a global duplicate.
58
65
  // Replace our prior handlers, including the obsolete wildcard post hook,
59
66
  // without dropping unrelated commands in a shared matcher group.
60
- const workspaceConfig = path.join(config.projectRoot, ".agents");
61
- await mkdir(workspaceConfig, { recursive: true, mode: 0o700 });
67
+ const workspaceConfig = path.join(absolute(projectRoot, "--project-root"), ".agents");
62
68
  const workspaceHooks = path.join(workspaceConfig, "hooks.json");
63
- let existing = {};
69
+ let existing = {}, existingText = "";
64
70
  try {
65
- const parsed = JSON.parse(await readFile(workspaceHooks, "utf8"));
71
+ existingText = await readFile(workspaceHooks, "utf8");
72
+ const parsed = JSON.parse(existingText);
66
73
  if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw new Error("not an object");
67
74
  existing = parsed;
68
75
  } catch (error) {
69
76
  if (error.code !== "ENOENT") throw new DaemonError("workspace_hooks_invalid", `cannot install Antigravity lifecycle hooks: ${error.message}`);
77
+ if (!install) throw new DaemonError("workspace_hooks_unqualified", "Antigravity hooks must be installed during qualification before daemon start", false, { hooks_path: workspaceHooks });
70
78
  }
71
79
  const isManagedHandler = handler => typeof handler?.command === "string"
72
- && handler.command.includes(config.entryPath) && handler.command.includes("hook handle");
80
+ && ((handler.command.includes("DD_AGY_HOOK_ENTRY") && handler.command.includes(" hook handle --event ") && handler.command.includes("DD_AGY_STATE_DIR")) || /(?:^|\/)dd-agy\.mjs['"]?\s+hook handle/.test(handler.command));
73
81
  const events = ["PreToolUse", "PostToolUse", "PreInvocation", "PostInvocation", "Stop"];
82
+ const existingManaged = Object.entries(existing).flatMap(([name, definition]) => {
83
+ if (events.includes(name) && Array.isArray(definition)) return definition;
84
+ if (!definition || typeof definition !== "object" || Array.isArray(definition)) return [];
85
+ return events.flatMap(event => Array.isArray(definition[event]) ? definition[event] : []);
86
+ }).flatMap(item => Array.isArray(item?.hooks) ? item.hooks : [item]).filter(handler => isManagedHandler(handler) && handler.command.includes("--state-dir") && !handler.command.includes("DD_AGY_STATE_DIR"));
87
+ if (existingManaged.some(handler => !stateDir || !handler.command.includes(`--state-dir ${shell(stateDir)}`))) {
88
+ throw new DaemonError("workspace_hooks_drifted", "qualified Antigravity hook routes to another daemon state directory", false, { hooks_path: workspaceHooks, expected_state_dir: stateDir });
89
+ }
74
90
  const retain = handlers => handlers.flatMap(item => {
75
91
  if (!Array.isArray(item?.hooks)) return isManagedHandler(item) ? [] : [item];
76
92
  const remaining = item.hooks.filter(handler => !isManagedHandler(handler));
@@ -106,16 +122,19 @@ export async function prepare(paths, config) {
106
122
  managed[event] = [...(managed[event] ?? []), ...handlers];
107
123
  }
108
124
  workspace["dd-flow"] = managed;
109
- await writeJson(workspaceHooks, workspace);
110
- // Workspace discovery covers root and children. Installing the same handler
111
- // globally as well invokes it twice, even under the same hook name.
112
- await writeJson(path.join(paths.config, "hooks.json"), {});
113
- config.hooksSha256 = createHash("sha256").update(JSON.stringify(hooks)).digest("hex");
114
- config.workspaceHooksPath = workspaceHooks;
125
+ const serializedWorkspace = `${JSON.stringify(workspace, null, 2)}\n`;
126
+ // Hook installation is a qualification/ownership boundary. Reusing the
127
+ // qualified command must not rewrite the project file when the pinned engine,
128
+ // daemon directory or workspace location changes.
129
+ const installed = isDeepStrictEqual(existing, JSON.parse(serializedWorkspace));
130
+ if (!installed && !install) throw new DaemonError("workspace_hooks_unqualified", "Antigravity hook definitions differ from the qualified contract", false, { hooks_path: workspaceHooks });
131
+ if (!installed) { await mkdir(workspaceConfig, { recursive: true, mode: 0o700 }); await writeFile(workspaceHooks, serializedWorkspace, { mode: 0o600 }); }
132
+ return { hooksSha256: createHash("sha256").update(JSON.stringify(hooks)).digest("hex"), workspaceHooksPath: workspaceHooks,
133
+ workspaceHooksSha256: createHash("sha256").update(installed ? existingText : serializedWorkspace).digest("hex"), workspaceHooksInstalled: installed ? "qualified_existing" : "qualified_install" };
115
134
  }
116
135
 
117
- export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
118
- const paths = locations(stateDir), request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
136
+ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000, requestId = null) {
137
+ const paths = locations(stateDir), request = { schema_id: REQUEST_SCHEMA, id: requestId ?? process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
119
138
  return await new Promise((resolve, reject) => {
120
139
  const client = net.createConnection(paths.socket); let buffer = "", settled = false, timer, checking = false, lastActivity = Date.now();
121
140
  const clock = new ObservationClock({ timeoutMs, onGap: gap => { void appendFile(paths.journal, `${JSON.stringify({ kind: "observation_gap", payload: { ...gap, operation_id: request.id } })}\n`, { mode: 0o600 }).catch(() => {}); } });
@@ -200,7 +219,7 @@ export const stopDaemon = async options => { const timeoutMs = options.timeoutMs
200
219
 
201
220
  export class Runtime {
202
221
  constructor(paths, state) { this.paths = paths; this.state = state; this.config = state.config; this.child = null; this.providerProcess = state.provider_process ?? null; this.providerFinalization = Promise.resolve(); this.buffer = ""; this.init = null; this.readyPromise = null; this.active = null; this.exited = false; this.toolObservations = new ToolObservations(); for (const reason of state.tool_observation_gaps ?? []) this.toolObservations.gaps.add(reason); for (const event of state.tool_observations ?? []) this.toolObservations.add(event); if (state.tool_calls?.length && !state.tool_observations) this.toolObservations.gaps.add("legacy_tools_not_attributed"); this.turnGeneration = state.turn_generation ?? 0; this.descendants = new Map((state.descendants ?? []).map(child => [child.provider_session_id, child])); this.lastResult = state.last_result ?? null; this.sessionObservations = new Map(state.session_observations ?? []); this.startupError = null; this.persisting = Promise.resolve(); this.draining = Promise.resolve(); this.lastActivityAt = state.last_activity_at ?? null; this.usageIngest = null; this.lastProviderEvent = null; }
203
- persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); this.persisting = this.persisting.then(() => writeJson(this.paths.state, snapshot)); for (const processRecord of [this.state.resource_process, this.providerProcess]) if (processRecord?.id && processRecord.lease_token) this.persisting = this.persisting.then(async () => { try { await heartbeatDaemonProcess(this.config, processRecord); } catch {} }); return this.persisting; }
222
+ persist(patch = {}) { Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), last_activity_at: this.lastActivityAt, session_observations: [...this.sessionObservations], tool_observations: [...this.toolObservations.calls.values()], tool_observation_gaps: [...this.toolObservations.gaps], turn_generation: this.turnGeneration, descendants: [...this.descendants.values()], sessions: this.init ? [{ provider_session_id: this.init.conversation_id, root_provider_session_id: this.init.conversation_id, cwd: this.config.cwd }] : [] }); const snapshot = structuredClone(this.state); const queued = performance.now(); this.persisting = this.persisting.then(async () => { const started = performance.now(); await writeJson(this.paths.state, snapshot); this.persistTiming = { queue_ms: Math.round(started - queued), write_ms: Math.round(performance.now() - started) }; }); return this.persisting; }
204
223
  async journal(kind, payload = {}) { const line = `${JSON.stringify({ schema_id: "dd-agy/daemon-event@1", event_id: randomUUID(), observed_at: new Date().toISOString(), kind, ...payload })}\n`; for (const journal of new Set([this.paths.journal, this.config.journal].filter(Boolean))) { await mkdir(path.dirname(journal), { recursive: true, mode: 0o700 }); await appendFile(journal, line, { mode: 0o600 }); } }
205
224
  args(sessionId) { return ["--input-format", "stream-json", "--output-format", "stream-json", "--model", this.config.model, "--effort", this.config.reasoning, "--mode", this.config.mode, "--dangerously-skip-permissions", "--print-timeout", "6h", `--gemini_dir=${this.paths.gemini}`, "--app_data_dir=runtime", ...(sessionId ? ["--conversation", sessionId] : ["--new-project"])]; }
206
225
  requireSessionIdentity(requestedId) {
@@ -264,7 +283,7 @@ export class Runtime {
264
283
  });
265
284
  });
266
285
  void this.providerFinalization.catch(() => {});
267
- try { await confirmDaemonProcess(this.config, providerProcess, this.child); await this.persist({ provider_process: providerProcess, provider_pid: this.child.pid }); }
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 }); }
268
287
  catch (error) { await cleanupFailedStart(this.config, providerProcess, this.child, error); throw error; }
269
288
  finally { allowProviderFinalization(); }
270
289
  let readyTimer;
@@ -318,6 +337,7 @@ export class Runtime {
318
337
  await this.completePendingTurn();
319
338
  }
320
339
  async completePendingTurn() {
340
+ await this.observeHookFailures();
321
341
  const current = this.active;
322
342
  if (!current?.terminalResult || current.terminalResult !== this.lastResult) return;
323
343
  await this.persist({ active_tree: !this.receipt().settled });
@@ -345,6 +365,7 @@ export class Runtime {
345
365
  if (!text) throw new DaemonError("prompt_required", "prompt is required");
346
366
  await this.start(); await this.draining;
347
367
  if (this.active) throw new DaemonError("operation_in_progress", "an Antigravity turn is already running");
368
+ if (this.state.last_hook_rejection && !this.receipt().settled) throw new DaemonError("tree_not_settled", "Rejected hook does not prove that the prior Antigravity tree stopped", false, { primary_error: this.state.last_hook_rejection });
348
369
  if (this.state.unclaimed_activity || (this.lastResult && !this.receipt().settled)) throw new DaemonError("tree_not_settled", "the prior Antigravity agent tree is not fully settled");
349
370
  let resolveTurn, rejectTurn;
350
371
  const reply = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject; });
@@ -353,6 +374,7 @@ export class Runtime {
353
374
  const current = { resolve: resolveTurn, reject: rejectTurn, generation: ++this.turnGeneration, operationId, previousError: this.lastResult?.error ?? null };
354
375
  // Reserve before persistence so different operation IDs cannot overlap.
355
376
  this.active = current;
377
+ this.hookRequests = new Map();
356
378
  const root = this.init?.conversation_id, prior = this.sessionObservations.get(root) ?? {};
357
379
  this.sessionObservations.set(root, { ...prior, step_floor: prior.last_step_index ?? null, stop: null, last_hook: null });
358
380
  this.lastProviderEvent = null;
@@ -381,17 +403,28 @@ export class Runtime {
381
403
  if (child.parent_provider_session_id !== this.init?.conversation_id) throw new DaemonError("agy_child_parent_unqualified", "Nested Antigravity worker is not a direct child of the controlled Session", false, { conversation_id: conversationId, parent_conversation_id: child.parent_provider_session_id });
382
404
  return child;
383
405
  }
384
- async rejectHook(error, event, payload) {
385
- const current = this.active;
386
- const rejection = { code: error.code ?? "agy_hook_rejected", message: error.message ?? String(error), event, conversation_id: payload.conversationId ?? null, tool: payload.toolCall?.name ?? null, daemon_id: this.config.daemonId, operation_id: current?.operationId ?? null, turn_generation: current?.generation ?? null };
387
- try { await this.journal("hook_rejected", { rejection }); await this.persist({ last_hook_rejection: rejection }); }
388
- catch (storageError) { if (current && this.active === current) { this.active = null; current.reject(storageError); } throw storageError; }
406
+ async rejectHook(error, event, payload, current = this.active) {
407
+ if (current && this.active !== current) return;
408
+ const rejection = this.state.last_hook_rejection && this.state.last_hook_rejection.turn_generation === current?.generation ? this.state.last_hook_rejection : { code: error.code ?? "agy_hook_rejected", message: error.message ?? String(error), details: error.details, event, conversation_id: payload.conversationId ?? null, tool: payload.toolCall?.name ?? null, daemon_id: this.config.daemonId, operation_id: current?.operationId ?? null, turn_generation: current?.generation ?? null };
409
+ this.state.last_hook_rejection = rejection;
389
410
  if (current && this.active === current) { this.active = null; current.reject(new DaemonError(rejection.code, rejection.message, false, rejection)); }
411
+ try { await this.journal("hook_rejected", { rejection }); await this.persist({ last_hook_rejection: rejection }); }
412
+ catch (storageError) { rejection.receipt_error = { code: storageError.code, message: storageError.message }; }
413
+ }
414
+ async observeHookFailures() {
415
+ for (const [requestId, binding] of this.hookRequests ?? []) {
416
+ if (binding.current !== this.active || !this.active) continue;
417
+ let failure;
418
+ try { failure = JSON.parse(await readFile(path.join(this.paths.dir, "hook-failures", `${requestId}.json`), "utf8")); }
419
+ catch (error) { if (error.code === "ENOENT") continue; failure = { code: "native_hook_receipt_invalid", message: error.message }; }
420
+ if (failure.request_id !== undefined && failure.request_id !== requestId) continue;
421
+ await this.rejectHook(Object.assign(new Error(failure.message ?? "Native hook failed"), { code: failure.code, details: failure.details }), binding.event, binding.payload, binding.current);
422
+ }
390
423
  }
391
- async observeHook(event, payload) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const parent = child?.parent_provider_session_id ?? null;
424
+ async observeHook(event, payload, budget = null) { const conversationId = payload.conversationId ?? this.init?.conversation_id; const child = await this.childForHook(conversationId); budget?.remaining("identity"); const prior = this.sessionObservations.get(conversationId) ?? {}; const execution = Number.isInteger(payload.executionNum) ? payload.executionNum : null; const step = Number.isInteger(payload.stepIdx) ? payload.stepIdx : null; const floor = prior.step_floor; const stale = step !== null && ((Number.isInteger(floor) && step <= floor) || (child && Number.isInteger(prior.last_step_index) && step < prior.last_step_index)); if (stale) { await this.journal("stale_hook", { hook_event: event, conversation_id: conversationId, step_index: step }); return { stale: true }; } const fingerprint = createHash("sha256").update(JSON.stringify({ event, payload })).digest("hex"); if (!stale && prior.last_hook !== fingerprint) this.markActivity(); if (conversationId && !stale) this.sessionObservations.set(conversationId, { ...prior, ...(step !== null ? { last_step_index: Math.max(prior.last_step_index ?? -1, step) } : {}), last_hook: fingerprint, ...(event === "PreToolUse" ? { last_pre_hook: fingerprint } : {}), ...(payload.transcriptPath ? { transcript_path: payload.transcriptPath } : {}), ...(event === "Stop" ? { stop: { fullyIdle: payload.fullyIdle === true, terminationReason: payload.terminationReason ?? null, executionNum: payload.executionNum ?? null } } : {}) }); if (!stale && event === "PreToolUse" && prior.last_pre_hook !== fingerprint) this.childActivity(conversationId); if (child && !stale && event === "Stop" && payload.fullyIdle === true) this.descendants.set(conversationId, { ...this.descendants.get(conversationId), tree_settled: true, settlement_evidence: "child_fully_idle_stop" }); const terminalPending = this.active?.terminalResult === this.lastResult; if (!child && !stale && event === "Stop" && payload.fullyIdle === true && (!this.active || terminalPending) && ["SUCCESS", "ERROR", "CANCELLED"].includes(this.lastResult?.status)) for (const [id, descendant] of this.descendants) if (descendant.status === "unknown") this.descendants.set(id, { ...descendant, status: "settled_by_root", settlement_evidence: "root_fully_idle_stop" }); const parent = child?.parent_provider_session_id ?? null;
392
425
  const observed = payload.model ? observedProfile(payload) : {};
393
426
  await observeModel({ journal: this.config.journal, harness: "antigravity-cli", sessionId: conversationId, parentSessionId: parent, requested: this.config, observed, source: "native.hook", evidence: payload.model ? "configured" : "unavailable", reason: payload.model ? null : "hook_omits_current_model" });
394
- const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
427
+ const eventId = createHash("sha256").update(JSON.stringify({ daemon: this.config.daemonId, event, conversationId, fully_idle: payload.fullyIdle ?? null, termination_reason: payload.terminationReason ?? null, execution: payload.executionNum ?? null, step: payload.stepIdx ?? null, tool: payload.toolCall?.name ?? null, args: payload.toolCall?.args ?? null })).digest("hex"); await this.persist(); await this.journal("hook", { stale, hook_event: event, event_id: eventId, conversation_id: conversationId, turn_generation: this.turnGeneration, execution_num: execution, step_index: step, termination_reason: payload.terminationReason ?? null, fully_idle: payload.fullyIdle ?? null, persistence_timing: this.persistTiming ?? null }); await this.completePendingTurn(); return { event_id: eventId, daemon_id: this.config.daemonId, parent_conversation_id: parent, profile: observed, requested_profile: { provider: this.config.provider, model: this.config.model, reasoning: this.config.reasoning, mode: this.config.mode }, project_root: this.config.projectRoot, dd_flow_bin: this.config.ddFlowBin, dd_flow_home: this.config.ddFlowHome };
395
428
  }
396
429
  async finalizeProviderForControl() {
397
430
  const pending = this.providerFinalization;
@@ -419,7 +452,8 @@ export class Runtime {
419
452
 
420
453
  export async function serveDaemon(stateDir) {
421
454
  const paths = locations(stateDir), state = await readState(paths.dir); if (!state?.config) throw new DaemonError("daemon_state_missing", "Antigravity daemon state is missing");
422
- const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
455
+ await heartbeatDaemonProcess(state.config, state.resource_process);
456
+ const runtime = new Runtime(paths, state); await removeSocket(paths); const server = net.createServer(socket => { let buffer = ""; socket.on("error", () => {}); socket.setEncoding("utf8"); socket.on("data", chunk => { buffer += chunk; const index = buffer.indexOf("\n"); if (index < 0) return; let request; try { request = JSON.parse(buffer.slice(0, index)); } catch (error) { socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, ok: false, error: errorPayload(error) })}\n`); return; } void durableDaemonDispatch(paths.dir, request, () => dispatch(runtime, request), () => dispatch(runtime, { operation: "session.inspect", params: request.params ?? {} })).then(result => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result })}\n`), error => socket.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: false, error: errorPayload(error) })}\n`)); }); }); runtime.server = server;
423
457
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); }); await chmod(paths.socket, 0o600);
424
458
  try { await runtime.start(state.config.resumeSessionId); runtime.requireSessionIdentity(state.config.resumeSessionId); }
425
459
  catch (error) {
@@ -433,15 +467,34 @@ export async function serveDaemon(stateDir) {
433
467
 
434
468
  async function dispatch(runtime, request) {
435
469
  if (request.schema_id !== REQUEST_SCHEMA) throw new DaemonError("daemon_protocol_mismatch", "unsupported dd-agy request schema"); const params = request.params ?? {};
436
- if (request.operation === "daemon.status") return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() };
470
+ if (request.operation === "daemon.status") { await runtime.observeHookFailures(); return { daemon_id: runtime.config.daemonId, shutdown_state: runtime.state.shutdown_state, active_tree: runtime.state.active_tree, active_operation: runtime.active?.operationId ?? null, last_hook_rejection: runtime.state.last_hook_rejection ?? null, last_activity_at: runtime.lastActivityAt, provider_ready: Boolean(runtime.init && !runtime.exited), pid: process.pid, provider_pid: runtime.child?.pid ?? null, versions: runtime.state.versions, config: runtime.config, sessions: runtime.state.sessions ?? [], receipt: runtime.receipt() }; }
437
471
  if (request.operation === "session.create") { if (params.sessionId) throw new DaemonError("invalid_create", "create does not accept a Session ID"); if (params.prompt) throw new DaemonError("invalid_create", "create does not execute a prompt; use session.prompt"); await runtime.start(); await runtime.zeroUsage(); return runtime.receipt(); }
438
472
  if (request.operation === "session.prompt") { const assertDispatch = captureDispatchGuard(runtime); await runtime.start(params.sessionId ?? null); runtime.requireSessionIdentity(params.sessionId); return await runtime.prompt(params.prompt, assertDispatch, request.id); }
439
473
  if (request.operation === "session.inspect") { runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
440
474
  if (request.operation === "session.resume") { await runtime.start(params.sessionId ?? runtime.init?.conversation_id ?? null); runtime.requireSessionIdentity(params.sessionId); return runtime.receipt(); }
441
475
  if (request.operation === "session.cancel") { runtime.requireSessionIdentity(params.sessionId); return await runtime.cancel(); }
442
476
  if (request.operation === "hook.observe") {
443
- try { return await runtime.observeHook(params.event, params.payload ?? {}); }
444
- catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}); throw error; }
477
+ // Check the process binding before correlating a request with an active
478
+ // turn. A stale/foreign daemon must not poison an unrelated operation.
479
+ if (runtime.config.hookBinding === "environment-v1" && params.daemonId !== runtime.config.daemonId) throw new DaemonError("agy_hook_binding_invalid", "Antigravity hook binding does not name this daemon", false, { expected_daemon_id: runtime.config.daemonId, observed_daemon_id: params.daemonId ?? null });
480
+ const current = runtime.active, budget = hookBudget(25_000, params.deadlineAt);
481
+ runtime.hookRequests ??= new Map();
482
+ if (/^[A-Za-z0-9-]{1,128}$/.test(request.id)) runtime.hookRequests.set(request.id, { current, event: params.event, payload: { conversationId: params.payload?.conversationId, toolCall: { name: params.payload?.toolCall?.name } } });
483
+ try {
484
+ const result = await budget.run(async () => {
485
+ if (runtime.hookRequests.has(request.id) && current?.operationId) {
486
+ const directory = path.join(runtime.paths.dir, "operations", createHash("sha256").update(current.operationId).digest("hex"), "hooks");
487
+ await mkdir(directory, { recursive: true, mode: 0o700 });
488
+ await writeJson(path.join(directory, `${request.id}.json`), { schema_id: "dd-flow/hook-request@1", request_id: request.id, daemon_id: runtime.config.daemonId, operation_id: current.operationId, turn_generation: current.generation, event: params.event, conversation_id: params.payload?.conversationId ?? null, tool: params.payload?.toolCall?.name ?? null });
489
+ }
490
+ budget.remaining("observation");
491
+ return await runtime.observeHook(params.event, params.payload ?? {}, budget);
492
+ }, "observation");
493
+ await runtime.observeHookFailures(); budget.remaining("observation");
494
+ if (current && runtime.state.last_hook_rejection?.turn_generation === current.generation) throw Object.assign(new Error(runtime.state.last_hook_rejection.message), runtime.state.last_hook_rejection);
495
+ return result;
496
+ }
497
+ catch (error) { await runtime.rejectHook(error, params.event, params.payload ?? {}, current); throw error; }
445
498
  }
446
499
  if (request.operation === "daemon.stop") { await runtime.close(params.cancelTree === true); await runtime.persist({ shutdown_state: "clean", active_tree: false }); await runtime.finishResource(); setImmediate(() => runtime.server?.close()); return { stopped: true, clean: true, settled: true, daemon_id: runtime.config.daemonId }; }
447
500
  throw new DaemonError("unknown_operation", `unknown dd-agy daemon operation: ${request.operation}`);
@@ -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, stopProcessGroup } from "./managed-daemon.mjs";
12
+ import { prepareRuntimeOwner, cleanupFailedStart, 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";
@@ -89,15 +89,18 @@ export async function startDaemon(options) {
89
89
  if (previous?.shutdown_state === "clean") await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
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
- try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1_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" }); }
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
93
  catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); 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
 
97
97
  export async function serveDaemon(stateDir) {
98
98
  const paths = locations(stateDir); const state = await readState(paths.state); if (!state || state.schema_id !== stateSchema) throw Object.assign(new Error("daemon state missing"), { code: "daemon_state_missing" });
99
- const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_DAEMON_ID: state.daemon_id, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await bridge.start(); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
100
- const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.then(() => writeState(paths.state, snapshot)).then(() => heartbeatDaemonProcess(snapshot.config, snapshot.resource_process).catch(() => {})); return persist; };
99
+ const bridge = new CodexBridge({ ...state.config, env: { ...state.config.env, CODEX_HOME: process.env.CODEX_HOME, DD_FLOW_DAEMON_ID: state.daemon_id, DD_FLOW_CODEX_STATE_DIR: paths.dir } }); await startManagedBridge({ config: state.config, state, bridge, kind: "codex-provider", publish: snapshot => writeState(paths.state, snapshot) }); const sessions = new Set(state.sessions ?? []); let active = null; let server; let persist = Promise.resolve();
100
+ const closeBridge = bridge.close.bind(bridge);
101
+ bridge.close = async () => { await closeBridge(); await finishDaemonProcess(state.config, state.provider_process, "stopped", "daemon_stopped"); };
102
+ await heartbeatDaemonProcess(state.config, state.resource_process);
103
+ const save = (patch = {}) => { Object.assign(state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...sessions] }); const snapshot = structuredClone(state); persist = persist.then(() => writeState(paths.state, snapshot)); return persist; };
101
104
  const dispatch = async (operation, params) => {
102
105
  const assertDispatch = captureDispatchGuard(bridge);
103
106
  if (operation === "hook.verify") {
@@ -62,6 +62,7 @@ export class CodexBridge {
62
62
  this.child.on("error", (error) => this.rejectAll(error));
63
63
  this.child.on("exit", (code) => this.rejectAll(Object.assign(new Error(`Codex app-server exited ${code}${this.stderr.trim() ? `: ${this.stderr.trim()}` : ""}`), { code: "bridge_exited" })));
64
64
  readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
65
+ if (this.options.onSpawn) await this.options.onSpawn(this.child);
65
66
  await this.request("initialize", { clientInfo: { name: "dd-codex", version: "0.1.0" }, capabilities: { optOutNotificationMethods: [], experimentalApi: true } });
66
67
  }
67
68
 
@@ -62,7 +62,7 @@ export async function serveDaemon(stateDir) {
62
62
  const dispatch = async request => {
63
63
  const params = request.params ?? {}, operation = request.operation;
64
64
  if (operation === "daemon.status") { let observationError; try { await runtime.refreshTopology(); } catch (error) { observationError = errorRecord(error); } return { observation_error: observationError ?? null, daemon_id: state.daemon_id, pid: process.pid, config: state.config, shutdown_state: state.shutdown_state, provider_ready: Boolean(runtime.child && !runtime.closed), active_operation: busy, active_tree: Boolean(runtime.active || runtime.workingState !== "idle" || runtime.topologyIncomplete || [...runtime.topology.values()].some(child => !["completed", "cancelled", "failed"].includes(child.status))), sessions: runtime.rootId ? [runtime.rootId] : [], provider_pid: runtime.closed ? null : runtime.child?.pid }; }
65
- if (operation === "hook.observe") return await runtime.observeHook(params.payload, params.eventId);
65
+ if (operation === "hook.observe") return await runtime.observeHook(params.payload, params.eventId, params.deadlineAt);
66
66
  if (operation === "session.inspect") return await runtime.inspect(params.sessionId, { ingestUsage: params.noFlow !== true });
67
67
  if (operation === "session.cancel") {
68
68
  runtime.requireIdentity(params.sessionId); const cleanup = await runtime.closeTree(true); let inspected;