@a-t-h-i/bot-lobby 0.6.55 → 0.6.57

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 (41) hide show
  1. package/THIRD_PARTY_NOTICES.md +12 -1
  2. package/package.json +4 -1
  3. package/src/execution/agent-runner.ts +7 -0
  4. package/src/execution/delegation.ts +3 -1
  5. package/src/execution/pi-runner.ts +14 -6
  6. package/src/harness/acp.ts +272 -0
  7. package/src/harness/catalog.ts +257 -0
  8. package/src/harness/claude-sdk.ts +169 -0
  9. package/src/harness/claude.ts +188 -0
  10. package/src/harness/cli-args.ts +62 -0
  11. package/src/harness/codex.ts +155 -0
  12. package/src/harness/instructions.ts +45 -0
  13. package/src/harness/model-manifest.json +829 -0
  14. package/src/harness/process.ts +175 -0
  15. package/src/harness/runner.ts +188 -0
  16. package/src/harness/status.ts +115 -0
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/planner.ts +3 -2
  19. package/src/lobby/pr-review.ts +2 -1
  20. package/src/lobby/quickfix.ts +6 -2
  21. package/src/master/goal-check.ts +1 -0
  22. package/src/master/master.ts +3 -3
  23. package/src/master/research.ts +2 -1
  24. package/src/pi/auto-compaction.ts +23 -5
  25. package/src/pi/delegation.ts +5 -3
  26. package/src/pi/grants.ts +10 -0
  27. package/src/pi/model-support.ts +6 -2
  28. package/src/pi/quiet.ts +2 -0
  29. package/src/schemas/configuration.ts +67 -23
  30. package/src/schemas/harness.ts +122 -0
  31. package/src/webui/api/index.ts +2 -1
  32. package/src/webui/api/settings.ts +7 -1
  33. package/src/webui/dev/fake-service.ts +13 -0
  34. package/src/webui/protocol.ts +9 -0
  35. package/webui/dist/assets/{TerminalView-CIfjkb5H.js → TerminalView-C8VC7KXy.js} +1 -1
  36. package/webui/dist/assets/index-Bs1_77_1.js +114 -0
  37. package/webui/dist/assets/index-DOLQ4FDd.css +2 -0
  38. package/webui/dist/build.json +16 -12
  39. package/webui/dist/index.html +2 -2
  40. package/webui/dist/assets/index-BkXhtB6A.css +0 -2
  41. package/webui/dist/assets/index-DIM2-FUQ.js +0 -114
@@ -0,0 +1,257 @@
1
+ /**
2
+ * What Bot Lobby knows about each third-party harness: how to start it, which
3
+ * models it serves and which effort levels each model takes.
4
+ *
5
+ * The model list is T3 Code's model manifest
6
+ * (`apps/server/src/provider/model-manifest.json`), copied as is into
7
+ * `model-manifest.json` next to this file; the effort descriptors and CLI
8
+ * effort maps are read the way T3 Code's `ClaudeModelCatalog.ts` reads them.
9
+ * The Antigravity launch details follow T3 Code's
10
+ * `antigravityAuthSupport.ts` (`buildAntigravityAcpSpawnInput`).
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { THINKING_LEVELS, type ThinkingLevelName } from "../schemas/configuration.ts";
14
+ import type { HarnessId, HarnessProtocol, HarnessSettings } from "../schemas/harness.ts";
15
+ import { tokenizeCliArgs } from "./cli-args.ts";
16
+
17
+ export interface HarnessInfo {
18
+ id: HarnessId;
19
+ label: string;
20
+ protocol: HarnessProtocol;
21
+ /** The command when the settings name no binary; empty when the user must give one. */
22
+ command: string;
23
+ /** Arguments that put the command into the mode Bot Lobby speaks (ACP), before the user's launch arguments. */
24
+ args: readonly string[];
25
+ /** The environment variable the home path setting sets. */
26
+ homeEnv?: string;
27
+ /** Model providers the settings offer (`value` "" keeps the harness's own). */
28
+ providers: ReadonlyArray<{ value: string; label: string }>;
29
+ /** Where the effort level goes: a CLI flag, the model variant (Antigravity) or the agent's own session option (ACP). */
30
+ effort: "flag" | "variant" | "session";
31
+ /** One line for the settings page. */
32
+ blurb: string;
33
+ }
34
+
35
+ const WINDOWS = process.platform === "win32";
36
+
37
+ export const HARNESSES: Record<HarnessId, HarnessInfo> = {
38
+ codex: {
39
+ id: "codex",
40
+ label: "Codex CLI",
41
+ protocol: "codex-exec",
42
+ command: "codex",
43
+ args: [],
44
+ homeEnv: "CODEX_HOME",
45
+ providers: [
46
+ { value: "", label: "Codex's own" },
47
+ { value: "openai", label: "OpenAI" },
48
+ { value: "ollama", label: "Ollama (local)" },
49
+ { value: "lmstudio", label: "LM Studio (local)" },
50
+ ],
51
+ effort: "flag",
52
+ blurb: "OpenAI's coding agent, run headless with codex exec.",
53
+ },
54
+ claude: {
55
+ id: "claude",
56
+ label: "Claude Code",
57
+ protocol: "claude-stream",
58
+ command: "claude",
59
+ args: [],
60
+ homeEnv: "CLAUDE_CONFIG_DIR",
61
+ providers: [
62
+ { value: "", label: "Anthropic" },
63
+ { value: "bedrock", label: "Amazon Bedrock" },
64
+ { value: "vertex", label: "Google Vertex AI" },
65
+ ],
66
+ effort: "flag",
67
+ blurb: "Anthropic's coding agent, driven through the Claude Agent SDK.",
68
+ },
69
+ antigravity: {
70
+ id: "antigravity",
71
+ label: "Antigravity",
72
+ protocol: "acp",
73
+ command: WINDOWS ? "agy_acp_server.exe" : "agy_acp_server.par",
74
+ args: process.platform === "linux" ? ["--uid="] : [],
75
+ providers: [],
76
+ effort: "variant",
77
+ blurb: "Google's Antigravity agent through its ACP server, signed in with your Antigravity account.",
78
+ },
79
+ gemini: {
80
+ id: "gemini",
81
+ label: "Gemini CLI",
82
+ protocol: "acp",
83
+ command: "gemini",
84
+ args: ["--acp"],
85
+ providers: [],
86
+ effort: "session",
87
+ blurb: "Google's Gemini CLI in ACP mode.",
88
+ },
89
+ opencode: {
90
+ id: "opencode",
91
+ label: "OpenCode",
92
+ protocol: "acp",
93
+ command: "opencode",
94
+ args: ["acp"],
95
+ providers: [],
96
+ effort: "session",
97
+ blurb: "OpenCode in ACP mode; models are named provider/model.",
98
+ },
99
+ acp: {
100
+ id: "acp",
101
+ label: "Custom ACP agent",
102
+ protocol: "acp",
103
+ command: "",
104
+ args: [],
105
+ providers: [],
106
+ effort: "session",
107
+ blurb: "Any agent that speaks the Agent Client Protocol: give its command and arguments.",
108
+ },
109
+ };
110
+
111
+ /** The command and arguments one run starts, with the user's launch arguments last. */
112
+ export function harnessCommand(id: HarnessId, settings: HarnessSettings): { command: string; args: string[] } {
113
+ const info = HARNESSES[id];
114
+ return { command: settings.binaryPath || info.command, args: [...info.args, ...tokenizeCliArgs(settings.launchArgs)] };
115
+ }
116
+
117
+ export interface HarnessModel {
118
+ id: string;
119
+ label: string;
120
+ /** False for the manifest's older models, listed after the current ones. */
121
+ current: boolean;
122
+ /** Effort levels (pi's names) the model takes; empty when the harness picks its own. */
123
+ efforts: ThinkingLevelName[];
124
+ /** The level the harness uses when none is chosen. */
125
+ defaultEffort?: ThinkingLevelName;
126
+ custom?: boolean;
127
+ }
128
+
129
+ interface ManifestModel {
130
+ slug: string;
131
+ name: string;
132
+ status?: string;
133
+ profile?: string;
134
+ }
135
+
136
+ interface ManifestOption {
137
+ id: string;
138
+ type?: string;
139
+ options?: Array<{ id: string; isDefault?: boolean }>;
140
+ }
141
+
142
+ interface ManifestProfile {
143
+ capabilities?: { optionDescriptors?: ManifestOption[] };
144
+ adapter?: { claudeCode?: { effortMap?: Record<string, string | null> } };
145
+ }
146
+
147
+ interface Manifest {
148
+ providers: Record<string, { models: ManifestModel[]; profiles?: Record<string, ManifestProfile> }>;
149
+ }
150
+
151
+ let manifest: Manifest | undefined;
152
+
153
+ function readManifest(): Manifest {
154
+ manifest ??= JSON.parse(readFileSync(new URL("./model-manifest.json", import.meta.url), "utf8")) as Manifest;
155
+ return manifest;
156
+ }
157
+
158
+ const MANIFEST_KEYS: Partial<Record<HarnessId, string>> = { codex: "codex", claude: "claudeAgent", antigravity: "antigravity" };
159
+
160
+ /** Codex's reasoning efforts, as T3 Code's `CodexProvider.ts` labels them. */
161
+ const CODEX_EFFORTS: ThinkingLevelName[] = ["minimal", "low", "medium", "high", "xhigh"];
162
+
163
+ const isLevel = (value: string): value is ThinkingLevelName => (THINKING_LEVELS as readonly string[]).includes(value);
164
+
165
+ function claudeEfforts(profile: ManifestProfile | undefined): { efforts: ThinkingLevelName[]; defaultEffort?: ThinkingLevelName } {
166
+ const option = profile?.capabilities?.optionDescriptors?.find((entry) => entry.id === "effort");
167
+ if (!option?.options) return { efforts: [] };
168
+ const map = profile?.adapter?.claudeCode?.effortMap ?? {};
169
+ const efforts = option.options.map((entry) => entry.id).filter((id): id is ThinkingLevelName => isLevel(id) && map[id] !== null);
170
+ const fallback = option.options.find((entry) => entry.isDefault)?.id;
171
+ return { efforts, ...(fallback && isLevel(fallback) ? { defaultEffort: fallback } : {}) };
172
+ }
173
+
174
+ /** Antigravity sells one model per effort (`…-flash-high`); the settings show one model with an effort slider. */
175
+ const VARIANT = /^(.*)-(low|medium|high)$/;
176
+ const VARIANT_LABEL = / \((Low|Medium|High)\)$/;
177
+
178
+ function antigravityModels(models: ManifestModel[]): HarnessModel[] {
179
+ const families = new Map<string, HarnessModel>();
180
+ const out: HarnessModel[] = [];
181
+ for (const model of models) {
182
+ const match = VARIANT.exec(model.slug);
183
+ if (!match) {
184
+ out.push({ id: model.slug, label: model.name, current: model.status === "current", efforts: [] });
185
+ continue;
186
+ }
187
+ const [, base, level] = match as unknown as [string, string, ThinkingLevelName];
188
+ let family = families.get(base);
189
+ if (!family) {
190
+ family = { id: base, label: model.name.replace(VARIANT_LABEL, ""), current: model.status === "current", efforts: [], defaultEffort: "high" };
191
+ families.set(base, family);
192
+ out.push(family);
193
+ }
194
+ family.efforts.push(level);
195
+ }
196
+ for (const family of families.values()) family.efforts.sort((a, b) => THINKING_LEVELS.indexOf(a) - THINKING_LEVELS.indexOf(b));
197
+ return out;
198
+ }
199
+
200
+ /** The models a harness offers: the manifest's current ones, its older ones, then the ones typed into settings. */
201
+ export function harnessModels(id: HarnessId, settings: HarnessSettings): HarnessModel[] {
202
+ const key = MANIFEST_KEYS[id];
203
+ const entry = key ? readManifest().providers[key] : undefined;
204
+ let models: HarnessModel[] = [];
205
+ if (entry && id === "antigravity") models = antigravityModels(entry.models);
206
+ else if (entry) {
207
+ models = entry.models.map((model) => {
208
+ const efforts = id === "codex" ? { efforts: CODEX_EFFORTS, defaultEffort: "medium" as const } : claudeEfforts(model.profile ? entry.profiles?.[model.profile] : undefined);
209
+ return { id: model.slug, label: model.name, current: model.status === "current", ...efforts };
210
+ });
211
+ }
212
+ models.sort((a, b) => Number(b.current) - Number(a.current));
213
+ const known = new Set(models.map((model) => model.id));
214
+ const fallbackEfforts = id === "codex" ? CODEX_EFFORTS : id === "claude" ? (["low", "medium", "high", "xhigh", "max"] as ThinkingLevelName[]) : [];
215
+ for (const custom of settings.customModels) {
216
+ if (!known.has(custom)) models.push({ id: custom, label: custom, current: true, efforts: fallbackEfforts, custom: true });
217
+ }
218
+ return models;
219
+ }
220
+
221
+ /** The nearest level at or below `level` that `efforts` holds, else the lowest one above it. */
222
+ export function nearestEffort(efforts: readonly ThinkingLevelName[], level: string): ThinkingLevelName | undefined {
223
+ if (efforts.length === 0) return undefined;
224
+ const at = THINKING_LEVELS.indexOf(isLevel(level) ? level : "medium");
225
+ const below = efforts.filter((effort) => THINKING_LEVELS.indexOf(effort) <= at);
226
+ return below.length > 0 ? below[below.length - 1] : efforts[0];
227
+ }
228
+
229
+ /**
230
+ * What the harness is told for an agent's effort: Codex's `model_reasoning_effort`,
231
+ * Claude Code's `--effort` (through the model's effort map) or Antigravity's
232
+ * model variant. Undefined leaves it to the harness (and to ACP session options).
233
+ */
234
+ export function harnessEffort(id: HarnessId, settings: HarnessSettings, model: string, level: string): string | undefined {
235
+ const info = HARNESSES[id];
236
+ if (info.effort === "session") return undefined;
237
+ const known = harnessModels(id, settings).find((entry) => entry.id === model);
238
+ if (id === "codex") return nearestEffort(known?.efforts.length ? known.efforts : CODEX_EFFORTS, level);
239
+ if (id === "claude") {
240
+ const efforts = known?.efforts.length ? known.efforts : (["low", "medium", "high", "xhigh", "max"] as ThinkingLevelName[]);
241
+ const chosen = nearestEffort(efforts, level);
242
+ if (!chosen) return undefined;
243
+ const key = MANIFEST_KEYS.claude!;
244
+ const manifestModel = readManifest().providers[key]?.models.find((entry) => entry.slug === model);
245
+ const profile = manifestModel?.profile ? readManifest().providers[key]?.profiles?.[manifestModel.profile] : undefined;
246
+ const mapped = profile?.adapter?.claudeCode?.effortMap?.[chosen];
247
+ return typeof mapped === "string" ? mapped : chosen;
248
+ }
249
+ return known?.efforts.length ? nearestEffort(known.efforts, level) : undefined;
250
+ }
251
+
252
+ /** The model id a run passes to the harness: Antigravity's variant slug carries the effort. */
253
+ export function harnessModelId(id: HarnessId, settings: HarnessSettings, model: string, level: string): string {
254
+ if (!model || HARNESSES[id].effort !== "variant") return model;
255
+ const effort = harnessEffort(id, settings, model, level);
256
+ return effort ? `${model}-${effort}` : model;
257
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Claude Code through the Claude Agent SDK, Anthropic's supported way for an
3
+ * app to drive it (and to run on a Claude plan's Agent SDK credit). The SDK
4
+ * starts the installed `claude` when there is one (or the one set in
5
+ * Settings > Harnesses), else its own bundled build, and hands back the same
6
+ * messages as `claude -p --output-format stream-json`, so `claudeEvents`
7
+ * maps them. When the SDK is not installed, Bot Lobby falls back to the CLI.
8
+ *
9
+ * The options follow T3 Code's Claude adapter (`apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts`):
10
+ * the `claude_code` system prompt preset with Bot Lobby's instructions
11
+ * appended, the effort, the executable path, extra arguments and the
12
+ * permission mode.
13
+ */
14
+ import type { Options, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
15
+ import type { PiStreamEvent } from "../execution/pi-runner.ts";
16
+ import { claudeCompactSettings, claudeEnv, claudeEvents, claudeTools, type ClaudeEvent } from "./claude.ts";
17
+ import type { HarnessOutcome, HarnessProcessOptions } from "./process.ts";
18
+ import type { HarnessAccess, HarnessCollector, HarnessPlan } from "./runner.ts";
19
+ import { findCommand } from "./status.ts";
20
+
21
+ type QueryFn = (params: { prompt: AsyncIterable<SDKUserMessage>; options?: Options }) => Query;
22
+
23
+ let loader: () => Promise<QueryFn | undefined> = async () => {
24
+ try {
25
+ return (await import("@anthropic-ai/claude-agent-sdk")).query as QueryFn;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ };
30
+
31
+ /** The SDK's `query`, or undefined when the package is not installed. */
32
+ export function loadClaudeSdk(): Promise<QueryFn | undefined> {
33
+ return loader();
34
+ }
35
+
36
+ /** Tests swap the SDK for a fake. */
37
+ export function setClaudeSdkLoader(next: () => Promise<QueryFn | undefined>): void {
38
+ loader = next;
39
+ }
40
+
41
+ /** Launch arguments as the SDK's `extraArgs`: `--flag value` pairs, a bare flag as null. */
42
+ export function extraArgsOf(tokens: readonly string[]): Record<string, string | null> {
43
+ const out: Record<string, string | null> = {};
44
+ for (let index = 0; index < tokens.length; index++) {
45
+ const token = tokens[index]!;
46
+ if (!token.startsWith("-")) continue;
47
+ const [name, inline] = token.replace(/^-+/, "").split(/=(.*)/s, 2) as [string, string | undefined];
48
+ const next = tokens[index + 1];
49
+ if (inline !== undefined) out[name] = inline;
50
+ else if (next !== undefined && !next.startsWith("-")) {
51
+ out[name] = next;
52
+ index++;
53
+ } else out[name] = null;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function claudeSdkOptions(plan: HarnessPlan, access: HarnessAccess, input: { cwd: string; env: Record<string, string | undefined>; launchArgs: string[]; abort: AbortController; stderr: (data: string) => void }): Options {
59
+ const { settings } = plan.harness;
60
+ const executable = settings.binaryPath || findCommand("claude");
61
+ const options: Options = {
62
+ cwd: input.cwd,
63
+ abortController: input.abort,
64
+ persistSession: false,
65
+ env: { ...input.env, ...claudeEnv(plan), CLAUDE_AGENT_SDK_CLIENT_APP: "bot-lobby" },
66
+ stderr: input.stderr,
67
+ ...(executable ? { pathToClaudeCodeExecutable: executable } : {}),
68
+ ...(plan.model ? { model: plan.model } : {}),
69
+ ...(plan.effort ? { effort: plan.effort as NonNullable<Options["effort"]> } : {}),
70
+ ...(plan.instructions ? { systemPrompt: { type: "preset", preset: "claude_code", append: plan.instructions } } : {}),
71
+ ...(input.launchArgs.length > 0 ? { extraArgs: extraArgsOf(input.launchArgs) } : {}),
72
+ };
73
+ const compact = claudeCompactSettings(plan);
74
+ if (compact) options.settings = compact as Options["settings"];
75
+ if (access.writes) {
76
+ options.permissionMode = "bypassPermissions";
77
+ options.allowDangerouslySkipPermissions = true;
78
+ } else {
79
+ const tools = claudeTools(access.tools ?? []);
80
+ options.tools = tools;
81
+ options.allowedTools = tools;
82
+ options.permissionMode = "dontAsk";
83
+ }
84
+ return options;
85
+ }
86
+
87
+ /** The user's messages as the SDK takes them: the prompt, then any steer, until the turn's result. */
88
+ function inputQueue(): { stream: AsyncIterable<SDKUserMessage>; push(text: string): void; end(): void } {
89
+ const waiting: Array<(result: IteratorResult<SDKUserMessage>) => void> = [];
90
+ const queued: SDKUserMessage[] = [];
91
+ let ended = false;
92
+ const message = (text: string): SDKUserMessage => ({ type: "user", message: { role: "user", content: [{ type: "text", text }] }, parent_tool_use_id: null });
93
+ return {
94
+ stream: {
95
+ [Symbol.asyncIterator]: () => ({
96
+ next: () => {
97
+ const item = queued.shift();
98
+ if (item) return Promise.resolve({ value: item, done: false });
99
+ if (ended) return Promise.resolve({ value: undefined, done: true });
100
+ return new Promise((resolve) => waiting.push(resolve));
101
+ },
102
+ }),
103
+ },
104
+ push(text) {
105
+ if (ended) return;
106
+ const item = message(text);
107
+ const resolve = waiting.shift();
108
+ if (resolve) resolve({ value: item, done: false });
109
+ else queued.push(item);
110
+ },
111
+ end() {
112
+ ended = true;
113
+ for (const resolve of waiting.splice(0)) resolve({ value: undefined, done: true });
114
+ },
115
+ };
116
+ }
117
+
118
+ const MAX_STDERR = 64_000;
119
+
120
+ /** One turn on Claude Code through the SDK, watched like a harness process (stall, cancel, steer). */
121
+ export async function runClaudeSdk(query: QueryFn, plan: HarnessPlan, access: HarnessAccess, input: { cwd: string; env: Record<string, string | undefined>; launchArgs: string[]; prompt: string }, collected: HarnessCollector, options: HarnessProcessOptions): Promise<HarnessOutcome> {
122
+ const abort = new AbortController();
123
+ const queue = inputQueue();
124
+ let stderr = "";
125
+ let lastOutput = Date.now();
126
+ let toolsInFlight = 0;
127
+ let killed = false;
128
+ let stalled = false;
129
+ const emit = (event: PiStreamEvent) => {
130
+ lastOutput = Date.now();
131
+ if (event.type === "tool_execution_start") toolsInFlight += 1;
132
+ if (event.type === "tool_execution_end") toolsInFlight = Math.max(0, toolsInFlight - 1);
133
+ options.onEvent?.(event);
134
+ };
135
+ const handle = claudeEvents({ emit, closeInput: () => queue.end() }, collected);
136
+ const onAbort = () => {
137
+ killed = true;
138
+ queue.end();
139
+ abort.abort();
140
+ };
141
+ const watch = setInterval(() => {
142
+ const limit = toolsInFlight > 0 ? options.toolStallTimeoutMs : options.stallTimeoutMs;
143
+ if (!killed && limit && limit > 0 && Date.now() - lastOutput >= limit) {
144
+ stalled = true;
145
+ onAbort();
146
+ }
147
+ }, 1000);
148
+ watch.unref();
149
+ if (options.signal?.aborted) onAbort();
150
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
151
+ queue.push(input.prompt);
152
+ options.onStart?.({ steer: (text) => queue.push(text) });
153
+ let exitCode = 0;
154
+ try {
155
+ const sdkOptions = claudeSdkOptions(plan, access, { ...input, abort, stderr: (data) => { lastOutput = Date.now(); if (stderr.length < MAX_STDERR) stderr += data; } });
156
+ for await (const message of query({ prompt: queue.stream, options: sdkOptions })) {
157
+ lastOutput = Date.now();
158
+ handle(message as unknown as ClaudeEvent);
159
+ }
160
+ } catch (error) {
161
+ exitCode = 1;
162
+ if (!killed && !collected.error) collected.error = (error as Error).message;
163
+ } finally {
164
+ clearInterval(watch);
165
+ queue.end();
166
+ options.signal?.removeEventListener("abort", onAbort);
167
+ }
168
+ return { exitCode, stderr, killed, stalled };
169
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Claude Code: `claude -p` with stream-json in and out, so a steering message
3
+ * can be sent while the agent works. Bot Lobby's instructions are appended to
4
+ * Claude Code's own system prompt from a file; read-only roles get only the
5
+ * matching tools and `dontAsk`, so anything else is refused.
6
+ *
7
+ * The flag set follows T3 Code's `apps/server/src/textGeneration/ClaudeTextGeneration.ts`
8
+ * (`--tools`, `--allowedTools`, `--permission-mode dontAsk`, `--effort`,
9
+ * `--settings`) and the auto-compact window of
10
+ * `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts`.
11
+ */
12
+ import type { HarnessDriver, HarnessIO } from "./process.ts";
13
+ import type { HarnessAccess, HarnessCollector, HarnessPlan } from "./runner.ts";
14
+
15
+ /** pi's tool names and Claude Code's for the same job. */
16
+ const CLAUDE_TOOLS: Record<string, string> = {
17
+ read: "Read",
18
+ grep: "Grep",
19
+ find: "Glob",
20
+ ls: "Glob",
21
+ bash: "Bash",
22
+ edit: "Edit",
23
+ write: "Write",
24
+ web_search: "WebSearch",
25
+ fetch_content: "WebFetch",
26
+ };
27
+
28
+ /** Claude Code's tool names as pi's, for the activity line and the edit log. */
29
+ const PI_NAMES: Record<string, string> = {
30
+ Read: "read",
31
+ Grep: "grep",
32
+ Glob: "find",
33
+ LS: "ls",
34
+ Bash: "bash",
35
+ Edit: "edit",
36
+ MultiEdit: "edit",
37
+ Write: "write",
38
+ NotebookEdit: "edit",
39
+ WebSearch: "web_search",
40
+ WebFetch: "fetch_content",
41
+ };
42
+
43
+ /** The Claude Code tools a read-only role may use, from its pi allowlist. */
44
+ export function claudeTools(tools: readonly string[]): string[] {
45
+ return [...new Set(tools.map((tool) => CLAUDE_TOOLS[tool]).filter((tool): tool is string => Boolean(tool)))];
46
+ }
47
+
48
+ export function claudeArgs(plan: HarnessPlan, access: HarnessAccess, launchArgs: string[], promptFile: string | undefined): { args: string[]; env: Record<string, string> } {
49
+ const args = ["-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose", "--no-session-persistence", ...launchArgs];
50
+ if (plan.model) args.push("--model", plan.model);
51
+ if (plan.effort) args.push("--effort", plan.effort);
52
+ if (promptFile) args.push("--append-system-prompt-file", promptFile);
53
+ if (access.writes) args.push("--permission-mode", "bypassPermissions");
54
+ else {
55
+ const tools = claudeTools(access.tools ?? []).join(",");
56
+ args.push("--tools", tools, "--allowedTools", tools, "--permission-mode", "dontAsk");
57
+ }
58
+ const compact = claudeCompactSettings(plan);
59
+ if (compact) args.push("--settings", JSON.stringify(compact));
60
+ return { args, env: claudeEnv(plan) };
61
+ }
62
+
63
+ /** Claude Code's compaction window, when the settings give one and the agent compacts. */
64
+ export function claudeCompactSettings(plan: HarnessPlan): { autoCompactWindow: number } | undefined {
65
+ return plan.autoCompact !== false && plan.harness.settings.compactAt > 0 ? { autoCompactWindow: plan.harness.settings.compactAt } : undefined;
66
+ }
67
+
68
+ /** The environment Claude Code runs with: compaction off and the model provider. */
69
+ export function claudeEnv(plan: HarnessPlan): Record<string, string> {
70
+ const { settings } = plan.harness;
71
+ const env: Record<string, string> = {};
72
+ if (plan.autoCompact === false) env.DISABLE_AUTO_COMPACT = "1";
73
+ if (settings.modelProvider === "bedrock") env.CLAUDE_CODE_USE_BEDROCK = "1";
74
+ if (settings.modelProvider === "vertex") env.CLAUDE_CODE_USE_VERTEX = "1";
75
+ return env;
76
+ }
77
+
78
+ interface ContentBlock {
79
+ type?: string;
80
+ text?: string;
81
+ thinking?: string;
82
+ id?: string;
83
+ name?: string;
84
+ input?: Record<string, unknown>;
85
+ tool_use_id?: string;
86
+ is_error?: boolean;
87
+ }
88
+
89
+ export interface ClaudeEvent {
90
+ type?: string;
91
+ subtype?: string;
92
+ model?: string;
93
+ message?: { id?: string; model?: string; content?: ContentBlock[] | string };
94
+ result?: string;
95
+ is_error?: boolean;
96
+ total_cost_usd?: number;
97
+ usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number };
98
+ errors?: string[];
99
+ }
100
+
101
+ function userMessage(text: string): string {
102
+ return JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } });
103
+ }
104
+
105
+ function toolArgs(input: Record<string, unknown> | undefined): Record<string, unknown> {
106
+ if (!input) return {};
107
+ const path = input.file_path ?? input.notebook_path ?? input.path;
108
+ return { ...input, ...(typeof path === "string" ? { path } : {}) };
109
+ }
110
+
111
+ /** What the Claude Code event handler needs: the stream to emit on, and how to end the turn's input. */
112
+ export interface ClaudeSink {
113
+ emit: HarnessIO["emit"];
114
+ closeInput(): void;
115
+ }
116
+
117
+ /**
118
+ * Claude Code's messages as pi's stream events. The CLI's stream-json lines
119
+ * and the Agent SDK's messages are the same objects.
120
+ */
121
+ export function claudeEvents(io: ClaudeSink, collector: HarnessCollector): (event: ClaudeEvent) => void {
122
+ const tools = new Map<string, string>();
123
+ let message: string | undefined;
124
+ return (event) => {
125
+ if (event.type === "system" && event.subtype === "init" && event.model) collector.model = event.model;
126
+ else if (event.type === "system" && event.subtype === "compact_boundary") {
127
+ io.emit({ type: "compaction" });
128
+ io.emit({ type: "compaction_end" });
129
+ } else if (event.type === "assistant" && Array.isArray(event.message?.content)) {
130
+ if (event.message.id && event.message.id !== message) {
131
+ message = event.message.id;
132
+ io.emit({ type: "turn_start" });
133
+ }
134
+ if (event.message.model) collector.model = event.message.model;
135
+ for (const block of event.message.content) {
136
+ if (block.type === "thinking" && block.thinking?.trim()) io.emit({ type: "thought", text: block.thinking.trim().slice(0, 1500) });
137
+ else if (block.type === "text" && block.text?.trim()) {
138
+ io.emit({ type: "writing" });
139
+ collector.text = block.text;
140
+ } else if (block.type === "tool_use" && block.id && block.name) {
141
+ const name = PI_NAMES[block.name] ?? block.name;
142
+ tools.set(block.id, name);
143
+ io.emit({ type: "tool_execution_start", toolName: name, args: toolArgs(block.input) });
144
+ }
145
+ }
146
+ } else if (event.type === "user" && Array.isArray(event.message?.content)) {
147
+ for (const block of event.message.content) {
148
+ if (block.type !== "tool_result" || !block.tool_use_id) continue;
149
+ const name = tools.get(block.tool_use_id);
150
+ if (!name) continue;
151
+ tools.delete(block.tool_use_id);
152
+ io.emit({ type: "tool_execution_end", toolName: name, isError: block.is_error === true });
153
+ }
154
+ } else if (event.type === "result") {
155
+ const usage = event.usage ?? {};
156
+ const input = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
157
+ const output = usage.output_tokens ?? 0;
158
+ const cost = event.total_cost_usd ?? 0;
159
+ collector.addUsage(input, output, cost, collector.model);
160
+ io.emit({ type: "usage", input, output, cost, ...(collector.model ? { model: collector.model } : {}) });
161
+ if (event.is_error || (event.subtype && event.subtype !== "success")) collector.error = event.result?.trim() || event.errors?.join("; ") || `Claude Code stopped: ${event.subtype ?? "error"}`;
162
+ else if (typeof event.result === "string" && event.result.trim()) collector.text = event.result;
163
+ // Claude Code exits once its input is closed and nothing it was sent is left to answer.
164
+ io.closeInput();
165
+ }
166
+ };
167
+ }
168
+
169
+ export function claudeDriver(io: HarnessIO, prompt: string, collector: HarnessCollector): HarnessDriver {
170
+ const handle = claudeEvents(io, collector);
171
+ return {
172
+ start() {
173
+ io.write(userMessage(prompt));
174
+ },
175
+ steer(text) {
176
+ io.write(userMessage(text));
177
+ },
178
+ line(text) {
179
+ let event: ClaudeEvent;
180
+ try {
181
+ event = JSON.parse(text) as ClaudeEvent;
182
+ } catch {
183
+ return;
184
+ }
185
+ handle(event);
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Splits a launch-arguments setting into argv the way a POSIX shell would
3
+ * (quotes, backslash escapes), without running a shell.
4
+ *
5
+ * Adapted from T3 Code, `packages/shared/src/cliArgs.ts` (`tokenizeCliArgs`).
6
+ */
7
+ export function tokenizeCliArgs(args?: string): ReadonlyArray<string> {
8
+ const input = args?.trim();
9
+ if (!input) return [];
10
+
11
+ const tokens: string[] = [];
12
+ let current = "";
13
+ let quote: "'" | '"' | undefined;
14
+ let quoted = false;
15
+
16
+ for (let index = 0; index < input.length; index++) {
17
+ const char = input[index];
18
+ if (char === undefined) continue;
19
+
20
+ if (quote) {
21
+ if (char === quote) {
22
+ quote = undefined;
23
+ quoted = true;
24
+ } else if (char === "\\" && quote === '"') {
25
+ const next = input[index + 1];
26
+ if (next !== undefined && ['"', "\\", "$", "`"].includes(next)) {
27
+ current += next;
28
+ index++;
29
+ } else {
30
+ current += char;
31
+ }
32
+ } else {
33
+ current += char;
34
+ }
35
+ continue;
36
+ }
37
+
38
+ if (char === "'" || char === '"') {
39
+ quote = char;
40
+ quoted = true;
41
+ } else if (/\s/.test(char)) {
42
+ if (current || quoted) {
43
+ tokens.push(current);
44
+ current = "";
45
+ quoted = false;
46
+ }
47
+ } else if (char === "\\") {
48
+ const next = input[index + 1];
49
+ if (next !== undefined && /[\s'"]/.test(next)) {
50
+ current += next;
51
+ index++;
52
+ } else {
53
+ current += char;
54
+ }
55
+ } else {
56
+ current += char;
57
+ }
58
+ }
59
+
60
+ if (current || quoted) tokens.push(current);
61
+ return tokens;
62
+ }