@deksden-com/dd-flow-cli 0.9.0-beta.21 → 0.9.0-beta.24

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 (49) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +9 -0
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli/help.js +1 -0
  5. package/dist/cli/run-cli.js +3 -0
  6. package/dist/cli.js +0 -0
  7. package/dist/harness-runtime/bin/dd-agy.mjs +34 -0
  8. package/dist/harness-runtime/bin/dd-codex.mjs +22 -0
  9. package/dist/harness-runtime/bin/dd-droid.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-grok.mjs +28 -0
  11. package/dist/harness-runtime/bin/dd-opencode.mjs +18 -0
  12. package/dist/harness-runtime/bin/dd-zcode.mjs +62 -0
  13. package/dist/harness-runtime/lib/daemon-operations.mjs +78 -0
  14. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +259 -0
  15. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  16. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +93 -0
  17. package/dist/harness-runtime/lib/dd-codex.mjs +387 -0
  18. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +123 -0
  19. package/dist/harness-runtime/lib/dd-droid.mjs +435 -0
  20. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +282 -0
  21. package/dist/harness-runtime/lib/dd-grok.mjs +168 -0
  22. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +157 -0
  23. package/dist/harness-runtime/lib/dd-opencode.mjs +80 -0
  24. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +419 -0
  25. package/dist/harness-runtime/lib/dd-zcode.mjs +598 -0
  26. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  27. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  28. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  29. package/dist/harness-runtime/lib/managed-daemon.mjs +119 -0
  30. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  31. package/dist/harness-runtime/lib/observation-clock.mjs +33 -0
  32. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  33. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  34. package/dist/harness-runtime/lib/process-json.mjs +61 -0
  35. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  36. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  37. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  38. package/dist/harness-runtime/lib/session-settlement.mjs +28 -0
  39. package/dist/services/code-checks.js +25 -11
  40. package/dist/services/execution-policy.js +61 -0
  41. package/dist/services/external-work-launch.js +76 -0
  42. package/dist/services/harness-adapter.js +31 -14
  43. package/dist/services/hooks.js +43 -20
  44. package/dist/services/runs.js +2 -1
  45. package/dist/services/stage-context.js +2 -2
  46. package/dist/services/vnext-execution-profile.js +38 -3
  47. package/dist/services/work-registry.js +8 -1
  48. package/dist/storage/database.js +21 -0
  49. package/package.json +8 -2
@@ -0,0 +1,598 @@
1
+ import { observedTimeout } from "./observation-clock.mjs";
2
+ import { spawn } from "node:child_process";
3
+ import { appendFile, mkdir, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import readline from "node:readline";
6
+ import { stopProcessGroup } from "./managed-daemon.mjs";
7
+ import { checkObservedProfile, observeModel, observedFields } from "./model-observations.mjs";
8
+
9
+ const ZCODE_HARNESS_CONTRACT = "dd-zcode-harness@1";
10
+
11
+ function fail(message, code) { const error = new Error(message); if (code) error.code = code; throw error; }
12
+ function absolute(value, label) { if (!value || !path.isAbsolute(value)) fail(`${label} must be an absolute path`); return path.resolve(value); }
13
+ function text(value, label) { if (typeof value !== "string" || !value.trim()) fail(`${label} is required`); return value.trim(); }
14
+ function now() { return new Date().toISOString(); }
15
+
16
+ function executable(command, args = []) {
17
+ const resolved = command ?? process.env.DD_ZCODE_ACP_BIN ?? "zcode-acp";
18
+ return /\.[cm]?js$/.test(resolved) ? { command: process.execPath, args: [resolved, ...args] } : { command: resolved, args };
19
+ }
20
+
21
+ async function commandOutput(command, args) {
22
+ const target = executable(command, args);
23
+ return await new Promise((resolve, reject) => {
24
+ const child = spawn(target.command, target.args, { stdio: ["ignore", "pipe", "pipe"] });
25
+ let stdout = ""; let stderr = "";
26
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
27
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
28
+ child.on("error", reject);
29
+ child.on("close", (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${target.command} exited ${code}`)));
30
+ });
31
+ }
32
+
33
+ async function runWithStdin(command, args, stdin, env = {}) {
34
+ const target = executable(command, args);
35
+ return await new Promise((resolve, reject) => {
36
+ const child = spawn(target.command, target.args, { env: { ...process.env, ...env }, stdio: ["pipe", "pipe", "pipe"] });
37
+ let stdout = ""; let stderr = "";
38
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
39
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
40
+ child.on("error", reject);
41
+ child.on("close", (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `${target.command} exited ${code}`)));
42
+ child.stdin.end(stdin);
43
+ });
44
+ }
45
+
46
+ class Journal {
47
+ constructor(file) {
48
+ this.file = file ? absolute(file, "--journal") : null;
49
+ this.pending = this.file ? mkdir(path.dirname(this.file), { recursive: true }) : Promise.resolve();
50
+ this.order = 0;
51
+ }
52
+ write(kind, payload) {
53
+ if (!this.file) return;
54
+ const line = JSON.stringify({ order: ++this.order, observed_at: now(), kind, payload });
55
+ this.pending = this.pending.then(() => appendFile(this.file, `${line}\n`));
56
+ }
57
+ async flush() { await this.pending; }
58
+ }
59
+
60
+ export class AcpBridge {
61
+ constructor(options) {
62
+ this.options = options;
63
+ this.nextId = 1;
64
+ this.pending = new Map();
65
+ this.sessionActivity = new Map();
66
+ this.notifications = Promise.resolve();
67
+ this.notificationError = null;
68
+ this.bridgeStderr = "";
69
+ this.toolCalls = new Map();
70
+ this.failedToolCalls = new Set();
71
+ this.assistantText = new Map();
72
+ this.providerErrors = new Map();
73
+ this.journal = new Journal(options.journal);
74
+ this.modelSessions = new Map(); this.modelProfiles = new Map();
75
+ }
76
+
77
+ configure(options) { this.options = { ...this.options, ...options }; }
78
+ registerModelSession(adapterId, sessionId, requested, parentSessionId = null, armed = true) {
79
+ this.modelSessions.set(adapterId, { sessionId, requested, parentSessionId, armed });
80
+ }
81
+ async recordModel(adapterId, observed, source) {
82
+ const identity = this.modelSessions.get(adapterId); if (!identity) return;
83
+ this.modelProfiles.set(identity.sessionId, observed);
84
+ await observeModel({ journal: this.options.journal, harness: this.options.clientInfo?.name === "dd-grok" ? "grok-acp" : "zcode-acp",
85
+ sessionId: identity.sessionId, parentSessionId: identity.parentSessionId, requested: identity.requested, observed, source });
86
+ }
87
+ async observeModelUpdate(message) {
88
+ const update = message.params?.update;
89
+ if (update?.sessionUpdate !== "config_option_update" || !Array.isArray(update.configOptions)) return;
90
+ const value = category => update.configOptions.find(option => option.type === "select" && (option.category === category || option.id === category))?.currentValue ?? null;
91
+ const observed = { model: value("model"), reasoning: value("thought_level"), mode: value("mode") };
92
+ const identity = this.modelSessions.get(message.params.sessionId); if (!identity?.armed) return;
93
+ checkObservedProfile(identity.requested, observed);
94
+ await this.recordModel(message.params.sessionId, observed, "acp.config_option_update");
95
+ }
96
+
97
+ async start() {
98
+ const target = executable(this.options.bin, this.options.commandArgs ?? ["server"]);
99
+ const cwd = this.options.cwd ? absolute(this.options.cwd, "--cwd") : process.cwd();
100
+ this.child = spawn(target.command, target.args, { cwd, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, ...(this.options.env ?? {}) } });
101
+ this.child.stderr.setEncoding("utf8").on("data", (chunk) => { this.bridgeStderr += String(chunk); this.journal.write("bridge_stderr", { text: String(chunk) }); });
102
+ this.child.on("error", (error) => { error.code ??= "bridge_exited"; this.rejectAll(error); });
103
+ this.child.on("exit", (code) => { const error = new Error(`zcode-acp exited ${code}${this.bridgeStderr.trim() ? `: ${this.bridgeStderr.trim()}` : ""}`); error.code = "bridge_exited"; this.rejectAll(error); });
104
+ readline.createInterface({ input: this.child.stdout }).on("line", (line) => this.receive(line));
105
+ return await this.request("initialize", {
106
+ protocolVersion: 1,
107
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, elicitation: { form: {} } },
108
+ clientInfo: this.options.clientInfo ?? { name: "dd-zcode", version: "0.1.0" },
109
+ });
110
+ }
111
+
112
+ receive(line) {
113
+ let message;
114
+ try { message = JSON.parse(line); } catch { this.journal.write("malformed", { line }); return; }
115
+ this.journal.write("inbound", message);
116
+ if (typeof message.params?.sessionId === "string" && isProductiveSessionNotification(message)) this.sessionActivity.set(message.params.sessionId, Date.now());
117
+ const update = message.params?.update;
118
+ // ZCode emits this as `session/update`; Grok Build wraps the same ACP
119
+ // payload in `_x.ai/session_notification`. Preserve either provider's
120
+ // native error before its generic JSON-RPC terminal response arrives.
121
+ if (typeof message.params?.sessionId === "string" && update?.sessionUpdate === "retry_state" && update.type === "failed") {
122
+ this.providerErrors.set(message.params.sessionId, update);
123
+ }
124
+ if (message.method === "session/update" && message.params?.update?.sessionUpdate === "agent_message_chunk" && message.params.update.content?.type === "text") {
125
+ const sessionId = message.params.sessionId;
126
+ this.assistantText.set(sessionId, `${this.assistantText.get(sessionId) ?? ""}${message.params.update.content.text ?? ""}`);
127
+ }
128
+ this.observeToolCall(message);
129
+ if (message.id !== undefined && message.method) { void this.answer(message).catch((error) => this.rejectAll(error)); return; }
130
+ if (message.id !== undefined) {
131
+ const pending = this.pending.get(message.id);
132
+ if (!pending) return;
133
+ clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); this.pending.delete(message.id);
134
+ if (message.error) {
135
+ const details = message.error.data?.details ?? message.error.data?.message ?? null;
136
+ const provider = this.providerErrors.get(pending.sessionId);
137
+ this.providerErrors.delete(pending.sessionId);
138
+ const providerDetails = provider?.message ?? null;
139
+ const messageText = [message.error.message, details, providerDetails].filter(Boolean).join(": ") || JSON.stringify(message.error);
140
+ const error = new Error(messageText);
141
+ error.code = /rate limit/i.test(messageText) ? "provider_rate_limited" : /payment required|usage balance exhausted/i.test(messageText) ? "provider_quota_exhausted" : "acp_request_failed";
142
+ error.retryable = /rate limit/i.test(messageText);
143
+ error.details = { acp_code: message.error.code ?? null, ...(details ? { provider_details: details } : {}), ...(providerDetails ? { provider_update: provider } : {}) };
144
+ pending.reject(error);
145
+ }
146
+ else { this.providerErrors.delete(pending.sessionId); pending.resolve(message.result ?? {}); }
147
+ return;
148
+ }
149
+ if (message.method) this.notifications = this.notifications
150
+ .then(async () => { await this.observeModelUpdate(message); await this.options.onNotification?.(message); })
151
+ .catch((error) => { this.notificationError ??= error; if (error.code === "profile_integrity_violation") this.rejectAll(error); });
152
+ }
153
+
154
+ observeToolCall(message) {
155
+ if (message.method !== "session/update") return;
156
+ const update = message.params?.update;
157
+ if (!update?.toolCallId) return;
158
+ const id = JSON.stringify([message.params?.sessionId ?? null, update.toolCallId]);
159
+ if (update.sessionUpdate === "tool_call") this.toolCalls.set(id, update?._meta?.claudeCode?.toolName ?? update.title?.split(":", 1)[0] ?? "unknown");
160
+ if (update.sessionUpdate === "tool_call_update" && ["failed", "error"].includes(update.status)) this.failedToolCalls.add(id);
161
+ }
162
+
163
+ toolSummary(sessionId) {
164
+ const by_tool = {};
165
+ let total = 0; let failures = 0;
166
+ for (const [id, name] of this.toolCalls) {
167
+ if (sessionId !== undefined && JSON.parse(id)[0] !== sessionId) continue;
168
+ total += 1; by_tool[name] = (by_tool[name] ?? 0) + 1;
169
+ if (this.failedToolCalls.has(id)) failures += 1;
170
+ }
171
+ return { total, failures, by_tool };
172
+ }
173
+ toolCursor() { return new Set(this.toolCalls.keys()); }
174
+ assistantTextCursor(sessionId) { return (this.assistantText.get(sessionId) ?? "").length; }
175
+ assistantTextSince(sessionId, cursor) { return (this.assistantText.get(sessionId) ?? "").slice(cursor); }
176
+ toolSummarySince(cursor) {
177
+ const by_tool = {}; let total = 0; let failures = 0;
178
+ for (const [id, name] of this.toolCalls) {
179
+ if (cursor.has(id)) continue;
180
+ total += 1; by_tool[name] = (by_tool[name] ?? 0) + 1;
181
+ if (this.failedToolCalls.has(id)) failures += 1;
182
+ }
183
+ return { total, failures, by_tool };
184
+ }
185
+
186
+ async answer(message) {
187
+ await this.notifications;
188
+ let result;
189
+ if (message.method === "session/request_permission") {
190
+ const options = message.params?.options ?? [];
191
+ const selected = this.options.permission === "allow" && !this.notificationError
192
+ ? options.find((item) => item.optionId === "allow_once") ?? options[0]
193
+ : options.find((item) => item.optionId === "deny");
194
+ result = selected ? { outcome: { outcome: "selected", optionId: selected.optionId } } : { outcome: { outcome: "cancelled" } };
195
+ } else if (message.method === "elicitation/create") {
196
+ result = this.options.answers ? { action: "accept", content: this.options.answers } : { action: "decline", reason: "dd-zcode has no declared answers" };
197
+ } else {
198
+ this.send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: `unsupported client request: ${message.method}` } });
199
+ return;
200
+ }
201
+ this.journal.write("interaction", { method: message.method, params: message.params, result });
202
+ this.send({ jsonrpc: "2.0", id: message.id, result });
203
+ }
204
+
205
+ request(method, params = {}, timeoutMs = 30_000, { idleSessionId = null, idleTimeoutMs = null } = {}) {
206
+ if (this.notificationError?.code === "profile_integrity_violation" && ["session/new", "session/prompt", "session/fork"].includes(method)) return Promise.reject(this.notificationError);
207
+ const id = this.nextId++;
208
+ return new Promise((resolve, reject) => {
209
+ const finish = (error) => {
210
+ const pending = this.pending.get(id); if (!pending) return;
211
+ clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); this.pending.delete(id); reject(error);
212
+ };
213
+ // Productive Turns use the sliding session-activity timeout below. A
214
+ // second wall-clock timeout would kill an active, long-running Turn.
215
+ const timer = Number.isFinite(timeoutMs) && timeoutMs > 0
216
+ ? observedTimeout(() => finish(Object.assign(new Error(`${method} timed out`), { code: "rpc_timeout", details: { method, session_id: params.sessionId ?? null } })), timeoutMs)
217
+ : null;
218
+ const validIdleTimeout = Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : null;
219
+ if (idleSessionId) this.sessionActivity.set(idleSessionId, Date.now());
220
+ if (params.sessionId) this.providerErrors.delete(params.sessionId);
221
+ let lastTimerAt = Date.now();
222
+ let resumedAt = null;
223
+ const checkIdle = async () => {
224
+ if (!this.pending.has(id)) return;
225
+ const observedAt = Date.now();
226
+ const observationGapMs = observedAt - lastTimerAt;
227
+ lastTimerAt = observedAt;
228
+ // A host pause makes wall time jump while this process cannot observe
229
+ // native progress. Keep the SAME native request alive so its late
230
+ // result can still be persisted. Grant one fresh observation window;
231
+ // do not rewrite provider activity or resend the request.
232
+ if (observationGapMs > 60_000) {
233
+ resumedAt = observedAt;
234
+ this.journal.write("observation_gap", { method, request_id: id, session_id: idleSessionId, observation_gap_ms: observationGapMs, observed_at: new Date(observedAt).toISOString() });
235
+ return;
236
+ }
237
+ const lastActivity = this.sessionActivity.get(idleSessionId) ?? Date.now();
238
+ if (observedAt - Math.max(lastActivity, resumedAt ?? lastActivity) < validIdleTimeout) return;
239
+ const error = new Error(`${method} received no session update for ${validIdleTimeout}ms`);
240
+ error.code = "subject_liveness_timeout";
241
+ error.details = { session_id: idleSessionId, last_activity_at: new Date(lastActivity).toISOString(), idle_timeout_ms: validIdleTimeout };
242
+ finish(error);
243
+ };
244
+ const idleTimer = validIdleTimeout ? setInterval(() => { void checkIdle(); }, Math.max(10, Math.min(1_000, Math.floor(validIdleTimeout / 4)))) : null;
245
+ this.pending.set(id, { resolve, reject, timer, idleTimer, sessionId: params.sessionId ?? null });
246
+ this.send({ jsonrpc: "2.0", id, method, params });
247
+ });
248
+ }
249
+
250
+ notify(method, params = {}) { this.send({ jsonrpc: "2.0", method, params }); }
251
+ send(message) { this.journal.write("outbound", message); this.child.stdin.write(`${JSON.stringify(message)}\n`); }
252
+ rejectAll(error) { for (const pending of this.pending.values()) { clearTimeout(pending.timer); if (pending.idleTimer) clearInterval(pending.idleTimer); pending.reject(error); } this.pending.clear(); }
253
+ async flush() { await this.notifications; await this.journal.flush(); if (this.notificationError) throw this.notificationError; }
254
+ async close() {
255
+ let flushError = null;
256
+ try { await this.flush(); } catch (error) { flushError = error; }
257
+ if (this.child) {
258
+ this.child.stdin.end();
259
+ await Promise.race([new Promise((resolve) => this.child.once("exit", resolve)), new Promise((resolve) => setTimeout(resolve, 2000))]);
260
+ await stopProcessGroup(this.child, 2_000);
261
+ }
262
+ await this.journal.flush();
263
+ if (flushError) throw flushError;
264
+ }
265
+ }
266
+
267
+ function isProductiveSessionNotification(message) {
268
+ if (!["session/update", "_x.ai/session_notification"].includes(message.method)) return false;
269
+ const update = message.params?.update;
270
+ // Transport heartbeat is not evidence of model work. Every other documented
271
+ // session update proves that the native session is still responsive.
272
+ return update?.sessionUpdate !== "heartbeat" && update?.type !== "heartbeat";
273
+ }
274
+
275
+ export function observedProfile(read) {
276
+ const model = read?.settings?.model?.current ?? {};
277
+ return {
278
+ provider: typeof model.providerId === "string" ? model.providerId : null,
279
+ model: typeof model.modelId === "string" ? model.modelId : null,
280
+ reasoning: read?.settings?.thoughtLevel?.current ?? null,
281
+ mode: read?.settings?.mode?.current ?? read?.projection?.mode ?? null,
282
+ };
283
+ }
284
+
285
+ export function assertProfile(requested, observed) { return checkObservedProfile(requested, observed); }
286
+
287
+ async function resume(bridge, sessionId, cwd) {
288
+ await bridge.request("session/resume", { sessionId: text(sessionId, "--session-id"), cwd: absolute(cwd, "--cwd"), mcpServers: [] });
289
+ return sessionId;
290
+ }
291
+
292
+ async function inspect(bridge, sessionId) {
293
+ const [read, subagents, usage] = await Promise.all([
294
+ bridge.request("zcode/session/read", { sessionId }),
295
+ bridge.request("zcode/session/subagents", { sessionId }),
296
+ bridge.request("zcode/session/usage", { sessionId }),
297
+ ]);
298
+ const observed = observedProfile(read);
299
+ await bridge.recordModel?.(sessionId, observed, "zcode/session/read");
300
+ return { read, subagents, usage, observed_profile: observed };
301
+ }
302
+
303
+ // The journal is the immutable transcript. CLI receipts only need the
304
+ // operational projection; returning every message makes a routine status
305
+ // command enormous and obscures the useful facts.
306
+ function publicEvidence(evidence) {
307
+ return {
308
+ ...evidence,
309
+ read: {
310
+ projection: evidence.read?.projection ?? null,
311
+ session: evidence.read?.session ?? null,
312
+ settings: evidence.read?.settings ?? null,
313
+ },
314
+ };
315
+ }
316
+
317
+ // ACP returns the terminal turn separately from its visible transcript. The
318
+ // probe contract needs the literal terminal answer, so extract only text parts
319
+ // from the last assistant message rather than asking a model to interpret it.
320
+ export function latestAssistantText(read) {
321
+ const messages = Array.isArray(read?.messages) ? read.messages : [];
322
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
323
+ const message = messages[index];
324
+ if (message?.info?.role !== "assistant") continue;
325
+ const text = (Array.isArray(message.parts) ? message.parts : [])
326
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
327
+ .map((part) => part.text)
328
+ .join("")
329
+ .trim();
330
+ if (text) return text;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ async function cancelTree(bridge, sessionId, before) {
336
+ const cancellations = [];
337
+ for (const child of before.running ?? []) {
338
+ const taskId = child.taskId ?? child.agentId;
339
+ if (taskId) {
340
+ try { cancellations.push(await bridge.request("session/cancelBackgroundTask", { sessionId, taskId })); }
341
+ catch (error) { cancellations.push({ taskId, cancelled: false, error: { code: error.code ?? "cancel_failed", message: error.message } }); }
342
+ }
343
+ }
344
+ // ZCode's stop is deliberately idempotent. Send it once: session/read
345
+ // returns the full transcript, so polling it while the turn is stopping can
346
+ // make cancellation slower than the work we are trying to interrupt.
347
+ bridge.notify("session/cancel", { sessionId });
348
+ let after = before;
349
+ for (let attempt = 0; attempt < 6; attempt += 1) {
350
+ await new Promise((resolve) => setTimeout(resolve, 1000));
351
+ after = await bridge.request("zcode/session/subagents", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
352
+ if (after.observation_error || !(after.running ?? []).length) break;
353
+ }
354
+ const read = await bridge.request("zcode/session/read", { sessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
355
+ const root_status = read?.projection?.status ?? read?.session?.status ?? null;
356
+ const settled = !after.observation_error && !(after.running ?? []).length && ["idle", "completed", "cancelled", "failed", "stopped"].includes(root_status);
357
+ return { cancellations, before, after, root_status, cancellation_requested: !settled, settled };
358
+ }
359
+
360
+ // Cancel one known child without cancelling its parent turn. The parent keeps
361
+ // its ACP listener, so later child lifecycle events still reach dd-flow.
362
+ export async function cancelChildWithBridge(bridge, options) {
363
+ requireJournal(options);
364
+ const identity = await controlledIdentity(bridge, options);
365
+ const childSessionId = text(options.childSessionId, "--child-session-id");
366
+ const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
367
+ const child = (before.running ?? []).find((item) => item.childSessionId === childSessionId);
368
+ if (!child) fail(`ZCode has no running child ${childSessionId}`, "child_not_running");
369
+ const taskId = child.taskId ?? child.agentId;
370
+ if (!taskId) fail(`ZCode child ${childSessionId} has no cancellation identifier`, "child_cancel_unsupported");
371
+ const cancellation = await bridge.request("session/cancelBackgroundTask", { sessionId: identity.adapterSessionId, taskId });
372
+ let after = before;
373
+ for (let attempt = 0; attempt < 6; attempt += 1) {
374
+ await new Promise((resolve) => setTimeout(resolve, 1000));
375
+ after = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
376
+ if (!(after.running ?? []).some((item) => item.childSessionId === childSessionId)) break;
377
+ }
378
+ if ((after.running ?? []).some((item) => item.childSessionId === childSessionId)) {
379
+ fail(`ZCode child ${childSessionId} did not stop`, "partial_cancellation");
380
+ }
381
+ return receipt(options, {
382
+ harness: "zcode-acp",
383
+ provider_session_id: identity.providerSessionId,
384
+ adapter_session_id: identity.adapterSessionId,
385
+ child_session_id: childSessionId,
386
+ cancellation,
387
+ before,
388
+ after,
389
+ });
390
+ }
391
+
392
+ export async function cancelChild(options) {
393
+ return await withBridge(options, async (bridge) => await cancelChildWithBridge(bridge, options));
394
+ }
395
+
396
+ async function controlledIdentity(bridge, options) {
397
+ const providerSessionId = text(options.sessionId, "--session-id");
398
+ const adapterSessionId = options.adapterSessionId ?? providerSessionId;
399
+ if (!options.liveSession) await resume(bridge, adapterSessionId, options.cwd);
400
+ const resolved = await bridge.request("zcode/session/resolve", { sessionId: adapterSessionId });
401
+ if (resolved.providerSessionId !== providerSessionId) fail(`ZCode Session identity mismatch: expected ${providerSessionId}, observed ${resolved.providerSessionId}`, "session_identity_mismatch");
402
+ bridge.registerModelSession?.(adapterSessionId, providerSessionId, profile(options));
403
+ return { providerSessionId, adapterSessionId };
404
+ }
405
+
406
+ async function applyProfile(bridge, sessionId, requested, assertDispatch = () => {}) {
407
+ assertDispatch();
408
+ if (requested.mode) await bridge.request("session/set_mode", { sessionId, modeId: requested.mode });
409
+ assertDispatch();
410
+ if (requested.reasoning) await bridge.request("session/setThoughtLevel", { sessionId, thoughtLevel: requested.reasoning });
411
+ assertDispatch();
412
+ if (requested.model) await bridge.request("session/setModel", { sessionId, modelId: requested.provider ? `${requested.provider}\\${requested.model}` : requested.model });
413
+ assertDispatch();
414
+ const read = await bridge.request("zcode/session/read", { sessionId });
415
+ const observed = observedProfile(read);
416
+ await bridge.recordModel?.(sessionId, observed, "zcode/session/read.applied");
417
+ const receipt = assertProfile(requested, observed);
418
+ const identity = bridge.modelSessions?.get(sessionId); if (identity) identity.armed = true;
419
+ return receipt;
420
+ }
421
+
422
+ function profile(options) { return { provider: options.provider ?? null, model: options.model ?? null, reasoning: options.reasoning ?? null, mode: options.mode ?? null }; }
423
+
424
+ function requiredProfile(options) {
425
+ const requested = profile(options);
426
+ for (const key of ["provider", "model", "reasoning", "mode"]) text(requested[key], `--${key}`);
427
+ return requested;
428
+ }
429
+
430
+ function requireJournal(options) { absolute(options.journal, "--journal"); }
431
+
432
+ function receipt(options, value) { return options.daemonId ? { daemon_id: options.daemonId, ...value } : value; }
433
+
434
+ async function withBridge(options, operation) {
435
+ const bridge = new AcpBridge(options);
436
+ try { const initialized = await bridge.start(); return await operation(bridge, initialized); }
437
+ finally { await bridge.close(); }
438
+ }
439
+
440
+ function flowForwarder(options, rootProviderSessionId, bridge) {
441
+ if (!options.ddFlowBin) return undefined;
442
+ const projectRoot = absolute(options.projectRoot, "--project-root");
443
+ return async (notification) => {
444
+ if (notification.method !== "session/update") return;
445
+ const update = notification.params?.update;
446
+ const command = update?.sessionUpdate === "tool_call" ? update.rawInput?.command ?? update.rawInput?.cmd : null;
447
+ if (typeof command !== "string" || !command.includes("dd-flow")) return;
448
+ const childId = notification.params?.update?._meta?.zcodeRuntime?.childSessionId;
449
+ const observed = bridge?.modelProfiles?.get(childId ?? rootProviderSessionId) ?? {};
450
+ if (childId && !observed.model) await observeModel({ journal: options.journal, harness: "zcode-acp", sessionId: childId, parentSessionId: rootProviderSessionId, requested: profile(options), observed, source: "zcode.child_tool_event", evidence: "unavailable" });
451
+ const payload = zcodeLifecycleEnvelope(notification, { ...options, observedProfile: observed }, rootProviderSessionId);
452
+ const stdout = await runWithStdin(options.ddFlowBin, ["zcode", "event", "handle", "--project-root", projectRoot, "--json"], `${JSON.stringify(payload)}\n`, options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {});
453
+ const receipt = stdout ? JSON.parse(stdout) : {};
454
+ if (receipt.ok === false) fail(`dd-flow rejected ZCode event: ${JSON.stringify(receipt.error ?? receipt)}`);
455
+ };
456
+ }
457
+
458
+ export function zcodeLifecycleEnvelope(notification, options, rootProviderSessionId) {
459
+ return { ...notification, _meta: { ddZcode: {
460
+ rootProviderSessionId,
461
+ requestedProfile: profile(options),
462
+ observedProfile: observedFields(options.observedProfile),
463
+ ...(options.daemonId ? { daemonId: options.daemonId } : {}),
464
+ } } };
465
+ }
466
+
467
+ async function forwardUsage(options, providerSessionId, usage, toolCalls) {
468
+ if (!options.ddFlowBin || !usage) return;
469
+ const projectRoot = absolute(options.projectRoot, "--project-root");
470
+ const payload = {
471
+ provider_session_id: providerSessionId,
472
+ daemon_id: options.daemonId ?? null,
473
+ observed_at: new Date().toISOString(),
474
+ usage,
475
+ // ZCode exposes counters for the addressed physical session only. State
476
+ // this explicitly so dd-flow never has to infer an aggregation model.
477
+ usage_scope: "physical_session",
478
+ completeness: usage?.usageIsIncomplete ? "partial" : "complete",
479
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
480
+ };
481
+ await runWithStdin(options.ddFlowBin, ["zcode", "usage", "ingest", "--project-root", projectRoot, "--json"], `${JSON.stringify(payload)}\n`, options.ddFlowHome ? { DD_FLOW_HOME: absolute(options.ddFlowHome, "--dd-flow-home") } : {});
482
+ }
483
+
484
+ export async function doctor(options = {}) {
485
+ const zcodeAcp = await commandOutput(options.bin, ["--version"]);
486
+ const zcodeAcpCommit = await commandOutput(options.bin, ["--dd-harness-commit"]);
487
+ const ddHarness = await commandOutput(options.bin, ["--dd-harness-version"]);
488
+ const zcodePath = options.zcodePath ?? process.env.ZCODE_PATH ?? "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs";
489
+ const zcode = await commandOutput(zcodePath, ["--version"]);
490
+ return { compatible: true, observed_runtime: { zcode, zcode_acp: zcodeAcp, zcode_acp_commit: zcodeAcpCommit, dd_harness_contract: ddHarness || ZCODE_HARNESS_CONTRACT } };
491
+ }
492
+
493
+ export async function createSession(options) {
494
+ requireJournal(options);
495
+ return await withBridge(options, async (bridge, initialized) => await createSessionWithBridge(bridge, options, initialized));
496
+ }
497
+
498
+ export async function createSessionWithBridge(bridge, options, initialized) {
499
+ requireJournal(options);
500
+ const cwd = absolute(options.cwd, "--cwd"); const requested = requiredProfile(options);
501
+ let forward;
502
+ bridge.configure({ ...options, onNotification: async (event) => { await forward?.(event); await options.onNotification?.(event); } });
503
+ const created = await bridge.request("session/new", { cwd, mcpServers: [] });
504
+ const resolved = await bridge.request("zcode/session/resolve", { sessionId: created.sessionId });
505
+ const providerSessionId = resolved.providerSessionId;
506
+ bridge.registerModelSession?.(created.sessionId, providerSessionId, requested, null, false);
507
+ const profile_receipt = await applyProfile(bridge, created.sessionId, requested);
508
+ forward = flowForwarder(options, providerSessionId, bridge);
509
+ const usage = await bridge.request("zcode/session/usage", { sessionId: created.sessionId });
510
+ await forwardUsage(options, providerSessionId, usage, bridge.toolSummary(created.sessionId));
511
+ await bridge.flush();
512
+ // ZCode materializes `subagents` only after its first prompt. Session
513
+ // creation is intentionally nonproductive, so querying that topology here
514
+ // would both fail on real ZCode and turn the common create contract into an
515
+ // implicit first turn. The first prompt/inspect records full evidence.
516
+ const evidence = { read: null, subagents: { running: [], completed: [] }, usage, observed_profile: profile_receipt.observed, tool_calls: bridge.toolSummary(created.sessionId) };
517
+ return receipt(options, { harness: "zcode-acp", runtime_family: "zcode", adapter_session_id: created.sessionId, provider_session_id: providerSessionId, cwd, initialized, profile: profile_receipt, evidence: publicEvidence(evidence) });
518
+ }
519
+
520
+ export async function promptSession(options) {
521
+ requireJournal(options);
522
+ return await withBridge(options, async (bridge) => await promptSessionWithBridge(bridge, options));
523
+ }
524
+
525
+ export async function promptSessionWithBridge(bridge, options) {
526
+ requireJournal(options);
527
+ const cwd = absolute(options.cwd, "--cwd"); const prompt = text(options.prompt, "prompt"); const requested = requiredProfile(options);
528
+ let forward;
529
+ bridge.configure({ ...options, onNotification: async (event) => { await forward?.(event); await options.onNotification?.(event); } });
530
+ const identity = await controlledIdentity(bridge, { ...options, cwd }); forward = flowForwarder(options, identity.providerSessionId, bridge);
531
+ const profile_receipt = await applyProfile(bridge, identity.adapterSessionId, requested, options.assertDispatch);
532
+ options.assertDispatch?.();
533
+ await forwardUsage(options, identity.providerSessionId, await bridge.request("zcode/session/usage", { sessionId: identity.adapterSessionId }), bridge.toolSummary(identity.adapterSessionId));
534
+ const timeoutMs = options.timeoutMs ?? (options.livenessTimeoutMs ? null : 1_800_000);
535
+ const textCursor = bridge.assistantTextCursor(identity.adapterSessionId);
536
+ options.assertDispatch?.();
537
+ const turn = await bridge.request("session/prompt", { sessionId: identity.adapterSessionId, prompt: [{ type: "text", text: prompt }] }, timeoutMs, {
538
+ idleSessionId: identity.adapterSessionId,
539
+ idleTimeoutMs: options.livenessTimeoutMs ?? null,
540
+ });
541
+ await bridge.flush();
542
+ const evidence = await inspect(bridge, identity.adapterSessionId);
543
+ evidence.tool_calls = bridge.toolSummary(identity.adapterSessionId);
544
+ await forwardUsage(options, identity.providerSessionId, evidence.usage, evidence.tool_calls);
545
+ const stopReason = typeof turn?.stopReason === "string" ? turn.stopReason : null;
546
+ if (stopReason && stopReason !== "end_turn") {
547
+ const error = new Error(`ZCode ended the provider request with ${stopReason}, not end_turn`);
548
+ error.code = stopReason === "cancelled" ? "zcode_turn_cancelled" : "zcode_turn_not_completed";
549
+ error.details = { stop_reason: stopReason, provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, evidence: publicEvidence(evidence) };
550
+ throw error;
551
+ }
552
+ if (!options.allowBackground && (evidence.subagents.running ?? []).length) {
553
+ await cancelTree(bridge, identity.adapterSessionId, evidence.subagents);
554
+ fail("background ZCode subagents cannot outlive the one-shot dd-zcode connection; the live child tree was cancelled");
555
+ }
556
+ return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, assistant_text: bridge.assistantTextSince(identity.adapterSessionId, textCursor), turn, profile: profile_receipt, evidence: publicEvidence(evidence) });
557
+ }
558
+
559
+ export async function inspectSession(options) {
560
+ requireJournal(options);
561
+ return await withBridge(options, async (bridge) => await inspectSessionWithBridge(bridge, options));
562
+ }
563
+
564
+ export async function inspectSessionWithBridge(bridge, options) {
565
+ requireJournal(options);
566
+ const identity = await controlledIdentity(bridge, options);
567
+ return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...publicEvidence(await inspect(bridge, identity.adapterSessionId)) });
568
+ }
569
+
570
+ export async function cancelSession(options) {
571
+ requireJournal(options);
572
+ return await withBridge(options, async (bridge) => await cancelSessionWithBridge(bridge, options));
573
+ }
574
+
575
+ export async function cancelSessionWithBridge(bridge, options) {
576
+ requireJournal(options);
577
+ const identity = await controlledIdentity(bridge, options);
578
+ const before = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId }).catch(error => ({ observation_error: { code: error.code, message: error.message } }));
579
+ return receipt(options, { harness: "zcode-acp", provider_session_id: identity.providerSessionId, adapter_session_id: identity.adapterSessionId, ...await cancelTree(bridge, identity.adapterSessionId, before) });
580
+ }
581
+
582
+ export async function forkSession(options) {
583
+ requireJournal(options);
584
+ return await withBridge(options, async (bridge) => await forkSessionWithBridge(bridge, options));
585
+ }
586
+
587
+ export async function forkSessionWithBridge(bridge, options) {
588
+ requireJournal(options);
589
+ if (!options.target || typeof options.target !== "object") fail("explicit --target-json is required");
590
+ const identity = await controlledIdentity(bridge, options);
591
+ const topology = await bridge.request("zcode/session/subagents", { sessionId: identity.adapterSessionId });
592
+ if ((topology.running ?? []).length) fail("cannot fork while background children are running");
593
+ const result = await bridge.request("session/fork", { sessionId: identity.adapterSessionId, target: options.target });
594
+ if (!result.forkedSessionId) fail("ZCode fork returned no provider Session ID");
595
+ return receipt(options, { harness: "zcode-acp", parent_provider_session_id: identity.providerSessionId, parent_adapter_session_id: identity.adapterSessionId, provider_session_id: result.forkedSessionId, adapter_session_id: result.forkedSessionId, target: options.target });
596
+ }
597
+
598
+ export async function promptFromFile(file) { return await readFile(absolute(file, "--prompt-file"), "utf8"); }
@@ -0,0 +1,14 @@
1
+ const generations = new WeakMap();
2
+
3
+ // A cancellation invalidates preparation already in flight, while a later
4
+ // explicit prompt captures a fresh generation and can resume normally.
5
+ export function captureDispatchGuard(owner) {
6
+ const generation = generations.get(owner) ?? 0;
7
+ return () => {
8
+ if (generation !== (generations.get(owner) ?? 0)) throw Object.assign(new Error("Prompt cancelled before native dispatch"), { code: "operation_cancelled" });
9
+ };
10
+ }
11
+
12
+ export function cancelPendingDispatch(owner) {
13
+ generations.set(owner, (generations.get(owner) ?? 0) + 1);
14
+ }