@deksden-com/dd-flow-cli 0.9.0-beta.134 → 0.9.0-beta.136

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 (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +33 -0
  3. package/dist/build-info.json +7 -7
  4. package/dist/cli/command-inputs.js +1 -0
  5. package/dist/cli/command-metadata.js +144 -0
  6. package/dist/cli/command-option-descriptions.js +186 -0
  7. package/dist/cli/completion-cli.js +70 -0
  8. package/dist/cli/completion.js +57 -0
  9. package/dist/cli/error-output.js +52 -0
  10. package/dist/cli/executor-onboarding.js +71 -0
  11. package/dist/cli/harnesses.js +84 -0
  12. package/dist/cli/models.js +39 -0
  13. package/dist/cli/operator-profiles.js +61 -0
  14. package/dist/cli/output-options.js +35 -0
  15. package/dist/cli/response-file.js +69 -0
  16. package/dist/cli/run-cli.js +5 -0
  17. package/dist/cli/setup.js +147 -0
  18. package/dist/harness-runtime/OPENCODE-LICENSE.txt +24 -0
  19. package/dist/harness-runtime/bin/dd-opencode.mjs +1 -1
  20. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +13 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +193 -39
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +295 -100
  23. package/dist/harness-runtime/lib/delegation-instructions.mjs +3 -3
  24. package/dist/harness-runtime/lib/managed-daemon.mjs +1 -1
  25. package/dist/harness-runtime/lib/native-hook-command.d.mts +2 -2
  26. package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
  27. package/dist/harness-runtime/lib/opencode-plugin.mjs +60 -0
  28. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-1y4a697q.js +81 -0
  29. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-2ny4vxct.js +92 -0
  30. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-6mptkzbc.js +0 -0
  31. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-7x67cf1q.js +52 -0
  32. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9rqn6x4v.js +16 -0
  33. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-9vdz0zwn.js +1661 -0
  34. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ecs5ftka.js +1466 -0
  35. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-frbwqjmf.js +145 -0
  36. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gne7h7mq.js +50 -0
  37. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-gx9wnkzr.js +105 -0
  38. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-m2eywc8w.js +0 -0
  39. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-nwcpakzx.js +12 -0
  40. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-q4g3afnw.js +623 -0
  41. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-qav6mfdz.js +0 -0
  42. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-sxt88q69.js +12 -0
  43. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-t1xa31vm.js +39 -0
  44. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ve4aws36.js +103 -0
  45. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vmytr4q8.js +4 -0
  46. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vt9jbch8.js +36 -0
  47. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-vyas3vgt.js +0 -0
  48. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-wjyeq25y.js +30 -0
  49. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-xgnj2w4v.js +20 -0
  50. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-y2etg5bs.js +9 -0
  51. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-ydhz1zrh.js +238 -0
  52. package/dist/harness-runtime/vendor/opencode-client/chunks/service-probe-z6qdwcsf.js +62 -0
  53. package/dist/harness-runtime/vendor/opencode-client/contract.d.ts +1 -0
  54. package/dist/harness-runtime/vendor/opencode-client/contract.js +12 -0
  55. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.d.ts +2806 -0
  56. package/dist/harness-runtime/vendor/opencode-client/effect/api/api.js +0 -0
  57. package/dist/harness-runtime/vendor/opencode-client/effect/api.d.ts +4 -0
  58. package/dist/harness-runtime/vendor/opencode-client/effect/api.js +0 -0
  59. package/dist/harness-runtime/vendor/opencode-client/effect/client.d.ts +2530 -0
  60. package/dist/harness-runtime/vendor/opencode-client/effect/client.js +15 -0
  61. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.d.ts +7 -0
  62. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client-error.js +6 -0
  63. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.d.ts +2529 -0
  64. package/dist/harness-runtime/vendor/opencode-client/effect/generated/client.js +8 -0
  65. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.d.ts +2 -0
  66. package/dist/harness-runtime/vendor/opencode-client/effect/generated/index.js +12 -0
  67. package/dist/harness-runtime/vendor/opencode-client/effect/index.d.ts +35 -0
  68. package/dist/harness-runtime/vendor/opencode-client/effect/index.js +75 -0
  69. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.d.ts +20 -0
  70. package/dist/harness-runtime/vendor/opencode-client/effect/rpc.js +11 -0
  71. package/dist/harness-runtime/vendor/opencode-client/effect/service.d.ts +90 -0
  72. package/dist/harness-runtime/vendor/opencode-client/effect/service.js +187 -0
  73. package/dist/harness-runtime/vendor/opencode-client/package.json +84 -0
  74. package/dist/harness-runtime/vendor/opencode-client/promise/api.d.ts +21 -0
  75. package/dist/harness-runtime/vendor/opencode-client/promise/api.js +0 -0
  76. package/dist/harness-runtime/vendor/opencode-client/promise/client.d.ts +252 -0
  77. package/dist/harness-runtime/vendor/opencode-client/promise/client.js +14 -0
  78. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.d.ts +8 -0
  79. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client-error.js +6 -0
  80. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.d.ts +259 -0
  81. package/dist/harness-runtime/vendor/opencode-client/promise/generated/client.js +7 -0
  82. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.d.ts +3 -0
  83. package/dist/harness-runtime/vendor/opencode-client/promise/generated/index.js +79 -0
  84. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.d.ts +8932 -0
  85. package/dist/harness-runtime/vendor/opencode-client/promise/generated/types.js +70 -0
  86. package/dist/harness-runtime/vendor/opencode-client/promise/index.d.ts +6 -0
  87. package/dist/harness-runtime/vendor/opencode-client/promise/index.js +83 -0
  88. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.d.ts +32 -0
  89. package/dist/harness-runtime/vendor/opencode-client/promise/rpc.js +7 -0
  90. package/dist/harness-runtime/vendor/opencode-client/promise/service.d.ts +24 -0
  91. package/dist/harness-runtime/vendor/opencode-client/promise/service.js +187 -0
  92. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.d.ts +12 -0
  93. package/dist/harness-runtime/vendor/opencode-client/pty-handoff.js +14 -0
  94. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.d.ts +20 -0
  95. package/dist/harness-runtime/vendor/opencode-client/rpc-runtime.js +14 -0
  96. package/dist/harness-runtime/vendor/opencode-client/service-contender.d.ts +37 -0
  97. package/dist/harness-runtime/vendor/opencode-client/service-contender.js +10 -0
  98. package/dist/harness-runtime/vendor/opencode-client/service-probe.d.ts +70 -0
  99. package/dist/harness-runtime/vendor/opencode-client/service-probe.js +16 -0
  100. package/dist/harness-runtime/vendor/opencode-client/service-timing.d.ts +12 -0
  101. package/dist/harness-runtime/vendor/opencode-client/service-timing.js +10 -0
  102. package/dist/harness-runtime/vendor/opencode-client/service-version.d.ts +2 -0
  103. package/dist/harness-runtime/vendor/opencode-client/service-version.js +6 -0
  104. package/dist/harness-runtime/vendor/opencode-client/service.d.ts +52 -0
  105. package/dist/harness-runtime/vendor/opencode-client/service.js +1 -0
  106. package/dist/harness-runtime/vendor/opencode-client/shared-events.d.ts +11 -0
  107. package/dist/harness-runtime/vendor/opencode-client/shared-events.js +8 -0
  108. package/dist/harness-runtime/vendor/opencode-client/solid/connection.d.ts +37 -0
  109. package/dist/harness-runtime/vendor/opencode-client/solid/connection.js +10 -0
  110. package/dist/harness-runtime/vendor/opencode-client/solid/data.d.ts +211 -0
  111. package/dist/harness-runtime/vendor/opencode-client/solid/data.js +18 -0
  112. package/dist/harness-runtime/vendor/opencode-client/solid/index.d.ts +3 -0
  113. package/dist/harness-runtime/vendor/opencode-client/solid/index.js +32 -0
  114. package/dist/harness-runtime/vendor/opencode-client/solid/pty.d.ts +22 -0
  115. package/dist/harness-runtime/vendor/opencode-client/solid/pty.js +8 -0
  116. package/dist/harness-runtime/vendor/opencode-plugin.mjs +3 -0
  117. package/dist/services/cli-operation-classifier.js +4 -0
  118. package/dist/services/cli-upgrade-allowlist.js +27 -0
  119. package/dist/services/discovery/agy-discovery.js +2 -0
  120. package/dist/services/discovery/codex-discovery.js +2 -0
  121. package/dist/services/discovery/droid-discovery.js +2 -0
  122. package/dist/services/discovery/grok-discovery.js +2 -0
  123. package/dist/services/discovery/opencode-discovery.js +2 -0
  124. package/dist/services/discovery/static.js +31 -0
  125. package/dist/services/discovery/zcode-discovery.js +2 -0
  126. package/dist/services/engines.js +23 -25
  127. package/dist/services/eval-snapshots.js +1 -1
  128. package/dist/services/executor-onboarding.js +56 -0
  129. package/dist/services/external-work-launch.js +1 -1
  130. package/dist/services/harness-discovery.js +29 -0
  131. package/dist/services/harness-doctor.js +75 -0
  132. package/dist/services/harness-probe-effects.js +51 -0
  133. package/dist/services/harness-version-probe.js +100 -0
  134. package/dist/services/hooks.js +1 -1
  135. package/dist/services/hybrid-contracts.js +455 -0
  136. package/dist/services/installation-lock.js +92 -0
  137. package/dist/services/managed-processes.js +30 -0
  138. package/dist/services/merge-server.js +1 -1
  139. package/dist/services/model-catalog.js +109 -0
  140. package/dist/services/model-discovery.js +46 -0
  141. package/dist/services/operator-profiles.js +236 -0
  142. package/dist/services/release-archive-producer.js +52 -0
  143. package/dist/services/release-archive.js +143 -0
  144. package/dist/services/release-cache.js +142 -0
  145. package/dist/services/release-compatibility.js +18 -0
  146. package/dist/services/release-download.js +67 -0
  147. package/dist/services/release-envelope.js +104 -0
  148. package/dist/services/release-manifest.js +45 -0
  149. package/dist/services/release-payload.js +114 -0
  150. package/dist/services/release-producer.js +63 -0
  151. package/dist/services/release-provenance.js +160 -0
  152. package/dist/services/release-registry.js +66 -0
  153. package/dist/services/release-source.js +194 -0
  154. package/dist/services/run-controller-recovery.js +2 -2
  155. package/dist/services/run-controller.js +3 -2
  156. package/dist/services/run-health.js +149 -0
  157. package/dist/services/runs.js +61 -13
  158. package/dist/services/setup.js +377 -0
  159. package/dist/storage/database.js +18 -3
  160. package/dist/storage/writer-contract.js +8 -0
  161. package/package.json +4 -1
@@ -1,128 +1,323 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
2
3
  import { ToolObservations, openCodeToolObservation } from "./tool-observations.mjs";
3
- import { observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
4
+ import { ObservationClock, observedTimeout, validateObservationDuration } from "./observation-clock.mjs";
4
5
  import { assertNativeLine } from "./native-streams.mjs";
5
6
  import { operationProgressReporter } from "./operation-progress.mjs";
6
7
 
8
+ export const OPENCODE_PROTOCOL = 2;
7
9
  export class OpenCodeError extends Error {
8
- constructor(code, message, retryable = false, details) { super(message); this.code = code; this.retryable = retryable; this.details = details; }
10
+ constructor(code, message, retryable = false, details) { super(message); Object.assign(this, { code, retryable, details }); }
11
+ }
12
+ const lost = (message, details) => new OpenCodeError("operation_observation_lost", message, false, { native_outcome: "unknown", ...details });
13
+ export const requestDigest = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
14
+ export const nativeModel = ({ provider, model, variant }) => ({ providerID: provider, id: model, ...(variant && variant !== "default" ? { variant } : {}) });
15
+
16
+ /** info/parts is our normalized projection, not a second v1 native protocol. */
17
+ export function normalizeMessage(message, sessionID) {
18
+ if (!message?.id || !message.type) throw lost("OpenCode message has no native identity");
19
+ const { model, time = {} } = message;
20
+ const parts = (message.content ?? []).map(part => part.type === "tool"
21
+ ? { ...part, tool: part.name, callID: part.id, state: { ...part.state, output: part.state?.result ?? part.state?.output } }
22
+ : { ...part });
23
+ if (message.type !== "assistant" && typeof message.text === "string") parts.push({ type: "text", text: message.text });
24
+ return { info: { id: message.id, sessionID, role: message.type, providerID: model?.providerID, modelID: model?.id,
25
+ variant: model?.variant ?? "default", mode: message.agent, finish: message.finish, error: message.error,
26
+ cost: message.cost, tokens: message.tokens, created: time.created, completed: time.completed, metadata: message.metadata, outcome: message.outcome }, parts };
27
+ }
28
+
29
+ export function providerFailure(native, sessionId, turnId) {
30
+ const message = String(native?.message ?? "OpenCode execution failed"), status = native?.status, type = String(native?.type ?? "unknown");
31
+ const quota = status === 402 || /quota|balance exhausted|insufficient.?credit|billing|payment required/i.test(message);
32
+ const code = quota ? "provider_quota_exhausted" : status === 401 || status === 403 ? "provider_auth_failed"
33
+ : /overload|server.?busy/i.test(`${type} ${message}`) || status === 503 ? "provider_capacity_transient"
34
+ : /rate.?limit|too many requests/i.test(message) ? "provider_rate_limited"
35
+ : status === 429 ? "provider_limit_unknown" : "opencode_provider_failed";
36
+ // Never forward response.body or provider credentials into controller/Judge.
37
+ return new OpenCodeError(code, `OpenCode native execution failed (${type}${status ? `, HTTP ${status}` : ""})`,
38
+ ["provider_capacity_transient", "provider_rate_limited"].includes(code), {
39
+ provider_session_id: sessionId, session_id: sessionId, turn_id: turnId, native_error_type: type,
40
+ native_turn_id: turnId, terminal_status: "failed",
41
+ ...(Number.isInteger(status) ? { http_status: status } : {}), observed_at: new Date().toISOString(), native_source: "opencode.v2.session.execution" });
9
42
  }
10
43
 
11
44
  export class OpenCodeClient {
12
- constructor({ baseUrl, password, directory, timeoutMs = 1_800_000 }) {
13
- this.baseUrl = new URL(baseUrl); this.password = password; this.directory = directory; this.timeoutMs = timeoutMs;
45
+ constructor({ baseUrl, password, directory, timeoutMs = 1_800_000, api }) {
46
+ Object.assign(this, { baseUrl: new URL(baseUrl), password, directory, timeoutMs });
47
+ this.ready = api ? Promise.resolve(api) : import("../vendor/opencode-client/promise/index.js").then(({ OpenCode }) => OpenCode.make({
48
+ baseUrl: this.baseUrl.href, headers: { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }
49
+ }));
14
50
  }
15
- async request(method, pathname, body, { directory = this.directory, timeoutMs = this.timeoutMs, progress, onDispatched } = {}) {
16
- const url = new URL(pathname, this.baseUrl); if (directory) url.searchParams.set("directory", directory);
17
- const controller = new AbortController(); const timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
51
+ async call(method, input, { timeoutMs, progress, onDispatched, productive = false } = {}) {
52
+ const observation = this.readObservation?.();
53
+ timeoutMs ??= observation?.timeoutMs ?? this.timeoutMs; progress ??= observation?.progress;
54
+ const api = await this.ready, controller = new AbortController();
55
+ const timer = observedTimeout(() => controller.abort(), timeoutMs, { progress });
18
56
  try {
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; }
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);
27
- return value;
28
- } catch (error) {
29
- controller.abort();
30
- if (error instanceof OpenCodeError || error.code === "operation_observation_lost") throw error;
31
- // Aborting HTTP only loses the observer; it does not cancel the provider.
32
- throw Object.assign(new OpenCodeError(error.name === "AbortError" || method === "POST" ? "operation_observation_lost" : "opencode_unavailable", error.message, true, { method, pathname }), { cause: error });
33
- } finally { clearTimeout(timer); }
57
+ const keys = method.split("."), name = keys.pop(), domain = keys.reduce((value, key) => value?.[key], api);
58
+ if (typeof domain?.[name] !== "function") throw new OpenCodeError("opencode_capability_missing", `OpenCode v2 does not expose ${method}`);
59
+ const options = { signal: controller.signal };
60
+ const pending = input === undefined ? domain[name](options) : domain[name](input, options);
61
+ void pending.catch(() => {}); await onDispatched?.(); const result = await pending;
62
+ assertNativeLine(JSON.stringify(result ?? null)); return result;
63
+ } catch (cause) {
64
+ if (cause instanceof OpenCodeError || cause.code === "operation_observation_lost") throw cause;
65
+ if (cause._tag === "SessionNotFoundError" || cause._tag === "NotFoundError") throw new OpenCodeError("session_not_found", "OpenCode native Session is unavailable");
66
+ if (productive && ["InvalidRequestError", "ConflictError", "UnauthorizedError", "ForbiddenError"].includes(cause._tag))
67
+ throw new OpenCodeError("opencode_admission_rejected", "OpenCode rejected native admission", false, { effect: "no_effect", native_type: cause._tag });
68
+ if (productive || controller.signal.aborted) throw Object.assign(lost(`OpenCode observer lost during ${method}`, { method }), { cause });
69
+ throw Object.assign(new OpenCodeError("opencode_unavailable", `OpenCode ${method} observation failed`, true,
70
+ { method, native_type: cause._tag ?? cause.name ?? null, native_reason: cause.reason ?? null }), { cause });
71
+ } finally { clearInterval(timer); }
72
+ }
73
+ async health() {
74
+ const info = await this.call("server.info", undefined, { timeoutMs: 5_000 });
75
+ if (!/^2\./.test(info?.version) || !Number.isInteger(info.pid)) throw new OpenCodeError("unsupported_version", "OpenCode v2 server identity is unavailable");
76
+ return info;
77
+ }
78
+ async pages(method, input) {
79
+ const items = new Map(), seen = new Set(); let cursor;
80
+ do {
81
+ const page = await this.call(method, { ...input, limit: 100, ...(cursor ? { cursor } : { order: "asc" }) });
82
+ if (!Array.isArray(page?.data)) throw lost(`OpenCode ${method} returned an invalid page`);
83
+ for (const item of page.data) { if (!item?.id) throw lost("OpenCode page item has no identity"); items.set(item.id, item); }
84
+ cursor = page.cursor?.next;
85
+ if (cursor && seen.has(cursor)) throw lost("OpenCode pagination cursor repeated");
86
+ if (cursor) seen.add(cursor);
87
+ } while (cursor);
88
+ return [...items.values()];
89
+ }
90
+ create(title, profile = {}, id) { return this.call("session.create", { ...(title ? { title } : {}), ...(id ? { id } : {}),
91
+ location: { directory: this.directory }, agent: profile.agent ?? "build", model: nativeModel(profile) }, { productive: true }); }
92
+ async inspect(id) { const info = await this.call("session.get", { sessionID: id }); return { ...info, directory: info.location?.directory }; }
93
+ list() { return this.pages("session.list", { directory: this.directory }); }
94
+ async statuses() { const active = await this.call("session.active"); return Object.fromEntries(Object.keys(active).map(id => [id, { type: "busy" }])); }
95
+ async children(id) { return (await this.pages("session.list", { parentID: id })).map(info => ({ ...info, directory: info.location?.directory })); }
96
+ async messages(id) { return (await this.pages("message.list", { sessionID: id })).map(message => normalizeMessage(message, id)); }
97
+ inbox(id) { return this.call("session.inbox.list", { sessionID: id }); }
98
+ async shells(id) { const result = await this.call("shell.list", { location: { directory: this.directory } }); return result.data.filter(item => item.metadata?.sessionID === id); }
99
+ async pending(id) {
100
+ const [inbox, forms, permissions] = await Promise.all([this.inbox(id), this.call("session.form.list", { sessionID: id }), this.call("permission.list", { sessionID: id })]);
101
+ const value = { inbox, forms: forms.data ?? forms, permissions: permissions.data ?? permissions };
102
+ if (Object.values(value).some(items => !Array.isArray(items))) throw lost("OpenCode returned invalid pending-input evidence", { session_id: id });
103
+ return value;
104
+ }
105
+ async logs(id, after = 0) {
106
+ const api = await this.ready, controller = new AbortController(), events = [];
107
+ const observation = this.readObservation?.();
108
+ const timer = observedTimeout(() => controller.abort(), observation?.timeoutMs ?? this.timeoutMs, { progress: observation?.progress });
109
+ try { for await (const event of api.session.log({ sessionID: id, after, follow: false }, { signal: controller.signal })) events.push(event); }
110
+ catch (cause) { throw Object.assign(lost("OpenCode durable log observation failed", { session_id: id }), { cause }); }
111
+ finally { clearInterval(timer); }
112
+ return events;
113
+ }
114
+ async abort(id) {
115
+ // Queued input must not become fresh work during interruption cleanup.
116
+ for (const item of await this.inbox(id)) await this.call("session.inbox.cancel", { sessionID: id, inboxID: item.id }, { productive: true });
117
+ const result = await this.call("session.interrupt", { sessionID: id, resume: false }, { productive: true });
118
+ const api = await this.ready;
119
+ if (typeof api.shell?.list === "function") for (const shell of await this.shells(id)) {
120
+ if (shell.status === "running") await this.call("shell.remove", { id: shell.id, location: { directory: this.directory } }, { productive: true });
121
+ }
122
+ return result;
34
123
  }
35
- health() { return this.request("GET", "/global/health", undefined, { directory: null, timeoutMs: 5_000 }); }
36
- create(title) { return this.request("POST", "/session", title ? { title } : {}); }
37
- inspect(id) { return this.request("GET", `/session/${encodeURIComponent(id)}`); }
38
- list() { return this.request("GET", "/session"); }
39
- statuses() { return this.request("GET", "/session/status"); }
40
- children(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/children`); }
41
- messages(id) { return this.request("GET", `/session/${encodeURIComponent(id)}/message`); }
42
- async prompt(id, { prompt, provider, model, variant, agent, onModelMessages, onObservationError, assertDispatch, onDispatched, livenessTimeoutMs = this.timeoutMs }) {
124
+ fork(id, messageID) { return this.call("session.fork", { sessionID: id, ...(messageID ? { before: messageID } : {}) }, { productive: true }); }
125
+ delete(id) { return this.call("session.remove", { sessionID: id }, { productive: true }); }
126
+ export(id) { return this.call("session.export", { sessionID: id }); }
127
+ import(data) { return this.call("session.import", data, { productive: true }); }
128
+
129
+ async prompt(id, { prompt, messageId = `msg_${randomUUID().replaceAll("-", "")}`, retained,
130
+ save, onModelMessages, onObservationError, assertDispatch, onDispatched, livenessTimeoutMs = this.timeoutMs }) {
43
131
  validateObservationDuration(livenessTimeoutMs);
44
- const previous = await this.messages(id);
45
- if (!Array.isArray(previous)) throw new OpenCodeError("operation_observation_lost", "OpenCode history cannot establish the prompt boundary", true);
46
- const previousIds = new Set(previous.map(message => message.info?.id).filter(Boolean));
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]]);
51
- // Native message content is progress; an HTTP poll or busy status is not.
52
- // Include descendants so a coordinating parent may legitimately be silent.
53
- const observe = async (session, seen = new Set(), parent = null) => {
54
- if (seen.has(session)) return [];
55
- seen.add(session);
56
- const options = { timeoutMs: Math.min(5_000, this.timeoutMs) };
57
- const [messages, children] = await Promise.all([
58
- this.request("GET", `/session/${encodeURIComponent(session)}/message`, undefined, options),
59
- this.request("GET", `/session/${encodeURIComponent(session)}/children`, undefined, options)
60
- ]);
61
- if (!Array.isArray(messages) || !Array.isArray(children)) throw new OpenCodeError("operation_observation_lost", "OpenCode observation shape is unproven", true);
62
- await onModelMessages?.(session, messages, parent);
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] : [];
70
- };
71
- const refreshObservation = () => observation ??= (async () => {
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() });
132
+ const request = { sessionID: id, id: messageId, text: prompt, delivery: "queue", resume: true }, digest = requestDigest(request);
133
+ if (retained && (retained.request_sha256 !== digest || retained.message_id !== messageId)) throw new OpenCodeError("operation_request_mismatch", "OpenCode operation already binds another prompt", false, { effect: "no_effect" });
134
+ let state = retained ?? { message_id: messageId, request_sha256: digest, phase: "prepared", cursor: 0 };
135
+ const previous = await this.messages(id), baseline = new Map([[id, new Set(previous.map(item => item.info.id))]]), parents = new Map();
136
+ const capture = async session => { for (const child of await this.children(session)) { parents.set(child.id, child.parentID); baseline.set(child.id, new Set((await this.messages(child.id)).map(item => item.info.id))); await capture(child.id); } };
137
+ await capture(id);
138
+ if (retained) {
139
+ const index = previous.findIndex(item => item.info.id === messageId);
140
+ if (index >= 0) baseline.set(id, new Set(previous.slice(0, index + 1).map(item => item.info.id)));
141
+ }
142
+ assertDispatch?.(); // Cancellation/ownership wins before observer setup.
143
+ const api = await this.ready, streamAbort = new AbortController(), clock = new ObservationClock({ timeoutMs: livenessTimeoutMs });
144
+ const publish = operationProgressReporter(), known = new Set([id, ...baseline.keys()]), signatures = new Map(), shellCursors = new Map(), liveSeen = new Set();
145
+ let progress = 0, marker, stream;
146
+ const liveParts = new Map(), liveShells = new Map(), backgroundShells = new Set();
147
+ const advance = async (observedAt = Date.now()) => { marker = { cursor: ++progress, observed_at: observedAt }; await publish("native_progress", { session_id: id, observed_at: new Date(marker.observed_at).toISOString() }); };
148
+ let connected;
149
+ const connection = new Promise(resolve => { connected = resolve; });
150
+ const connectionTimer = api.event?.subscribe ? observedTimeout(() => { connected(false); streamAbort.abort(); }, livenessTimeoutMs) : null;
151
+ if (api.event?.subscribe) stream = (async () => {
152
+ try { for await (const event of api.event.subscribe({ signal: streamAbort.signal, onActivity: () => connected(true) })) {
153
+ connected(true); // Stream establishment is not model progress.
154
+ const data = event.data, floor = baseline.get(data?.sessionID);
155
+ if (known.has(data?.sessionID) && data?.assistantMessageID && !floor?.has(data.assistantMessageID)
156
+ && event.created >= clock.startedWall && event.created <= Date.now() && event.id && !liveSeen.has(event.id)
157
+ && ["session.text.delta", "session.reasoning.delta", "session.tool.input.delta", "session.tool.progress"].includes(event.type)) {
158
+ liveSeen.add(event.id); if (liveSeen.size > 1024) liveSeen.delete(liveSeen.values().next().value);
159
+ const key = `${data.sessionID}:${data.assistantMessageID}:${data.id ?? ""}`, signature = requestDigest(data.metadata ?? null);
160
+ if (event.type === "session.tool.progress" && typeof data.metadata?.shellID === "string") liveShells.set(key, data.metadata.shellID);
161
+ if (typeof data.delta === "string" && data.delta.length || event.type === "session.tool.progress" && liveParts.get(key) !== signature) {
162
+ liveParts.set(key, signature); await advance(event.created);
163
+ }
78
164
  }
79
- if (seenSignatures.size < 1_024) seenSignatures.add(next);
80
- highWater = Math.max(highWater, projection.length);
81
- signature = next;
82
- }
83
- catch (error) { await onObservationError?.(error); /* Missing data is not progress. */ }
84
- })().finally(() => { observation = null; });
85
- // Begin observing before the long-running request. Waiting for the first
86
- // interval can let a short liveness window expire before any native
87
- // content has had a chance to renew it.
88
- void refreshObservation();
89
- const timer = setInterval(() => { void refreshObservation(); }, Math.max(10, Math.min(5_000, livenessTimeoutMs / 4)));
165
+ } } catch (error) { if (!streamAbort.signal.aborted) await onObservationError?.(error); }
166
+ finally { connected(false); }
167
+ })();
168
+ const priorObservation = this.readObservation;
169
+ this.readObservation = () => {
170
+ if (clock.sample(marker)) throw lost("OpenCode operation observation expired", { session_id: id, message_id: messageId, observation: clock.state() });
171
+ return { timeoutMs: Math.max(1, Math.ceil(livenessTimeoutMs - clock.elapsed)), progress: () => marker };
172
+ };
90
173
  try {
91
- assertDispatch?.();
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 });
93
- if (!reply?.info?.id || reply.info.sessionID !== id || reply.info.role !== "assistant" || previousIds.has(reply.info.id) || !Array.isArray(reply.parts)) {
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 });
174
+ if (api.event?.subscribe && !await connection) throw new OpenCodeError("opencode_unavailable", "Native observer stream could not be established before dispatch", true, { effect: "no_effect" });
175
+ clearInterval(connectionTimer);
176
+ if (!retained) {
177
+ await save?.(state); assertDispatch?.();
178
+ // Subscribe before dispatch; persisted intent precedes HTTP. Unknown
179
+ // ack permits reconciliation only, never another same-ID wake.
180
+ state = { ...state, phase: "dispatched" }; await save?.(state);
181
+ try {
182
+ const admission = await this.call("session.prompt", request, { productive: true, onDispatched });
183
+ if (admission?.id !== messageId || admission.sessionID !== id) throw lost("OpenCode prompt acknowledgement has another identity");
184
+ state = { ...state, phase: "admitted" }; await save?.(state);
185
+ } catch (error) {
186
+ if (error.details?.effect === "no_effect") throw error;
187
+ await onObservationError?.(error);
188
+ }
95
189
  }
96
- if (reply.info.error) {
97
- const native = reply.info.error;
98
- const message = native.data?.message ?? native.message ?? native.name ?? "OpenCode provider failed";
99
- const status = native.data?.statusCode;
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";
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 });
190
+ for (;;) {
191
+ try {
192
+ const events = await this.logs(id, state.cursor);
193
+ let delivered = state.delivered === true, terminal = state.terminal;
194
+ for (const event of events) {
195
+ if (!Number.isInteger(event.durable?.seq) || event.durable.seq <= state.cursor) continue;
196
+ if (event.type === "session.inbox.delivered" && event.data.inboxID === messageId) { delivered = true; terminal = null; }
197
+ if (delivered && event.type === "session.execution.started") terminal = null;
198
+ if (delivered && ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].includes(event.type)) terminal = { type: event.type, error: event.data.error };
199
+ state = { ...state, cursor: event.durable.seq, delivered, terminal }; await save?.(state);
200
+ }
201
+ const observe = async (session, visited = new Set()) => {
202
+ if (visited.has(session)) throw lost("OpenCode topology contains a cycle"); visited.add(session); known.add(session);
203
+ const messages = await this.messages(session);
204
+ await onModelMessages?.(session, messages, parents.get(session) ?? null);
205
+ const floor = baseline.get(session) ?? new Set(), current = messages.filter(item => item.info.role === "assistant" && !floor.has(item.info.id));
206
+ for (const item of current) {
207
+ const key = `${session}:${item.info.id}`, encoded = JSON.stringify(item.parts.map(stablePart)), next = requestDigest(encoded), before = signatures.get(key);
208
+ if (before?.signature !== next && !before?.seen.has(next) && (!before || before.seen.size < 128 || encoded.length > before.highWater)) {
209
+ if (item.parts.length) await advance();
210
+ const seen = before?.seen ?? new Set(); if (seen.size < 128) seen.add(next);
211
+ signatures.set(key, { signature: next, seen, highWater: Math.max(before?.highWater ?? 0, encoded.length) });
212
+ }
213
+ for (const part of item.parts) {
214
+ let shellID = part.type === "tool" ? part.state?.metadata?.shellID ?? liveShells.get(`${session}:${item.info.id}:${part.id}`) : null;
215
+ if (!shellID && part.tool === "shell" && part.state?.status === "running") {
216
+ // Tool progress metadata is volatile in the CLI. Reconcile
217
+ // only an unambiguous native shell in this current message.
218
+ const candidates = (await this.shells(session)).filter(shell => shell.time.started >= item.info.created);
219
+ if (candidates.length === 1) shellID = candidates[0].id;
220
+ }
221
+ if (!shellID) continue;
222
+ const cursor = shellCursors.get(shellID) ?? 0;
223
+ shellCursors.set(shellID, cursor); // Zero-output shells still own their native notification.
224
+ if (part.tool === "shell" && part.state?.metadata?.status === "running") backgroundShells.add(shellID);
225
+ const output = await this.call("shell.output", { id: shellID, location: { directory: this.directory }, cursor, limit: 65536 }), data = output.data ?? output;
226
+ if (data.cursor > cursor) { shellCursors.set(shellID, data.cursor); await advance(); }
227
+ }
228
+ }
229
+ for (const child of await this.children(session)) {
230
+ if (child.parentID !== session) throw lost("OpenCode child has a foreign parent");
231
+ parents.set(child.id, session); await observe(child.id, visited);
232
+ }
233
+ return current;
234
+ };
235
+ const current = await observe(id), rootMessages = await this.messages(id);
236
+ // CLI v2 defaults to non-persistent event payloads. The native
237
+ // projected user + idle records remain durable and carry the
238
+ // exact terminal error; do not invent a 'latest assistant' outcome.
239
+ const deliveredIndex = rootMessages.findIndex(item => item.info.id === messageId && item.info.role === "user");
240
+ if (deliveredIndex >= 0) {
241
+ for (const item of rootMessages.slice(deliveredIndex + 1)) {
242
+ if (item.info.role === "synthetic" && item.info.metadata?.source === "subagent"
243
+ && typeof item.info.metadata.childID === "string" && parents.get(item.info.metadata.childID) !== id) {
244
+ // Native messages and topology are separate reads: a child can
245
+ // finish between them. Reconcile its ancestry before rejecting
246
+ // the notification, never trust the notification alone.
247
+ const child = (await this.children(id)).find(child => child.id === item.info.metadata.childID && child.parentID === id);
248
+ if (child) { parents.set(child.id, id); await observe(child.id); }
249
+ }
250
+ if (item.info.role === "user" || item.info.role === "synthetic" && !(
251
+ item.info.metadata?.source === "subagent" && parents.get(item.info.metadata.childID) === id
252
+ || item.info.metadata?.source === "shell" && shellCursors.has(item.info.metadata.shellID)))
253
+ throw new OpenCodeError("opencode_foreign_input", "OpenCode received input outside the owned operation", false, { session_id: id, message_id: item.info.id, role: item.info.role, source: item.info.metadata?.source });
254
+ }
255
+ const terminal = rootMessages.slice(deliveredIndex + 1).findLast(item => item.info.role === "idle");
256
+ if (terminal) {
257
+ if (!["succeeded", "failed", "interrupted"].includes(terminal.info.outcome)) throw lost("OpenCode idle record has no proven native outcome");
258
+ state = { ...state, delivered: true, terminal: { type: `session.execution.${terminal.info.outcome}`, error: terminal.info.error } }; await save?.(state);
259
+ }
260
+ }
261
+ const statuses = await this.statuses(); let unsettled = false;
262
+ // A background child's native completion notification is delivered
263
+ // asynchronously. An idle parent before that delivery is not the
264
+ // completion of the coordinating operation.
265
+ const deliveredNotifications = new Set(rootMessages.filter(item => item.info.role === "synthetic" && item.info.metadata?.source === "subagent").map(item => item.info.metadata.childID));
266
+ const shellNotifications = new Set(rootMessages.filter(item => item.info.role === "synthetic" && item.info.metadata?.source === "shell").map(item => item.info.metadata.shellID));
267
+ unsettled ||= [...backgroundShells].some(shell => !shellNotifications.has(shell));
268
+ for (const item of current) for (const part of item.parts) {
269
+ if (part.tool === "subagent" && part.state?.input?.background === true) {
270
+ const child = part.state?.metadata?.sessionID;
271
+ if (!child || !deliveredNotifications.has(child)) unsettled = true;
272
+ }
273
+ }
274
+ for (const session of known) {
275
+ const pending = await this.pending(session);
276
+ if (pending.forms.length || pending.permissions.length) throw new OpenCodeError("opencode_native_hitl", "OpenCode requires native operator input", false, { provider_session_id: session, form_ids: pending.forms.map(item => item.id), permission_ids: pending.permissions.map(item => item.id) });
277
+ unsettled ||= Boolean(statuses[session]) || pending.inbox.length > 0;
278
+ if (typeof api.shell?.list === "function") unsettled ||= (await this.shells(session)).some(shell => shell.status === "running");
279
+ }
280
+ if (state.delivered && state.terminal && !unsettled) {
281
+ if (state.terminal.type === "session.execution.failed") throw providerFailure(state.terminal.error, id, messageId);
282
+ if (state.terminal.type === "session.execution.interrupted") throw new OpenCodeError("opencode_execution_interrupted", "OpenCode execution was interrupted", false, { provider_session_id: id, turn_id: messageId });
283
+ const final = current.findLast(item => item.info.completed !== undefined && item.info.finish && item.info.finish !== "tool-calls");
284
+ if (!final) throw lost("OpenCode terminal has no current assistant response", { session_id: id, message_id: messageId });
285
+ if (final.info.error) throw providerFailure(final.info.error, id, messageId);
286
+ return { ...final, admission_message_id: messageId, log_cursor: state.cursor };
287
+ }
288
+ } catch (error) {
289
+ if (error instanceof OpenCodeError && !["opencode_unavailable", "operation_observation_lost"].includes(error.code)) throw error;
290
+ await onObservationError?.(error);
291
+ }
292
+ if (clock.sample(marker)) throw lost("OpenCode native progress stopped or observation could not recover", { session_id: id, message_id: messageId, observation: clock.state() });
293
+ await delay(Math.max(5, Math.min(500, livenessTimeoutMs / 4)));
103
294
  }
104
- return reply;
105
- }
106
- finally { clearInterval(timer); await observation; }
295
+ } finally { clearInterval(connectionTimer); this.readObservation = priorObservation; streamAbort.abort(); await stream; }
107
296
  }
108
- fork(id, messageID) { return this.request("POST", `/session/${encodeURIComponent(id)}/fork`, messageID ? { messageID } : {}); }
109
- abort(id) { return this.request("POST", `/session/${encodeURIComponent(id)}/abort`); }
110
- delete(id) { return this.request("DELETE", `/session/${encodeURIComponent(id)}`); }
111
297
  }
112
298
 
113
299
  function stablePart(part) {
114
- if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: part.text, synthetic: part.synthetic === true };
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 } };
116
- if (part.type === "subtask") return { type: "subtask", prompt: part.prompt, description: part.description, agent: part.agent };
300
+ if (part.type === "text" || part.type === "reasoning") return { type: part.type, text: part.text };
301
+ if (part.type === "tool") return { type: "tool", tool: part.tool, state: part.state };
117
302
  return { type: part.type };
118
303
  }
119
304
  export function canonicalHistory(messages) {
120
- return messages.map(({ info, parts }) => ({ role: info.role, ...(info.role === "assistant" ? { provider: info.providerID, model: info.modelID, mode: info.mode, finish: info.finish ?? null, error: info.error?.name ?? null } : {}), parts: parts.map(stablePart) }));
305
+ return messages.map(({ info, parts }) => ({ role: info.role, ...(info.role === "assistant" ? { provider: info.providerID, model: info.modelID, mode: info.mode, finish: info.finish ?? null, error: info.error?.type ?? null } : {}), parts: parts.map(stablePart) }));
121
306
  }
122
- export function historyDigest(messages) { return createHash("sha256").update(JSON.stringify(canonicalHistory(messages))).digest("hex"); }
307
+ export function historyDigest(messages) { return requestDigest(canonicalHistory(messages)); }
123
308
  export function usageSnapshot(messages, sessionId) {
124
- const assistants = messages.filter((item) => item.info?.role === "assistant"); const tokens = assistants.reduce((sum, item) => { const value = item.info.tokens ?? {}; const cache = value.cache ?? {}; sum.input += value.input ?? 0; sum.output += value.output ?? 0; sum.reasoning += value.reasoning ?? 0; sum.cache_read += cache.read ?? 0; sum.cache_write += cache.write ?? 0; return sum; }, { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 });
309
+ const assistants = messages.filter(item => item.info?.role === "assistant"), tokens = { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };
310
+ let complete = true, cost = 0, costComplete = true;
311
+ for (const item of assistants) {
312
+ const value = item.info.tokens; complete &&= Boolean(value && [value.input, value.output, value.reasoning, value.cache?.read, value.cache?.write].every(Number.isFinite));
313
+ for (const name of ["input", "output", "reasoning"]) tokens[name] += value?.[name] ?? 0;
314
+ tokens.cache_read += value?.cache?.read ?? 0; tokens.cache_write += value?.cache?.write ?? 0;
315
+ costComplete &&= Number.isFinite(item.info.cost); cost += item.info.cost ?? 0;
316
+ }
125
317
  const tools = new ToolObservations();
126
318
  for (const item of messages) for (const part of item.parts ?? []) tools.add(openCodeToolObservation(part, item.info?.sessionID ?? sessionId, item.info?.id));
127
- return { input_tokens: tokens.input, output_tokens: tokens.output, reasoning_tokens: tokens.reasoning, cached_input_tokens: tokens.cache_read, cache_write_tokens: tokens.cache_write, total_tokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache_read + tokens.cache_write, tool_calls: tools.summary(), cost_usd: assistants.reduce((sum, item) => sum + (item.info.cost ?? 0), 0) };
319
+ return { input_tokens: tokens.input, output_tokens: tokens.output, reasoning_tokens: tokens.reasoning,
320
+ cached_input_tokens: tokens.cache_read, cache_write_tokens: tokens.cache_write,
321
+ total_tokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache_read + tokens.cache_write,
322
+ tool_calls: tools.summary(), cost_usd: costComplete ? cost : null, completeness: complete ? "complete" : "partial" };
128
323
  }
@@ -22,8 +22,8 @@ const ADAPTERS = {
22
22
  },
23
23
  opencode: {
24
24
  name: "OpenCode",
25
- launch: "Use the model-facing native `task` tool with description, prompt, and an available subagent_type that can execute the assignment. Put the assignment message in prompt. Omit task_id to create a fresh child. `subtask` is an API message part, not the model tool name. Use foreground completion, or the exposed background completion mechanism when available. Do not create a new root Session or use an external runner.",
26
- wait: "Await each native task result or its background completion notification. Retain its returned task handle; never synthesize a Session ID or relaunch a quiet child."
25
+ launch: "Use OpenCode v2's native `subagent` tool with agent: \"dd-flow-worker\", description, and prompt containing the assignment. Omit sessionID for a fresh child and omit model overrides. For independent fanout assignments use background: true. Do not create a new root Session or use an external runner. Use `shell` with explicit workdir and timeout: 0 for issued lifecycle calls, never background those CLI calls.",
26
+ wait: "Await each native subagent result or its automatic background completion notification. Retain the real returned sessionID. Do not invent a wait tool or poll by sending child prompts. Native notifications can continue the parent automatically; a running tool handle is not completion. Never synthesize a Session ID or relaunch a quiet child."
27
27
  },
28
28
  antigravity: {
29
29
  name: "Antigravity",
@@ -46,7 +46,7 @@ function requireAdapter(harness) {
46
46
  export function delegationContract(harness) {
47
47
  const key = delegationAdapter(harness);
48
48
  requireAdapter(harness);
49
- return `dd-flow/delegation/${key}@${key === "codex" ? 2 : 1}`;
49
+ return `dd-flow/delegation/${key}@${["codex", "opencode"].includes(key) ? 2 : 1}`;
50
50
  }
51
51
 
52
52
  export const lifecycleCommandCompletionInstruction = "Run runtime-issued lifecycle CLI calls in the foreground, never with a native background or detached-shell option. A shell tool may yield before the lifecycle process exits. Retain and poll its exact process/session handle to completion, including inside a tool wrapper; empty interim output is not a CLI result. Read final stdout or response_file before deciding continuation, and never reissue the command while it is pending.";
@@ -294,7 +294,7 @@ async function executeRuntimeProcess(config, action, options = {}, { timeoutMs,
294
294
 
295
295
  export async function registerDaemonProcess(config, input) {
296
296
  const owner = bindRuntimeOwner(config);
297
- const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
297
+ const harness = ({ "codex-daemon": "codex-desktop", "codex-provider": "codex-desktop", "zcode-daemon": "zcode-acp", "zcode-provider": "zcode-acp", "grok-daemon": "grok-acp", "grok-provider": "grok-acp", "agy-daemon": "antigravity-cli", "opencode-daemon": "opencode-server", "opencode-provider": "opencode-server", "droid-daemon": "droid-cli" })[input.kind];
298
298
  const scope = owner ? { owner: owner.owner_id, "project-id": owner.project_id, run: owner.run_id, work: owner.work_id, role: owner.role, "state-dir": owner.state_dir, "adapter-executable": owner.adapter_executable, ...(owner.budget ? { "budget-json": JSON.stringify(owner.budget), harness } : {}), ...(owner.project_id && owner.run_id || owner.budget ? { "dispatch-barrier": "run-generation@1" } : {}), operation: `${owner.operation_id}:${input.operation ?? input.kind}` } : {};
299
299
  const options = { ...input, ...scope, id: `PROC-${input.kind}-${randomUUID()}`, "owner-pid": process.pid, "lease-ms": 300_000 };
300
300
  const renewal = createRenewalState();
@@ -1,7 +1,7 @@
1
- export function invokeNativeHook(input: { bin: string; args: string[]; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
1
+ export function invokeNativeHook(input: { bin: string; args: string[]; nodeBin?: string; payload: unknown; home?: string | undefined; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number }): Promise<Record<string, unknown> & { timing?: Record<string, number> }>;
2
2
  export function hookBudget(timeoutMs?: number, deadlineAt?: number | null): { deadlineAt: number; remaining(phase: string, maximum?: number): number; run<T>(action: () => T | Promise<T>, phase: string): Promise<T> };
3
3
  export function readNativeHookInput(stream: NodeJS.ReadStream, budget: { remaining(phase: string): number }, maxBytes?: number): Promise<string>;
4
4
  export function acceptNativeHookRequest(stateDir: string, binding: Record<string, unknown>, current?: { requests: Map<string, { binding: Record<string, unknown> }>; accepting?: Map<string, Promise<Record<string, unknown>>> } | null): Promise<Record<string, unknown>>;
5
5
  export function writeNativeHookFailure(stateDir: string, binding: Record<string, unknown> | undefined, error: unknown, budget: ReturnType<typeof hookBudget>): Promise<void>;
6
6
  export function readNativeHookFailures(stateDir: string, requests: Map<string, { current: unknown; binding: Record<string, unknown> }>, current: unknown, reject: (error: Error & { code?: string; details?: Record<string, unknown> }) => void): Promise<void>;
7
- export function runBoundedCommand(input: { bin: string; args: string[]; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; startupMs?: number; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;
7
+ export function runBoundedCommand(input: { bin: string; args: string[]; nodeBin?: string; input?: string | null; cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number; timeoutCode?: string; inheritDeadline?: boolean; cleanupWithinBudget?: boolean; maintenanceProgress?: { action: string; processId: string | null; startupMs?: number; inactivityMs: number } | null; signal?: AbortSignal }): Promise<string>;
@@ -117,7 +117,7 @@ export async function terminateCommandTree(child, { platform = process.platform,
117
117
  }
118
118
 
119
119
  /** Preserve the native hook protocol across the CLI process boundary. */
120
- export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout", inheritDeadline = true, cleanupWithinBudget = false, maintenanceProgress = null, signal }) {
120
+ export async function runBoundedCommand({ bin, args, nodeBin = process.execPath, input = null, cwd, env = {}, timeoutMs = 15_000, timeoutCode = "native_hook_timeout", inheritDeadline = true, cleanupWithinBudget = false, maintenanceProgress = null, signal }) {
121
121
  if (signal?.aborted) throw Object.assign(new Error("Bounded CLI request cancelled before launch"), { code: "process_maintenance_cancelled", details: { effect: "no_effect" } });
122
122
  const inherited = inheritDeadline ? env.DD_FLOW_HOOK_DEADLINE_AT ?? process.env.DD_FLOW_HOOK_DEADLINE_AT : undefined;
123
123
  if (inherited !== undefined) {
@@ -137,7 +137,7 @@ export async function runBoundedCommand({ bin, args, input = null, cwd, env = {}
137
137
  const deadlineAt = Math.floor(Date.now() + Math.max(0, commandMs - Math.min(250, commandMs / 10)));
138
138
  const script = /\.[cm]?js$/.test(bin);
139
139
  const output = await new Promise((resolve, reject) => {
140
- const child = spawn(script ? process.execPath : bin, [...(script ? [bin] : []), ...args], {
140
+ const child = spawn(script ? nodeBin : bin, [...(script ? [bin] : []), ...args], {
141
141
  cwd, env: { ...process.env, ...env, DD_FLOW_HOOK_DEADLINE_AT: String(deadlineAt) }, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"]
142
142
  });
143
143
  let stdout = "", stderr = "", failure, cleanupTimer, settled = false, termination;
@@ -241,8 +241,8 @@ function hookDiagnostics(stderr) {
241
241
  }).slice(-16);
242
242
  }
243
243
 
244
- export async function invokeNativeHook({ bin, args, payload, home, cwd, env, timeoutMs = 15_000 }) {
245
- const output = await runBoundedCommand({ bin, args, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
244
+ export async function invokeNativeHook({ bin, args, nodeBin, payload, home, cwd, env, timeoutMs = 15_000 }) {
245
+ const output = await runBoundedCommand({ bin, args, nodeBin, input: `${JSON.stringify(payload)}\n`, cwd, env: { ...env, DD_FLOW_HOME: home }, timeoutMs });
246
246
  let result;
247
247
  try { result = JSON.parse(output); } catch { throw Object.assign(new Error("Invalid native hook JSON response"), { code: "native_hook_response_invalid" }); }
248
248
  if (result?.ok === false && typeof result.error?.code === "string") throw Object.assign(new Error(result.error.message ?? "Native hook failed"), { code: result.error.code, details: result.error.details });
@@ -0,0 +1,60 @@
1
+ import path from "node:path";
2
+ import * as Plugin from "../vendor/opencode-plugin.mjs";
3
+ import { hookBudget, invokeNativeHook } from "./native-hook-command.mjs";
4
+
5
+ /** Launch-bound server plugin; never uses a process-global 'last Session'. */
6
+ export function makeOpenCodePlugin(binding, invoke = invokeNativeHook) {
7
+ return Plugin.define({ id: binding.ddFlowBin ? "dd-flow" : "dd-flow-security", async setup(ctx) {
8
+ const participating = new Map();
9
+ const key = event => JSON.stringify([binding.daemonId, event.sessionID, event.messageID, event.id]);
10
+ const envelope = async (phase, event, budget, outcome) => {
11
+ const info = await budget.run(() => ctx.session.get({ sessionID: event.sessionID }), "identity");
12
+ if (!info?.id || info.id !== event.sessionID || !info.location?.directory) throw new Error("OpenCode Session identity is unavailable");
13
+ const input = event.input;
14
+ if (!input || typeof input !== "object" || typeof input.command !== "string") throw new Error("OpenCode shell input is unavailable");
15
+ const cwd = path.resolve(info.location.directory, input.workdir ?? ".");
16
+ return { schema_id: "dd-flow/opencode-tool-event@1", native_protocol: 2,
17
+ event_id: [binding.daemonId, event.sessionID, event.messageID, event.id].join(":"), phase,
18
+ daemon_id: binding.daemonId, server_version: binding.version, plugin_sha256: process.env.DD_OPENCODE_PLUGIN_SHA256,
19
+ session: { provider_session_id: info.id, parent_provider_session_id: info.parentID ?? null,
20
+ agent_id: event.agent, directory: cwd, project_id: info.projectID },
21
+ message_id: event.messageID, tool_call_id: event.id, tool: "shell", native_tool: event.tool,
22
+ profile: { provider: info.model?.providerID, model: info.model?.id, variant: info.model?.variant ?? "default", agent: event.agent },
23
+ input: { ...input, workdir: cwd }, ...(outcome ? { outcome } : {}) };
24
+ };
25
+ const deliver = (payload, budget) => invoke({ bin: binding.ddFlowBin, nodeBin: binding.nodeBin,
26
+ args: ["opencode", "event", "handle", "--project-root", binding.projectRoot, "--json"],
27
+ payload, home: binding.ddFlowHome, timeoutMs: budget.remaining("admission", 15000) });
28
+ await ctx.shell.hook("create.before", event => {
29
+ delete event.env.OPENCODE_PASSWORD; delete event.env.OPENCODE_SERVER_PASSWORD;
30
+ });
31
+ await ctx.tool.hook("execute.before", async event => {
32
+ if (event.tool !== "shell" || !binding.ddFlowBin) return;
33
+ const budget = hookBudget(), payload = await envelope("before", event, budget);
34
+ event.input = { ...event.input, workdir: payload.session.directory };
35
+ const result = await deliver({ ...payload, input: event.input }, budget);
36
+ if (result?.permissionDecision === "deny" || result?.hookSpecificOutput?.permissionDecision === "deny") throw new Error("dd-flow denied native shell admission");
37
+ if (result?.observed) {
38
+ participating.set(key(event), payload);
39
+ await ctx.storage.set(key(event), JSON.parse(JSON.stringify(payload)));
40
+ // The common observer, not OpenCode's fixed 120-second cutoff, owns
41
+ // flow CLI inactivity. No lifecycle process is detached here.
42
+ event.input = { ...event.input, ...(result.hookSpecificOutput?.updatedInput ?? {}), timeout: 0, background: false };
43
+ } else if (event.input.timeout === undefined) event.input.timeout = 0;
44
+ });
45
+ await ctx.tool.hook("execute.after", async event => {
46
+ if (event.tool !== "shell") return;
47
+ const budget = hookBudget(), original = participating.get(key(event)) ?? await ctx.storage.get(key(event));
48
+ if (!original) return; // Ordinary shell tools do not create lifecycle receipts.
49
+ const output = event.result?.output;
50
+ const success = event.status === "completed" && output?.status !== "running" && output?.exit === 0 && !output?.timeout && !output?.signal;
51
+ const outcome = { status: success ? "completed" : output?.status === "running" ? "running" : "failed",
52
+ exit_code: output?.exit ?? null, signal: output?.signal ?? null, shell_id: output?.shellID ?? null };
53
+ const payload = await envelope("after", event, budget, outcome);
54
+ await deliver({ ...payload, ...(original ? { event_id: original.event_id } : {}) }, budget);
55
+ participating.delete(key(event));
56
+ await ctx.storage.remove(key(event));
57
+ });
58
+ return () => participating.clear();
59
+ } });
60
+ }