@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.
Files changed (47) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/cli/hook-ingress.js +5 -10
  3. package/dist/cli/run-cli.js +9 -3
  4. package/dist/harness-runtime/bin/dd-agy.mjs +20 -7
  5. package/dist/harness-runtime/bin/dd-droid.mjs +2 -2
  6. package/dist/harness-runtime/bin/dd-grok.mjs +4 -4
  7. package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
  8. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  9. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  10. package/dist/harness-runtime/lib/daemon-operations.mjs +13 -2
  11. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  12. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +89 -36
  13. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +7 -4
  14. package/dist/harness-runtime/lib/dd-codex.mjs +1 -0
  15. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +1 -1
  16. package/dist/harness-runtime/lib/dd-droid.mjs +24 -17
  17. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +107 -20
  18. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +5 -3
  19. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +6 -10
  20. package/dist/harness-runtime/lib/dd-zcode.mjs +63 -26
  21. package/dist/harness-runtime/lib/delegation-instructions.d.mts +1 -1
  22. package/dist/harness-runtime/lib/delegation-instructions.mjs +6 -1
  23. package/dist/harness-runtime/lib/managed-daemon.mjs +38 -4
  24. package/dist/harness-runtime/lib/native-hook-command.mjs +87 -13
  25. package/dist/harness-runtime/lib/process-json.mjs +3 -2
  26. package/dist/services/code-checks.js +53 -16
  27. package/dist/services/controller-fanout.js +37 -6
  28. package/dist/services/eval-snapshots.js +148 -38
  29. package/dist/services/external-work-launch.js +60 -5
  30. package/dist/services/prompts.js +2 -0
  31. package/dist/services/review-copy.js +136 -0
  32. package/dist/services/run-control-worker.js +16 -11
  33. package/dist/services/run-controller-adapter.js +9 -0
  34. package/dist/services/runs.js +3 -0
  35. package/dist/services/runtime-scope-stop.js +66 -6
  36. package/dist/services/vnext-code-review.js +6 -2
  37. package/dist/services/vnext-code.js +1 -0
  38. package/dist/services/vnext-fanout.js +9 -1
  39. package/dist/services/vnext-merge.js +8 -4
  40. package/dist/services/vnext-plan-review.js +4 -15
  41. package/dist/services/vnext-plan.js +4 -0
  42. package/dist/services/vnext-protocolize.js +4 -2
  43. package/dist/services/vnext-specify.js +7 -0
  44. package/dist/services/work-registry.js +208 -22
  45. package/dist/services/workspace-files.js +83 -0
  46. package/dist/services/worktrees.js +3 -1
  47. package/package.json +1 -1
@@ -188,6 +188,7 @@ export class AcpBridge {
188
188
  this.child.on("error", (error) => { error.code ??= "bridge_exited"; this.rejectAll(error); });
189
189
  this.child.on("exit", (code) => { const error = new Error(`zcode-acp exited ${code}${this.bridgeStderr.trim() ? `: ${this.bridgeStderr.trim()}` : ""}`); error.code = "bridge_exited"; this.rejectAll(error); });
190
190
  readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
191
+ if (this.options.onSpawn) await this.options.onSpawn(this.child);
191
192
  return await this.request("initialize", {
192
193
  protocolVersion: 1,
193
194
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, elicitation: { form: {} } },
@@ -455,10 +456,47 @@ async function resume(bridge, sessionId, cwd) {
455
456
  return sessionId;
456
457
  }
457
458
 
459
+ /** Ended children are paginated by the native API (20 by default). A partial
460
+ * directory is never settlement evidence, including during teardown. */
461
+ export async function readZcodeSubagents(bridge, sessionId, timeoutMs = 30_000, retained = false) {
462
+ const deadline = performance.now() + timeoutMs;
463
+ const cursors = new Set(), ended = new Map();
464
+ let first, cursor;
465
+ for (;;) {
466
+ const remaining = deadline - performance.now();
467
+ if (remaining <= 0) fail("ZCode child observation deadline expired", "native_topology_incomplete");
468
+ const response = await bridge.request(retained ? "zcode/session/retainedSubagents" : "zcode/session/subagents", { sessionId, ...(cursor ? { endedCursor: cursor } : {}) }, remaining);
469
+ if (retained && response?.sessionId !== sessionId) fail("Retained ZCode child directory has a foreign Session identity", "native_topology_incomplete");
470
+ const page = retained ? response.topology : response;
471
+ if (performance.now() >= deadline) fail("ZCode child observation deadline expired", "native_topology_incomplete");
472
+ if (!page || !Array.isArray(page.running)) fail("ZCode child directory is unavailable", "native_topology_incomplete");
473
+ if (!first) first = page;
474
+ if (!page.ended) {
475
+ if (cursor) fail("ZCode child page omitted ended outcomes", "native_topology_incomplete");
476
+ return page; // Older qualified bridges expose an unpaginated completed array.
477
+ }
478
+ if (!Array.isArray(page.ended.items) || !Number.isSafeInteger(page.ended.total) || page.ended.total < 0
479
+ || page.revision !== first.revision || page.ended.total !== first.ended.total) fail("ZCode child directory changed or is malformed during pagination", "native_topology_incomplete");
480
+ for (const child of page.ended.items) {
481
+ if (typeof child.childSessionId !== "string" || !child.childSessionId) fail("ZCode ended child has no identity", "native_topology_incomplete");
482
+ const prior = ended.get(child.childSessionId);
483
+ if (prior && JSON.stringify(prior) !== JSON.stringify(child)) fail("ZCode child pages contain conflicting outcomes", "native_topology_incomplete");
484
+ ended.set(child.childSessionId, child);
485
+ }
486
+ cursor = page.ended.nextCursor;
487
+ if (!cursor) {
488
+ if (ended.size !== first.ended.total) fail("ZCode child directory is incomplete", "native_topology_incomplete");
489
+ return { ...first, ended: { total: first.ended.total, items: [...ended.values()] } };
490
+ }
491
+ if (typeof cursor !== "string" || cursors.has(cursor) || !Number.isSafeInteger(first.revision)) fail("ZCode child pagination has no advancing, versioned cursor", "native_topology_incomplete");
492
+ cursors.add(cursor);
493
+ }
494
+ }
495
+
458
496
  async function inspect(bridge, sessionId) {
459
497
  const [read, subagents] = await Promise.all([
460
498
  bridge.request("zcode/session/read", { sessionId }),
461
- bridge.request("zcode/session/subagents", { sessionId }),
499
+ readZcodeSubagents(bridge, sessionId),
462
500
  ]);
463
501
  let usage = null, usage_observation_error = null;
464
502
  try { usage = await readZcodeUsage(bridge, sessionId); }
@@ -521,9 +559,8 @@ export async function observeClosedZcodeTree(bridge, root, retainedChildren = []
521
559
  const resident = await bridge.request("zcode/session/resident", { sessionId }, timeout());
522
560
  if (resident?.sessionId !== sessionId || typeof resident.resident !== "boolean") throw new Error("Invalid residency identity or state");
523
561
  residents.push(resident);
524
- const retained = await bridge.request("zcode/session/retainedSubagents", { sessionId }, timeout());
525
- const tree = retained?.topology;
526
- if (retained?.sessionId !== sessionId || !Array.isArray(tree?.childSessionIds)
562
+ const tree = await readZcodeSubagents(bridge, sessionId, deadline - performance.now(), true);
563
+ if (!Array.isArray(tree?.childSessionIds)
527
564
  || tree.childSessionIds.some(id => typeof id !== "string" || !id || id === sessionId)
528
565
  || !Array.isArray(tree?.running) || !Array.isArray(tree?.ended?.items)
529
566
  || !Number.isSafeInteger(tree.ended.total) || tree.ended.total < 0
@@ -562,7 +599,7 @@ async function cancelTree(bridge, sessionId, before, providerSessionId, retained
562
599
  await new Promise((resolve) => setTimeout(resolve, 1000));
563
600
  const timeout = Math.max(1, deadline - Date.now());
564
601
  [after, read] = await Promise.all([
565
- bridge.request("zcode/session/subagents", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
602
+ readZcodeSubagents(bridge, sessionId, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
566
603
  bridge.request("zcode/session/read", { sessionId }, timeout).catch(error => ({ observation_error: { code: error.code, message: error.message } })),
567
604
  ]);
568
605
  root_status = read?.projection?.status ?? read?.session?.status ?? null;
@@ -588,7 +625,7 @@ export async function cancelChildWithBridge(bridge, options) {
588
625
  requireJournal(options);
589
626
  const identity = await controlledIdentity(bridge, options);
590
627
  const childSessionId = text(options.childSessionId, "--child-session-id");
591
- const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
628
+ const before = await readZcodeSubagents(bridge, identity.adapterSessionId);
592
629
  const child = (before.running ?? []).find((item) => item.childSessionId === childSessionId);
593
630
  if (!child) fail(`ZCode has no running child ${childSessionId}`, "child_not_running");
594
631
  const taskId = child.taskId ?? child.agentId;
@@ -597,7 +634,7 @@ export async function cancelChildWithBridge(bridge, options) {
597
634
  let after = before;
598
635
  for (let attempt = 0; attempt < 6; attempt += 1) {
599
636
  await new Promise((resolve) => setTimeout(resolve, 1000));
600
- after = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
637
+ after = await readZcodeSubagents(bridge, identity.adapterSessionId);
601
638
  if (!(after.running ?? []).some((item) => item.childSessionId === childSessionId)) break;
602
639
  }
603
640
  if ((after.running ?? []).some((item) => item.childSessionId === childSessionId)) {
@@ -752,22 +789,19 @@ async function forwardUsageBestEffort(options, providerSessionId, usage, toolCal
752
789
  }
753
790
 
754
791
  export function zcodeLifecycleQualification(sha256, bridgeCommit, harnessContract) {
755
- // Behavioural evidence: dd-eval production-invocations probes on 2026-09-13
756
- // and 2026-09-22. The latter exercised public commands, file-backed JSON,
757
- // concurrent children and continuation on bridge contract @2.
758
- // Pin both providers of native event identity, not a string in the bundle.
759
- // 60af0d3 adds retainedSubagents without changing native event forwarding.
760
- const qualifiedTuples = new Map([
761
- ["e9f1868c0fdb863537ed910ee3828b9be96b8c2fd805473f63b439e1113266b8", new Set(["e0600fe3c46e215695f257e65aa6f674ced998d8", "60af0d31e13076a313d9770f10aa70f7c94742cf"])],
762
- ["500ae84fa2cb8dd74c1264e5f3d3709c004c59b58899f1b0c3e205137a0ef466", new Set(["8c0a893f3c26a0b96cb138acf762db84c800298b", "f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"])],
763
- // ZCode Desktop 3.14.3 retains CLI 0.16.9; the published-engine native probe
764
- // passed root, concurrent child, and same-child continuation on this binary.
765
- ["b1df2ef3e5bd76c4af3ecb296bc003a10d3f13191a26610bd0ba940feadad529", new Set(["f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"])],
792
+ // The native app bundle is an auto-updated runtime. Its whole-file SHA is
793
+ // evidence for provenance, not a protocol identity: an unchanged bridge
794
+ // must not reject every normal ZCode patch release. The bridge contract is
795
+ // the compatibility boundary; native event identity is checked by the
796
+ // lifecycle observer and receipts during the bounded qualification probe.
797
+ const qualifiedBridgeCommits = new Map([
798
+ ["e0600fe3c46e215695f257e65aa6f674ced998d8", ZCODE_HARNESS_CONTRACT],
799
+ ["60af0d31e13076a313d9770f10aa70f7c94742cf", ZCODE_HARNESS_CONTRACT],
800
+ ["8c0a893f3c26a0b96cb138acf762db84c800298b", ZCODE_HARNESS_CONTRACT],
801
+ ["f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833", "dd-zcode-harness@2"],
802
+ ["5a8c89ab716a4b2ccc50df81cb4d204ff5c06368", "dd-zcode-harness@2"],
766
803
  ]);
767
- const qualified = qualifiedTuples.get(sha256)?.has(bridgeCommit) === true
768
- && (bridgeCommit === "f4d47b517073f9bdb8d4eba3d0d59ce66c6a2833"
769
- ? harnessContract === "dd-zcode-harness@2"
770
- : harnessContract === ZCODE_HARNESS_CONTRACT);
804
+ const qualified = qualifiedBridgeCommits.get(bridgeCommit) === harnessContract;
771
805
  return { status: qualified ? "qualified" : "unqualified", mechanism: "cli-invocation@1", sha256,
772
806
  capabilities: qualified ? ["root", "concurrent_children", "child_continuation"] : [],
773
807
  native_nested_children: false, reason: qualified ? null : "native_event_contract_unqualified" };
@@ -801,8 +835,11 @@ export async function createSessionWithBridge(bridge, options, initialized) {
801
835
  // fact that allocation was already admitted.
802
836
  await options.onSessionAliasAllocated?.({ adapter_session_id: created.sessionId, cwd });
803
837
  bridge.allocationObservers?.set(created.sessionId, async identity => {
804
- if (identity.cwd !== cwd) fail("ZCode allocation workspace mismatch", "session_identity_mismatch");
805
- await options.onSessionAllocated?.(identity);
838
+ // ZCode 0.46.x emits adapter/provider identities on this notification but
839
+ // does not repeat the workspace. The cwd from session/new is authoritative
840
+ // for this live bridge; reject only an explicitly conflicting workspace.
841
+ if (identity.cwd !== undefined && identity.cwd !== cwd) fail("ZCode allocation workspace mismatch", "session_identity_mismatch");
842
+ await options.onSessionAllocated?.({ ...identity, cwd });
806
843
  });
807
844
  const resolved = await bridge.request("zcode/session/resolve", { sessionId: created.sessionId });
808
845
  const providerSessionId = resolved.providerSessionId;
@@ -918,7 +955,7 @@ export async function cancelSession(options) {
918
955
  export async function cancelSessionWithBridge(bridge, options) {
919
956
  requireJournal(options);
920
957
  const identity = await controlledIdentity(bridge, options);
921
- const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
958
+ const before = await readZcodeSubagents(bridge, identity.adapterSessionId).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
922
959
  return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before, identity.providerSessionId, options.knownChildSessionIds) });
923
960
  }
924
961
 
@@ -931,7 +968,7 @@ export async function forkSessionWithBridge(bridge, options) {
931
968
  requireJournal(options);
932
969
  if (!options.target || typeof options.target !== "object") fail("explicit --target-json is required");
933
970
  const identity = await controlledIdentity(bridge, options);
934
- const topology = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
971
+ const topology = await readZcodeSubagents(bridge, identity.adapterSessionId);
935
972
  if ((topology.running ?? []).length) fail("cannot fork while background children are running");
936
973
  const result = await bridge.request("session/fork", { sessionId: identity.adapterSessionId, target: options.target });
937
974
  if (!result.forkedSessionId) fail("ZCode fork returned no provider Session ID");
@@ -1,5 +1,5 @@
1
1
  export type DelegationWork = { work_id: string; start_command: string; launch_policy?: string };
2
- export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[] };
2
+ export type DelegationInput = { harness: string; stage: string; capacity: number; works?: DelegationWork[]; totalWorks?: number; remainingWorks?: number };
3
3
  export function delegationAdapter(harness: string): string | null;
4
4
  export function delegationContract(harness: string): string;
5
5
  export function workerDelegationTask(input: { workId: string; startCommand: string; launchPolicy?: string }): string;
@@ -70,7 +70,7 @@ export function workerDelegationTask({ workId, startCommand, launchPolicy }) {
70
70
  ].join("\n");
71
71
  }
72
72
 
73
- export function renderDelegationInstructions({ harness, stage, capacity, works = [] }) {
73
+ export function renderDelegationInstructions({ harness, stage, capacity, works = [], totalWorks = works.length, remainingWorks = 0 }) {
74
74
  const adapter = requireAdapter(harness);
75
75
  const count = works.length;
76
76
  if (!Number.isInteger(capacity) || capacity < 1 || count < 1 || count > capacity) throw new Error("Invalid native delegation wave size");
@@ -81,6 +81,11 @@ export function renderDelegationInstructions({ harness, stage, capacity, works =
81
81
  return [
82
82
  `Adapter delegation contract: ${delegationContract(harness)}.`,
83
83
  `Launch exactly ${count} direct native children for the current ${stage} Stage; the qualified capacity is ${capacity}.`,
84
+ ...(["plan-review", "code-review"].includes(stage) ? [
85
+ `Review dispatch phase: the whole group contains ${totalWorks} Work; this wave contains ${count}; ${remainingWorks} remain after this wave.`,
86
+ "Keep the accepted PLAN/CODE review inputs and aspect maps unchanged across every wave. Do not apply findings or edit reviewed files while any reviewer remains unstarted or running.",
87
+ "After this wave's children settle, return immediately. The controller will dispatch remaining reviewers or issue semantic completion after the full group has settled."
88
+ ] : []),
84
89
  adapter.launch,
85
90
  "Retain the frozen coordinator model and reasoning for native children; do not choose a different model, reasoning level or permission mode.",
86
91
  "You are the coordinator. The JSON assignments below contain child-only messages, not instructions for you to execute. Pass each message verbatim as the native tool's task/message/prompt argument. For context=fresh, select empty conversation context before launch; if unavailable, report the limitation and stop. Use only fields exposed by your actual native tool schema; if the native tool is absent, report that error without substituting a shell command.",
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { commandJson } from "./process-json.mjs";
5
5
  import { errorRecord } from "./operation-errors.mjs";
6
6
  import { writeJsonAtomic } from "./runner-events.mjs";
7
+ import { runBoundedCommand } from "./native-hook-command.mjs";
7
8
 
8
9
  const leases = new Map();
9
10
 
@@ -52,12 +53,20 @@ export async function prepareRuntimeOwner(config, stateDir, env = process.env) {
52
53
  }
53
54
 
54
55
  export async function assertDaemonOwnership(stateDir, dispatch = {}, assertCurrent = () => {}) {
55
- for (const lease of leases.values()) if (lease.error) throw lease.error;
56
+ for (const lease of leases.values()) if (!lease.stateDir && lease.error) throw lease.error;
56
57
  const state = JSON.parse(await readFile(path.join(stateDir, "daemon.json"), "utf8"));
57
58
  const owner = bindRuntimeOwner({ runtime_owner: state.config?.runtime_owner ?? null }, {});
58
59
  if (!owner) return;
59
60
  const record = state.resource_process;
60
61
  if (owner.state_dir !== stateDir || !record?.id || !record.lease_token) throw Object.assign(new Error("owned daemon has no confirmed resource binding"), { code: "process_ownership_unknown" });
62
+ for (const lease of leases.values()) {
63
+ if (lease.stateDir !== stateDir) continue;
64
+ if (Date.now() >= lease.expiresAt) throw Object.assign(new Error("managed process renewal is overdue"), { code: "process_ownership_unknown", details: { process_id: lease.record.id, cause: lease.error ? errorRecord(lease.error) : null } });
65
+ // A failed renewal is not a lost lease. Reconfirm before productive work;
66
+ // never dispatch on the old lease merely because it has time remaining.
67
+ if (lease.error && !lease.lost) await heartbeatDaemonProcess(lease.config, lease.record);
68
+ if (lease.error) throw lease.error;
69
+ }
61
70
  for (;;) {
62
71
  assertCurrent();
63
72
  const result = await runtimeProcess(state.config, "admission", { id: record.id, "lease-token": record.lease_token, owner: owner.owner_id, "state-dir": stateDir, "daemon-id": state.daemon_id ?? state.config?.daemonId, ...dispatch });
@@ -156,6 +165,8 @@ export async function runtimeProcess(config, action, options = {}) {
156
165
  if (!resourceHome || !ddFlowHome || !ddFlowBin) return null;
157
166
  const args = ["runtime", "process", action];
158
167
  for (const [key, value] of Object.entries(options)) if (value !== null && value !== undefined) args.push(`--${key}`, String(value));
168
+ if (action === "heartbeat") return JSON.parse(await runBoundedCommand({ bin: ddFlowBin, args: [...args, "--json"], cwd: config.cwd,
169
+ env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }, timeoutMs: 15_000, timeoutCode: "process_heartbeat_timeout" }));
159
170
  return await commandJson(ddFlowBin, args, {
160
171
  cwd: config.cwd,
161
172
  env: { ...(config.env ?? {}), DD_FLOW_HOME: ddFlowHome, DD_FLOW_RESOURCE_HOME: resourceHome }
@@ -164,7 +175,7 @@ export async function runtimeProcess(config, action, options = {}) {
164
175
 
165
176
  export async function registerDaemonProcess(config, input) {
166
177
  const owner = bindRuntimeOwner(config);
167
- const harness = ({ "codex-daemon": "codex-desktop", "zcode-daemon": "zcode-acp", "grok-daemon": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
178
+ const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
168
179
  const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
169
180
  const result = await runtimeProcess(config, "register", { ...input, ...scope, "owner-pid": process.pid, "lease-ms": 300_000 });
170
181
  return result?.process ? { id: result.process.id, lease_token: result.process.lease_token, kind: input.kind } : null;
@@ -179,11 +190,33 @@ export async function confirmDaemonProcess(config, record, child) {
179
190
  await runtimeProcess(config, "confirm", { id: record.id, "lease-token": record.lease_token, pid: child.pid, "process-group-id": child.pid, "owner-pid": record.kind?.endsWith("-daemon") ? child.pid : process.pid, "lease-ms": 300_000 });
180
191
  }
181
192
 
193
+ /** Provider bridges use a different process group from their daemon. Publish
194
+ * the exact binding before initialize can start productive provider traffic. */
195
+ export async function startManagedBridge({ config, state, bridge, kind, publish }) {
196
+ state.pid = process.pid;
197
+ await publish(state);
198
+ const provider = config.runtime_owner ? await registerDaemonProcess(config, { kind, owner: config.runtime_owner.owner_id, operation: kind }) : null;
199
+ bridge.options.onSpawn = async child => {
200
+ await confirmDaemonProcess(config, provider, child);
201
+ await heartbeatDaemonProcess(config, provider);
202
+ state.provider_pid = child.pid;
203
+ state.provider_process = provider;
204
+ await publish(state);
205
+ };
206
+ try { return await bridge.start(); }
207
+ catch (error) { await cleanupFailedStart(config, provider, bridge.child, error); throw error; }
208
+ }
209
+
210
+ export async function closeManagedBridge(config, state, bridge) {
211
+ await bridge.close();
212
+ await finishDaemonProcess(config, state.provider_process, "stopped", "daemon_stopped");
213
+ }
214
+
182
215
  export async function heartbeatDaemonProcess(config, record) {
183
216
  if (!record?.id || !record.lease_token) return;
184
217
  let lease = leases.get(record.id);
185
218
  if (!lease) {
186
- lease = { pending: null, error: null, timer: null, lost: false };
219
+ lease = { pending: null, error: null, timer: null, lost: false, expiresAt: Date.now() + 300_000, stateDir: config.runtime_owner?.state_dir, config, record };
187
220
  leases.set(record.id, lease);
188
221
  lease.timer = setInterval(() => { void heartbeatDaemonProcess(config, record).catch(() => {}); }, 60_000);
189
222
  lease.timer.unref();
@@ -198,8 +231,9 @@ export async function heartbeatDaemonProcess(config, record) {
198
231
  throw Object.assign(new Error("managed process lease is no longer owned; new productive operations are blocked"), { code: "process_lease_lost", details: { process_id: record.id } });
199
232
  }
200
233
  lease.error = null;
234
+ lease.expiresAt = Date.now() + 300_000;
201
235
  } catch (cause) {
202
- lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id } });
236
+ lease.error = cause.code === "process_lease_lost" ? cause : Object.assign(new Error("cannot confirm managed process ownership"), { code: "process_ownership_unknown", cause, details: { process_id: record.id, cause: errorRecord(cause) } });
203
237
  throw lease.error;
204
238
  }
205
239
  })();
@@ -1,31 +1,105 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { reportedFailure } from "./process-json.mjs";
3
+
4
+ /** Native command hooks have a 30s outer limit; leave room for transport and cleanup. */
5
+ export function hookBudget(timeoutMs = 25_000, deadlineAt = null) {
6
+ const started = performance.now();
7
+ const duration = Math.max(0, Math.min(timeoutMs, Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : timeoutMs));
8
+ const remaining = (phase, maximum = duration) => {
9
+ const value = duration - (performance.now() - started);
10
+ if (value <= 0) throw Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" } });
11
+ return Math.max(1, Math.min(maximum, value));
12
+ };
13
+ return { deadlineAt: Date.now() + duration, remaining, async run(action, phase) {
14
+ const timeoutMs = remaining(phase);
15
+ let timer;
16
+ try { return await Promise.race([Promise.resolve().then(action), new Promise((_, reject) => { timer = setTimeout(() => reject(Object.assign(new Error(`Native hook deadline exceeded during ${phase}`), { code: "native_hook_timeout", details: { phase, effect: "unknown" } })), timeoutMs); })]); }
17
+ finally { clearTimeout(timer); }
18
+ } };
19
+ }
20
+
21
+ /** Bound native hook ingress before parsing or opening adapter state. */
22
+ export function readNativeHookInput(stream, budget, maxBytes = 1_048_576) {
23
+ return new Promise((resolve, reject) => {
24
+ const chunks = []; let bytes = 0, done = false;
25
+ const cleanup = () => { clearTimeout(timer); stream.off("data", onData); stream.off("end", onEnd); stream.off("error", onError); };
26
+ const finish = (error, value) => { if (done) return; done = true; cleanup(); if (error) { stream.destroy(); reject(error); } else resolve(value); };
27
+ const onData = chunk => {
28
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ bytes += buffer.length;
30
+ if (bytes > maxBytes) finish(Object.assign(new Error("Native hook input exceeds 1 MiB"), { code: "native_hook_input_too_large" }));
31
+ else chunks.push(buffer);
32
+ };
33
+ const onEnd = () => finish(null, Buffer.concat(chunks).toString("utf8"));
34
+ const onError = error => finish(error);
35
+ let timeoutMs;
36
+ try { timeoutMs = budget.remaining("input"); } catch (error) { reject(error); return; }
37
+ const timer = setTimeout(() => finish(Object.assign(new Error("Native hook deadline exceeded during input"), { code: "native_hook_timeout", details: { phase: "input", effect: "unknown" } })), timeoutMs);
38
+ stream.on("data", onData).once("end", onEnd).once("error", onError);
39
+ stream.resume();
40
+ });
41
+ }
42
+
43
+ export async function terminateCommandTree(child, { platform = process.platform, launch = spawn, timeoutMs = 400 } = {}) {
44
+ if (!child.pid) return;
45
+ if (platform !== "win32") { process.kill(-child.pid, "SIGKILL"); return; }
46
+ // Node's child.kill on Windows only terminates the leader. taskkill is the
47
+ // native subtree primitive; bound its own lifetime as well as the request.
48
+ await new Promise((resolve, reject) => {
49
+ const killer = launch("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
50
+ let settled = false, timer;
51
+ const finish = error => { if (settled) return; settled = true; clearTimeout(timer); error ? reject(error) : resolve(); };
52
+ timer = setTimeout(() => { killer.kill("SIGKILL"); killer.unref(); finish(Object.assign(new Error("Windows subtree cleanup timed out"), { code: "process_cleanup_timeout" })); }, timeoutMs);
53
+ killer.once("error", finish);
54
+ killer.once("close", code => finish(code === 0 ? null : Object.assign(new Error(`taskkill exited ${code}`), { code: "process_cleanup_failed" })));
55
+ });
56
+ }
2
57
 
3
58
  /** Preserve the native hook protocol across the CLI process boundary. */
4
- export async function invokeNativeHook({ bin, args, payload, home, timeoutMs = 15_000 }) {
59
+ export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout" }) {
60
+ const started = performance.now();
5
61
  const script = /\.[cm]?js$/.test(bin);
6
62
  const output = await new Promise((resolve, reject) => {
7
63
  const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
8
- env: { ...process.env, DD_FLOW_HOME: home }, stdio: ["pipe", "pipe", "pipe"]
64
+ cwd, env: { ...process.env, ...env }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
9
65
  });
10
- let stdout = "", stderr = "", failure;
11
- const stop = error => { failure ??= error; child.kill("SIGKILL"); };
12
- const timer = setTimeout(() => stop(Object.assign(new Error("Native hook admission timed out"), { code: "native_hook_timeout" })), timeoutMs);
66
+ let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
67
+ const finish = (error, value) => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(cleanupTimer); error ? reject(error) : resolve(value); };
68
+ const stop = error => {
69
+ if (termination || settled) return;
70
+ failure ??= error;
71
+ failure.details = { ...failure.details, elapsed_ms: Math.round(performance.now() - started), effect: "unknown" };
72
+ termination = terminateCommandTree(child).catch(cleanup => {
73
+ if (cleanup.code === "ESRCH") return;
74
+ failure.details.cleanup_error = { code: cleanup.code, message: cleanup.message };
75
+ failure.details.cleanup_unconfirmed = true;
76
+ child.kill("SIGKILL");
77
+ });
78
+ cleanupTimer ??= setTimeout(() => {
79
+ failure.details.cleanup_unconfirmed = true;
80
+ child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
81
+ finish(failure);
82
+ }, 500);
83
+ };
84
+ const timer = setTimeout(() => stop(Object.assign(new Error("Bounded CLI request timed out"), { code: timeoutCode })), timeoutMs);
13
85
  child.stdout.setEncoding("utf8").on("data", chunk => { stdout += chunk; if (stdout.length > 1_048_576) stop(new Error("Native hook response exceeds protocol limit")); });
14
86
  child.stderr.setEncoding("utf8").on("data", chunk => { stderr = (stderr + chunk).slice(-65_536); });
15
87
  child.stdin.on("error", error => { failure ??= error; });
16
- child.on("error", error => { clearTimeout(timer); reject(error); });
88
+ child.on("error", error => finish(failure ?? error));
17
89
  child.on("close", code => {
18
- clearTimeout(timer);
19
- if (failure) return reject(failure);
90
+ if (failure) { void (termination ?? Promise.resolve()).then(() => finish(failure)); return; }
20
91
  if (code !== 0) {
21
- let record;
22
- try { record = JSON.parse(stderr.trim()).error; } catch { /* Non-JSON stderr remains diagnostic evidence. */ }
23
- return reject(Object.assign(new Error(record?.message ?? (stderr.trim() || "Native hook admission failed")), { code: record?.code ?? "native_hook_failed", details: record?.details }));
92
+ return finish(reportedFailure(stdout, stderr, "Native hook admission failed", "native_hook_failed"));
24
93
  }
25
- resolve(stdout);
94
+ finish(null, stdout);
26
95
  });
27
- child.stdin.end(`${JSON.stringify(payload)}\n`);
96
+ child.stdin.end(input);
28
97
  });
98
+ return output;
99
+ }
100
+
101
+ export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
102
+ const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
29
103
  let result;
30
104
  try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
31
105
  if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
@@ -5,17 +5,18 @@ function failure(message, code) {
5
5
  return Object.assign(new Error(message), { code });
6
6
  }
7
7
 
8
- function reportedFailure(stdout, stderr, fallback) {
8
+ export function reportedFailure(stdout, stderr, fallback, fallbackCode = "flow_reconciliation_failed") {
9
9
  for (const text of [stdout, stderr]) {
10
10
  for (const line of [text.trim(), ...text.trim().split(/\n/).reverse()]) {
11
11
  try {
12
12
  const value = JSON.parse(line);
13
13
  const error = value?.error;
14
14
  if (typeof error?.code === "string" && error.code) return reportedError(error, fallback);
15
+ if (typeof value?.code === "string" && value.code) return reportedError({ ...value, message: typeof error === "string" ? error : value.message }, fallback);
15
16
  } catch { /* Not a structured CLI error line. */ }
16
17
  }
17
18
  }
18
- return failure(stderr.trim() || stdout.trim() || fallback, "flow_reconciliation_failed");
19
+ return failure(stderr.trim() || stdout.trim() || fallback, fallbackCode);
19
20
  }
20
21
 
21
22
  function processTarget(executable, args) {
@@ -1,4 +1,5 @@
1
1
  import crypto from "node:crypto";
2
+ import { isGitlessWorkspace, ownedWorkspaceHookPath, projectServicePathFilter, workspaceGitFiles } from "./workspace-files.js";
2
3
  import { spawn, spawnSync } from "node:child_process";
3
4
  import fs from "node:fs";
4
5
  import path from "node:path";
@@ -43,6 +44,28 @@ export function readCodeCheckProfile(workspaceRoot) {
43
44
  throw new AppError("invalid_code_check_profile", "An input declaration references an unknown alias", 2, { file, alias });
44
45
  return { profile: value, hash: crypto.createHash("sha256").update(bytes).digest("hex"), file };
45
46
  }
47
+ /**
48
+ * Planned aliases are qualification-owned extensions: their provider Work is
49
+ * expected to materialize them after PLAN. Freeze the project policy and
50
+ * existing aliases, while still validating every planned alias definition at
51
+ * execution time. Unknown edits and changes to existing policy remain part
52
+ * of the hash and therefore fail closed.
53
+ */
54
+ export function codeCheckProfileHash(workspaceRoot, checks = []) {
55
+ const { profile } = readCodeCheckProfile(workspaceRoot);
56
+ if (!profile)
57
+ return null;
58
+ const planned = new Set(checks.filter(check => check.availability === "planned" && check.command.startsWith("@check/")).map(check => check.command));
59
+ if (!planned.size)
60
+ return crypto.createHash("sha256").update(JSON.stringify(profile)).digest("hex");
61
+ const canonical = {
62
+ ...profile,
63
+ aliases: Object.fromEntries(Object.entries(profile.aliases).filter(([alias]) => !planned.has(alias))),
64
+ ports_by_alias: Object.fromEntries(Object.entries(profile.ports_by_alias ?? {}).filter(([alias]) => !planned.has(alias))),
65
+ inputs_by_alias: Object.fromEntries(Object.entries(profile.inputs_by_alias ?? {}).filter(([alias]) => !planned.has(alias)))
66
+ };
67
+ return crypto.createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
68
+ }
46
69
  export function validateCodeCheckCommands(workspaceRoot, commands) {
47
70
  const { profile, file } = readCodeCheckProfile(workspaceRoot);
48
71
  return commands.map((command) => {
@@ -139,7 +162,8 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
139
162
  const generation = assertRunMutationAllowed(context, input.projectId, input.runId);
140
163
  for (const check of input.checks)
141
164
  validateCheckDeclaration(input.workspaceRoot, check);
142
- const { hash: profileHash, profile } = readCodeCheckProfile(input.workspaceRoot);
165
+ const { profile } = readCodeCheckProfile(input.workspaceRoot);
166
+ const profileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
143
167
  if (input.expectedProfileHash !== undefined && input.expectedProfileHash !== profileHash)
144
168
  throw new AppError("check_profile_drift", "The frozen check profile differs from the workspace profile", 1, { expected_profile_hash: input.expectedProfileHash, actual_profile_hash: profileHash });
145
169
  const checks = input.checks.map((check) => { const ports = profile?.ports_by_alias?.[check.command]; const inputs = profile?.inputs_by_alias?.[check.command]; return { ...check, ...(check.ports || !ports ? {} : { ports }), ...(check.inputs || !inputs ? {} : { inputs }) }; });
@@ -239,7 +263,7 @@ async function runCodeChecksOwned(context, input, workspaceGateId) {
239
263
  input.progress?.(`check ${index + 1}/${groups.length} ${status}: ${group.command}`);
240
264
  }
241
265
  if (receipts.every(receipt => receipt.status === "passed")) {
242
- const current = workspaceState(input.workspaceRoot, [input.runHome]), { hash: currentProfileHash } = readCodeCheckProfile(input.workspaceRoot);
266
+ const current = workspaceState(input.workspaceRoot, [input.runHome]), currentProfileHash = codeCheckProfileHash(input.workspaceRoot, input.checks);
243
267
  for (const receipt of receipts) {
244
268
  const group = groups.find(candidate => candidate.declarations.some(check => receipt.check_refs.includes(checkRef(check))));
245
269
  const currentHash = group ? checkInputHash(input.workspaceRoot, group, currentProfileHash, workspaceInputFingerprint(input.workspaceRoot, current.files, group.inputs, [input.runHome])) : null;
@@ -414,7 +438,7 @@ export function workspaceChangedPaths(root) {
414
438
  // A workspace can be intentionally gitless. Keep the same conservative
415
439
  // file inventory used for fingerprints instead of making result validation
416
440
  // fail before the work contract can report its actual error.
417
- if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
441
+ if (isGitlessWorkspace(root, status))
418
442
  return listFiles(root);
419
443
  if (status.status !== 0 || status.error)
420
444
  throw new AppError("workspace_status_failed", "Could not determine changed workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
@@ -434,7 +458,8 @@ export function workspaceChangedPaths(root) {
434
458
  paths.push(normalizeWorkspacePath(root, prior));
435
459
  }
436
460
  }
437
- return [...new Set(paths)].sort();
461
+ const ownedHook = paths.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root) : null;
462
+ return [...new Set(paths)].filter(file => file !== ownedHook).sort();
438
463
  }
439
464
  /** Ignored machine state cannot be reported as a product change. Tracked paths
440
465
  * remain eligible regardless of their directory name. */
@@ -443,15 +468,15 @@ export function ignoredWorkspacePaths(root, paths) {
443
468
  if (!normalized.length)
444
469
  return [];
445
470
  const status = spawnSync("git", ["check-ignore", "-z", "--stdin"], { cwd: root, encoding: "utf8", input: `${normalized.join("\0")}\0`, maxBuffer: 64 * 1024 * 1024 });
446
- // Projects may intentionally run without Git (the flow supports a local
447
- // workspace identity independently from version control). In that case
448
- // there is no ignore database to consult; a declared path is a product
449
- // path, not an infrastructure path. Other Git failures remain fail-closed.
450
- if (status.status === 128 && /not a git repository|outside a repository/i.test(status.stderr ?? ""))
451
- return [];
452
- if (![0, 1].includes(status.status ?? -1) || status.error)
471
+ // The canonical owned hook has one meaning across Git status, check cache
472
+ // and MERGE, without changing a sibling worktree's shared Git excludes.
473
+ const gitless = isGitlessWorkspace(root, status);
474
+ if (!gitless && (![0, 1].includes(status.status ?? -1) || status.error))
453
475
  throw new AppError("workspace_ignore_check_failed", "Could not classify reported workspace paths", 1, { workspace_root: root, stderr: status.stderr || null, cause: status.error?.message ?? null });
454
- return [...new Set(status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)))].sort();
476
+ const ownedHook = normalized.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, gitless ? [] : undefined) : null;
477
+ if (gitless)
478
+ return ownedHook ? [ownedHook] : [];
479
+ return [...new Set([...status.stdout.split("\0").filter(Boolean).map((item) => normalizeWorkspacePath(root, item)), ...(ownedHook ? [ownedHook] : [])])].sort();
455
480
  }
456
481
  export function normalizeWorkspacePath(root, value) {
457
482
  if (typeof value !== "string" || !value || value.includes("\0") || path.isAbsolute(value))
@@ -480,11 +505,14 @@ function deduplicate(checks, commands) {
480
505
  return [...groups.values()].map(group => ({ ...group, gate: [...group.declarations].map(check => check.run_at).sort()[0], declarations: [...group.declarations].sort((left, right) => checkRef(left).localeCompare(checkRef(right))) }));
481
506
  }
482
507
  function workspaceState(root, ignoredRoots = []) {
483
- const git = spawnSync("git", ["ls-files", "-co", "--exclude-standard", "-z"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
508
+ const git = workspaceGitFiles(root, ["-co", "--exclude-standard"]);
484
509
  const ignored = ignoredRoots.map(entry => path.resolve(entry));
485
- const listing = [...new Set(git.status === 0 ? git.stdout.split("\0").filter(Boolean) : listFiles(root))].sort();
510
+ const listing = [...new Set(git ?? listFiles(root))].sort();
511
+ const ownedHook = listing.includes(".agents/hooks.json") ? ownedWorkspaceHookPath(root, git ? undefined : []) : null;
486
512
  const files = new Map(), hash = crypto.createHash("sha256");
487
513
  for (const relative of listing) {
514
+ if (relative === ownedHook)
515
+ continue;
488
516
  const file = path.resolve(root, relative);
489
517
  if (ignored.some(ignoredRoot => file === ignoredRoot || file.startsWith(`${ignoredRoot}${path.sep}`)))
490
518
  continue;
@@ -677,8 +705,17 @@ function collectCheckArtifacts(root, required) {
677
705
  const missing = required.filter((relative) => !retained.has(path.normalize(relative)));
678
706
  return { complete: missing.length === 0, missing, items };
679
707
  }
680
- function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
681
- return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
708
+ function listFiles(root) {
709
+ const allowed = projectServicePathFilter(root, [], []);
710
+ const ownedHook = ownedWorkspaceHookPath(root, []);
711
+ const visit = (current) => fs.readdirSync(current, { withFileTypes: true }).flatMap(entry => {
712
+ const absolute = path.join(current, entry.name);
713
+ if ([".git", "node_modules"].includes(entry.name) || !allowed(absolute))
714
+ return [];
715
+ return entry.isDirectory() ? visit(absolute) : [path.relative(root, absolute)];
716
+ });
717
+ return visit(root).filter(file => file.split(path.sep).join("/") !== ownedHook).sort();
718
+ }
682
719
  export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
683
720
  return new Promise((resolve) => {
684
721
  const started = Date.now();