@twelvehart/orcats 0.0.0-seed.0 → 0.2.3

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 (275) hide show
  1. package/LICENSE +48 -0
  2. package/NOTICE +5 -0
  3. package/README.md +399 -0
  4. package/bin/orcats +4 -0
  5. package/dist/backends/acp-client.d.ts +74 -0
  6. package/dist/backends/acp-client.d.ts.map +1 -0
  7. package/dist/backends/acp-run.d.ts +31 -0
  8. package/dist/backends/acp-run.d.ts.map +1 -0
  9. package/dist/backends/claude-run.d.ts +35 -0
  10. package/dist/backends/claude-run.d.ts.map +1 -0
  11. package/dist/backends/claude-stream-json.d.ts +15 -0
  12. package/dist/backends/claude-stream-json.d.ts.map +1 -0
  13. package/dist/backends/codex-jsonl.d.ts +34 -0
  14. package/dist/backends/codex-jsonl.d.ts.map +1 -0
  15. package/dist/backends/codex-run.d.ts +40 -0
  16. package/dist/backends/codex-run.d.ts.map +1 -0
  17. package/dist/backends/codex.d.ts +5 -0
  18. package/dist/backends/codex.d.ts.map +1 -0
  19. package/dist/backends/conversation-config.d.ts +12 -0
  20. package/dist/backends/conversation-config.d.ts.map +1 -0
  21. package/dist/backends/index.d.ts +14 -0
  22. package/dist/backends/index.d.ts.map +1 -0
  23. package/dist/backends/opencode-run.d.ts +48 -0
  24. package/dist/backends/opencode-run.d.ts.map +1 -0
  25. package/dist/backends/opencode-sse.d.ts +35 -0
  26. package/dist/backends/opencode-sse.d.ts.map +1 -0
  27. package/dist/backends/pi-rpc.d.ts +21 -0
  28. package/dist/backends/pi-rpc.d.ts.map +1 -0
  29. package/dist/backends/pi-run.d.ts +21 -0
  30. package/dist/backends/pi-run.d.ts.map +1 -0
  31. package/dist/backends/select.d.ts +17 -0
  32. package/dist/backends/select.d.ts.map +1 -0
  33. package/dist/backends/subprocess-run.d.ts +68 -0
  34. package/dist/backends/subprocess-run.d.ts.map +1 -0
  35. package/dist/backends/types.d.ts +17 -0
  36. package/dist/backends/types.d.ts.map +1 -0
  37. package/dist/backends/unsupported.d.ts +9 -0
  38. package/dist/backends/unsupported.d.ts.map +1 -0
  39. package/dist/baseline/index.d.ts +56 -0
  40. package/dist/baseline/index.d.ts.map +1 -0
  41. package/dist/cli/args.d.ts +22 -0
  42. package/dist/cli/args.d.ts.map +1 -0
  43. package/dist/cli/embedded.d.ts +7 -0
  44. package/dist/cli/embedded.d.ts.map +1 -0
  45. package/dist/cli/main.d.ts +6 -0
  46. package/dist/cli/main.d.ts.map +1 -0
  47. package/dist/cli/version.d.ts +2 -0
  48. package/dist/cli/version.d.ts.map +1 -0
  49. package/dist/conversation/ask-user.d.ts +17 -0
  50. package/dist/conversation/ask-user.d.ts.map +1 -0
  51. package/dist/conversation/conversation.d.ts +46 -0
  52. package/dist/conversation/conversation.d.ts.map +1 -0
  53. package/dist/conversation/harness.d.ts +14 -0
  54. package/dist/conversation/harness.d.ts.map +1 -0
  55. package/dist/conversation/index.d.ts +5 -0
  56. package/dist/conversation/index.d.ts.map +1 -0
  57. package/dist/conversation/queue.d.ts +15 -0
  58. package/dist/conversation/queue.d.ts.map +1 -0
  59. package/dist/flow/accessors.d.ts +13 -0
  60. package/dist/flow/accessors.d.ts.map +1 -0
  61. package/dist/flow/approvals.d.ts +6 -0
  62. package/dist/flow/approvals.d.ts.map +1 -0
  63. package/dist/flow/args.d.ts +4 -0
  64. package/dist/flow/args.d.ts.map +1 -0
  65. package/dist/flow/context.d.ts +36 -0
  66. package/dist/flow/context.d.ts.map +1 -0
  67. package/dist/flow/index.d.ts +5 -0
  68. package/dist/flow/index.d.ts.map +1 -0
  69. package/dist/index.d.ts +16 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/loop/builder/index.d.ts +17 -0
  72. package/dist/loop/builder/index.d.ts.map +1 -0
  73. package/dist/loop/builder/types.d.ts +97 -0
  74. package/dist/loop/builder/types.d.ts.map +1 -0
  75. package/dist/loop/context/compaction.d.ts +38 -0
  76. package/dist/loop/context/compaction.d.ts.map +1 -0
  77. package/dist/loop/context/index.d.ts +4 -0
  78. package/dist/loop/context/index.d.ts.map +1 -0
  79. package/dist/loop/context/offload.d.ts +34 -0
  80. package/dist/loop/context/offload.d.ts.map +1 -0
  81. package/dist/loop/context/types.d.ts +13 -0
  82. package/dist/loop/context/types.d.ts.map +1 -0
  83. package/dist/loop/engine/bridge.d.ts +21 -0
  84. package/dist/loop/engine/bridge.d.ts.map +1 -0
  85. package/dist/loop/engine/index.d.ts +73 -0
  86. package/dist/loop/engine/index.d.ts.map +1 -0
  87. package/dist/loop/execution.d.ts +68 -0
  88. package/dist/loop/execution.d.ts.map +1 -0
  89. package/dist/loop/fanout.d.ts +148 -0
  90. package/dist/loop/fanout.d.ts.map +1 -0
  91. package/dist/loop/firing.d.ts +54 -0
  92. package/dist/loop/firing.d.ts.map +1 -0
  93. package/dist/loop/graph/analyze.d.ts +8 -0
  94. package/dist/loop/graph/analyze.d.ts.map +1 -0
  95. package/dist/loop/graph/index.d.ts +3 -0
  96. package/dist/loop/graph/index.d.ts.map +1 -0
  97. package/dist/loop/graph/types.d.ts +35 -0
  98. package/dist/loop/graph/types.d.ts.map +1 -0
  99. package/dist/loop/index.d.ts +7 -0
  100. package/dist/loop/index.d.ts.map +1 -0
  101. package/dist/loop/io/index.d.ts +4 -0
  102. package/dist/loop/io/index.d.ts.map +1 -0
  103. package/dist/loop/io/linear.d.ts +142 -0
  104. package/dist/loop/io/linear.d.ts.map +1 -0
  105. package/dist/loop/io/sink.d.ts +47 -0
  106. package/dist/loop/io/sink.d.ts.map +1 -0
  107. package/dist/loop/io/source.d.ts +64 -0
  108. package/dist/loop/io/source.d.ts.map +1 -0
  109. package/dist/loop/serve.d.ts +97 -0
  110. package/dist/loop/serve.d.ts.map +1 -0
  111. package/dist/loop/state/index.d.ts +5 -0
  112. package/dist/loop/state/index.d.ts.map +1 -0
  113. package/dist/loop/state/manifest.d.ts +36 -0
  114. package/dist/loop/state/manifest.d.ts.map +1 -0
  115. package/dist/loop/state/port.d.ts +33 -0
  116. package/dist/loop/state/port.d.ts.map +1 -0
  117. package/dist/loop/state/snapshot.d.ts +15 -0
  118. package/dist/loop/state/snapshot.d.ts.map +1 -0
  119. package/dist/loop/state/sqlite.d.ts +31 -0
  120. package/dist/loop/state/sqlite.d.ts.map +1 -0
  121. package/dist/loop/termination-contract.d.ts +28 -0
  122. package/dist/loop/termination-contract.d.ts.map +1 -0
  123. package/dist/loop/termination.d.ts +33 -0
  124. package/dist/loop/termination.d.ts.map +1 -0
  125. package/dist/model/backend-config.d.ts +30 -0
  126. package/dist/model/backend-config.d.ts.map +1 -0
  127. package/dist/model/brand.d.ts +9 -0
  128. package/dist/model/brand.d.ts.map +1 -0
  129. package/dist/model/errors.d.ts +11 -0
  130. package/dist/model/errors.d.ts.map +1 -0
  131. package/dist/model/index.d.ts +8 -0
  132. package/dist/model/index.d.ts.map +1 -0
  133. package/dist/model/json-schema.d.ts +4 -0
  134. package/dist/model/json-schema.d.ts.map +1 -0
  135. package/dist/model/result.d.ts +3 -0
  136. package/dist/model/result.d.ts.map +1 -0
  137. package/dist/model/schemas.d.ts +435 -0
  138. package/dist/model/schemas.d.ts.map +1 -0
  139. package/dist/model/structured.d.ts +9 -0
  140. package/dist/model/structured.d.ts.map +1 -0
  141. package/dist/monitor/index.d.ts +139 -0
  142. package/dist/monitor/index.d.ts.map +1 -0
  143. package/dist/plan/index.d.ts +3 -0
  144. package/dist/plan/index.d.ts.map +1 -0
  145. package/dist/plan/persistent.d.ts +23 -0
  146. package/dist/plan/persistent.d.ts.map +1 -0
  147. package/dist/plan/verdict.d.ts +9 -0
  148. package/dist/plan/verdict.d.ts.map +1 -0
  149. package/dist/review/index.d.ts +4 -0
  150. package/dist/review/index.d.ts.map +1 -0
  151. package/dist/review/loop.d.ts +117 -0
  152. package/dist/review/loop.d.ts.map +1 -0
  153. package/dist/review/reviewers.d.ts +13 -0
  154. package/dist/review/reviewers.d.ts.map +1 -0
  155. package/dist/review/tool.d.ts +20 -0
  156. package/dist/review/tool.d.ts.map +1 -0
  157. package/dist/run-output/index.d.ts +112 -0
  158. package/dist/run-output/index.d.ts.map +1 -0
  159. package/dist/runner/index.d.ts +2 -0
  160. package/dist/runner/index.d.ts.map +1 -0
  161. package/dist/runner/typecheck.d.ts +23 -0
  162. package/dist/runner/typecheck.d.ts.map +1 -0
  163. package/dist/test-utils/fakes.d.ts +76 -0
  164. package/dist/test-utils/fakes.d.ts.map +1 -0
  165. package/dist/test-utils/fixtures.d.ts +4 -0
  166. package/dist/test-utils/fixtures.d.ts.map +1 -0
  167. package/dist/test-utils/index.d.ts +4 -0
  168. package/dist/test-utils/index.d.ts.map +1 -0
  169. package/dist/test-utils/tier2.d.ts +8 -0
  170. package/dist/test-utils/tier2.d.ts.map +1 -0
  171. package/dist/tools/fs.d.ts +11 -0
  172. package/dist/tools/fs.d.ts.map +1 -0
  173. package/dist/tools/git.d.ts +10 -0
  174. package/dist/tools/git.d.ts.map +1 -0
  175. package/dist/tools/github.d.ts +13 -0
  176. package/dist/tools/github.d.ts.map +1 -0
  177. package/dist/tools/index.d.ts +7 -0
  178. package/dist/tools/index.d.ts.map +1 -0
  179. package/dist/tools/linear.d.ts +117 -0
  180. package/dist/tools/linear.d.ts.map +1 -0
  181. package/dist/tools/process.d.ts +40 -0
  182. package/dist/tools/process.d.ts.map +1 -0
  183. package/dist/tools/terminal.d.ts +23 -0
  184. package/dist/tools/terminal.d.ts.map +1 -0
  185. package/package.json +89 -9
  186. package/src/backends/acp-client.ts +324 -0
  187. package/src/backends/acp-run.ts +636 -0
  188. package/src/backends/claude-run.ts +226 -0
  189. package/src/backends/claude-stream-json.ts +273 -0
  190. package/src/backends/codex-jsonl.ts +339 -0
  191. package/src/backends/codex-run.ts +177 -0
  192. package/src/backends/codex.ts +84 -0
  193. package/src/backends/conversation-config.ts +26 -0
  194. package/src/backends/index.ts +13 -0
  195. package/src/backends/opencode-run.ts +432 -0
  196. package/src/backends/opencode-sse.ts +360 -0
  197. package/src/backends/pi-rpc.ts +242 -0
  198. package/src/backends/pi-run.ts +154 -0
  199. package/src/backends/select.ts +77 -0
  200. package/src/backends/subprocess-run.ts +305 -0
  201. package/src/backends/types.ts +29 -0
  202. package/src/backends/unsupported.ts +48 -0
  203. package/src/baseline/index.ts +437 -0
  204. package/src/cli/args.ts +109 -0
  205. package/src/cli/embedded.ts +206 -0
  206. package/src/cli/main.ts +332 -0
  207. package/src/cli/version.ts +1 -0
  208. package/src/conversation/ask-user.ts +105 -0
  209. package/src/conversation/conversation.ts +109 -0
  210. package/src/conversation/harness.ts +39 -0
  211. package/src/conversation/index.ts +4 -0
  212. package/src/conversation/queue.ts +86 -0
  213. package/src/flow/accessors.ts +43 -0
  214. package/src/flow/approvals.ts +10 -0
  215. package/src/flow/args.ts +26 -0
  216. package/src/flow/context.ts +106 -0
  217. package/src/flow/index.ts +4 -0
  218. package/src/index.ts +17 -0
  219. package/src/loop/builder/index.ts +346 -0
  220. package/src/loop/builder/types.ts +131 -0
  221. package/src/loop/context/compaction.ts +175 -0
  222. package/src/loop/context/index.ts +3 -0
  223. package/src/loop/context/offload.ts +79 -0
  224. package/src/loop/context/types.ts +17 -0
  225. package/src/loop/engine/bridge.ts +61 -0
  226. package/src/loop/engine/index.ts +166 -0
  227. package/src/loop/execution.ts +453 -0
  228. package/src/loop/fanout.ts +444 -0
  229. package/src/loop/firing.ts +188 -0
  230. package/src/loop/graph/analyze.ts +128 -0
  231. package/src/loop/graph/index.ts +2 -0
  232. package/src/loop/graph/types.ts +45 -0
  233. package/src/loop/index.ts +11 -0
  234. package/src/loop/io/index.ts +3 -0
  235. package/src/loop/io/linear.ts +714 -0
  236. package/src/loop/io/sink.ts +170 -0
  237. package/src/loop/io/source.ts +255 -0
  238. package/src/loop/serve.ts +307 -0
  239. package/src/loop/state/index.ts +4 -0
  240. package/src/loop/state/manifest.ts +56 -0
  241. package/src/loop/state/port.ts +43 -0
  242. package/src/loop/state/snapshot.ts +129 -0
  243. package/src/loop/state/sqlite.ts +265 -0
  244. package/src/loop/termination-contract.ts +89 -0
  245. package/src/loop/termination.ts +156 -0
  246. package/src/model/backend-config.ts +34 -0
  247. package/src/model/brand.ts +11 -0
  248. package/src/model/errors.ts +28 -0
  249. package/src/model/index.ts +7 -0
  250. package/src/model/json-schema.ts +14 -0
  251. package/src/model/result.ts +28 -0
  252. package/src/model/schemas.ts +218 -0
  253. package/src/model/structured.ts +26 -0
  254. package/src/monitor/index.ts +367 -0
  255. package/src/plan/index.ts +2 -0
  256. package/src/plan/persistent.ts +98 -0
  257. package/src/plan/verdict.ts +3 -0
  258. package/src/review/index.ts +3 -0
  259. package/src/review/loop.ts +365 -0
  260. package/src/review/reviewers.ts +92 -0
  261. package/src/review/tool.ts +41 -0
  262. package/src/run-output/index.ts +319 -0
  263. package/src/runner/index.ts +1 -0
  264. package/src/runner/typecheck.ts +72 -0
  265. package/src/test-utils/fakes.ts +260 -0
  266. package/src/test-utils/fixtures.ts +33 -0
  267. package/src/test-utils/index.ts +3 -0
  268. package/src/test-utils/tier2.ts +12 -0
  269. package/src/tools/fs.ts +44 -0
  270. package/src/tools/git.ts +34 -0
  271. package/src/tools/github.ts +25 -0
  272. package/src/tools/index.ts +6 -0
  273. package/src/tools/linear.ts +428 -0
  274. package/src/tools/process.ts +156 -0
  275. package/src/tools/terminal.ts +116 -0
@@ -0,0 +1,432 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { z } from "zod";
3
+ import {
4
+ createOpenCodeServerManager,
5
+ createOpenCodeSseConsumer,
6
+ type OpenCodeServerManager,
7
+ type OpenCodeServerProcess
8
+ } from "./opencode-sse.ts";
9
+ import { errorMessage, splitLines } from "./subprocess-run.ts";
10
+ import type { AutonomousRequest, LlmBackend } from "./types.ts";
11
+ import { StreamConversation } from "../conversation/index.ts";
12
+ import { backendFailed, jsonSchemaFromZod, type BackendConfig } from "../model/index.ts";
13
+
14
+ /** HTTP/SSE surface the driver needs from an `opencode serve` instance (Scala
15
+ * `OpencodeHttp`). The default is `fetch`-backed (integration only); unit tests
16
+ * inject a scripted fake. `openEvents` resolves once the SSE connection is open
17
+ * so the turn-starting POST can't race ahead of the stream. */
18
+ export interface OpenCodeHttp {
19
+ postJson(path: string, body: string, signal?: AbortSignal): Promise<string>;
20
+ openEvents(signal: AbortSignal): Promise<AsyncIterable<string>>;
21
+ }
22
+
23
+ export interface OpenCodeBackend extends LlmBackend<"opencode"> {
24
+ /** Stop the shared `opencode serve` process. Caller-invoked at runtime end —
25
+ * the package has no global scope hook, so the backend owner drives teardown. */
26
+ shutdown(signal?: NodeJS.Signals): Promise<void>;
27
+ }
28
+
29
+ export interface OpenCodeBackendOptions {
30
+ readonly command?: string;
31
+ readonly cwd?: string;
32
+ readonly env?: NodeJS.ProcessEnv;
33
+ readonly capacity?: number;
34
+ readonly config?: BackendConfig<"opencode">;
35
+ /** Fail the turn if the `/event` stream delivers nothing for this many ms.
36
+ * opencode 1.16.2 can stop emitting events entirely (no `session.idle`, no
37
+ * heartbeat) — e.g. when a structured-output turn follows tool use — which
38
+ * would otherwise hang the driver forever. Defaults to 120s; an actively
39
+ * streaming turn resets the timer on every event, so legitimate slow turns are
40
+ * unaffected. */
41
+ readonly inactivityTimeoutMs?: number;
42
+ /** Absolute per-turn wall-clock cap. Unlike the inactivity watchdog, this
43
+ * never resets — it fires regardless of SSE activity, guarding against agents
44
+ * that loop indefinitely while still emitting events. Defaults to 600s. */
45
+ readonly wallClockTimeoutMs?: number;
46
+ /** Fail `opencode serve` startup if no listening URL appears before this many ms. Defaults to 30s. */
47
+ readonly startupTimeoutMs?: number;
48
+ /** Seam: start (or reuse) the serve process. Defaults to spawning `opencode serve`. */
49
+ readonly startServer?: () => Promise<OpenCodeServerProcess>;
50
+ /** Seam: build an HTTP/SSE client for a started server. Defaults to `fetch`. */
51
+ readonly connect?: (server: OpenCodeServerProcess) => OpenCodeHttp;
52
+ }
53
+
54
+ interface Deferred<T> {
55
+ readonly promise: Promise<T>;
56
+ resolve(value: T | PromiseLike<T>): void;
57
+ reject(reason?: unknown): void;
58
+ }
59
+
60
+ interface ResolvedOpenCodeConfig<Output> {
61
+ model?: string;
62
+ systemPrompt?: string;
63
+ readOnly?: boolean;
64
+ schema?: z.ZodType<Output>;
65
+ resumeSessionId?: string;
66
+ }
67
+
68
+ export async function runOpenCodeConversation<Output>(
69
+ request: AutonomousRequest<Output, "opencode">,
70
+ options: OpenCodeBackendOptions,
71
+ manager: OpenCodeServerManager,
72
+ connect: (server: OpenCodeServerProcess) => OpenCodeHttp,
73
+ conversation: StreamConversation<"opencode">
74
+ ): Promise<void> {
75
+ const config = resolveOpenCodeConfig(request, options);
76
+
77
+ // Stream-scoped: closed in `finally` on every exit (success, error, cancel) so
78
+ // a turn doesn't leak its SSE connection against the shared server — mirrors
79
+ // Scala `OpencodeBackend.runAutonomous`'s `finally source.interrupt()`.
80
+ // `session.idle` is an event *on* the stream, not the stream ending, so a
81
+ // successful turn must close it explicitly.
82
+ const streamController = new AbortController();
83
+ const forwardAbort = (): void => {
84
+ streamController.abort();
85
+ };
86
+ if (conversation.signal.aborted) {
87
+ streamController.abort();
88
+ } else {
89
+ conversation.signal.addEventListener("abort", forwardAbort, { once: true });
90
+ }
91
+ let abortServerTurn: (() => void) | undefined;
92
+
93
+ const wallClockMs = options.wallClockTimeoutMs ?? 600_000;
94
+ const wallClock: Deferred<"wallclock"> = Promise.withResolvers<"wallclock">();
95
+ const wallClockTimer = setTimeout(() => {
96
+ wallClock.resolve("wallclock");
97
+ }, wallClockMs);
98
+
99
+ const turn = async (): Promise<void> => {
100
+ const server = await manager.get();
101
+ const http = connect(server);
102
+
103
+ // Open the SSE stream first so no turn events are missed, then start the turn.
104
+ const events = await http.openEvents(streamController.signal);
105
+ const serverSession = await resolveServerSession(http, config.resumeSessionId, streamController.signal);
106
+
107
+ // Best-effort server-side abort on cancel (Scala `OpencodeConversation.cancel`):
108
+ // without it a cancelled turn keeps editing headless on the shared server.
109
+ abortServerTurn = (): void => {
110
+ void http.postJson(`/session/${serverSession}/abort`, "{}").catch(() => undefined);
111
+ };
112
+ if (conversation.signal.aborted) {
113
+ abortServerTurn();
114
+ } else {
115
+ conversation.signal.addEventListener("abort", abortServerTurn, { once: true });
116
+ }
117
+
118
+ const body = openCodeMessageBody(request.prompt, config);
119
+ await http.postJson(`/session/${serverSession}/prompt_async`, body, streamController.signal);
120
+
121
+ const consumer = createOpenCodeSseConsumer(conversation, serverSession);
122
+ const inactivityMs = options.inactivityTimeoutMs ?? 120_000;
123
+ const iterator = events[Symbol.asyncIterator]();
124
+
125
+ // Deadline-based watchdog: only lines relevant to this session push the
126
+ // deadline out, so foreign-session chatter can't mask a dead turn.
127
+ let deadline = Date.now() + inactivityMs;
128
+ for (;;) {
129
+ const inactivity: Deferred<"stalled"> = Promise.withResolvers<"stalled">();
130
+ const timer = setTimeout(
131
+ () => {
132
+ inactivity.resolve("stalled");
133
+ },
134
+ Math.max(deadline - Date.now(), 0)
135
+ );
136
+ const next = await Promise.race([iterator.next(), inactivity.promise]);
137
+ clearTimeout(timer);
138
+
139
+ if (next === "stalled") {
140
+ if (!conversation.signal.aborted) {
141
+ conversation.fail(
142
+ backendFailed(
143
+ "opencode",
144
+ `opencode emitted no event for ${String(inactivityMs)}ms; treating the turn as stalled`
145
+ )
146
+ );
147
+ }
148
+ return;
149
+ }
150
+ if (next.done) {
151
+ break;
152
+ }
153
+ if (conversation.signal.aborted) {
154
+ return;
155
+ }
156
+ const relevant = await consumer.consume(next.value);
157
+ if (consumer.completed) {
158
+ return;
159
+ }
160
+ if (relevant) {
161
+ deadline = Date.now() + inactivityMs;
162
+ }
163
+ }
164
+
165
+ if (!conversation.signal.aborted) {
166
+ consumer.finish();
167
+ }
168
+ };
169
+
170
+ try {
171
+ const winner = await Promise.race([turn().then(() => "done" as const), wallClock.promise]);
172
+ if (winner === "wallclock" && !conversation.signal.aborted) {
173
+ conversation.fail(
174
+ backendFailed(
175
+ "opencode",
176
+ `opencode turn exceeded ${String(wallClockMs)}ms wall-clock limit`
177
+ )
178
+ );
179
+ }
180
+ } catch (error) {
181
+ if (!conversation.signal.aborted) {
182
+ conversation.fail(backendFailed("opencode", errorMessage(error)));
183
+ }
184
+ } finally {
185
+ clearTimeout(wallClockTimer);
186
+ conversation.signal.removeEventListener("abort", forwardAbort);
187
+ if (abortServerTurn) {
188
+ conversation.signal.removeEventListener("abort", abortServerTurn);
189
+ }
190
+ streamController.abort();
191
+ }
192
+ }
193
+
194
+ async function resolveServerSession(
195
+ http: OpenCodeHttp,
196
+ resumeSessionId: string | undefined,
197
+ signal: AbortSignal
198
+ ): Promise<string> {
199
+ if (resumeSessionId) {
200
+ return resumeSessionId;
201
+ }
202
+ const response = await http.postJson("/session", JSON.stringify({}), signal);
203
+ return (JSON.parse(response) as { readonly id: string }).id;
204
+ }
205
+
206
+ function openCodeMessageBody<Output>(prompt: string, config: ResolvedOpenCodeConfig<Output>): string {
207
+ const tools = toolFlags(config.readOnly);
208
+ return JSON.stringify({
209
+ parts: [{ type: "text", text: prompt }],
210
+ ...(config.model === undefined ? {} : { model: splitModel(config.model) }),
211
+ ...(config.systemPrompt === undefined ? {} : { system: config.systemPrompt }),
212
+ ...(tools === undefined ? {} : { tools }),
213
+ ...(config.schema === undefined
214
+ ? {}
215
+ : { format: { type: "json_schema", schema: jsonSchemaFromZod(config.schema) } })
216
+ });
217
+ }
218
+
219
+ /** Autonomous turns disable the native `question` tool (nobody can answer);
220
+ * read-only turns also disable the write tools. */
221
+ function toolFlags(readOnly: boolean | undefined): Record<string, boolean> | undefined {
222
+ const flags: Record<string, boolean> = { question: false };
223
+ if (readOnly) {
224
+ flags.write = false;
225
+ flags.edit = false;
226
+ flags.bash = false;
227
+ flags.patch = false;
228
+ }
229
+ return flags;
230
+ }
231
+
232
+ function splitModel(model: string): { readonly providerID: string; readonly modelID: string } {
233
+ const slash = model.indexOf("/");
234
+ if (slash < 0) {
235
+ return { providerID: model, modelID: "" };
236
+ }
237
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
238
+ }
239
+
240
+ function resolveOpenCodeConfig<Output>(
241
+ request: AutonomousRequest<Output, "opencode">,
242
+ options: OpenCodeBackendOptions
243
+ ): ResolvedOpenCodeConfig<Output> {
244
+ const config: ResolvedOpenCodeConfig<Output> = {};
245
+ const optionConfig = options.config;
246
+ const requestConfig = request.config;
247
+
248
+ setValue(config, "model", requestConfig?.model ?? optionConfig?.model);
249
+ setValue(config, "systemPrompt", requestConfig?.systemPrompt ?? optionConfig?.systemPrompt);
250
+ setValue(config, "readOnly", requestConfig?.readOnly ?? optionConfig?.readOnly);
251
+ setValue(
252
+ config,
253
+ "schema",
254
+ requestConfig?.structuredOutput?.schema ??
255
+ request.schema ??
256
+ (optionConfig?.structuredOutput?.schema as z.ZodType<Output> | undefined)
257
+ );
258
+ setValue(
259
+ config,
260
+ "resumeSessionId",
261
+ requestConfig?.resumeSessionId === undefined ? undefined : String(requestConfig.resumeSessionId)
262
+ );
263
+
264
+ return config;
265
+ }
266
+
267
+ function setValue<Output, Key extends keyof ResolvedOpenCodeConfig<Output>>(
268
+ config: ResolvedOpenCodeConfig<Output>,
269
+ key: Key,
270
+ value: ResolvedOpenCodeConfig<Output>[Key]
271
+ ): void {
272
+ if (value !== undefined) {
273
+ config[key] = value;
274
+ }
275
+ }
276
+
277
+ export function opencode(options: OpenCodeBackendOptions = {}): OpenCodeBackend {
278
+ const manager = createOpenCodeServerManager({
279
+ start: options.startServer ?? (() => defaultStartServer(options))
280
+ });
281
+ const connect = options.connect ?? createFetchOpenCodeHttp;
282
+
283
+ return {
284
+ tag: "opencode",
285
+ autonomous<Output = unknown>(request: AutonomousRequest<Output, "opencode">) {
286
+ const conversation = new StreamConversation({
287
+ backend: "opencode",
288
+ capacity: options.capacity ?? 256,
289
+ canAskUser: false
290
+ // Cancellation aborts the conversation signal, which closes the SSE
291
+ // stream and best-effort aborts the server-side session; the shared
292
+ // server process itself is left running.
293
+ });
294
+
295
+ queueMicrotask(() => {
296
+ void runOpenCodeConversation(request, options, manager, connect, conversation);
297
+ });
298
+
299
+ return conversation;
300
+ },
301
+ async shutdown(signal?: NodeJS.Signals) {
302
+ await manager.shutdown(signal);
303
+ }
304
+ };
305
+ }
306
+
307
+ // --- Default `fetch` + `opencode serve` transport (integration only) ---
308
+
309
+ const ListeningLine = /listening on (https?:\/\/\S+)/;
310
+
311
+ async function defaultStartServer(options: OpenCodeBackendOptions): Promise<OpenCodeServerProcess> {
312
+ const command = options.command ?? "opencode";
313
+ const password = crypto.randomUUID();
314
+ // Preemptive Basic auth: the spawn handed the server this password via env, so
315
+ // every request must carry it or the server replies 401 (Scala JavaNetOpencodeHttp).
316
+ const authHeader = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
317
+ const child = spawn(command, ["serve", "--port", "0", "--log-level", "WARN"], {
318
+ cwd: options.cwd,
319
+ env: { ...(options.env ?? process.env), OPENCODE_SERVER_PASSWORD: password },
320
+ stdio: ["ignore", "pipe", "pipe"]
321
+ });
322
+
323
+ // Read until the "listening on …" line WITHOUT breaking the stream: a `break`
324
+ // out of `for await (splitLines(stdout))` calls the iterator's `.return()`,
325
+ // which destroys `child.stdout` — then the server can't drain and a post-startup
326
+ // log write hits a closed pipe. Instead attach a persistent `data` listener
327
+ // (keeps the stream in flowing mode, draining forever) and resolve off it.
328
+ const stdout = child.stdout;
329
+ let baseUrl: string;
330
+ let startupTimer: ReturnType<typeof setTimeout> | undefined;
331
+ try {
332
+ const startupMs = options.startupTimeoutMs ?? 30_000;
333
+ const startup: Deferred<string> = Promise.withResolvers<string>();
334
+ let buffer = "";
335
+ let resolved = false;
336
+ startupTimer = setTimeout(() => {
337
+ if (!resolved) {
338
+ resolved = true;
339
+ startup.reject(new Error(`opencode serve did not report a listening URL within ${String(startupMs)}ms`));
340
+ }
341
+ }, Math.max(startupMs, 0));
342
+ stdout.on("data", (chunk: Buffer | string) => {
343
+ if (resolved) {
344
+ return; // listener stays attached purely to drain the pipe
345
+ }
346
+ buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
347
+ const match = ListeningLine.exec(buffer);
348
+ if (match?.[1] !== undefined) {
349
+ resolved = true;
350
+ startup.resolve(match[1]);
351
+ }
352
+ });
353
+ child.once("error", (error: Error) => {
354
+ if (!resolved) {
355
+ resolved = true;
356
+ startup.reject(error);
357
+ }
358
+ });
359
+ stdout.once("end", () => {
360
+ if (!resolved) {
361
+ resolved = true;
362
+ startup.reject(new Error("opencode serve did not report a listening URL"));
363
+ }
364
+ });
365
+ baseUrl = await startup.promise;
366
+ } catch (error) {
367
+ child.kill("SIGINT");
368
+ throw error;
369
+ } finally {
370
+ clearTimeout(startupTimer);
371
+ }
372
+
373
+ return {
374
+ url: baseUrl,
375
+ authHeader,
376
+ stop(signal = "SIGINT") {
377
+ child.kill(signal);
378
+ return Promise.resolve();
379
+ }
380
+ };
381
+ }
382
+
383
+ /** Default `fetch`-backed transport (Scala `JavaNetOpencodeHttp`). Sends the
384
+ * server's preemptive `Authorization` header on every request and surfaces
385
+ * non-2xx responses as errors instead of returning the body as success. */
386
+ export function createFetchOpenCodeHttp(server: OpenCodeServerProcess): OpenCodeHttp {
387
+ const headers: Record<string, string> = server.authHeader
388
+ ? { authorization: server.authHeader }
389
+ : {};
390
+ return {
391
+ async postJson(path, body, signal) {
392
+ const response = await fetch(`${server.url}${path}`, {
393
+ method: "POST",
394
+ headers: { ...headers, "content-type": "application/json" },
395
+ body,
396
+ ...(signal === undefined ? {} : { signal })
397
+ });
398
+ if (!response.ok) {
399
+ const errorBody = (await response.text()).trim();
400
+ throw new Error(
401
+ `opencode POST ${path} failed with ${String(response.status)}${errorBody ? `: ${errorBody}` : ""}`
402
+ );
403
+ }
404
+ return await response.text();
405
+ },
406
+ async openEvents(signal) {
407
+ const response = await fetch(`${server.url}/event`, { headers, signal });
408
+ if (!response.ok) {
409
+ throw new Error(`opencode GET /event failed with ${String(response.status)}`);
410
+ }
411
+ if (!response.body) {
412
+ throw new Error("opencode /event returned no body");
413
+ }
414
+ return splitLines(streamBytes(response.body));
415
+ }
416
+ };
417
+ }
418
+
419
+ async function* streamBytes(body: ReadableStream<Uint8Array>): AsyncIterable<Uint8Array> {
420
+ const reader = body.getReader();
421
+ try {
422
+ for (;;) {
423
+ const { done, value } = await reader.read();
424
+ if (done) {
425
+ return;
426
+ }
427
+ yield value;
428
+ }
429
+ } finally {
430
+ reader.releaseLock();
431
+ }
432
+ }