@deksden-com/dd-flow-cli 0.9.0-beta.124 → 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 (113) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -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/harness-runtime/bin/dd-agy.mjs +3 -2
  14. package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
  15. package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
  16. package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
  17. package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
  18. package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
  19. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  20. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  21. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  22. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  23. package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
  24. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
  25. package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
  26. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
  27. package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
  28. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
  29. package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
  30. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
  31. package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
  32. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
  33. package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
  34. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
  35. package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
  36. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  37. package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
  38. package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
  39. package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
  40. package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
  41. package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
  42. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  43. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  44. package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
  45. package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
  46. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  47. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  48. package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
  49. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  50. package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
  51. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  52. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  53. package/dist/harness-runtime/lib/process-json.mjs +136 -38
  54. package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
  55. package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
  56. package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
  57. package/dist/runtime/context.js +8 -4
  58. package/dist/schemas/run-control-receipt.schema.json +19 -0
  59. package/dist/services/code-checks.js +79 -9
  60. package/dist/services/codex-capacity.js +117 -41
  61. package/dist/services/codex-hook-delivery.js +2 -2
  62. package/dist/services/compatibility-preflight.js +4 -1
  63. package/dist/services/controller-fanout.js +9 -76
  64. package/dist/services/engines.js +70 -20
  65. package/dist/services/eval-snapshots.js +179 -40
  66. package/dist/services/external-work-launch.js +13 -8
  67. package/dist/services/harness-adapter.js +175 -11
  68. package/dist/services/hooks.js +65 -21
  69. package/dist/services/lifecycle-invocations.js +404 -32
  70. package/dist/services/managed-lease-monitor.js +128 -0
  71. package/dist/services/managed-processes.js +103 -47
  72. package/dist/services/merge-server.js +58 -21
  73. package/dist/services/recovery-observation-budget.js +67 -7
  74. package/dist/services/recovery-snapshot-database.js +7 -4
  75. package/dist/services/run-control-receipt.js +1 -1
  76. package/dist/services/run-control-worker.js +177 -118
  77. package/dist/services/run-control.js +99 -8
  78. package/dist/services/run-controller-adapter.js +169 -46
  79. package/dist/services/run-controller-capture.js +14 -2
  80. package/dist/services/run-controller-process.js +11 -4
  81. package/dist/services/run-controller-state.js +68 -7
  82. package/dist/services/run-controller.js +166 -94
  83. package/dist/services/run-fork.js +8 -3
  84. package/dist/services/run-recovery-guard.js +75 -0
  85. package/dist/services/run-recovery-runtime.js +1 -56
  86. package/dist/services/run-recovery.js +17 -13
  87. package/dist/services/runs.js +39 -6
  88. package/dist/services/runtime-budget.js +32 -15
  89. package/dist/services/runtime-scope-control.js +8 -0
  90. package/dist/services/runtime-scope-resume.js +30 -8
  91. package/dist/services/runtime-scope-worker.js +118 -73
  92. package/dist/services/runtime-service.js +6 -7
  93. package/dist/services/scope-writer-cleanup.js +3 -2
  94. package/dist/services/stage-context.js +135 -0
  95. package/dist/services/stage-lifecycle.js +3 -15
  96. package/dist/services/stage-pause.js +3 -2
  97. package/dist/services/usage.js +14 -3
  98. package/dist/services/vnext-code-review.js +10 -9
  99. package/dist/services/vnext-code.js +10 -8
  100. package/dist/services/vnext-contracts.js +10 -1
  101. package/dist/services/vnext-execution-profile.js +75 -2
  102. package/dist/services/vnext-fanout.js +5 -0
  103. package/dist/services/vnext-merge.js +10 -9
  104. package/dist/services/vnext-plan-review.js +21 -17
  105. package/dist/services/vnext-plan.js +9 -7
  106. package/dist/services/vnext-protocolize.js +15 -9
  107. package/dist/services/vnext-specify.js +6 -6
  108. package/dist/services/work-registry.js +34 -4
  109. package/dist/services/workspace-bootstrap.js +14 -3
  110. package/dist/shared/errors.js +11 -6
  111. package/dist/storage/agent-output.js +26 -0
  112. package/dist/storage/database.js +80 -21
  113. package/package.json +7 -4
@@ -1,9 +1,10 @@
1
- import { spawn } from "node:child_process";
2
1
  import { readFile } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
 
5
4
  import { AcpBridge } from "./dd-zcode.mjs";
6
5
  import { checkObservedProfile, observeModel } from "./model-observations.mjs";
6
+ import { errorRecord } from "./operation-errors.mjs";
7
+ import { nativeObservationError } from "./native-streams.mjs";
7
8
 
8
9
  const GROK_HARNESS_CONTRACT = "dd-grok-harness@1";
9
10
 
@@ -14,11 +15,7 @@ function executable(command, args = []) { const resolved = command ?? process.en
14
15
 
15
16
  async function commandOutput(command, args) {
16
17
  const target = executable(command, args);
17
- return await new Promise((resolve, reject) => {
18
- const child = spawn(target.command, target.args, { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = "";
19
- child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk)); child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
20
- child.on("error", reject); child.on("close", (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${target.command} exited ${code}`)));
21
- });
18
+ return await commandText(target.command, target.args, { phase: "native-version" });
22
19
  }
23
20
 
24
21
  function profile(options) { return { provider: "xai", model: options.model ?? null, reasoning: options.reasoning ?? null, mode: options.mode ?? "bypassPermissions" }; }
@@ -56,8 +53,10 @@ function bridgeOptions(options) {
56
53
 
57
54
  async function withBridge(options, operation) {
58
55
  const bridge = new AcpBridge(bridgeOptions(options));
59
- try { const initialized = await bridge.start(); return await operation(bridge, initialized); }
60
- finally { await bridge.close(); }
56
+ let primaryError, result;
57
+ try { const initialized = await bridge.start(); result = await operation(bridge, initialized); return result; }
58
+ catch (error) { primaryError = error; throw error; }
59
+ finally { try { await bridge.close(); } catch (error) { if (!primaryError) throw nativeObservationError(error, result === undefined ? {} : { result }); primaryError.cleanup_error = errorRecord(error); primaryError.details = { ...primaryError.details, cleanup_error: primaryError.cleanup_error }; } }
61
60
  }
62
61
 
63
62
  export function observedProfile(initialized, requested) {
@@ -88,10 +87,7 @@ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
88
87
  const measured = usage.usage && typeof usage.usage === "object" ? usage.usage : usage;
89
88
  const input = JSON.stringify({ provider_session_id: providerSessionId, daemon_id: options.daemonId ?? null, observed_at: new Date().toISOString(), usage: measured, tool_calls: toolCalls, usage_scope: usageScope(options, providerSessionId), completeness: measured.usageIsIncomplete ? "partial" : "complete" });
90
89
  const target = executable(options.ddFlowBin, ["grok", "usage", "ingest", "--project-root", absolute(options.projectRoot, "--project-root"), "--json"]);
91
- await new Promise((resolve, reject) => {
92
- const child = spawn(target.command, target.args, { env: { ...process.env, ...(options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {}) }, stdio: ["pipe", "ignore", "pipe"] }); let stderr = "";
93
- child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk)); child.on("error", reject); child.on("close", (code) => code === 0 ? resolve() : reject(new Error(stderr || "dd-flow grok usage failed"))); child.stdin.end(`${input}\n`);
94
- });
90
+ await commandText(target.command, target.args, { input: `${input}\n`, env: options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {}, phase: "usage-ingest" });
95
91
  }
96
92
 
97
93
  async function forwardUsageBestEffort(options, providerSessionId, usage, toolCalls) {
@@ -153,10 +149,15 @@ export async function promptSessionWithBridge(bridge, options) {
153
149
  requireJournal(options); const sessionId = await identity(bridge, options); const prompt = text(options.prompt, "prompt"); const requested = requiredProfile(options); const profileReceipt = assertProfile(requested, observedProfile(options.initialized, requested));
154
150
  const local = { ...options, rootProviderSessionId: options.rootProviderSessionId ?? sessionId }; const initial_usage_error = await forwardUsageBestEffort(local, sessionId, await sessionUsage(bridge, sessionId), sessionToolCalls(local, bridge, sessionId));
155
151
  options.assertDispatch?.();
156
- const cursor = bridge.toolCursor(); const textCursor = bridge.assistantTextCursor(sessionId); const turn = await bridge.request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, null, { idleSessionId: sessionId, idleTimeoutMs: options.timeoutMs ?? 600_000 }); await bridge.flush();
152
+ bridge.dispatchOperationId = options.operationId;
153
+ const cursor = bridge.toolCursor(); const textCursor = bridge.assistantTextCursor(sessionId); const turn = await bridge.request("session/prompt", { sessionId, prompt: [{ type: "text", text: prompt }] }, null, { idleSessionId: sessionId, idleTimeoutMs: options.livenessTimeoutMs ?? options.timeoutMs ?? 600_000 });
154
+ try {
155
+ await bridge.flush();
157
156
  const evidence = await inspect(bridge, sessionId, local); evidence.tool_calls = recordToolCalls(local, bridge, sessionId, cursor); const usage_ingest_error = await forwardUsageBestEffort(local, sessionId, evidence.usage, evidence.tool_calls); if (initial_usage_error) evidence.initial_usage_error = initial_usage_error; if (usage_ingest_error) evidence.usage_ingest_error = usage_ingest_error;
158
157
  if (!options.allowBackground && (evidence.subagents?.subagents ?? []).length) { await cancelTree(bridge, sessionId); fail("background Grok Build subagents cannot outlive the one-shot dd-grok connection"); }
159
158
  return receipt(options, { harness: "grok-acp", provider_session_id: sessionId, adapter_session_id: sessionId, assistant_text: bridge.assistantTextSince(sessionId, textCursor), turn, profile: profileReceipt, evidence });
159
+ } catch (error) { throw Object.assign(new Error("Grok native Turn completed but mandatory observation failed", { cause: error }), { code: "native_outcome_observation_failed", retryable: false,
160
+ details: { provider_session_id: sessionId, native_outcome: { status: "completed", turn }, observation_error: errorRecord(error), settled: false, journal_locator: options.journal } }); }
160
161
  }
161
162
 
162
163
  export async function inspectSession(options) { requireJournal(options); return await withBridge(options, async (bridge, initialized) => await inspectSessionWithBridge(bridge, { ...options, initialized })); }
@@ -173,3 +174,4 @@ export async function forkSessionWithBridge(bridge, options) {
173
174
  const child = text(result.newSessionId, "Grok Build fork newSessionId"); return receipt(options, { harness: "grok-acp", parent_provider_session_id: sessionId, provider_session_id: child, adapter_session_id: child, target, fork: result });
174
175
  }
175
176
  export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
177
+ import { commandText } from "./process-json.mjs";
@@ -1,6 +1,8 @@
1
+ import { callDaemonObserved } from './daemon-observation.mjs';
2
+ import { admitNativeRequestBuffer } from "./native-streams.mjs";
1
3
  import { captureDispatchGuard, cancelPendingDispatch } from "./dispatch-fence.mjs";
2
4
  import { openCodeToolObservation, journalToolObservations } from "./tool-observations.mjs";
3
- import { observedTimeout } from "./observation-clock.mjs";
5
+ import { ObservationClock } from "./observation-clock.mjs";
4
6
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
7
  import { appendFile, chmod, copyFile, lstat, mkdir, open, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
6
8
  import { spawn } from "node:child_process";
@@ -24,24 +26,8 @@ async function readState(dir) { try { return JSON.parse(await readFile(locations
24
26
  async function removeSocket(paths) { try { if (!(await lstat(paths.socket)).isSocket()) throw new DaemonError("unsafe_socket", "refusing to replace non-socket daemon path"); await unlink(paths.socket); } catch (error) { if (error.code !== "ENOENT") throw error; } }
25
27
  function executable(bin, args = []) { return /\.[cm]?js$/.test(bin) ? { command: process.execPath, args: [bin, ...args] } : { command: bin, args }; }
26
28
  function isolatedEnv(config) { return { ...process.env, XDG_DATA_HOME: config.dataHome, XDG_CONFIG_HOME: config.configHome, XDG_CACHE_HOME: config.cacheHome, OPENCODE_CONFIG_DIR: config.adapterDir, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_SERVER_PASSWORD: config.password, ...(config.ddFlowHome ? { DD_FLOW_HOME: config.ddFlowHome } : {}), ...(config.resourceHome ? { DD_FLOW_RESOURCE_HOME: config.resourceHome } : {}), ...(config.ddFlowBin ? { DD_OPENCODE_DD_FLOW_BIN: config.ddFlowBin, DD_OPENCODE_NODE_BIN: process.execPath, DD_OPENCODE_PROJECT_ROOT: config.projectRoot } : {}), DD_OPENCODE_DAEMON_ID: config.daemonId, DD_OPENCODE_PROVIDER: config.provider, DD_OPENCODE_MODEL: config.model, DD_OPENCODE_VARIANT: config.variant, DD_OPENCODE_AGENT: config.agent, DD_OPENCODE_SERVER_VERSION: config.version ?? "unknown", DD_OPENCODE_PLUGIN_SHA256: config.pluginSha256 ?? "unknown" }; }
27
- async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
28
- const target = executable(bin, args);
29
- return await new Promise((resolve, reject) => {
30
- const child = spawn(target.command, target.args, { cwd: config.cwd, env: isolatedEnv(config), detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] });
31
- let stdout = "", stderr = "", timeoutError, inputError, cleanup;
32
- const timer = setTimeout(() => {
33
- timeoutError = new DaemonError("opencode_timeout", `${args[0]} timed out`, true);
34
- cleanup = stopProcessGroup(child, 1_000).catch(error => { timeoutError.details = { cleanup_error: errorRecord(error) }; reject(timeoutError); });
35
- }, timeoutMs);
36
- child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk); child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
37
- child.on("error", error => { clearTimeout(timer); reject(error); });
38
- child.on("close", async code => { clearTimeout(timer); await cleanup; if (timeoutError) reject(timeoutError); else if (inputError) reject(inputError); else if (code === 0) resolve(stdout); else reject(new DaemonError("opencode_cli_failed", stderr.trim() || `${target.command} exited ${code}`)); });
39
- child.stdin.on("error", error => {
40
- inputError ??= error;
41
- cleanup ??= stopProcessGroup(child, 1_000).catch(cause => { clearTimeout(timer); inputError.details = { ...inputError.details, cleanup_error: errorRecord(cause) }; reject(timeoutError ?? inputError); });
42
- });
43
- child.stdin.end(stdin ?? "");
44
- });
29
+ export async function run(bin, args, config, { stdin, timeoutMs = 30_000 } = {}) {
30
+ return await commandText(bin, args, { cwd: config.cwd, env: isolatedEnv(config), input: stdin ?? null, timeoutMs, phase: "native-utility" });
45
31
  }
46
32
  async function forwardUsage(config, sessionId, snapshot) {
47
33
  if (!config.ddFlowBin) return;
@@ -49,7 +35,7 @@ async function forwardUsage(config, sessionId, snapshot) {
49
35
  await run(config.ddFlowBin, ["opencode", "usage", "ingest", "--project-root", config.projectRoot, "--json"], config, { stdin: `${JSON.stringify(payload)}\n` });
50
36
  }
51
37
  async function freePort() { return await new Promise((resolve, reject) => { const server = net.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); }); }
52
- function errorPayload(error) { return { code: error.code ?? "operation_failed", message: error.message ?? String(error), retryable: error.retryable === true, ...(error.details === undefined ? {} : { details: error.details }) }; }
38
+ const errorPayload = errorRecord;
53
39
 
54
40
  async function writePlugin(paths, config) {
55
41
  const directory = path.join(paths.config, "opencode", "plugins"); await mkdir(directory, { recursive: true, mode: 0o700 });
@@ -66,7 +52,10 @@ async function prepare(paths, config, authSource) {
66
52
  async function importArchive(paths, config, archiveFile) { if (!archiveFile) return null; const manifest = JSON.parse(await readFile(absolute(archiveFile, "--session-archive"), "utf8")); if (manifest.schema_id !== ARCHIVE_SCHEMA || !manifest.payload) throw new DaemonError("invalid_session_archive", "OpenCode archive is incompatible"); if (manifest.opencode_version !== config.version) throw new DaemonError("session_archive_version_mismatch", "OpenCode archive version differs from the daemon version", false, { archive_version: manifest.opencode_version, current_version: config.version }); const payload = Buffer.from(manifest.payload, "base64").toString("utf8"); await writeFile(paths.archive, payload, { mode: 0o600 }); const stdout = await run(config.bin, ["import", paths.archive], config); const match = stdout.match(/ses_[A-Za-z0-9]+/); return { provider_session_id: match?.[0] ?? manifest.provider_session_id, manifest };
67
53
  }
68
54
 
69
- export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) { const paths = locations(stateDir), request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params }; return await new Promise((resolve, reject) => { const client = net.createConnection(paths.socket); let buffer = "", settled = false; const timer = operation === "session.prompt" ? null : observedTimeout(() => finish(new DaemonError("daemon_timeout", `${operation} timed out`, true)), timeoutMs); const finish = (error, result) => { if (settled) return; settled = true; clearTimeout(timer); client.destroy(); error ? reject(error) : resolve(result); }; client.setEncoding("utf8"); client.on("connect", () => client.write(`${JSON.stringify(request)}\n`)); client.on("data", chunk => { buffer += chunk; const i = buffer.indexOf("\n"); if (i < 0) return; try { const response = JSON.parse(buffer.slice(0, i)); if (!response.ok) return finish(new DaemonError(response.error.code, response.error.message, response.error.retryable, response.error.details)); finish(null, response.result); } catch (error) { finish(new DaemonError("daemon_protocol_mismatch", error.message)); } }); client.on("error", error => finish(new DaemonError("daemon_not_running", error.message, true))); client.on("end", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true))); client.on("close", () => finish(new DaemonError("daemon_connection_closed", `${operation} connection closed before a response`, true))); }); }
55
+ export async function callDaemon(stateDir, operation, params = {}, timeoutMs = 30_000) {
56
+ const request = { schema_id: REQUEST_SCHEMA, id: process.env.DD_EVAL_OPERATION_ID ?? randomUUID(), operation, params };
57
+ return await callDaemonObserved({ stateDir, socket: locations(stateDir).socket, request, responseSchema: RESPONSE_SCHEMA, harness: 'opencode', timeoutMs });
58
+ }
70
59
  function sameConfig(state, config) { return ["cwd","journal","bin","provider","model","variant","agent","projectRoot","ddFlowBin","ddFlowHome","sessionArchive","resourceHome","noFlow"].every(key => (state.config?.[key] ?? null) === (config[key] ?? null)); }
71
60
  export async function startDaemon(options) {
72
61
  const paths = locations(options.stateDir); await mkdir(paths.dir, { recursive: true, mode: 0o700 }); await chmod(paths.dir, 0o700);
@@ -81,7 +70,7 @@ export async function startDaemon(options) {
81
70
  const resourceProcess = await registerDaemonProcess(config, { kind: "opencode-daemon", owner: `opencode:${path.basename(paths.dir)}`, operation: `opencode-daemon:${path.basename(paths.dir)}`, stdout: paths.log, stderr: paths.log });
82
71
  const state = { schema_id: STATE_SCHEMA, daemon_id: daemonId, shutdown_state: "starting", active_tree: false, auth_status: config.authStatus, versions: { opencode: config.version }, config, sessions: imported ? [{ provider_session_id: imported.provider_session_id, parent_provider_session_id: null, root_provider_session_id: imported.provider_session_id, cwd: config.cwd }] : (previous?.sessions ?? []), resource_process: resourceProcess }; await writeJson(paths.state, state);
83
72
  const log = await open(paths.log, "a", 0o600), entry = absolute(options.entryPath, "dd-opencode entry"); const child = spawn(process.execPath, [entry, "daemon", "serve", "--state-dir", paths.dir], { cwd: config.cwd, env: isolatedEnv(config), detached: true, stdio: ["ignore", log.fd, log.fd] }); child.unref(); await log.close();
84
- try { await confirmDaemonProcess(config, resourceProcess, child); const deadline = Date.now() + 20_000; while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
73
+ try { await confirmDaemonProcess(config, resourceProcess, child); const startupClock = new ObservationClock({ timeoutMs: 20_000 }); while (!startupClock.sample()) { await new Promise(resolve => setTimeout(resolve, 100)); try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); } catch (error) { if (!["daemon_not_running","daemon_timeout"].includes(error.code)) throw error; } } throw new DaemonError("daemon_start_failed", "daemon did not become ready", true); }
85
74
  catch (error) { await cleanupFailedStart(config, resourceProcess, child, error); throw error; }
86
75
  }
87
76
  export const stopDaemon = async 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, { readState: () => readState(options.stateDir) }); };
@@ -139,7 +128,7 @@ export class Runtime {
139
128
  try { await this.client.abort(id); } catch (error) { errors.push({ session_id: id, phase: "abort", ...errorPayload(error) }); }
140
129
  return errors;
141
130
  }
142
- async dispatch(operation, params) {
131
+ async dispatch(operation, params, operationId = null) {
143
132
  const assertDispatch = captureDispatchGuard(this);
144
133
  if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume", "session.archive"].includes(operation)) assertDaemonDispatch(this.paths.dir);
145
134
  if (operation === "daemon.status") { const { password, ...config } = this.state.config; return { daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket, versions: this.state.versions, auth_status: this.state.auth_status, shutdown_state: this.state.shutdown_state, active_tree: this.state.active_tree, active_operation: this.active, sessions: [...this.sessions.values()], config }; }
@@ -161,7 +150,8 @@ export class Runtime {
161
150
  });
162
151
  if (operation === "session.inspect" || operation === "session.resume") { const result = await this.describe(params.sessionId); await this.persist(); return result; }
163
152
  if (operation === "session.prompt") { await this.requireSettled(params.sessionId); return this.productive(operation, async () => { assertDispatch(); const reply = await this.client.prompt(params.sessionId, { prompt: params.prompt, provider: this.state.config.provider, model: this.state.config.model, variant: this.state.config.variant, agent: this.state.config.agent,
164
- assertDispatch,
153
+ assertDispatch, livenessTimeoutMs: params.livenessTimeoutMs ?? this.client.timeoutMs,
154
+ onDispatched: () => this.journal("native_dispatch", { operation_id: operationId, provider_session_id: params.sessionId }),
165
155
  onModelMessages: (id, messages, parent) => this.observeModels(id, messages, parent),
166
156
  onObservationError: error => observeModel({ journal: this.state.config.journal ?? this.paths.journal, harness: "opencode-server", sessionId: params.sessionId, requested: this.state.config, source: "native.message_observer", evidence: "unavailable", reason: error.code ?? "observation_unavailable" })
167
157
  }); const assistant_text = reply.parts.filter(part => part.type === "text").map(part => part.text ?? "").join("");
@@ -233,17 +223,17 @@ export async function serveDaemon(stateDir) {
233
223
  scheduleDaemonProcessHeartbeat(state.config, state.resource_process);
234
224
  scheduleDaemonProcessHeartbeat(state.config, providerProcess);
235
225
  state.provider_process = providerProcess; state.provider_pid = child.pid; await writeJson(paths.state, state);
236
- const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
226
+ const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, password: state.config.password, directory: state.config.cwd }); const startupClock = new ObservationClock({ timeoutMs: 15_000 }); while (!startupClock.sample()) { try { await client.health(); break; } catch { await new Promise(resolve => setTimeout(resolve, 100)); } } await client.health(); const runtime = new Runtime(paths, state, client, child, providerProcess); await removeSocket(paths);
237
227
  const server = net.createServer(connection => {
238
228
  connection.setEncoding("utf8"); let buffer = "", consumed = false;
239
229
  connection.on("data", chunk => {
240
230
  if (consumed) return;
241
- buffer += chunk; const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
231
+ buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; } const at = buffer.indexOf("\n"); if (at < 0) return; consumed = true;
242
232
  void (async () => {
243
233
  let request;
244
234
  try {
245
235
  request = JSON.parse(buffer.slice(0, at));
246
- const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}), () => runtime.dispatch("session.inspect", request.params ?? {}));
236
+ const result = await durableDaemonDispatch(paths.dir, request, () => runtime.dispatch(request.operation, request.params ?? {}, request.id), () => runtime.dispatch("session.inspect", request.params ?? {}));
247
237
  if (result._shutdown) retireDaemonEndpoint(server);
248
238
  connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
249
239
  } catch (error) {
@@ -260,3 +250,4 @@ export async function serveDaemon(stateDir) {
260
250
  }
261
251
  }
262
252
  import { authorizeRetainedDaemonResume } from "./driver-recovery.mjs";
253
+ import { commandText } from "./process-json.mjs";
@@ -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,4 +1,6 @@
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";
@@ -6,6 +8,9 @@ import net from "node:net";
6
8
  import { durableDaemonDispatch, completeDaemonShutdown, commitDaemonShutdown, assertDaemonDispatch, assertDaemonRestartable, trackDaemonConnections, retireDaemonEndpoint, retireFailedDaemon } from "./daemon-operations.mjs";
7
9
  import path from "node:path";
8
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"];
@@ -212,8 +180,8 @@ export async function startDaemon(options) {
212
180
  await log.close();
213
181
  try {
214
182
  await confirmDaemonProcess(config, resourceProcess, child);
215
- const deadline = Date.now() + 30_000;
216
- while (Date.now() < deadline) {
183
+ const startupClock = new ObservationClock({ timeoutMs: 30_000 });
184
+ while (!startupClock.sample()) {
217
185
  await new Promise((resolve) => setTimeout(resolve, 100));
218
186
  try { return await callDaemon(paths.dir, "daemon.status", {}, 1000); }
219
187
  catch (error) { if (error.code !== "daemon_not_running" && error.code !== "daemon_timeout") throw error; }
@@ -251,13 +219,18 @@ export class DaemonRuntime {
251
219
  const adapter = params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId;
252
220
  const generation = this.activeGeneration ?? this.controlGeneration ?? 0;
253
221
  return {
254
- ...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,
255
223
  neverPromptedRootProof: this.neverPromptedRootProof(params.sessionId),
256
224
  daemonId: this.state.daemon_id, allowBackground: true, liveSession: this.liveAdapters.has(adapter),
257
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
+ },
258
231
  // The bridge owns the quiet timer; the daemon owns the durable status.
259
232
  // Do not infer provider state or cancel here.
260
- onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs }) => {
233
+ onInactivity: async ({ requestId, sessionId, lastActivityAt, idleTimeoutMs, cursor }) => {
261
234
  const request = this.state.active_request;
262
235
  // `sessionId` is ACP identity; the durable request's `session_id` is
263
236
  // native/provider identity. These strings can coincide but are not
@@ -286,7 +259,8 @@ export class DaemonRuntime {
286
259
  // The probe is asynchronous. Do not recreate a completed or
287
260
  // reconnected request with a stale callback snapshot.
288
261
  const current = this.state.active_request;
289
- 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;
290
264
  await this.persist({ active_request: {
291
265
  ...current,
292
266
  observation: { state: "quiet", request_id: requestId, detected_at: new Date().toISOString(), last_activity_at: new Date(lastActivityAt).toISOString(), quiet_threshold_ms: idleTimeoutMs, diagnostics },
@@ -389,9 +363,15 @@ export class DaemonRuntime {
389
363
  if (this.activeProductive) throw new DaemonError("operation_busy", `${this.activeProductive} is already running`, true);
390
364
  this.activeProductive = name;
391
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);
392
372
  try {
393
373
  await this.persist({ active_tree: true, active_operation: name, ...(request ? { active_request: request } : {}) });
394
- const result = await task();
374
+ const result = await Promise.race([task(), hookFailed]);
395
375
  this.track(result);
396
376
  const running = this.treeRunning(result);
397
377
  await this.persist({ active_tree: running, active_operation: null, active_request: null });
@@ -403,12 +383,12 @@ export class DaemonRuntime {
403
383
  // daemon state no longer carried the only correlation to it.
404
384
  const active = this.state?.active_request;
405
385
  try {
406
- await this.persist({
386
+ await hookBudget(1_000, null).run(() => this.persist({
407
387
  active_operation: null,
408
388
  active_request: active?.allocation
409
389
  ? { ...active, outcome: "unknown", error: errorPayload(error) }
410
390
  : null,
411
- });
391
+ }), "failure_persistence");
412
392
  } catch (cleanupError) {
413
393
  // Preserve the operation's primary failure if durable cleanup itself
414
394
  // is unavailable. The caller will retain the outcome as unknown and
@@ -416,7 +396,15 @@ export class DaemonRuntime {
416
396
  if (error && typeof error === "object") error.cleanup_error = errorPayload(cleanupError);
417
397
  }
418
398
  throw error;
419
- } 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; }
420
408
  }
421
409
 
422
410
  async requireSettled(params) {
@@ -448,7 +436,7 @@ export class DaemonRuntime {
448
436
  await this.persist();
449
437
  }
450
438
 
451
- async dispatch(operation, params) {
439
+ async dispatch(operation, params, operationId = null) {
452
440
  if (["session.create", "session.prompt", "session.start", "session.fork", "session.resume"].includes(operation)) assertDaemonDispatch(this.paths.dir);
453
441
  if (operation === "daemon.status") return {
454
442
  daemon_id: this.state.daemon_id, pid: process.pid, socket: this.paths.socket,
@@ -462,10 +450,24 @@ export class DaemonRuntime {
462
450
  const sessionId = String(params.sessionId ?? "");
463
451
  const session = this.sessions.get(sessionId);
464
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 });
465
- 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;
466
466
  }
467
+ if (operation === "hook.failed") { await this.observeHookFailures(); return { observed: true }; }
467
468
  if (operation === "session.create") {
468
469
  const request = {
470
+ operation_id: operationId,
469
471
  request_id: randomUUID(),
470
472
  session_id: params.sessionId ?? null,
471
473
  adapter_session_id: params.adapterSessionId ?? params.sessionId ?? null,
@@ -479,6 +481,7 @@ export class DaemonRuntime {
479
481
  if (this.sessions.get(params.sessionId)?.close_receipt) throw new DaemonError("session_closed", "Closed Session requires explicit recovery before another prompt");
480
482
  const known = this.sessions.get(params.sessionId);
481
483
  const request = {
484
+ operation_id: operationId,
482
485
  request_id: randomUUID(),
483
486
  session_id: params.sessionId,
484
487
  adapter_session_id: params.adapterSessionId ?? known?.adapter_session_id ?? params.sessionId,
@@ -661,7 +664,7 @@ export async function serveDaemon(stateDir) {
661
664
  connection.setEncoding("utf8"); let buffer = "", consumed = false;
662
665
  connection.on("data", (chunk) => {
663
666
  if (consumed) return;
664
- buffer += chunk;
667
+ buffer += chunk; if (!admitNativeRequestBuffer(buffer, connection, RESPONSE_SCHEMA)) { consumed = true; return; }
665
668
  const newline = buffer.indexOf("\n");
666
669
  if (newline < 0) return;
667
670
  consumed = true;
@@ -671,7 +674,7 @@ export async function serveDaemon(stateDir) {
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 ?? {}));
675
678
  if (result?._shutdown) retireDaemonEndpoint(server);
676
679
  connection.end(`${JSON.stringify({ schema_id: RESPONSE_SCHEMA, id: request.id, ok: true, result: { ...result, _shutdown: undefined } })}\n`);
677
680
  } catch (error) {