@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,154 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { z } from "zod";
5
+ import { composeBackendPrompt } from "./conversation-config.ts";
6
+ import { createPiRpcConsumer, piPromptCommand, piRpcArgs, type PiRpcOptions } from "./pi-rpc.ts";
7
+ import {
8
+ errorMessage,
9
+ runSubprocessConversation,
10
+ type SubprocessProcess,
11
+ type SubprocessSpawner
12
+ } from "./subprocess-run.ts";
13
+ import type { AutonomousRequest, LlmBackend } from "./types.ts";
14
+ import { StreamConversation } from "../conversation/index.ts";
15
+ import { backendFailed, type BackendConfig } from "../model/index.ts";
16
+
17
+ export type PiProcess = SubprocessProcess;
18
+ export type PiProcessSpawner = SubprocessSpawner;
19
+
20
+ export interface PiBackendOptions {
21
+ readonly command?: string;
22
+ readonly cwd?: string;
23
+ readonly env?: NodeJS.ProcessEnv;
24
+ readonly capacity?: number;
25
+ readonly spawnProcess?: PiProcessSpawner;
26
+ readonly config?: BackendConfig<"pi">;
27
+ readonly inactivityTimeoutMs?: number;
28
+ readonly wallClockTimeoutMs?: number;
29
+ /** Base directory for per-session Pi `--session-dir`s. Defaults to the OS temp dir. */
30
+ readonly sessionsDir?: string;
31
+ }
32
+
33
+ interface ResolvedPiConfig<Output> {
34
+ model?: string;
35
+ systemPrompt?: string;
36
+ readOnly?: boolean;
37
+ selfManagedGit?: boolean;
38
+ retryAttempts?: number;
39
+ schema?: z.ZodType<Output>;
40
+ resumeSessionId?: string;
41
+ }
42
+
43
+ /** Pi's read-only tool allow-list (Scala `PiArgs.ReadOnlyTools`). */
44
+ const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
45
+
46
+ /** Build the `pi --mode rpc` argv (Scala `PiArgs.rpc`): session dir, `--continue`
47
+ * on resume, model, and the read-only `--tools` gate. The prompt is sent as a
48
+ * stdin RPC command, not on argv. */
49
+ function piArgs<Output>(sessionDir: string, config: ResolvedPiConfig<Output>): readonly string[] {
50
+ return [
51
+ ...piRpcArgs(sessionDir),
52
+ ...(config.resumeSessionId ? ["--continue"] : []),
53
+ ...(config.model ? ["--model", config.model] : []),
54
+ ...(config.readOnly ? ["--tools", READ_ONLY_TOOLS.join(",")] : [])
55
+ ];
56
+ }
57
+
58
+ export async function runPiConversation<Output>(
59
+ request: AutonomousRequest<Output, "pi">,
60
+ options: PiBackendOptions,
61
+ conversation: StreamConversation<"pi">,
62
+ setProcess: (process: PiProcess) => void
63
+ ): Promise<void> {
64
+ const command = options.command ?? "pi";
65
+ const config = resolvePiConfig(request, options);
66
+
67
+ try {
68
+ const piSessionId = config.resumeSessionId ?? crypto.randomUUID();
69
+ const sessionDir = join(options.sessionsDir ?? tmpdir(), `orca-pi-${piSessionId}`);
70
+ // Synchronous so the process spawns within the first microtask (matching
71
+ // codex/claude): a cancel right after start still finds the child to kill.
72
+ mkdirSync(sessionDir, { recursive: true });
73
+
74
+ const args = piArgs(sessionDir, config);
75
+ const consumerOptions: PiRpcOptions<Output> =
76
+ config.schema === undefined ? {} : { schema: config.schema };
77
+
78
+ await runSubprocessConversation({
79
+ backend: "pi",
80
+ command,
81
+ args,
82
+ conversation,
83
+ setProcess,
84
+ stdin: "pipe",
85
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
86
+ ...(options.env === undefined ? {} : { env: options.env }),
87
+ ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }),
88
+ ...(options.inactivityTimeoutMs === undefined ? {} : { inactivityTimeoutMs: options.inactivityTimeoutMs }),
89
+ ...(options.wallClockTimeoutMs === undefined ? {} : { wallClockTimeoutMs: options.wallClockTimeoutMs }),
90
+ onStart: (process) => {
91
+ process.write?.(`${piPromptCommand(composeBackendPrompt(request.prompt, config))}\n`);
92
+ // Pi rpc keeps reading stdin for the next command; leave it open. The
93
+ // shared helper kills the process once `agent_end` settles the turn.
94
+ },
95
+ createConsumer: () => createPiRpcConsumer(conversation, piSessionId, consumerOptions)
96
+ });
97
+ } catch (error) {
98
+ if (!conversation.signal.aborted) {
99
+ conversation.fail(backendFailed("pi", errorMessage(error)));
100
+ }
101
+ }
102
+ }
103
+
104
+ function resolvePiConfig<Output>(
105
+ request: AutonomousRequest<Output, "pi">,
106
+ options: PiBackendOptions
107
+ ): ResolvedPiConfig<Output> {
108
+ const optionConfig = options.config;
109
+ const requestConfig = request.config;
110
+ const config: ResolvedPiConfig<Output> = {};
111
+ const model = requestConfig?.model ?? optionConfig?.model;
112
+ if (model !== undefined) config.model = model;
113
+ const systemPrompt = requestConfig?.systemPrompt ?? optionConfig?.systemPrompt;
114
+ if (systemPrompt !== undefined) config.systemPrompt = systemPrompt;
115
+ const readOnly = requestConfig?.readOnly ?? optionConfig?.readOnly;
116
+ if (readOnly !== undefined) config.readOnly = readOnly;
117
+ const selfManagedGit = requestConfig?.selfManagedGit ?? optionConfig?.selfManagedGit;
118
+ if (selfManagedGit !== undefined) config.selfManagedGit = selfManagedGit;
119
+ const retryAttempts = requestConfig?.retry?.attempts ?? optionConfig?.retry?.attempts;
120
+ if (retryAttempts !== undefined) config.retryAttempts = retryAttempts;
121
+ const schema =
122
+ requestConfig?.structuredOutput?.schema ??
123
+ request.schema ??
124
+ (optionConfig?.structuredOutput?.schema as z.ZodType<Output> | undefined);
125
+ if (schema !== undefined) config.schema = schema;
126
+ if (requestConfig?.resumeSessionId !== undefined)
127
+ config.resumeSessionId = String(requestConfig.resumeSessionId);
128
+ return config;
129
+ }
130
+
131
+ export function pi(options: PiBackendOptions = {}): LlmBackend<"pi"> {
132
+ return {
133
+ tag: "pi",
134
+ autonomous<Output = unknown>(request: AutonomousRequest<Output, "pi">) {
135
+ let child: PiProcess | undefined;
136
+ const conversation = new StreamConversation({
137
+ backend: "pi",
138
+ capacity: options.capacity ?? 256,
139
+ canAskUser: false,
140
+ onCancel: () => {
141
+ child?.kill("SIGTERM");
142
+ }
143
+ });
144
+
145
+ queueMicrotask(() => {
146
+ void runPiConversation(request, options, conversation, (process) => {
147
+ child = process;
148
+ });
149
+ });
150
+
151
+ return conversation;
152
+ }
153
+ };
154
+ }
@@ -0,0 +1,77 @@
1
+ import { BackendTagSchema, type BackendConfig, type BackendTag } from "../model/index.ts";
2
+ import { claude } from "./claude-run.ts";
3
+ import { codex } from "./codex.ts";
4
+ import { opencode } from "./opencode-run.ts";
5
+ import { pi } from "./pi-run.ts";
6
+ import type { LlmBackend } from "./types.ts";
7
+
8
+ export type PortableBackendConfig = Omit<BackendConfig, "resumeSessionId" | "structuredOutput">;
9
+
10
+ export interface SelectBackendOptions {
11
+ readonly default: BackendTag;
12
+ readonly config?: PortableBackendConfig;
13
+ readonly perBackend?: Partial<Record<BackendTag, PortableBackendConfig>>;
14
+ readonly env?: NodeJS.ProcessEnv;
15
+ }
16
+
17
+ export interface SelectedBackend {
18
+ readonly tag: BackendTag;
19
+ readonly backend: LlmBackend;
20
+ readonly model?: string;
21
+ readonly shutdown?: () => Promise<void>;
22
+ }
23
+
24
+ export function selectBackend(options: SelectBackendOptions): SelectedBackend {
25
+ const env = options.env ?? process.env;
26
+ const raw = env.ORCA_BACKEND;
27
+ const candidate = raw === undefined || raw === "" ? options.default : raw;
28
+ const parsed = BackendTagSchema.safeParse(candidate);
29
+ if (!parsed.success) {
30
+ throw new Error(
31
+ `Unsupported backend "${candidate}" (expected one of: ${BackendTagSchema.options.join(", ")})`
32
+ );
33
+ }
34
+
35
+ const tag = parsed.data;
36
+ const mergedConfig: PortableBackendConfig = {
37
+ ...(options.config ?? {}),
38
+ ...(options.perBackend?.[tag] ?? {})
39
+ };
40
+ const model = env.ORCA_BACKEND_MODEL === undefined || env.ORCA_BACKEND_MODEL === ""
41
+ ? mergedConfig.model
42
+ : env.ORCA_BACKEND_MODEL;
43
+ const config: PortableBackendConfig = {
44
+ ...mergedConfig,
45
+ ...(model === undefined ? {} : { model })
46
+ };
47
+
48
+ switch (tag) {
49
+ case "claude":
50
+ return {
51
+ tag,
52
+ backend: claude({ config }),
53
+ ...(model === undefined ? {} : { model })
54
+ };
55
+ case "codex":
56
+ return {
57
+ tag,
58
+ backend: codex({ config }),
59
+ ...(model === undefined ? {} : { model })
60
+ };
61
+ case "opencode": {
62
+ const backend = opencode({ config });
63
+ return {
64
+ tag,
65
+ backend,
66
+ ...(model === undefined ? {} : { model }),
67
+ shutdown: () => backend.shutdown()
68
+ };
69
+ }
70
+ case "pi":
71
+ return {
72
+ tag,
73
+ backend: pi({ config }),
74
+ ...(model === undefined ? {} : { model })
75
+ };
76
+ }
77
+ }
@@ -0,0 +1,305 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ backendFailed,
4
+ type BackendTag
5
+ } from "../model/index.ts";
6
+ import type { StreamConversation } from "../conversation/index.ts";
7
+
8
+ const DefaultSubprocessInactivityTimeoutMs = 120_000;
9
+
10
+ interface Deferred<T> {
11
+ readonly promise: Promise<T>;
12
+ resolve(value: T | PromiseLike<T>): void;
13
+ reject(reason?: unknown): void;
14
+ }
15
+ const DefaultSubprocessWallClockTimeoutMs = 600_000;
16
+
17
+ /** A spawned agent subprocess: a stdout (and optional stderr) line/byte stream,
18
+ * an exit promise, a kill signal, and — for backends that feed their prompt over
19
+ * stdin (claude, pi) — an optional write/end pair. Codex leaves stdin ignored and
20
+ * never touches these. */
21
+ export interface SubprocessProcess {
22
+ readonly stdout: AsyncIterable<string | Uint8Array>;
23
+ readonly stderr?: AsyncIterable<string | Uint8Array>;
24
+ readonly exit: Promise<number | null>;
25
+ kill(signal?: NodeJS.Signals): void;
26
+ write?(data: string): void;
27
+ endStdin?(): void;
28
+ }
29
+
30
+ export interface SubprocessSpawnOptions {
31
+ readonly cwd?: string;
32
+ readonly env?: NodeJS.ProcessEnv;
33
+ /** Whether stdin is wired. Defaults to "ignore" so codex stays byte-identical;
34
+ * claude/pi pass "pipe" to write the opening user turn. */
35
+ readonly stdin?: "ignore" | "pipe";
36
+ }
37
+
38
+ export type SubprocessSpawner = (
39
+ command: string,
40
+ args: readonly string[],
41
+ options: SubprocessSpawnOptions
42
+ ) => SubprocessProcess;
43
+
44
+ /** The per-line consumer half a backend supplies: incremental `consume(line)`
45
+ * plus a `finish()` that the helper calls after a clean (zero-exit) stream end.
46
+ * `signal` aborts once the turn is settled — required for persistent processes
47
+ * (pi rpc) that don't exit after a turn. */
48
+ export interface SubprocessConsumer {
49
+ readonly signal: AbortSignal;
50
+ consume(raw: string): Promise<void>;
51
+ finish(): void;
52
+ }
53
+
54
+ export interface RunSubprocessOptions<B extends BackendTag> {
55
+ readonly backend: B;
56
+ readonly command: string;
57
+ readonly args: readonly string[];
58
+ readonly conversation: StreamConversation<B>;
59
+ readonly createConsumer: () => SubprocessConsumer;
60
+ readonly cwd?: string;
61
+ readonly env?: NodeJS.ProcessEnv;
62
+ readonly stdin?: "ignore" | "pipe";
63
+ readonly spawnProcess?: SubprocessSpawner;
64
+ /** Fail when stdout produces no line for this many ms. Defaults to 120s. */
65
+ readonly inactivityTimeoutMs?: number;
66
+ /** Fail after this absolute turn duration even if stdout stays active. Defaults to 600s. */
67
+ readonly wallClockTimeoutMs?: number;
68
+ /** Records the spawned process so the accessor can `kill` it on cancellation. */
69
+ readonly setProcess?: (process: SubprocessProcess) => void;
70
+ /** Runs right after spawn (before the read loop) — claude/pi write the opening
71
+ * user turn to stdin and close it here. */
72
+ readonly onStart?: (process: SubprocessProcess) => void | Promise<void>;
73
+ }
74
+
75
+ type SubprocessTimeoutKind = "inactivity" | "wallclock";
76
+
77
+ interface SubprocessTimeout {
78
+ readonly type: "timeout";
79
+ readonly kind: SubprocessTimeoutKind;
80
+ }
81
+
82
+ /** Shared spawn → stdout-line-stream → consumer → outcome plumbing for
83
+ * subprocess-stream backends (codex, claude, pi). Owns process spawn, line
84
+ * splitting, stderr capture, non-zero-exit failure, cancellation checks, and
85
+ * timeout settlement; the per-backend command/args builder and line consumer
86
+ * plug in.
87
+ *
88
+ * The helper deliberately does NOT catch spawn / stream / consumer exceptions —
89
+ * each driver wraps the call in its own try/catch/finally so spawn-error→fail and
90
+ * resource teardown stay synchronous with the failure (matters for deterministic
91
+ * cleanup ordering; see codex's ask_user bridge). Use {@link errorMessage} +
92
+ * {@link backendFailed} in that catch. */
93
+ export async function runSubprocessConversation<B extends BackendTag>(
94
+ options: RunSubprocessOptions<B>
95
+ ): Promise<void> {
96
+ const { conversation, backend } = options;
97
+ const spawnProcess = options.spawnProcess ?? spawnSubprocess;
98
+ const spawnOptions: { cwd?: string; env?: NodeJS.ProcessEnv; stdin?: "ignore" | "pipe" } = {};
99
+ if (options.cwd !== undefined) {
100
+ spawnOptions.cwd = options.cwd;
101
+ }
102
+ if (options.env !== undefined) {
103
+ spawnOptions.env = options.env;
104
+ }
105
+ if (options.stdin !== undefined) {
106
+ spawnOptions.stdin = options.stdin;
107
+ }
108
+
109
+ const process = spawnProcess(options.command, options.args, spawnOptions);
110
+ options.setProcess?.(process);
111
+
112
+ const stderr = collectText(process.stderr);
113
+ await options.onStart?.(process);
114
+
115
+ const timeout: Deferred<SubprocessTimeout> = Promise.withResolvers<SubprocessTimeout>();
116
+ const inactivityMs = options.inactivityTimeoutMs ?? DefaultSubprocessInactivityTimeoutMs;
117
+ const wallClockMs = options.wallClockTimeoutMs ?? DefaultSubprocessWallClockTimeoutMs;
118
+ let timeoutSettled = false;
119
+ let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
120
+
121
+ const settleTimeout = (kind: SubprocessTimeoutKind): void => {
122
+ if (timeoutSettled) {
123
+ return;
124
+ }
125
+ timeoutSettled = true;
126
+ process.kill("SIGTERM");
127
+ timeout.resolve({ type: "timeout", kind });
128
+ };
129
+ const resetInactivityTimer = (): void => {
130
+ clearTimeout(inactivityTimer);
131
+ inactivityTimer = setTimeout(() => {
132
+ settleTimeout("inactivity");
133
+ }, Math.max(inactivityMs, 0));
134
+ };
135
+
136
+ const wallClockTimer = setTimeout(() => {
137
+ settleTimeout("wallclock");
138
+ }, Math.max(wallClockMs, 0));
139
+ resetInactivityTimer();
140
+
141
+ try {
142
+ const consumer = options.createConsumer();
143
+ const lines = splitLines(process.stdout)[Symbol.asyncIterator]();
144
+ for (;;) {
145
+ const next: IteratorResult<string> | SubprocessTimeout = await Promise.race([lines.next(), timeout.promise]);
146
+ if (isSubprocessTimeout(next)) {
147
+ failSubprocessTimeout(conversation, backend, next.kind, inactivityMs, wallClockMs);
148
+ return;
149
+ }
150
+ if (next.done) {
151
+ break;
152
+ }
153
+ resetInactivityTimer();
154
+ if (conversation.signal.aborted) {
155
+ return;
156
+ }
157
+ await consumer.consume(next.value);
158
+ if (consumer.signal.aborted) {
159
+ // Kill the child once the consumer has settled the conversation on a
160
+ // terminal event (success, modeled failure, or early parse/tool error).
161
+ // Safe because we only reach here after consuming that event — the agent's
162
+ // session rollout is already flushed — and it stops a persistent process
163
+ // (pi rpc) or a stalled CLI from lingering after Orca has the outcome.
164
+ process.kill();
165
+ break;
166
+ }
167
+ }
168
+
169
+ // The consumer already settled the conversation (success/failure); the
170
+ // exit-code path below is only for a stream that ended without one.
171
+ if (consumer.signal.aborted) {
172
+ return;
173
+ }
174
+
175
+ const exit: number | null | SubprocessTimeout = await Promise.race([process.exit, timeout.promise]);
176
+ if (isSubprocessTimeout(exit)) {
177
+ failSubprocessTimeout(conversation, backend, exit.kind, inactivityMs, wallClockMs);
178
+ return;
179
+ }
180
+ const stderrText = (await stderr).trim();
181
+ if (conversation.signal.aborted) {
182
+ return;
183
+ }
184
+
185
+ if (exit !== 0) {
186
+ const exitCodeText = exit === null ? "unknown" : String(exit);
187
+ conversation.fail(
188
+ backendFailed(
189
+ backend,
190
+ `${backend} exited with code ${exitCodeText}${stderrText ? `: ${stderrText}` : ""}`
191
+ )
192
+ );
193
+ return;
194
+ }
195
+
196
+ consumer.finish();
197
+ } finally {
198
+ clearTimeout(inactivityTimer);
199
+ clearTimeout(wallClockTimer);
200
+ }
201
+ }
202
+
203
+ export function spawnSubprocess(
204
+ command: string,
205
+ args: readonly string[],
206
+ options: SubprocessSpawnOptions
207
+ ): SubprocessProcess {
208
+ const stdinMode = options.stdin ?? "ignore";
209
+ const child = spawn(command, [...args], {
210
+ cwd: options.cwd,
211
+ env: options.env,
212
+ stdio: [stdinMode === "pipe" ? "pipe" : "ignore", "pipe", "pipe"]
213
+ });
214
+
215
+ if (!child.stdout) {
216
+ throw new Error(`failed to capture stdout for ${command}`);
217
+ }
218
+
219
+ const exit = Promise.withResolvers<number | null>();
220
+ child.on("error", exit.reject);
221
+ child.on("close", exit.resolve);
222
+
223
+ return {
224
+ stdout: child.stdout,
225
+ ...(child.stderr ? { stderr: child.stderr } : {}),
226
+ exit: exit.promise,
227
+ kill(signal?: NodeJS.Signals) {
228
+ child.kill(signal);
229
+ },
230
+ write(data: string) {
231
+ child.stdin?.write(data);
232
+ },
233
+ endStdin() {
234
+ child.stdin?.end();
235
+ }
236
+ };
237
+ }
238
+
239
+ export async function* splitLines(
240
+ chunks: AsyncIterable<string | Uint8Array>
241
+ ): AsyncIterable<string> {
242
+ const decoder = new TextDecoder();
243
+ let buffer = "";
244
+
245
+ for await (const chunk of chunks) {
246
+ buffer += decodeChunk(decoder, chunk);
247
+ let newlineIndex = buffer.indexOf("\n");
248
+ while (newlineIndex !== -1) {
249
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
250
+ buffer = buffer.slice(newlineIndex + 1);
251
+ yield line;
252
+ newlineIndex = buffer.indexOf("\n");
253
+ }
254
+ }
255
+
256
+ buffer += decoder.decode();
257
+ if (buffer.length > 0) {
258
+ yield buffer.replace(/\r$/, "");
259
+ }
260
+ }
261
+
262
+ export async function collectText(
263
+ chunks: AsyncIterable<string | Uint8Array> | undefined
264
+ ): Promise<string> {
265
+ if (!chunks) {
266
+ return "";
267
+ }
268
+
269
+ const decoder = new TextDecoder();
270
+ const text: string[] = [];
271
+ for await (const chunk of chunks) {
272
+ text.push(decodeChunk(decoder, chunk));
273
+ }
274
+ text.push(decoder.decode());
275
+ return text.join("");
276
+ }
277
+
278
+ function decodeChunk(decoder: TextDecoder, chunk: string | Uint8Array): string {
279
+ return typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
280
+ }
281
+
282
+ function isSubprocessTimeout(value: IteratorResult<string> | number | null | SubprocessTimeout): value is SubprocessTimeout {
283
+ return typeof value === "object" && value !== null && (value as { readonly type?: unknown }).type === "timeout";
284
+ }
285
+
286
+ function failSubprocessTimeout<B extends BackendTag>(
287
+ conversation: StreamConversation<B>,
288
+ backend: B,
289
+ kind: SubprocessTimeoutKind,
290
+ inactivityMs: number,
291
+ wallClockMs: number
292
+ ): void {
293
+ if (conversation.signal.aborted) {
294
+ return;
295
+ }
296
+ const message =
297
+ kind === "inactivity"
298
+ ? `${backend} emitted no stdout for ${String(inactivityMs)}ms; treating the turn as stalled`
299
+ : `${backend} turn exceeded ${String(wallClockMs)}ms wall-clock limit`;
300
+ conversation.fail(backendFailed(backend, message));
301
+ }
302
+
303
+ export function errorMessage(error: unknown): string {
304
+ return error instanceof Error ? error.message : String(error);
305
+ }
@@ -0,0 +1,29 @@
1
+ import type { z } from "zod";
2
+ import type { Conversation } from "../conversation/index.ts";
3
+ import type { BackendConfig, BackendTag } from "../model/index.ts";
4
+ export type {
5
+ BackendApprovalPolicy,
6
+ BackendConfig,
7
+ BackendResult,
8
+ BackendRetryConfig,
9
+ BackendSandboxMode,
10
+ StructuredOutputConfig
11
+ } from "../model/index.ts";
12
+
13
+ export interface AutonomousRequest<Output = unknown, B extends BackendTag = BackendTag> {
14
+ readonly prompt: string;
15
+ readonly schema?: z.ZodType<Output>;
16
+ readonly config?: BackendConfig<B, Output>;
17
+ }
18
+
19
+ export interface LlmBackend<B extends BackendTag = BackendTag> {
20
+ readonly tag: B;
21
+ autonomous<Output = unknown>(request: AutonomousRequest<Output, B>): Conversation<B>;
22
+ }
23
+
24
+ export interface LlmTool {
25
+ autonomous<B extends BackendTag, Output = unknown>(
26
+ backend: LlmBackend<B>,
27
+ request: AutonomousRequest<Output, B>
28
+ ): Conversation<B>;
29
+ }
@@ -0,0 +1,48 @@
1
+ import type { AutonomousRequest, LlmBackend } from "./types.ts";
2
+ import { StreamConversation } from "../conversation/index.ts";
3
+ import {
4
+ backendFailed,
5
+ sessionId,
6
+ unsupportedFeature,
7
+ type BackendTag,
8
+ type SessionId
9
+ } from "../model/index.ts";
10
+
11
+ export function unsupportedBackend<B extends BackendTag>(tag: B): LlmBackend<B> {
12
+ return {
13
+ tag,
14
+ autonomous(request: AutonomousRequest) {
15
+ const conversation = new StreamConversation({ backend: tag });
16
+ queueMicrotask(() => {
17
+ if (request.config?.resumeSessionId) {
18
+ conversation.fail(
19
+ unsupportedFeature(`${tag} resume`, `${tag} backend does not support session resume`)
20
+ );
21
+ return;
22
+ }
23
+ if (request.schema || request.config?.structuredOutput) {
24
+ conversation.fail(
25
+ unsupportedFeature(
26
+ `${tag} structured output`,
27
+ `${tag} backend does not support live structured output`
28
+ )
29
+ );
30
+ return;
31
+ }
32
+ conversation.fail(backendFailed(tag, `${tag} backend is not implemented yet`));
33
+ });
34
+ return conversation;
35
+ }
36
+ };
37
+ }
38
+
39
+ export function fakeResult<B extends BackendTag>(
40
+ backend: B,
41
+ output: string
42
+ ): { readonly backend: B; readonly sessionId: SessionId<B>; readonly output: string } {
43
+ return {
44
+ backend,
45
+ sessionId: sessionId(backend, `${backend}-fake-session`),
46
+ output
47
+ };
48
+ }