@a-t-h-i/bot-lobby 0.6.55 → 0.6.56
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/THIRD_PARTY_NOTICES.md +12 -1
- package/package.json +4 -1
- package/src/execution/agent-runner.ts +4 -0
- package/src/execution/pi-runner.ts +9 -4
- package/src/harness/acp.ts +272 -0
- package/src/harness/catalog.ts +257 -0
- package/src/harness/claude-sdk.ts +169 -0
- package/src/harness/claude.ts +188 -0
- package/src/harness/cli-args.ts +62 -0
- package/src/harness/codex.ts +155 -0
- package/src/harness/instructions.ts +45 -0
- package/src/harness/model-manifest.json +829 -0
- package/src/harness/process.ts +175 -0
- package/src/harness/runner.ts +188 -0
- package/src/harness/status.ts +115 -0
- package/src/lobby/host.ts +2 -0
- package/src/lobby/planner.ts +2 -1
- package/src/lobby/pr-review.ts +1 -0
- package/src/lobby/quickfix.ts +4 -1
- package/src/master/master.ts +3 -3
- package/src/master/research.ts +1 -0
- package/src/pi/model-support.ts +6 -2
- package/src/schemas/configuration.ts +33 -13
- package/src/schemas/harness.ts +122 -0
- package/src/webui/api/index.ts +2 -1
- package/src/webui/api/settings.ts +7 -1
- package/src/webui/dev/fake-service.ts +13 -0
- package/src/webui/protocol.ts +9 -0
- package/webui/dist/assets/{TerminalView-CIfjkb5H.js → TerminalView-BVehQ7yB.js} +1 -1
- package/webui/dist/assets/index-BKiKxUUG.js +114 -0
- package/webui/dist/assets/index-DOLQ4FDd.css +2 -0
- package/webui/dist/build.json +15 -11
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BkXhtB6A.css +0 -2
- package/webui/dist/assets/index-DIM2-FUQ.js +0 -114
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI: one `codex exec --json` run per step. The prompt goes over
|
|
3
|
+
* stdin, Bot Lobby's instructions as `developer_instructions`, and the JSONL
|
|
4
|
+
* events (`thread.started`, `item.*`, `turn.completed`) become the same
|
|
5
|
+
* stream events a pi subagent sends.
|
|
6
|
+
*
|
|
7
|
+
* The flag set follows T3 Code's `apps/server/src/textGeneration/CodexTextGeneration.ts`
|
|
8
|
+
* (`exec`, `--skip-git-repo-check`, `-s`, `--model`, `model_reasoning_effort`)
|
|
9
|
+
* and `apps/server/src/provider/codexLaunchArgs.ts`.
|
|
10
|
+
*/
|
|
11
|
+
import type { HarnessDriver, HarnessIO } from "./process.ts";
|
|
12
|
+
import type { HarnessAccess, HarnessCollector, HarnessPlan } from "./runner.ts";
|
|
13
|
+
|
|
14
|
+
/** Longest instructions passed on the command line; longer ones lead the prompt instead. */
|
|
15
|
+
const MAX_ARG_BYTES = 100_000;
|
|
16
|
+
/** "Never" for Codex's compaction threshold, which cannot be switched off. */
|
|
17
|
+
const NO_COMPACTION = 1_000_000_000_000;
|
|
18
|
+
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio"]);
|
|
19
|
+
|
|
20
|
+
/** A TOML basic string: JSON's escapes are valid TOML. */
|
|
21
|
+
export function tomlString(value: string): string {
|
|
22
|
+
return JSON.stringify(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function codexArgs(plan: HarnessPlan, access: HarnessAccess, launchArgs: string[]): { args: string[]; promptPrefix: string } {
|
|
26
|
+
const { settings } = plan.harness;
|
|
27
|
+
const args = ["exec", ...launchArgs, "--json", "--skip-git-repo-check", "-s", access.writes ? "workspace-write" : "read-only", "-c", 'approval_policy="never"'];
|
|
28
|
+
if (plan.model) args.push("--model", plan.model);
|
|
29
|
+
if (plan.effort) args.push("-c", `model_reasoning_effort=${tomlString(plan.effort)}`);
|
|
30
|
+
const provider = settings.modelProvider;
|
|
31
|
+
if (LOCAL_PROVIDERS.has(provider)) args.push("--oss", "--local-provider", provider);
|
|
32
|
+
else if (provider) args.push("-c", `model_provider=${tomlString(provider)}`);
|
|
33
|
+
if (plan.autoCompact === false) args.push("-c", `model_auto_compact_token_limit=${NO_COMPACTION}`);
|
|
34
|
+
else if (settings.compactAt > 0) args.push("-c", `model_auto_compact_token_limit=${settings.compactAt}`);
|
|
35
|
+
let promptPrefix = "";
|
|
36
|
+
if (plan.instructions) {
|
|
37
|
+
if (Buffer.byteLength(plan.instructions) <= MAX_ARG_BYTES) args.push("-c", `developer_instructions=${tomlString(plan.instructions)}`);
|
|
38
|
+
else promptPrefix = plan.instructions;
|
|
39
|
+
}
|
|
40
|
+
args.push("-");
|
|
41
|
+
return { args, promptPrefix };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface CodexItem {
|
|
45
|
+
id?: string;
|
|
46
|
+
type?: string;
|
|
47
|
+
text?: string;
|
|
48
|
+
command?: string;
|
|
49
|
+
exit_code?: number | null;
|
|
50
|
+
status?: string;
|
|
51
|
+
changes?: Array<{ path?: string; kind?: string }>;
|
|
52
|
+
server?: string;
|
|
53
|
+
tool?: string;
|
|
54
|
+
query?: string;
|
|
55
|
+
message?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface CodexEvent {
|
|
59
|
+
type?: string;
|
|
60
|
+
thread_id?: string;
|
|
61
|
+
item?: CodexItem;
|
|
62
|
+
usage?: { input_tokens?: number; cached_input_tokens?: number; output_tokens?: number };
|
|
63
|
+
error?: { message?: string };
|
|
64
|
+
message?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toolOf(item: CodexItem): { name: string; args: Record<string, unknown> } | undefined {
|
|
68
|
+
switch (item.type) {
|
|
69
|
+
case "command_execution":
|
|
70
|
+
return { name: "bash", args: { command: item.command ?? "" } };
|
|
71
|
+
case "mcp_tool_call":
|
|
72
|
+
return { name: `mcp__${item.server ?? "server"}__${item.tool ?? "tool"}`, args: {} };
|
|
73
|
+
case "web_search":
|
|
74
|
+
return { name: "web_search", args: { query: item.query ?? "" } };
|
|
75
|
+
default:
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function codexDriver(io: HarnessIO, plan: HarnessPlan, prompt: string, collector: HarnessCollector): HarnessDriver {
|
|
81
|
+
const started = new Set<string>();
|
|
82
|
+
const onItem = (phase: "started" | "updated" | "completed", item: CodexItem) => {
|
|
83
|
+
if (item.type === "agent_message" && phase === "completed") {
|
|
84
|
+
io.emit({ type: "writing" });
|
|
85
|
+
if (item.text?.trim()) collector.text = item.text;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (item.type === "reasoning" && phase === "completed") {
|
|
89
|
+
if (item.text?.trim()) io.emit({ type: "thought", text: item.text.trim().slice(0, 1500) });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (item.type === "file_change" && phase === "completed") {
|
|
93
|
+
for (const change of item.changes ?? []) {
|
|
94
|
+
if (!change.path) continue;
|
|
95
|
+
const name = change.kind === "add" ? "write" : "edit";
|
|
96
|
+
io.emit({ type: "tool_execution_start", toolName: name, args: { path: change.path } });
|
|
97
|
+
io.emit({ type: "tool_execution_end", toolName: name, isError: item.status === "failed" });
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (item.type === "error" && item.message) {
|
|
102
|
+
collector.warning = item.message;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const tool = toolOf(item);
|
|
106
|
+
if (!tool) return;
|
|
107
|
+
const id = item.id ?? `${tool.name}:${started.size}`;
|
|
108
|
+
if (!started.has(id)) {
|
|
109
|
+
started.add(id);
|
|
110
|
+
io.emit({ type: "tool_execution_start", toolName: tool.name, args: tool.args });
|
|
111
|
+
if (phase !== "completed") return;
|
|
112
|
+
}
|
|
113
|
+
if (phase === "completed") io.emit({ type: "tool_execution_end", toolName: tool.name, isError: item.status === "failed" || (typeof item.exit_code === "number" && item.exit_code !== 0) });
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
start() {
|
|
117
|
+
io.write(prompt);
|
|
118
|
+
io.closeInput();
|
|
119
|
+
},
|
|
120
|
+
line(text) {
|
|
121
|
+
let event: CodexEvent;
|
|
122
|
+
try {
|
|
123
|
+
event = JSON.parse(text) as CodexEvent;
|
|
124
|
+
} catch {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
switch (event.type) {
|
|
128
|
+
case "thread.started":
|
|
129
|
+
collector.session = event.thread_id;
|
|
130
|
+
return;
|
|
131
|
+
case "turn.started":
|
|
132
|
+
io.emit({ type: "turn_start" });
|
|
133
|
+
return;
|
|
134
|
+
case "item.started":
|
|
135
|
+
case "item.updated":
|
|
136
|
+
case "item.completed":
|
|
137
|
+
if (event.item) onItem(event.type.slice("item.".length) as "started" | "updated" | "completed", event.item);
|
|
138
|
+
return;
|
|
139
|
+
case "turn.completed": {
|
|
140
|
+
const input = (event.usage?.input_tokens ?? 0);
|
|
141
|
+
const output = event.usage?.output_tokens ?? 0;
|
|
142
|
+
collector.addUsage(input, output, 0, plan.model || undefined);
|
|
143
|
+
io.emit({ type: "usage", input, output, cost: 0, ...(plan.model ? { model: plan.model } : {}) });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
case "turn.failed":
|
|
147
|
+
collector.error = event.error?.message ?? "Codex reported a failed turn";
|
|
148
|
+
return;
|
|
149
|
+
case "error":
|
|
150
|
+
if (event.message) collector.warning = event.message;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How Bot Lobby's prompts reach a third-party harness.
|
|
3
|
+
*
|
|
4
|
+
* Codex takes them as developer instructions and Claude Code appends them to
|
|
5
|
+
* its own system prompt; ACP has no system prompt field, so they ride in the
|
|
6
|
+
* first prompt, wrapped so they cannot be mistaken for the user's words.
|
|
7
|
+
* Adapted from T3 Code, `packages/provider-core/src/server/runtimeInstructions.ts`
|
|
8
|
+
* (`buildRuntimeInstructions`) and `orchestrationInstructions.ts`
|
|
9
|
+
* (`t3AcpPromptWithInstructions`).
|
|
10
|
+
*/
|
|
11
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
12
|
+
import { HARNESSES } from "./catalog.ts";
|
|
13
|
+
|
|
14
|
+
/** Pi's tool names in the role prompts, and what stands in for them on another harness. */
|
|
15
|
+
const HARNESS_NOTE = `## Running on another harness
|
|
16
|
+
|
|
17
|
+
You run in Bot Lobby through a third-party harness, not pi. Where these instructions name pi's tools (read, edit, write, bash, grep, find, ls), use your own equivalents. Bot Lobby's own tools (ask_user_question, ask_expert, the file desk and task board tools, web_search and fetch_content) are not available here: work from the brief, use your own web tools if you have them, and put any open question or blocker in your report instead of asking.`;
|
|
18
|
+
|
|
19
|
+
function toSingleLine(value: string): string {
|
|
20
|
+
return value.replaceAll(/\s+/g, " ").trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Who and what the agent runs as, in case it is asked. */
|
|
24
|
+
export function runtimeInstructions(harness: HarnessRun, effort?: string): string {
|
|
25
|
+
const model = toSingleLine(harness.model);
|
|
26
|
+
const modelInfo = model ? `, as ${model}` : "";
|
|
27
|
+
const effortInfo = effort ? ` with ${toSingleLine(effort)} reasoning effort` : "";
|
|
28
|
+
return `<runtime_info>In case you're asked: you are running in Bot Lobby through the ${HARNESSES[harness.id].label} harness${modelInfo}${effortInfo}. No need to mention this otherwise.</runtime_info>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The instructions a harness run gets: the compiled role prompt and the
|
|
33
|
+
* harness note when prompts are injected, else only the report format the
|
|
34
|
+
* engine parses (`contract`), which a run cannot do without.
|
|
35
|
+
*/
|
|
36
|
+
export function harnessInstructions(input: { harness: HarnessRun; systemPrompt?: string; contract?: string; effort?: string }): string {
|
|
37
|
+
const body = input.harness.settings.injectPrompts ? [input.systemPrompt ?? "", HARNESS_NOTE] : [input.contract ?? ""];
|
|
38
|
+
return [...body, runtimeInstructions(input.harness, input.effort)].filter((part) => part.trim().length > 0).join("\n\n---\n\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** ACP: the instructions go in front of the first prompt, wrapped; a native slash command stays first. */
|
|
42
|
+
export function acpPromptWithInstructions(prompt: string, instructions: string): string {
|
|
43
|
+
if (prompt.trimStart().startsWith("/") || !instructions.trim()) return prompt;
|
|
44
|
+
return `<bot_lobby_instructions>\n${instructions}\n</bot_lobby_instructions>\n\n<user_request>\n${prompt}\n</user_request>`;
|
|
45
|
+
}
|