@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,636 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { readFile, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import {
|
|
6
|
+
createAcpClient,
|
|
7
|
+
type AcpClient,
|
|
8
|
+
type AcpProcess,
|
|
9
|
+
type AcpProcessSpawner,
|
|
10
|
+
type AcpRequestMessage
|
|
11
|
+
} from "./acp-client.ts";
|
|
12
|
+
import { composeBackendPrompt, type SharedBackendConfig } from "./conversation-config.ts";
|
|
13
|
+
import type { AutonomousRequest } from "./types.ts";
|
|
14
|
+
import type { StreamConversation } from "../conversation/index.ts";
|
|
15
|
+
import {
|
|
16
|
+
backendFailed,
|
|
17
|
+
jsonSchemaFromZod,
|
|
18
|
+
parseStructuredOutput,
|
|
19
|
+
sessionId,
|
|
20
|
+
structuredOutputValidationFailed,
|
|
21
|
+
type BackendTag,
|
|
22
|
+
type Usage
|
|
23
|
+
} from "../model/index.ts";
|
|
24
|
+
|
|
25
|
+
const DefaultAcpInactivityTimeoutMs = 120_000;
|
|
26
|
+
|
|
27
|
+
export interface AcpBackendRuntimeOptions<B extends BackendTag, Output = unknown> {
|
|
28
|
+
readonly backend: B;
|
|
29
|
+
readonly command: string;
|
|
30
|
+
readonly args?: readonly string[];
|
|
31
|
+
readonly config?: SharedBackendConfig<Output>;
|
|
32
|
+
readonly cwd?: string;
|
|
33
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
34
|
+
readonly spawnProcess?: AcpProcessSpawner;
|
|
35
|
+
readonly setProcess: (process: AcpProcess) => void;
|
|
36
|
+
readonly setCancel: (cancel: () => Promise<void>) => void;
|
|
37
|
+
readonly cancelTimeoutMs?: number;
|
|
38
|
+
readonly shutdownTimeoutMs?: number;
|
|
39
|
+
readonly requestTimeoutMs?: number;
|
|
40
|
+
readonly inactivityTimeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function experimentalAcpBackendEnabled(backend: "claude" | "codex"): boolean {
|
|
44
|
+
const value = process.env.ORCA_EXPERIMENTAL_ACP_BACKENDS;
|
|
45
|
+
if (value === undefined) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return value === "1" || value.split(",").map((item) => item.trim()).includes(backend);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function claudeAcpCommand(): { readonly command: string; readonly args: readonly string[] } {
|
|
52
|
+
return { command: process.env.ORCA_CLAUDE_ACP_COMMAND ?? "claude-agent-acp", args: [] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function codexAcpCommand(): { readonly command: string; readonly args: readonly string[] } {
|
|
56
|
+
const command = process.env.ORCA_CODEX_ACP_COMMAND;
|
|
57
|
+
if (command !== undefined) {
|
|
58
|
+
return { command, args: [] };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
command: "npx",
|
|
62
|
+
args: [
|
|
63
|
+
"--prefer-offline=false",
|
|
64
|
+
"--prefer-online=true",
|
|
65
|
+
"-y",
|
|
66
|
+
"@agentclientprotocol/codex-acp@1.1.0"
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AcpSessionResponse {
|
|
72
|
+
readonly sessionId?: string;
|
|
73
|
+
readonly configOptions?: unknown;
|
|
74
|
+
readonly modes?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface AcpPromptResponse {
|
|
78
|
+
readonly stopReason?: string;
|
|
79
|
+
readonly usage?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function runAcpConversation<Output, B extends "claude" | "codex">(
|
|
83
|
+
request: AutonomousRequest<Output, B>,
|
|
84
|
+
options: AcpBackendRuntimeOptions<B, Output>,
|
|
85
|
+
conversation: StreamConversation<B>
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
let client: AcpClient | undefined;
|
|
88
|
+
let activeSessionId = "";
|
|
89
|
+
let activePrompt: Promise<AcpPromptResponse> | undefined;
|
|
90
|
+
let output = "";
|
|
91
|
+
const sessionCwd = options.cwd ?? process.cwd();
|
|
92
|
+
let markPromptActivity = (): void => {};
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
client = await runAcpPhase(options.backend, "initialization", () =>
|
|
96
|
+
Promise.resolve(createAcpClient({
|
|
97
|
+
command: options.command,
|
|
98
|
+
args: options.args ?? [],
|
|
99
|
+
cwd: sessionCwd,
|
|
100
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
101
|
+
...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }),
|
|
102
|
+
requestTimeoutMs: options.requestTimeoutMs ?? 600_000,
|
|
103
|
+
handleRequest: (message) => handleClientRequest(message, sessionCwd, options.config?.readOnly === true),
|
|
104
|
+
onIncomingMessage: () => {
|
|
105
|
+
markPromptActivity();
|
|
106
|
+
}
|
|
107
|
+
}))
|
|
108
|
+
);
|
|
109
|
+
options.setProcess(client.process);
|
|
110
|
+
const acpClient = client;
|
|
111
|
+
options.setCancel(() => cancelAcpTurn(acpClient, activeSessionId, activePrompt, options.cancelTimeoutMs));
|
|
112
|
+
const done = acpClient.done.then(
|
|
113
|
+
() => ({ type: "success" as const }),
|
|
114
|
+
(error: unknown) => ({ type: "failed" as const, error })
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const updates = consumeAcpMessages(
|
|
118
|
+
acpClient,
|
|
119
|
+
conversation,
|
|
120
|
+
(text) => {
|
|
121
|
+
output += text;
|
|
122
|
+
},
|
|
123
|
+
() => {
|
|
124
|
+
markPromptActivity();
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await runAcpPhase(options.backend, "initialization", () => acpClient.request("initialize", {
|
|
129
|
+
protocolVersion: 1,
|
|
130
|
+
clientCapabilities: {
|
|
131
|
+
fs: {
|
|
132
|
+
readTextFile: true,
|
|
133
|
+
writeTextFile: options.config?.readOnly === true ? false : true
|
|
134
|
+
},
|
|
135
|
+
terminal: false
|
|
136
|
+
},
|
|
137
|
+
clientInfo: { name: "orcats", title: "Orcats", version: "0.0.0" }
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
const session = await runAcpPhase(options.backend, "session creation", () => acpClient.request<AcpSessionResponse>("session/new", {
|
|
141
|
+
cwd: sessionCwd,
|
|
142
|
+
mcpServers: []
|
|
143
|
+
}));
|
|
144
|
+
activeSessionId = session.sessionId ?? "";
|
|
145
|
+
await runAcpPhase(options.backend, "session mode configuration", () =>
|
|
146
|
+
configureAcpMode(acpClient, activeSessionId, session, options.config)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const watchdog = createAcpTurnWatchdog(
|
|
150
|
+
acpClient,
|
|
151
|
+
options.requestTimeoutMs ?? 600_000,
|
|
152
|
+
options.inactivityTimeoutMs ?? DefaultAcpInactivityTimeoutMs
|
|
153
|
+
);
|
|
154
|
+
markPromptActivity = () => {
|
|
155
|
+
watchdog.markActivity();
|
|
156
|
+
};
|
|
157
|
+
activePrompt = runAcpPhase(options.backend, "prompt execution", () => Promise.race([
|
|
158
|
+
acpClient.request<AcpPromptResponse>("session/prompt", {
|
|
159
|
+
sessionId: activeSessionId,
|
|
160
|
+
prompt: [{ type: "text", text: composeAcpPrompt(request.prompt, options.config) }]
|
|
161
|
+
}),
|
|
162
|
+
watchdog.done
|
|
163
|
+
]));
|
|
164
|
+
let promptResult: AcpPromptResponse;
|
|
165
|
+
try {
|
|
166
|
+
promptResult = await activePrompt;
|
|
167
|
+
} finally {
|
|
168
|
+
watchdog.stop();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (conversation.signal.aborted) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await runAcpPhase(options.backend, "shutdown", () =>
|
|
175
|
+
shutdownAcpClient(acpClient, updates, done, options.shutdownTimeoutMs)
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (promptResult.stopReason !== undefined && promptResult.stopReason !== "end_turn") {
|
|
179
|
+
conversation.fail(
|
|
180
|
+
backendFailed(
|
|
181
|
+
options.backend,
|
|
182
|
+
`${backendLabel(options.backend)} ACP prompt stopped with ${promptResult.stopReason}`
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await conversation.emit({ type: "assistant_turn_end" });
|
|
189
|
+
|
|
190
|
+
const structured = parseAcpStructuredOutput(options.config?.schema ?? request.schema, output);
|
|
191
|
+
if (structured.type === "failed") {
|
|
192
|
+
conversation.fail(structured.error);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const usage = normalizeAcpUsage(promptResult.usage);
|
|
197
|
+
conversation.succeed({
|
|
198
|
+
backend: options.backend,
|
|
199
|
+
sessionId: sessionId(options.backend, activeSessionId),
|
|
200
|
+
output,
|
|
201
|
+
...(structured.value === undefined ? {} : { structured: structured.value }),
|
|
202
|
+
...(usage === undefined ? {} : { usage })
|
|
203
|
+
});
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!conversation.signal.aborted) {
|
|
206
|
+
conversation.fail(backendFailed(options.backend, errorMessage(error)));
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
client?.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function composeAcpPrompt(prompt: string, config: SharedBackendConfig<unknown> | undefined): string {
|
|
214
|
+
const composed = composeBackendPrompt(prompt, config ?? {});
|
|
215
|
+
if (config?.schema === undefined) {
|
|
216
|
+
return composed;
|
|
217
|
+
}
|
|
218
|
+
return [
|
|
219
|
+
composed,
|
|
220
|
+
"Return only JSON matching this JSON Schema:",
|
|
221
|
+
JSON.stringify(jsonSchemaFromZod(config.schema))
|
|
222
|
+
].join("\n\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function configureAcpMode(
|
|
226
|
+
client: AcpClient,
|
|
227
|
+
sessionIdValue: string,
|
|
228
|
+
session: AcpSessionResponse,
|
|
229
|
+
config: SharedBackendConfig<unknown> | undefined
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
const targetMode = selectAcpMode(session, config?.readOnly === true);
|
|
232
|
+
if (targetMode === undefined) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
await client.request("session/set_config_option", {
|
|
236
|
+
sessionId: sessionIdValue,
|
|
237
|
+
configId: "mode",
|
|
238
|
+
value: targetMode
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function selectAcpMode(session: AcpSessionResponse, readOnly: boolean): string | undefined {
|
|
243
|
+
const advertisedModes = acpModeIds(session);
|
|
244
|
+
const preferences = readOnly
|
|
245
|
+
? ["plan", "read-only", "readOnly"]
|
|
246
|
+
: ["bypassPermissions", "dontAsk", "agent", "default"];
|
|
247
|
+
return preferences.find((mode) => advertisedModes.has(mode));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function acpModeIds(session: AcpSessionResponse): Set<string> {
|
|
251
|
+
const ids = new Set<string>();
|
|
252
|
+
if (Array.isArray(session.configOptions)) {
|
|
253
|
+
const modeOption = session.configOptions.find(
|
|
254
|
+
(option): option is Record<string, unknown> => isObject(option) && option.id === "mode"
|
|
255
|
+
);
|
|
256
|
+
const options = isObject(modeOption) ? modeOption.options : undefined;
|
|
257
|
+
if (Array.isArray(options)) {
|
|
258
|
+
for (const option of options) {
|
|
259
|
+
if (isObject(option) && typeof option.value === "string") {
|
|
260
|
+
ids.add(option.value);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (isObject(session.modes) && Array.isArray(session.modes.availableModes)) {
|
|
266
|
+
for (const mode of session.modes.availableModes) {
|
|
267
|
+
if (isObject(mode) && typeof mode.id === "string") {
|
|
268
|
+
ids.add(mode.id);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return ids;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function consumeAcpMessages<B extends "claude" | "codex">(
|
|
276
|
+
client: AcpClient,
|
|
277
|
+
conversation: StreamConversation<B>,
|
|
278
|
+
appendOutput: (text: string) => void,
|
|
279
|
+
onActivity: () => void
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
for await (const message of client.messages()) {
|
|
282
|
+
if (!("method" in message) || message.method !== "session/update") {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
onActivity();
|
|
286
|
+
const update = readUpdate(message.params);
|
|
287
|
+
if (update === undefined) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (update.sessionUpdate === "agent_message_chunk") {
|
|
291
|
+
const text = textFromContent(update.content);
|
|
292
|
+
if (text.length > 0) {
|
|
293
|
+
appendOutput(text);
|
|
294
|
+
await emitIfOpen(conversation, { type: "assistant_text_delta", text });
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (update.sessionUpdate === "tool_call") {
|
|
299
|
+
await emitIfOpen(conversation, {
|
|
300
|
+
type: "assistant_tool_call",
|
|
301
|
+
id: stringField(update, "toolCallId"),
|
|
302
|
+
name: stringField(update, "title") || stringField(update, "kind") || "tool_call",
|
|
303
|
+
input: update.rawInput ?? {}
|
|
304
|
+
});
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (update.sessionUpdate === "tool_call_update" && update.status === "completed") {
|
|
308
|
+
await emitIfOpen(conversation, {
|
|
309
|
+
type: "tool_result",
|
|
310
|
+
toolCallId: stringField(update, "toolCallId"),
|
|
311
|
+
output: update.content ?? update.rawOutput ?? ""
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function emitIfOpen<B extends "claude" | "codex">(
|
|
318
|
+
conversation: StreamConversation<B>,
|
|
319
|
+
event: Parameters<StreamConversation<B>["emit"]>[0]
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
try {
|
|
322
|
+
await conversation.emit(event);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (errorMessage(error) !== "Queue is closed") {
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
interface AcpTurnWatchdog {
|
|
331
|
+
readonly done: Promise<never>;
|
|
332
|
+
markActivity(): void;
|
|
333
|
+
stop(): void;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function createAcpTurnWatchdog(
|
|
337
|
+
client: AcpClient,
|
|
338
|
+
wallClockTimeoutMs: number,
|
|
339
|
+
inactivityTimeoutMs: number | undefined
|
|
340
|
+
): AcpTurnWatchdog {
|
|
341
|
+
let wallClockTimer: ReturnType<typeof setTimeout> | undefined;
|
|
342
|
+
let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
|
|
343
|
+
let rejectWatchdog: (error: Error) => void = () => {};
|
|
344
|
+
|
|
345
|
+
const done = new Promise<never>((_resolve, reject) => {
|
|
346
|
+
rejectWatchdog = reject;
|
|
347
|
+
wallClockTimer = setTimeout(() => {
|
|
348
|
+
reject(new Error(`turn exceeded ${String(wallClockTimeoutMs)}ms wall-clock limit`));
|
|
349
|
+
client.close("SIGKILL", true);
|
|
350
|
+
}, wallClockTimeoutMs);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const resetInactivity = (): void => {
|
|
354
|
+
if (inactivityTimeoutMs === undefined) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (inactivityTimer !== undefined) {
|
|
358
|
+
clearTimeout(inactivityTimer);
|
|
359
|
+
}
|
|
360
|
+
inactivityTimer = setTimeout(() => {
|
|
361
|
+
rejectWatchdog(new Error(`emitted no session updates for ${String(inactivityTimeoutMs)}ms`));
|
|
362
|
+
client.close("SIGKILL", true);
|
|
363
|
+
}, inactivityTimeoutMs);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
resetInactivity();
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
done,
|
|
370
|
+
markActivity: resetInactivity,
|
|
371
|
+
stop() {
|
|
372
|
+
if (wallClockTimer !== undefined) {
|
|
373
|
+
clearTimeout(wallClockTimer);
|
|
374
|
+
}
|
|
375
|
+
if (inactivityTimer !== undefined) {
|
|
376
|
+
clearTimeout(inactivityTimer);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function shutdownAcpClient(
|
|
383
|
+
client: AcpClient,
|
|
384
|
+
updates: Promise<void>,
|
|
385
|
+
done: Promise<{ readonly type: "success" } | { readonly type: "failed"; readonly error: unknown }>,
|
|
386
|
+
timeoutMs = 2_000
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
client.close("SIGTERM");
|
|
389
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
390
|
+
const settled = Promise.all([updates, done]).then(([, doneResult]) => {
|
|
391
|
+
if (doneResult.type === "failed") {
|
|
392
|
+
throw doneResult.error;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
const timeoutReached = new Promise<never>((_resolve, reject) => {
|
|
396
|
+
timeout = setTimeout(() => {
|
|
397
|
+
client.close("SIGKILL", true);
|
|
398
|
+
reject(new Error(`ACP shutdown timed out after ${String(timeoutMs)}ms`));
|
|
399
|
+
}, timeoutMs);
|
|
400
|
+
});
|
|
401
|
+
try {
|
|
402
|
+
await Promise.race([settled, timeoutReached]);
|
|
403
|
+
} finally {
|
|
404
|
+
if (timeout !== undefined) {
|
|
405
|
+
clearTimeout(timeout);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
await settled;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function cancelAcpTurn(
|
|
412
|
+
client: AcpClient,
|
|
413
|
+
sessionIdValue: string,
|
|
414
|
+
activePrompt: Promise<unknown> | undefined,
|
|
415
|
+
timeoutMs = 2_000
|
|
416
|
+
): Promise<void> {
|
|
417
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
418
|
+
try {
|
|
419
|
+
if (sessionIdValue.length > 0) {
|
|
420
|
+
client.notify("session/cancel", { sessionId: sessionIdValue });
|
|
421
|
+
}
|
|
422
|
+
const timeout = new Promise<void>((resolve) => {
|
|
423
|
+
timer = setTimeout(() => {
|
|
424
|
+
client.close("SIGKILL", true);
|
|
425
|
+
resolve();
|
|
426
|
+
}, timeoutMs);
|
|
427
|
+
});
|
|
428
|
+
await Promise.race([activePrompt?.catch(() => undefined) ?? Promise.resolve(), timeout]);
|
|
429
|
+
} finally {
|
|
430
|
+
if (timer !== undefined) {
|
|
431
|
+
clearTimeout(timer);
|
|
432
|
+
}
|
|
433
|
+
client.close("SIGTERM");
|
|
434
|
+
await waitForAcpExit(client, timeoutMs);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function waitForAcpExit(client: AcpClient, timeoutMs: number): Promise<void> {
|
|
439
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
440
|
+
const forceClose = new Promise<void>((resolve) => {
|
|
441
|
+
timer = setTimeout(() => {
|
|
442
|
+
client.close("SIGKILL", true);
|
|
443
|
+
resolve();
|
|
444
|
+
}, timeoutMs);
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
await Promise.race([client.done.catch(() => undefined), forceClose]);
|
|
448
|
+
} finally {
|
|
449
|
+
if (timer !== undefined) {
|
|
450
|
+
clearTimeout(timer);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function handleClientRequest(
|
|
456
|
+
message: AcpRequestMessage,
|
|
457
|
+
cwd: string,
|
|
458
|
+
readOnly: boolean
|
|
459
|
+
): Promise<unknown> {
|
|
460
|
+
if (message.method === "fs/read_text_file") {
|
|
461
|
+
const pathOrUri = pathOrUriFromParams(message.params);
|
|
462
|
+
if (pathOrUri === undefined) {
|
|
463
|
+
throw new Error("missing path");
|
|
464
|
+
}
|
|
465
|
+
const path = await resolveContainedPath(cwd, pathOrUri, "read");
|
|
466
|
+
return { content: await readFile(path, "utf8") };
|
|
467
|
+
}
|
|
468
|
+
if (message.method === "fs/write_text_file") {
|
|
469
|
+
if (readOnly) {
|
|
470
|
+
throw new Error("write request rejected in read-only mode");
|
|
471
|
+
}
|
|
472
|
+
const pathOrUri = pathOrUriFromParams(message.params);
|
|
473
|
+
const content = isObject(message.params) ? message.params.content : undefined;
|
|
474
|
+
if (pathOrUri === undefined || typeof content !== "string") {
|
|
475
|
+
throw new Error("missing write payload");
|
|
476
|
+
}
|
|
477
|
+
const path = await resolveContainedPath(cwd, pathOrUri, "write");
|
|
478
|
+
await writeFile(path, content);
|
|
479
|
+
return {};
|
|
480
|
+
}
|
|
481
|
+
if (message.method === "session/request_permission") {
|
|
482
|
+
return { outcome: { outcome: "cancelled" } };
|
|
483
|
+
}
|
|
484
|
+
throw new Error(`unsupported ACP client request ${message.method}`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function pathOrUriFromParams(params: unknown): string | undefined {
|
|
488
|
+
if (!isObject(params)) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
if (typeof params.path === "string") {
|
|
492
|
+
return params.path;
|
|
493
|
+
}
|
|
494
|
+
return typeof params.uri === "string" ? params.uri : undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async function resolveContainedPath(cwd: string, pathOrUri: string, mode: "read" | "write"): Promise<string> {
|
|
498
|
+
const root = await realpath(cwd);
|
|
499
|
+
const path = pathOrUri.startsWith("file://")
|
|
500
|
+
? resolve(fileURLToPath(pathOrUri))
|
|
501
|
+
: resolve(cwd, pathOrUri);
|
|
502
|
+
const target = mode === "read" ? await realpath(path) : await resolveWritablePath(path);
|
|
503
|
+
const pathFromRoot = relative(root, target);
|
|
504
|
+
if (pathFromRoot === ".." || pathFromRoot.startsWith("../") || isAbsolute(pathFromRoot)) {
|
|
505
|
+
throw new Error("path outside backend cwd");
|
|
506
|
+
}
|
|
507
|
+
return target;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function resolveWritablePath(path: string): Promise<string> {
|
|
511
|
+
try {
|
|
512
|
+
return await realpath(path);
|
|
513
|
+
} catch {
|
|
514
|
+
return resolve(await realpath(dirname(path)), basename(path));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function readUpdate(params: unknown): Record<string, unknown> | undefined {
|
|
519
|
+
if (!isObject(params) || !isObject(params.update)) {
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
return params.update;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function textFromContent(content: unknown): string {
|
|
526
|
+
if (isObject(content) && content.type === "text" && typeof content.text === "string") {
|
|
527
|
+
return content.text;
|
|
528
|
+
}
|
|
529
|
+
return "";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function stringField(source: Record<string, unknown>, key: string): string {
|
|
533
|
+
const value = source[key];
|
|
534
|
+
return typeof value === "string" ? value : "";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function parseAcpStructuredOutput<Output>(
|
|
538
|
+
schema: z.ZodType<Output> | undefined,
|
|
539
|
+
output: string
|
|
540
|
+
):
|
|
541
|
+
| { readonly type: "success"; readonly value?: Output }
|
|
542
|
+
| { readonly type: "failed"; readonly error: ReturnType<typeof structuredOutputValidationFailed> } {
|
|
543
|
+
if (!schema) {
|
|
544
|
+
return { type: "success" };
|
|
545
|
+
}
|
|
546
|
+
let raw: unknown;
|
|
547
|
+
try {
|
|
548
|
+
raw = JSON.parse(jsonCandidate(output)) as unknown;
|
|
549
|
+
} catch {
|
|
550
|
+
return {
|
|
551
|
+
type: "failed",
|
|
552
|
+
error: structuredOutputValidationFailed({
|
|
553
|
+
raw: output,
|
|
554
|
+
issues: ["ACP structured output was not valid JSON"]
|
|
555
|
+
})
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const parsed = parseStructuredOutput(schema, raw);
|
|
559
|
+
if (parsed.isErr()) {
|
|
560
|
+
return { type: "failed", error: parsed.error };
|
|
561
|
+
}
|
|
562
|
+
return { type: "success", value: parsed.value.value };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function normalizeAcpUsage(usage: unknown): Usage | undefined {
|
|
566
|
+
if (!isObject(usage)) {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
const input = numberField(usage, "inputTokens") ?? numberField(usage, "input_tokens");
|
|
570
|
+
const cached =
|
|
571
|
+
numberField(usage, "cachedInputTokens") ??
|
|
572
|
+
numberField(usage, "cacheCreationInputTokens") ??
|
|
573
|
+
numberField(usage, "cacheReadInputTokens") ??
|
|
574
|
+
numberField(usage, "cached_input_tokens") ??
|
|
575
|
+
0;
|
|
576
|
+
const output = numberField(usage, "outputTokens") ?? numberField(usage, "output_tokens");
|
|
577
|
+
const total = numberField(usage, "totalTokens") ?? numberField(usage, "total_tokens");
|
|
578
|
+
if ((input === undefined && total === undefined) || output === undefined) {
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
const normalizedInput = input ?? Math.max((total ?? 0) - output, 0);
|
|
582
|
+
const reasoning =
|
|
583
|
+
numberField(usage, "reasoningOutputTokens") ??
|
|
584
|
+
numberField(usage, "reasoning_output_tokens") ??
|
|
585
|
+
numberField(usage, "thoughtTokens") ??
|
|
586
|
+
numberField(usage, "thought_tokens");
|
|
587
|
+
return {
|
|
588
|
+
input: normalizedInput + cached,
|
|
589
|
+
output,
|
|
590
|
+
...(reasoning === undefined ? {} : { reasoning })
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function jsonCandidate(output: string): string {
|
|
595
|
+
const trimmed = output.trim();
|
|
596
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
597
|
+
return trimmed;
|
|
598
|
+
}
|
|
599
|
+
const objectStart = trimmed.indexOf("{");
|
|
600
|
+
const arrayStart = trimmed.indexOf("[");
|
|
601
|
+
const startCandidates = [objectStart, arrayStart].filter((index) => index >= 0);
|
|
602
|
+
if (startCandidates.length === 0) {
|
|
603
|
+
return trimmed;
|
|
604
|
+
}
|
|
605
|
+
const start = Math.min(...startCandidates);
|
|
606
|
+
return trimmed.slice(start);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function numberField(source: Record<string, unknown>, key: string): number | undefined {
|
|
610
|
+
const value = source[key];
|
|
611
|
+
return typeof value === "number" ? value : undefined;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function runAcpPhase<T>(
|
|
615
|
+
backend: "claude" | "codex",
|
|
616
|
+
phase: string,
|
|
617
|
+
operation: () => Promise<T>
|
|
618
|
+
): Promise<T> {
|
|
619
|
+
try {
|
|
620
|
+
return await operation();
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw new Error(`${backendLabel(backend)} ACP ${phase} failed: ${errorMessage(error)}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function backendLabel(backend: "claude" | "codex"): string {
|
|
627
|
+
return backend === "claude" ? "Claude" : "Codex";
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
631
|
+
return typeof value === "object" && value !== null;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function errorMessage(error: unknown): string {
|
|
635
|
+
return error instanceof Error ? error.message : String(error);
|
|
636
|
+
}
|