@deksden-com/dd-flow-cli 0.9.0-beta.100 → 0.9.0-beta.104
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/dist/build-info.json +3 -3
- package/dist/cli/hook-ingress.js +5 -10
- package/dist/cli/run-cli.js +9 -3
- package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
- package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
- package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
- package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
- package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
- package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
- package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
- package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +38 -4
- package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
- package/dist/harness-runtime/lib/process-json.mjs +3 -2
- package/dist/services/code-checks.js +53 -16
- package/dist/services/controller-fanout.js +37 -6
- package/dist/services/eval-snapshots.js +148 -38
- package/dist/services/external-work-launch.js +60 -5
- package/dist/services/prompts.js +2 -0
- package/dist/services/review-copy.js +136 -0
- package/dist/services/run-control-worker.js +16 -11
- package/dist/services/run-controller-adapter.js +9 -0
- package/dist/services/runs.js +3 -0
- package/dist/services/runtime-scope-stop.js +66 -6
- package/dist/services/vnext-code-review.js +6 -2
- package/dist/services/vnext-code.js +1 -0
- package/dist/services/vnext-fanout.js +9 -1
- package/dist/services/vnext-merge.js +8 -4
- package/dist/services/vnext-plan-review.js +4 -15
- package/dist/services/vnext-plan.js +4 -0
- package/dist/services/vnext-protocolize.js +4 -2
- package/dist/services/vnext-specify.js +7 -0
- package/dist/services/work-registry.js +208 -22
- package/dist/services/workspace-files.js +83 -0
- package/dist/services/worktrees.js +3 -1
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.104",
|
|
4
|
+
"cli_commit": "d8b1b96db830c5e56e8409dfc94837fb73638673",
|
|
5
|
+
"built_at": "2026-09-25T13:44:30.094Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.1.1",
|
|
8
8
|
"commit": "678daa038287c948ada5b2d785a6dcc925c7b891",
|
package/dist/cli/hook-ingress.js
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
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:
|
|
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
|
-
|
|
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");
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -304,10 +304,16 @@ export async function runCli(args, io = defaultIo, env = process.env) {
|
|
|
304
304
|
let earlyContext;
|
|
305
305
|
try {
|
|
306
306
|
const command = quote(["dd-flow", ...output.args]);
|
|
307
|
-
const
|
|
308
|
-
|
|
307
|
+
const router = createRouterContext(env);
|
|
308
|
+
const event = observedLifecycleNativeEvent(router, command, env.DD_FLOW_DAEMON_ID);
|
|
309
|
+
// A correctable prepare rejection precedes the terminal command's
|
|
310
|
+
// single-use claim. Correlate its unique hook by the same lifecycle
|
|
311
|
+
// match key used at dispatch, including compound provider tool shells;
|
|
312
|
+
// otherwise a repaired retry sees two fresh, unclaimed events.
|
|
313
|
+
if (event || (error.details.phase === "prepare" && error.details.effect === "no_effect"
|
|
314
|
+
&& fs.existsSync(path.join(router.ddFlowHome, "db.sqlite")))) {
|
|
309
315
|
earlyContext = createContext({ ...env }, "hook");
|
|
310
|
-
observeLifecycleCommand(earlyContext, command, event)({ error });
|
|
316
|
+
observeLifecycleCommand(earlyContext, command, event ?? undefined)({ error });
|
|
311
317
|
}
|
|
312
318
|
}
|
|
313
319
|
catch (persistence) {
|
|
@@ -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 {
|
|
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
|
|
13
|
-
const
|
|
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
|
|
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
|
|
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
|
|
43
|
-
const
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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() +
|
|
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.
|
|
100
|
-
const
|
|
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
|
|