@deksden-com/dd-flow-cli 0.9.0-beta.123 → 0.9.0-beta.127

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 (122) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +33 -1
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +4 -3
  6. package/dist/cli/help.js +1 -0
  7. package/dist/cli/hook-ingress.js +1 -1
  8. package/dist/cli/run-cli.js +126 -193
  9. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  10. package/dist/cli/runtime-maintenance-ingress.js +51 -0
  11. package/dist/cli/runtime-process.js +128 -0
  12. package/dist/cli.js +8 -5
  13. package/dist/domain/stage-catalog.js +1 -1
  14. package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  17. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  18. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  19. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  20. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  21. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  22. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  23. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  24. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  25. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  26. package/dist/harness-runtime/lib/daemon-operations.mjs +159 -15
  27. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +215 -86
  28. package/dist/harness-runtime/lib/dd-agy.mjs +11 -13
  29. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +47 -23
  30. package/dist/harness-runtime/lib/dd-codex.mjs +257 -99
  31. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +32 -30
  32. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  33. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +96 -38
  34. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  35. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +54 -20
  36. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  37. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +81 -76
  38. package/dist/harness-runtime/lib/dd-zcode.mjs +204 -116
  39. package/dist/harness-runtime/lib/driver-recovery.mjs +12 -2
  40. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  41. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  42. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  43. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  44. package/dist/harness-runtime/lib/managed-daemon.d.mts +11 -0
  45. package/dist/harness-runtime/lib/managed-daemon.mjs +321 -65
  46. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  47. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  48. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  49. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  50. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  51. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  52. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  53. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  54. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  55. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  56. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  57. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  58. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  59. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  60. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  61. package/dist/runtime/context.js +8 -4
  62. package/dist/schemas/flow-run.schema.json +1 -0
  63. package/dist/schemas/plan-review-decision.schema.json +3 -3
  64. package/dist/schemas/run-control-receipt.schema.json +19 -0
  65. package/dist/services/code-checks.js +93 -27
  66. package/dist/services/codex-capacity.js +251 -74
  67. package/dist/services/codex-hook-delivery.js +2 -2
  68. package/dist/services/compatibility-preflight.js +4 -1
  69. package/dist/services/controller-fanout.js +9 -75
  70. package/dist/services/engines.js +70 -20
  71. package/dist/services/eval-snapshots.js +179 -40
  72. package/dist/services/external-work-launch.js +21 -9
  73. package/dist/services/harness-adapter.js +195 -25
  74. package/dist/services/hooks.js +65 -21
  75. package/dist/services/lifecycle-invocations.js +404 -32
  76. package/dist/services/managed-lease-monitor.js +128 -0
  77. package/dist/services/managed-processes.js +117 -53
  78. package/dist/services/merge-server.js +66 -23
  79. package/dist/services/recovery-observation-budget.js +67 -7
  80. package/dist/services/recovery-snapshot-database.js +7 -4
  81. package/dist/services/review-deferral.js +23 -0
  82. package/dist/services/run-control-receipt.js +1 -1
  83. package/dist/services/run-control-worker.js +177 -118
  84. package/dist/services/run-control.js +99 -8
  85. package/dist/services/run-controller-adapter.js +177 -51
  86. package/dist/services/run-controller-capture.js +14 -2
  87. package/dist/services/run-controller-process.js +11 -4
  88. package/dist/services/run-controller-recovery.js +17 -0
  89. package/dist/services/run-controller-state.js +68 -7
  90. package/dist/services/run-controller.js +207 -107
  91. package/dist/services/run-fork.js +8 -3
  92. package/dist/services/run-recovery-guard.js +75 -0
  93. package/dist/services/run-recovery-runtime.js +21 -30
  94. package/dist/services/run-recovery.js +17 -13
  95. package/dist/services/runs.js +40 -7
  96. package/dist/services/runtime-budget.js +32 -15
  97. package/dist/services/runtime-scope-control.js +8 -0
  98. package/dist/services/runtime-scope-resume.js +30 -8
  99. package/dist/services/runtime-scope-worker.js +118 -73
  100. package/dist/services/runtime-service.js +6 -7
  101. package/dist/services/scope-writer-cleanup.js +3 -2
  102. package/dist/services/stage-context.js +135 -0
  103. package/dist/services/stage-lifecycle.js +3 -15
  104. package/dist/services/stage-pause.js +3 -2
  105. package/dist/services/usage.js +14 -3
  106. package/dist/services/verification-matrix.js +30 -1
  107. package/dist/services/vnext-code-review.js +26 -13
  108. package/dist/services/vnext-code.js +10 -8
  109. package/dist/services/vnext-contracts.js +10 -1
  110. package/dist/services/vnext-execution-profile.js +75 -2
  111. package/dist/services/vnext-fanout.js +5 -0
  112. package/dist/services/vnext-merge.js +30 -11
  113. package/dist/services/vnext-plan-review.js +102 -35
  114. package/dist/services/vnext-plan.js +9 -7
  115. package/dist/services/vnext-protocolize.js +15 -9
  116. package/dist/services/vnext-specify.js +6 -6
  117. package/dist/services/work-registry.js +43 -6
  118. package/dist/services/workspace-bootstrap.js +14 -3
  119. package/dist/shared/errors.js +11 -6
  120. package/dist/storage/agent-output.js +26 -0
  121. package/dist/storage/database.js +80 -21
  122. package/package.json +7 -4
@@ -1,6 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { ToolObservations, openCodeToolObservation } from "./tool-observations.mjs";
3
- import { observedTimeout } from "./observation-clock.mjs";
3
+ import { observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
4
+ import { assertNativeLine } from "./native-streams.mjs";
5
+ import { operationProgressReporter } from "./operation-progress.mjs";
4
6
 
5
7
  export class OpenCodeError extends Error {
6
8
  constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
@@ -10,18 +12,24 @@ export class OpenCodeClient {
10
12
  constructor({ baseUrl, password, directory, timeoutMs = 1_800_000 }) {
11
13
  this.baseUrl = new URL(baseUrl); this.password = password; this.directory = directory; this.timeoutMs = timeoutMs;
12
14
  }
13
- async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress } = {}) {
15
+ async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress, onDispatched } = {}) {
14
16
  const url = new URL(pathname, this.baseUrl); if (directory) url.searchParams.set("directory", directory);
15
17
  const controller = new AbortController(); const timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
16
18
  try {
17
- const response = await fetch(url, { method, headers: { authorization: `Basic ${Buffer.from(`opencode:${this.password}`).toString("base64")}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal });
18
- const text = await response.text(); let value = null; try { value = text ? JSON.parse(text) : null; } catch { value = text; }
19
+ const pending = fetch(url, { method, headers: { authorization: `Basic ${Buffer.from(`opencode:${this.password}`).toString("base64")}`, ...(body === undefined ? {} : { "content-type": "application/json" }) }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal });
20
+ void pending.catch(() => {}); await onDispatched?.(); const response = await pending;
21
+ let text = "";
22
+ const decoder = new TextDecoder();
23
+ if (response.body) for await (const chunk of response.body) { text += decoder.decode(chunk, { stream: true }); assertNativeLine(text); }
24
+ text += decoder.decode(); assertNativeLine(text);
25
+ let value = null; try { value = text ? JSON.parse(text) : null; } catch { value = text; }
19
26
  if (!response.ok) throw new OpenCodeError(response.status === 404 ? "session_not_found" : "opencode_http_error", `OpenCode ${method} ${pathname} returned ${response.status}`, response.status >= 500, value);
20
27
  return value;
21
28
  } catch (error) {
22
- if (error instanceof OpenCodeError) throw error;
29
+ controller.abort();
30
+ if (error instanceof OpenCodeError || error.code === "operation_observation_lost") throw error;
23
31
  // Aborting HTTP only loses the observer; it does not cancel the provider.
24
- throw new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname });
32
+ throw Object.assign(new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname }), { cause: error });
25
33
  } finally { clearTimeout(timer); }
26
34
  }
27
35
  health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
@@ -31,11 +39,15 @@ export class OpenCodeClient {
31
39
  statuses() { return this.request("GET", "/session/status"); }
32
40
  children(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/children`); }
33
41
  messages(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/message`); }
34
- async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch }) {
42
+ async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch, onDispatched, livenessTimeoutMs = this.timeoutMs }) {
43
+ validateObservationDuration(livenessTimeoutMs);
35
44
  const previous = await this.messages(id);
36
45
  if (!Array.isArray(previous)) throw new OpenCodeError("operation_observation_lost", "OpenCode history cannot establish the prompt boundary", true);
37
46
  const previousIds = new Set(previous.map(message => message.info?.id).filter(Boolean));
38
- let marker, observation = null;
47
+ const publish = operationProgressReporter();
48
+ let marker, observation = null, cursor = 0, signature = null, highWater = 0;
49
+ const seenSignatures = new Set();
50
+ const floors = new Map([[id, previousIds]]);
39
51
  // Native message content is progress; an HTTP poll or busy status is not.
40
52
  // Include descendants so a coordinating parent may legitimately be silent.
41
53
  const observe = async (session, seen = new Set(), parent = null) => {
@@ -46,21 +58,38 @@ export class OpenCodeClient {
46
58
  this.request("GET", `/session/${encodeURIComponent(session)}/message`, undefined, options),
47
59
  this.request("GET", `/session/${encodeURIComponent(session)}/children`, undefined, options)
48
60
  ]);
61
+ if (!Array.isArray(messages) || !Array.isArray(children)) throw new OpenCodeError("operation_observation_lost", "OpenCode observation shape is unproven", true);
49
62
  await onModelMessages?.(session, messages, parent);
50
- return [session, messages, ...await Promise.all((children ?? []).map(child => observe(child.id, seen, session)))];
63
+ // A newly discovered child does not turn its old transcript into work.
64
+ if (!floors.has(session)) floors.set(session, new Set(messages.map(message => message.info?.id).filter(Boolean)));
65
+ const current = messages.filter(message => message.info?.role === "assistant" && message.info?.sessionID === session && !floors.get(session).has(message.info?.id));
66
+ const content = current.map(message => ({ id: message.info?.id, parts: (message.parts ?? []).filter(part => ["text", "reasoning", "tool", "subtask"].includes(part.type)).map(stablePart) })).sort((a, b) => String(a.id).localeCompare(String(b.id)));
67
+ const checked = children.filter(child => child.parentID === session && typeof child.id === "string").sort((a, b) => a.id.localeCompare(b.id));
68
+ const descendants = (await Promise.all(checked.map(child => observe(child.id, seen, session)))).filter(value => value.length > 0);
69
+ return content.some(message => message.parts.length) || descendants.length ? [session, content, ...descendants] : [];
51
70
  };
52
71
  const refreshObservation = () => observation ??= (async () => {
53
- try { marker = createHash("sha256").update(JSON.stringify(await observe(id))).digest("hex"); }
72
+ try { const projection = JSON.stringify(await observe(id)), next = createHash("sha256").update(projection).digest("hex");
73
+ // Cumulative text can grow indefinitely without retaining every hash;
74
+ // non-growing snapshot states have bounded current-operation replay proof.
75
+ if (signature !== null && !seenSignatures.has(next) && (projection.length > highWater || seenSignatures.size < 1_024)) {
76
+ marker = { cursor: ++cursor, observed_at: Date.now() };
77
+ await publish("native_progress", { session_id: id, observed_at: new Date(marker.observed_at).toISOString() });
78
+ }
79
+ if (seenSignatures.size < 1_024) seenSignatures.add(next);
80
+ highWater = Math.max(highWater, projection.length);
81
+ signature = next;
82
+ }
54
83
  catch (error) { await onObservationError?.(error); /* Missing data is not progress. */ }
55
84
  })().finally(() => { observation = null; });
56
85
  // Begin observing before the long-running request. Waiting for the first
57
86
  // interval can let a short liveness window expire before any native
58
87
  // content has had a chance to renew it.
59
88
  void refreshObservation();
60
- const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000, this.timeoutMs / 4)));
89
+ const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000, livenessTimeoutMs / 4)));
61
90
  try {
62
91
  assertDispatch?.();
63
- const reply = await this.request("POST", `/session/${encodeURIComponent(id)}/message`, { model: { providerID: provider, modelID: model }, variant, agent, parts: [{ type: "text", text: prompt }] }, { progress: () => marker });
92
+ const reply = await this.request("POST", `/session/${encodeURIComponent(id)}/message`, { model: { providerID: provider, modelID: model }, variant, agent, parts: [{ type: "text", text: prompt }] }, { timeoutMs: livenessTimeoutMs, progress: () => marker, onDispatched });
64
93
  if (!reply?.info?.id || reply.info.sessionID !== id || reply.info.role !== "assistant" || previousIds.has(reply.info.id) || !Array.isArray(reply.parts)) {
65
94
  throw new OpenCodeError("operation_observation_lost", "OpenCode did not return an identifiable current assistant response", true, { session_id: id, message_id: reply?.info?.id ?? null });
66
95
  }
@@ -69,7 +98,8 @@ export class OpenCodeClient {
69
98
  const message = native.data?.message ?? native.message ?? native.name ?? "OpenCode provider failed";
70
99
  const status = native.data?.statusCode;
71
100
  const code = status === 402 || /payment required|quota|usage balance exhausted|insufficient.?credit|billing/i.test(message) ? "provider_quota_exhausted" : /rate.?limit|too many requests/i.test(message) ? "provider_rate_limited" : status === 429 ? "provider_limit_unknown" : "opencode_provider_failed";
72
- throw new OpenCodeError(code, message, code === "provider_rate_limited", { session_id: id, message_id: reply.info.id, provider_error: native });
101
+ throw new OpenCodeError(code, message, code === "provider_rate_limited", { session_id: id, provider_session_id: id, message_id: reply.info.id, provider_error: native,
102
+ observed_at: new Date().toISOString(), native_source: "opencode.reply.info.error", turn_id: reply.info.id });
73
103
  }
74
104
  return reply;
75
105
  }
@@ -81,7 +111,7 @@ export class OpenCodeClient {
81
111
  }
82
112
 
83
113
  function stablePart(part) {
84
- if (part.type === "text") return { type: "text", text: part.text, synthetic: part.synthetic === true };
114
+ if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: part.text, synthetic: part.synthetic === true };
85
115
  if (part.type === "tool") return { type: "tool", tool: part.tool, state: { status: part.state?.status, input: part.state?.input, output: part.state?.output, error: part.state?.error } };
86
116
  if (part.type === "subtask") return { type: "subtask", prompt: part.prompt, description: part.description, agent: part.agent };
87
117
  return { type: part.type };
@@ -1,11 +1,16 @@
1
- import { observedTimeout } from "./observation-clock.mjs";
1
+ import { callDaemonObserved } from './daemon-observation.mjs';
2
+ import { admitNativeRequestBuffer } from "./native-streams.mjs";
3
+ import { ObservationClock } from "./observation-clock.mjs";
2
4
  import { spawn } from "node:child_process";
3
5
  import { createHash, randomUUID } from "node:crypto";
4
6
  import { chmod, lstat, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
5
7
  import net from "node:net";
6
- import { durableDaemonDispatch } from "./daemon-operations.mjs";
8
+ import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
7
9
  import path from "node:path";
8
- import { prepareRuntimeOwner, cleanupFailedDaemonStart, closeManagedBridge, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
10
+ import { prepareRuntimeOwner, cleanupFailedDaemonStart, confirmDaemonProcess, confirmDaemonStopped, finishDaemonProcess, scheduleDaemonProcessHeartbeat, registerDaemonProcess, startManagedBridge, stopProcessGroup } from "./managed-daemon.mjs";
11
+ import { acceptNativeHookRequest, hookBudget, readNativeHookFailures } from "./native-hook-command.mjs";
12
+ import { parseLifecycleCommand, continuationCommand } from "./lifecycle-command.mjs";
13
+ import { errorRecord, isProvenShellCorrection } from "./operation-errors.mjs";
9
14
 
10
15
  import {
11
16
  AcpBridge,
@@ -80,48 +85,11 @@ async function removeSocket(socket, dir) {
80
85
  } catch (error) { if (error.code !== "ENOENT") throw error; }
81
86
  }
82
87
 
83
- function errorPayload(error) {
84
- return {
85
- code: error.code ?? "operation_failed",
86
- message: error.message ?? String(error),
87
- retryable: error.retryable === true,
88
- ...(error.details === undefined ? {} : { details: error.details }),
89
- };
90
- }
88
+ const errorPayload = errorRecord;
91
89
 
92
90
  export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
93
- const { socket } = locations(stateDir);
94
91
  const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
95
- return await new Promise((resolve, reject) => {
96
- const client = net.createConnection(socket);
97
- let buffer = ""; let settled = false;
98
- // A prompt has its own sliding ACP liveness deadline. Do not add an
99
- // independent wall-clock deadline here: it can cut off an actively
100
- // producing Turn merely because it is long.
101
- const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
102
- ? observedTimeout(() => { client.destroy(); reject(new DaemonError("daemon_timeout", `${operation} timed out`, true)); }, timeoutMs)
103
- : null;
104
- const finish = (error, value) => {
105
- if (settled) return;
106
- settled = true; if (timer) clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(value);
107
- };
108
- client.setEncoding("utf8");
109
- client.on("connect", () => client.write(`${JSON.stringify(request)}\n`));
110
- client.on("data", (chunk) => {
111
- buffer += chunk;
112
- const newline = buffer.indexOf("\n");
113
- if (newline < 0) return;
114
- try {
115
- const response = JSON.parse(buffer.slice(0, newline));
116
- if (response.schema_id !== RESPONSE_SCHEMA || response.id !== request.id) return finish(new DaemonError("daemon_protocol_mismatch", "invalid daemon response"));
117
- if (!response.ok) return finish(new DaemonError(response.error?.code ?? "operation_failed", response.error?.message ?? "daemon operation failed", response.error?.retryable, response.error?.details));
118
- finish(null, response.result);
119
- } catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); }
120
- });
121
- client.on("error", (error) => finish(new DaemonError("daemon_not_running", error.message, true)));
122
- client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
123
- client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true)));
124
- });
92
+ return await callDaemonObserved({ stateDir, socket: locations(stateDir).socket, request, responseSchema: RESPONSE_SCHEMA, harness: 'zcode', timeoutMs });
125
93
  }
126
94
 
127
95
  const ZCODE_ROUTING_ENV = ["ZCODE_HOME", "ZCODE_STORAGE_DIR", "ZCODE_SESSION_DB_PATH"];
@@ -163,13 +131,14 @@ export async function startDaemon(options) {
163
131
  await prepareRuntimeOwner(config, paths.dir);
164
132
  await mkdir(paths.dir, { recursive: true, mode: 0o700 });
165
133
  await chmod(paths.dir, 0o700);
134
+ let previous = await readState(paths.dir); assertDaemonRestartable(previous);
166
135
  try {
167
136
  const status = await callDaemon(paths.dir, "daemon.status", {}, 1000);
168
137
  if (!sameConfig(status, config)) throw new DaemonError("daemon_config_mismatch", "a daemon is already running with different configuration");
169
138
  return { ...status, already_running: true };
170
139
  } catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
171
140
 
172
- const previous = await readState(paths.dir);
141
+ previous = await readState(paths.dir); assertDaemonRestartable(previous);
173
142
  if (previous?.shutdown_state === "clean") {
174
143
  if (!sameNativeRouting(previous.config, config)) throw new DaemonError("daemon_config_mismatch", "retained ZCode native storage routing changed");
175
144
  await authorizeRetainedDaemonResume(paths.dir, previous, options.sessionId, config);
@@ -211,8 +180,8 @@ export async function startDaemon(options) {
211
180
  await log.close();
212
181
  try {
213
182
  await confirmDaemonProcess(config, resourceProcess, child);
214
- const deadline = Date.now() + 30_000;
215
- while (Date.now() < deadline) {
183
+ const startupClock = new ObservationClock({ timeoutMs: 30_000 });
184
+ while (!startupClock.sample()) {
216
185
  await new Promise((resolve) => setTimeout(resolve, 100));
217
186
  try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
218
187
  catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
@@ -227,7 +196,7 @@ export async function startDaemon(options) {
227
196
  export async function stopDaemon(options) {
228
197
  const timeoutMs = options.timeoutMs ?? 30_000;
229
198
  const { socket } = locations(options.stateDir);
230
- return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs);
199
+ return await confirmDaemonStopped(() => callDaemon(options.stateDir, "daemon.stop", { cancelTree: options.cancelTree === true }, timeoutMs), socket, timeoutMs, { readState: () => readState(options.stateDir) });
231
200
  }
232
201
 
233
202
  export class DaemonRuntime {
@@ -241,7 +210,7 @@ export class DaemonRuntime {
241
210
  persist(patch = {}) {
242
211
  Object.assign(this.state, patch, { pid: process.pid, updated_at: new Date().toISOString(), sessions: [...this.sessions.values()] });
243
212
  const snapshot = structuredClone(this.state);
244
- this.persisting = this.persisting.then(() => writeState(this.paths.state, snapshot));
213
+ this.persisting = this.persisting.catch(() => {}).then(() => writeState(this.paths.state, snapshot));
245
214
  return this.persisting;
246
215
  }
247
216
 
@@ -250,13 +219,18 @@ export class DaemonRuntime {
250
219
  const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
251
220
  const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
252
221
  return {
253
- ...this.state.config, ...params, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
222
+ ...this.state.config, ...params, operationId: this.state.active_request?.request_id, knownChildSessionIds: known?.known_child_session_ids ?? [], adapterSessionId: adapter,
254
223
  neverPromptedRootProof: this.neverPromptedRootProof(params.sessionId),
255
224
  daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
256
225
  assertDispatch: () => { if (generation !== (this.controlGeneration ?? 0)) throw new DaemonError("operation_cancelled", "ZCode prompt was cancelled before native dispatch"); },
226
+ onActivity: async ({ sessionId, requestId, progress }) => {
227
+ const request = this.state.active_request;
228
+ if (!request || request.adapter_session_id !== sessionId || this.activeGeneration !== request.generation) return;
229
+ await this.persist({ active_request: { ...request, observation: { state: "productive", request_id: requestId, progress } } });
230
+ },
257
231
  // The bridge owns the quiet timer; the daemon owns the durable status.
258
232
  // Do not infer provider state or cancel here.
259
- onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
233
+ onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs, cursor }) => {
260
234
  const request = this.state.active_request;
261
235
  // `sessionId` is ACP identity; the durable request's `session_id` is
262
236
  // native/provider identity. These strings can coincide but are not
@@ -285,7 +259,8 @@ export class DaemonRuntime {
285
259
  // The probe is asynchronous. Do not recreate a completed or
286
260
  // reconnected request with a stale callback snapshot.
287
261
  const current = this.state.active_request;
288
- if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation) return;
262
+ if (!current || current.request_id !== request.request_id || current.adapter_session_id !== adapterSessionId || this.activeGeneration !== request.generation
263
+ || cursor !== undefined && this.bridge.pending?.get(requestId)?.progress?.cursor !== cursor && cursor !== 0) return;
289
264
  await this.persist({ active_request: {
290
265
  ...current,
291
266
  observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
@@ -388,9 +363,15 @@ export class DaemonRuntime {
388
363
  if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
389
364
  this.activeProductive = name;
390
365
  this.activeGeneration = this.controlGeneration ?? 0;
366
+ const current = this.nativeHookOperation = { operationId: request?.operation_id, generation: this.activeGeneration, requests: new Map() };
367
+ let rejectHook;
368
+ const hookFailed = new Promise((_, reject) => { rejectHook = reject; });
369
+ void hookFailed.catch(() => {}); // A hook can fail while the dispatch receipt is still persisting.
370
+ current.reject = error => { if (this.nativeHookOperation !== current || current.error) return; current.error = error; this.bridge.lifecycleFailure ??= error; this.bridge.notificationError = this.bridge.lifecycleFailure; this.bridge.rejectAll(this.bridge.lifecycleFailure); rejectHook(this.bridge.lifecycleFailure); };
371
+ const timer = setInterval(() => { void this.observeHookFailures(current).catch(error => current.reject(error)); }, 100);
391
372
  try {
392
373
  await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
393
- const result = await task();
374
+ const result = await Promise.race([task(), hookFailed]);
394
375
  this.track(result);
395
376
  const running = this.treeRunning(result);
396
377
  await this.persist({ active_tree: running, active_operation: null, active_request: null });
@@ -402,12 +383,12 @@ export class DaemonRuntime {
402
383
  // daemon state no longer carried the only correlation to it.
403
384
  const active = this.state?.active_request;
404
385
  try {
405
- await this.persist({
386
+ await hookBudget(1_000, null).run(() => this.persist({
406
387
  active_operation: null,
407
388
  active_request: active?.allocation
408
389
  ? { ...active, outcome: "unknown", error: errorPayload(error) }
409
390
  : null,
410
- });
391
+ }), "failure_persistence");
411
392
  } catch (cleanupError) {
412
393
  // Preserve the operation's primary failure if durable cleanup itself
413
394
  // is unavailable. The caller will retain the outcome as unknown and
@@ -415,7 +396,15 @@ export class DaemonRuntime {
415
396
  if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
416
397
  }
417
398
  throw error;
418
- } finally { this.activeProductive = null; this.activeGeneration = null; }
399
+ } finally { clearInterval(timer); current.requests.clear(); if (this.nativeHookOperation === current) this.nativeHookOperation = null; this.activeProductive = null; this.activeGeneration = null; }
400
+ }
401
+
402
+ async observeHookFailures(current = this.nativeHookOperation) {
403
+ if (!current || this.nativeHookOperation !== current || current.reading) return;
404
+ current.reading = true;
405
+ try { await readNativeHookFailures(this.paths.dir, current.requests, current, error => {
406
+ if (!isProvenShellCorrection(error)) current.reject(error);
407
+ }); } finally { current.reading = false; }
419
408
  }
420
409
 
421
410
  async requireSettled(params) {
@@ -447,7 +436,8 @@ export class DaemonRuntime {
447
436
  await this.persist();
448
437
  }
449
438
 
450
- async dispatch(operation, params) {
439
+ async dispatch(operation, params, operationId = null) {
440
+ if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
451
441
  if (operation === "daemon.status") return {
452
442
  daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
453
443
  cwd: this.state.cwd, journal: this.state.journal, versions: this.state.versions,
@@ -460,10 +450,24 @@ export class DaemonRuntime {
460
450
  const sessionId = String(params.sessionId ?? "");
461
451
  const session = this.sessions.get(sessionId);
462
452
  if (!session?.root_provider_session_id) throw new DaemonError("hook_identity_unknown", "ZCode hook Session is not a tracked controlled Session", false, { session_id: sessionId });
463
- return { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} };
453
+ const identity = { daemonId: this.state.daemon_id, rootProviderSessionId: session.root_provider_session_id, parentProviderSessionId: session.parent_provider_session_id ?? null, observedProfile: this.bridge.modelProfiles?.get(sessionId) ?? {} };
454
+ const payload = params.payload, command = payload?.tool_input?.command ?? payload?.tool_input?.cmd;
455
+ if (payload?.hook_event_name === "PreToolUse" && typeof command === "string" && (["lifecycle", "auxiliary"].includes(parseLifecycleCommand(command).participation) || continuationCommand(command))) {
456
+ const current = this.nativeHookOperation, active = this.state.active_request;
457
+ const tool = payload.tool_use_id ?? payload.tool_call_id;
458
+ const pending = [...(this.bridge.pending?.values() ?? [])].some(request => request.method === "session/prompt" && request.sessionId === active?.adapter_session_id);
459
+ const root = active?.session_id ?? active?.allocation?.provider_session_id;
460
+ if (!current || !active || !pending || current.generation !== (this.controlGeneration ?? 0) || session.root_provider_session_id !== root || !tool) throw new DaemonError("native_hook_unproven", "Native hook has no exact active Session operation");
461
+ const binding = await hookBudget(1_000, null).run(() => acceptNativeHookRequest(this.paths.dir, { daemon_id: this.state.daemon_id, operation_id: current.operationId, turn_generation: current.generation, provider_session_id: sessionId, root_provider_session_id: session.root_provider_session_id, tool_call_id: tool, event: "PreToolUse", command_sha256: createHash("sha256").update(command).digest("hex"), native_cwd: payload.cwd ?? null }, current), "hook_binding");
462
+ if (this.nativeHookOperation !== current) throw new DaemonError("native_hook_unproven", "Native hook operation changed during acceptance");
463
+ current.requests.set(binding.request_id, { current, binding }); identity.nativeHookBinding = binding;
464
+ }
465
+ return identity;
464
466
  }
467
+ if (operation === "hook.failed") { await this.observeHookFailures(); return { observed: true }; }
465
468
  if (operation === "session.create") {
466
469
  const request = {
470
+ operation_id: operationId,
467
471
  request_id: randomUUID(),
468
472
  session_id: params.sessionId ?? null,
469
473
  adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
@@ -477,6 +481,7 @@ export class DaemonRuntime {
477
481
  if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
478
482
  const known = this.sessions.get(params.sessionId);
479
483
  const request = {
484
+ operation_id: operationId,
480
485
  request_id: randomUUID(),
481
486
  session_id: params.sessionId,
482
487
  adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
@@ -548,12 +553,17 @@ export class DaemonRuntime {
548
553
  await this.persist({ active_tree: this.treeRunning(result) || Boolean(this.activeProductive) });
549
554
  return result;
550
555
  }
551
- if (operation === "daemon.stop") return await this.prepareStop(params.cancelTree === true);
556
+ if (operation === "daemon.stop") return await completeDaemonShutdown(this.paths.dir, this.state, patch => this.persist(patch), () => this.prepareStop(params.cancelTree === true), [
557
+ ["provider_resource", () => finishDaemonProcess(this.state.config, this.state.provider_process)],
558
+ ["daemon_resource", () => finishDaemonProcess(this.state.config, this.state.resource_process)]
559
+ ]);
552
560
  throw new DaemonError("unknown_operation", `unknown daemon operation: ${operation}`);
553
561
  }
554
562
 
555
563
  async prepareStop(cancel) {
564
+ if (this.activeProductive && !cancel) throw new DaemonError("tree_not_settled", "productive operation is running");
556
565
  if (cancel) this.controlGeneration = (this.controlGeneration ?? 0) + 1;
566
+ if (!this.state.native_stop_proof || this.state.native_stop_proof.daemon_id !== this.state.daemon_id) {
557
567
  const unsettled = [];
558
568
  for (const session of this.sessions.values()) {
559
569
  if (session.close_receipt) {
@@ -603,6 +613,10 @@ export class DaemonRuntime {
603
613
  }
604
614
  }
605
615
  if (unsettled.length) throw new DaemonError("tree_not_settled", "daemon still owns running Sessions", false, { sessions: unsettled });
616
+ commitDaemonShutdown(this.paths.dir);
617
+ await this.persist({ native_stop_proof: { daemon_id: this.state.daemon_id, sessions: [...this.sessions.keys()], observed_at: new Date().toISOString() } });
618
+ }
619
+ commitDaemonShutdown(this.paths.dir);
606
620
  // A cooperative close can make the native Session non-resident while its
607
621
  // original ACP prompt is still pending. For an explicitly whole-RUN
608
622
  // cancellation this daemon owns the bridge process group, so retire that
@@ -614,7 +628,7 @@ export class DaemonRuntime {
614
628
  if (typeof this.bridge.close === "function") await this.bridge.close();
615
629
  bridgeClosed = true;
616
630
  } catch (error) {
617
- throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false);
631
+ throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details);
618
632
  }
619
633
  }
620
634
  const deadline = Date.now() + 5000;
@@ -625,24 +639,11 @@ export class DaemonRuntime {
625
639
  // A bridge without a close method occurs only in in-memory diagnostics: it
626
640
  // owns no OS process boundary. Production AcpBridge always has one.
627
641
  try { if (!bridgeClosed && typeof this.bridge.close === "function") await this.bridge.close(); }
628
- catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false); }
642
+ catch (error) { throw new DaemonError(error.code ?? "provider_process_stop_incomplete", error.message ?? String(error), false, error.details); }
629
643
  await this.persist({ active_tree: false, shutdown_state: "stopping", active_operation: null, active_request: null, physical_settlement: { confirmed_at: new Date().toISOString(), bridge_pid: this.bridge.child?.pid ?? null } });
630
644
  return { daemon_id: this.state.daemon_id, stopped: true, clean: true, physical_settlement: this.state.physical_settlement, _shutdown: true };
631
645
  }
632
646
 
633
- async shutdown() {
634
- // prepareStop has already closed the owned bridge. Signal shutdown may get
635
- // here directly, so close remains idempotent.
636
- await closeManagedBridge(this.state.config, this.state, this.bridge);
637
- await this.persist({ active_tree: false, shutdown_state: "clean", stopped_at: new Date().toISOString() });
638
- const resourceProcess = this.state.resource_process;
639
- if (resourceProcess?.id && resourceProcess.lease_token) {
640
- try { await finishDaemonProcess(this.state.config, resourceProcess, "stopped", "daemon_stopped"); }
641
- catch { /* Keep the state; the next reconciler owns stale processes. */ }
642
- }
643
- await new Promise((resolve) => this.server.close(resolve));
644
- await removeSocket(this.paths.socket, this.paths.dir);
645
- }
646
647
  }
647
648
 
648
649
  export function zcodeBridgeEnv(config, daemonId) {
@@ -660,27 +661,31 @@ export async function serveDaemon(stateDir) {
660
661
  scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
661
662
  await removeSocket(paths.socket, paths.dir);
662
663
  const server = net.createServer((connection) => {
663
- connection.setEncoding("utf8"); let buffer = "";
664
+ connection.setEncoding("utf8"); let buffer = "", consumed = false;
664
665
  connection.on("data", (chunk) => {
665
- buffer += chunk;
666
+ if (consumed) return;
667
+ buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; }
666
668
  const newline = buffer.indexOf("\n");
667
669
  if (newline < 0) return;
670
+ consumed = true;
668
671
  const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
669
672
  void (async () => {
670
673
  let request;
671
674
  try {
672
675
  request = JSON.parse(line);
673
676
  if (request.schema_id !== REQUEST_SCHEMA || !request.id || !request.operation) throw new DaemonError("daemon_protocol_mismatch", "invalid daemon request");
674
- const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
677
+ const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}, request.id), () => runtime.dispatch("session.inspect", request.params ?? {}));
678
+ if (result?._shutdown) retireDaemonEndpoint(server);
675
679
  connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
676
- if (result?._shutdown) setImmediate(() => void runtime.shutdown().then(() => process.exit(0)));
677
680
  } catch (error) {
681
+ await retireFailedDaemon(server, state, error, patch => runtime.persist(patch));
678
682
  connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request?.id ?? null, ok: false, error: errorPayload(error) })}\n`);
679
683
  }
680
684
  })();
681
685
  });
682
686
  });
683
687
  runtime.server = server;
688
+ trackDaemonConnections(server);
684
689
  await new Promise((resolve, reject) => { server.once("error", reject); server.listen(paths.socket, resolve); });
685
690
  await chmod(paths.socket, 0o600);
686
691
  await runtime.persist({ shutdown_state: "running", active_tree: false });