@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.
- package/LICENSE +48 -0
- package/NOTICE +5 -0
- package/README.md +399 -0
- package/bin/orcats +4 -0
- package/dist/backends/acp-client.d.ts +74 -0
- package/dist/backends/acp-client.d.ts.map +1 -0
- package/dist/backends/acp-run.d.ts +31 -0
- package/dist/backends/acp-run.d.ts.map +1 -0
- package/dist/backends/claude-run.d.ts +35 -0
- package/dist/backends/claude-run.d.ts.map +1 -0
- package/dist/backends/claude-stream-json.d.ts +15 -0
- package/dist/backends/claude-stream-json.d.ts.map +1 -0
- package/dist/backends/codex-jsonl.d.ts +34 -0
- package/dist/backends/codex-jsonl.d.ts.map +1 -0
- package/dist/backends/codex-run.d.ts +40 -0
- package/dist/backends/codex-run.d.ts.map +1 -0
- package/dist/backends/codex.d.ts +5 -0
- package/dist/backends/codex.d.ts.map +1 -0
- package/dist/backends/conversation-config.d.ts +12 -0
- package/dist/backends/conversation-config.d.ts.map +1 -0
- package/dist/backends/index.d.ts +14 -0
- package/dist/backends/index.d.ts.map +1 -0
- package/dist/backends/opencode-run.d.ts +48 -0
- package/dist/backends/opencode-run.d.ts.map +1 -0
- package/dist/backends/opencode-sse.d.ts +35 -0
- package/dist/backends/opencode-sse.d.ts.map +1 -0
- package/dist/backends/pi-rpc.d.ts +21 -0
- package/dist/backends/pi-rpc.d.ts.map +1 -0
- package/dist/backends/pi-run.d.ts +21 -0
- package/dist/backends/pi-run.d.ts.map +1 -0
- package/dist/backends/select.d.ts +17 -0
- package/dist/backends/select.d.ts.map +1 -0
- package/dist/backends/subprocess-run.d.ts +68 -0
- package/dist/backends/subprocess-run.d.ts.map +1 -0
- package/dist/backends/types.d.ts +17 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/unsupported.d.ts +9 -0
- package/dist/backends/unsupported.d.ts.map +1 -0
- package/dist/baseline/index.d.ts +56 -0
- package/dist/baseline/index.d.ts.map +1 -0
- package/dist/cli/args.d.ts +22 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/embedded.d.ts +7 -0
- package/dist/cli/embedded.d.ts.map +1 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/version.d.ts +2 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/conversation/ask-user.d.ts +17 -0
- package/dist/conversation/ask-user.d.ts.map +1 -0
- package/dist/conversation/conversation.d.ts +46 -0
- package/dist/conversation/conversation.d.ts.map +1 -0
- package/dist/conversation/harness.d.ts +14 -0
- package/dist/conversation/harness.d.ts.map +1 -0
- package/dist/conversation/index.d.ts +5 -0
- package/dist/conversation/index.d.ts.map +1 -0
- package/dist/conversation/queue.d.ts +15 -0
- package/dist/conversation/queue.d.ts.map +1 -0
- package/dist/flow/accessors.d.ts +13 -0
- package/dist/flow/accessors.d.ts.map +1 -0
- package/dist/flow/approvals.d.ts +6 -0
- package/dist/flow/approvals.d.ts.map +1 -0
- package/dist/flow/args.d.ts +4 -0
- package/dist/flow/args.d.ts.map +1 -0
- package/dist/flow/context.d.ts +36 -0
- package/dist/flow/context.d.ts.map +1 -0
- package/dist/flow/index.d.ts +5 -0
- package/dist/flow/index.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/loop/builder/index.d.ts +17 -0
- package/dist/loop/builder/index.d.ts.map +1 -0
- package/dist/loop/builder/types.d.ts +97 -0
- package/dist/loop/builder/types.d.ts.map +1 -0
- package/dist/loop/context/compaction.d.ts +38 -0
- package/dist/loop/context/compaction.d.ts.map +1 -0
- package/dist/loop/context/index.d.ts +4 -0
- package/dist/loop/context/index.d.ts.map +1 -0
- package/dist/loop/context/offload.d.ts +34 -0
- package/dist/loop/context/offload.d.ts.map +1 -0
- package/dist/loop/context/types.d.ts +13 -0
- package/dist/loop/context/types.d.ts.map +1 -0
- package/dist/loop/engine/bridge.d.ts +21 -0
- package/dist/loop/engine/bridge.d.ts.map +1 -0
- package/dist/loop/engine/index.d.ts +73 -0
- package/dist/loop/engine/index.d.ts.map +1 -0
- package/dist/loop/execution.d.ts +68 -0
- package/dist/loop/execution.d.ts.map +1 -0
- package/dist/loop/fanout.d.ts +148 -0
- package/dist/loop/fanout.d.ts.map +1 -0
- package/dist/loop/firing.d.ts +54 -0
- package/dist/loop/firing.d.ts.map +1 -0
- package/dist/loop/graph/analyze.d.ts +8 -0
- package/dist/loop/graph/analyze.d.ts.map +1 -0
- package/dist/loop/graph/index.d.ts +3 -0
- package/dist/loop/graph/index.d.ts.map +1 -0
- package/dist/loop/graph/types.d.ts +35 -0
- package/dist/loop/graph/types.d.ts.map +1 -0
- package/dist/loop/index.d.ts +7 -0
- package/dist/loop/index.d.ts.map +1 -0
- package/dist/loop/io/index.d.ts +4 -0
- package/dist/loop/io/index.d.ts.map +1 -0
- package/dist/loop/io/linear.d.ts +142 -0
- package/dist/loop/io/linear.d.ts.map +1 -0
- package/dist/loop/io/sink.d.ts +47 -0
- package/dist/loop/io/sink.d.ts.map +1 -0
- package/dist/loop/io/source.d.ts +64 -0
- package/dist/loop/io/source.d.ts.map +1 -0
- package/dist/loop/serve.d.ts +97 -0
- package/dist/loop/serve.d.ts.map +1 -0
- package/dist/loop/state/index.d.ts +5 -0
- package/dist/loop/state/index.d.ts.map +1 -0
- package/dist/loop/state/manifest.d.ts +36 -0
- package/dist/loop/state/manifest.d.ts.map +1 -0
- package/dist/loop/state/port.d.ts +33 -0
- package/dist/loop/state/port.d.ts.map +1 -0
- package/dist/loop/state/snapshot.d.ts +15 -0
- package/dist/loop/state/snapshot.d.ts.map +1 -0
- package/dist/loop/state/sqlite.d.ts +31 -0
- package/dist/loop/state/sqlite.d.ts.map +1 -0
- package/dist/loop/termination-contract.d.ts +28 -0
- package/dist/loop/termination-contract.d.ts.map +1 -0
- package/dist/loop/termination.d.ts +33 -0
- package/dist/loop/termination.d.ts.map +1 -0
- package/dist/model/backend-config.d.ts +30 -0
- package/dist/model/backend-config.d.ts.map +1 -0
- package/dist/model/brand.d.ts +9 -0
- package/dist/model/brand.d.ts.map +1 -0
- package/dist/model/errors.d.ts +11 -0
- package/dist/model/errors.d.ts.map +1 -0
- package/dist/model/index.d.ts +8 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/json-schema.d.ts +4 -0
- package/dist/model/json-schema.d.ts.map +1 -0
- package/dist/model/result.d.ts +3 -0
- package/dist/model/result.d.ts.map +1 -0
- package/dist/model/schemas.d.ts +435 -0
- package/dist/model/schemas.d.ts.map +1 -0
- package/dist/model/structured.d.ts +9 -0
- package/dist/model/structured.d.ts.map +1 -0
- package/dist/monitor/index.d.ts +139 -0
- package/dist/monitor/index.d.ts.map +1 -0
- package/dist/plan/index.d.ts +3 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/persistent.d.ts +23 -0
- package/dist/plan/persistent.d.ts.map +1 -0
- package/dist/plan/verdict.d.ts +9 -0
- package/dist/plan/verdict.d.ts.map +1 -0
- package/dist/review/index.d.ts +4 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/loop.d.ts +117 -0
- package/dist/review/loop.d.ts.map +1 -0
- package/dist/review/reviewers.d.ts +13 -0
- package/dist/review/reviewers.d.ts.map +1 -0
- package/dist/review/tool.d.ts +20 -0
- package/dist/review/tool.d.ts.map +1 -0
- package/dist/run-output/index.d.ts +112 -0
- package/dist/run-output/index.d.ts.map +1 -0
- package/dist/runner/index.d.ts +2 -0
- package/dist/runner/index.d.ts.map +1 -0
- package/dist/runner/typecheck.d.ts +23 -0
- package/dist/runner/typecheck.d.ts.map +1 -0
- package/dist/test-utils/fakes.d.ts +76 -0
- package/dist/test-utils/fakes.d.ts.map +1 -0
- package/dist/test-utils/fixtures.d.ts +4 -0
- package/dist/test-utils/fixtures.d.ts.map +1 -0
- package/dist/test-utils/index.d.ts +4 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/tier2.d.ts +8 -0
- package/dist/test-utils/tier2.d.ts.map +1 -0
- package/dist/tools/fs.d.ts +11 -0
- package/dist/tools/fs.d.ts.map +1 -0
- package/dist/tools/git.d.ts +10 -0
- package/dist/tools/git.d.ts.map +1 -0
- package/dist/tools/github.d.ts +13 -0
- package/dist/tools/github.d.ts.map +1 -0
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/linear.d.ts +117 -0
- package/dist/tools/linear.d.ts.map +1 -0
- package/dist/tools/process.d.ts +40 -0
- package/dist/tools/process.d.ts.map +1 -0
- package/dist/tools/terminal.d.ts +23 -0
- package/dist/tools/terminal.d.ts.map +1 -0
- package/package.json +89 -9
- package/src/backends/acp-client.ts +324 -0
- package/src/backends/acp-run.ts +636 -0
- package/src/backends/claude-run.ts +226 -0
- package/src/backends/claude-stream-json.ts +273 -0
- package/src/backends/codex-jsonl.ts +339 -0
- package/src/backends/codex-run.ts +177 -0
- package/src/backends/codex.ts +84 -0
- package/src/backends/conversation-config.ts +26 -0
- package/src/backends/index.ts +13 -0
- package/src/backends/opencode-run.ts +432 -0
- package/src/backends/opencode-sse.ts +360 -0
- package/src/backends/pi-rpc.ts +242 -0
- package/src/backends/pi-run.ts +154 -0
- package/src/backends/select.ts +77 -0
- package/src/backends/subprocess-run.ts +305 -0
- package/src/backends/types.ts +29 -0
- package/src/backends/unsupported.ts +48 -0
- package/src/baseline/index.ts +437 -0
- package/src/cli/args.ts +109 -0
- package/src/cli/embedded.ts +206 -0
- package/src/cli/main.ts +332 -0
- package/src/cli/version.ts +1 -0
- package/src/conversation/ask-user.ts +105 -0
- package/src/conversation/conversation.ts +109 -0
- package/src/conversation/harness.ts +39 -0
- package/src/conversation/index.ts +4 -0
- package/src/conversation/queue.ts +86 -0
- package/src/flow/accessors.ts +43 -0
- package/src/flow/approvals.ts +10 -0
- package/src/flow/args.ts +26 -0
- package/src/flow/context.ts +106 -0
- package/src/flow/index.ts +4 -0
- package/src/index.ts +17 -0
- package/src/loop/builder/index.ts +346 -0
- package/src/loop/builder/types.ts +131 -0
- package/src/loop/context/compaction.ts +175 -0
- package/src/loop/context/index.ts +3 -0
- package/src/loop/context/offload.ts +79 -0
- package/src/loop/context/types.ts +17 -0
- package/src/loop/engine/bridge.ts +61 -0
- package/src/loop/engine/index.ts +166 -0
- package/src/loop/execution.ts +453 -0
- package/src/loop/fanout.ts +444 -0
- package/src/loop/firing.ts +188 -0
- package/src/loop/graph/analyze.ts +128 -0
- package/src/loop/graph/index.ts +2 -0
- package/src/loop/graph/types.ts +45 -0
- package/src/loop/index.ts +11 -0
- package/src/loop/io/index.ts +3 -0
- package/src/loop/io/linear.ts +714 -0
- package/src/loop/io/sink.ts +170 -0
- package/src/loop/io/source.ts +255 -0
- package/src/loop/serve.ts +307 -0
- package/src/loop/state/index.ts +4 -0
- package/src/loop/state/manifest.ts +56 -0
- package/src/loop/state/port.ts +43 -0
- package/src/loop/state/snapshot.ts +129 -0
- package/src/loop/state/sqlite.ts +265 -0
- package/src/loop/termination-contract.ts +89 -0
- package/src/loop/termination.ts +156 -0
- package/src/model/backend-config.ts +34 -0
- package/src/model/brand.ts +11 -0
- package/src/model/errors.ts +28 -0
- package/src/model/index.ts +7 -0
- package/src/model/json-schema.ts +14 -0
- package/src/model/result.ts +28 -0
- package/src/model/schemas.ts +218 -0
- package/src/model/structured.ts +26 -0
- package/src/monitor/index.ts +367 -0
- package/src/plan/index.ts +2 -0
- package/src/plan/persistent.ts +98 -0
- package/src/plan/verdict.ts +3 -0
- package/src/review/index.ts +3 -0
- package/src/review/loop.ts +365 -0
- package/src/review/reviewers.ts +92 -0
- package/src/review/tool.ts +41 -0
- package/src/run-output/index.ts +319 -0
- package/src/runner/index.ts +1 -0
- package/src/runner/typecheck.ts +72 -0
- package/src/test-utils/fakes.ts +260 -0
- package/src/test-utils/fixtures.ts +33 -0
- package/src/test-utils/index.ts +3 -0
- package/src/test-utils/tier2.ts +12 -0
- package/src/tools/fs.ts +44 -0
- package/src/tools/git.ts +34 -0
- package/src/tools/github.ts +25 -0
- package/src/tools/index.ts +6 -0
- package/src/tools/linear.ts +428 -0
- package/src/tools/process.ts +156 -0
- 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
|
+
}
|