@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
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
The web UI's look (colours, radii, shadows, control and dialog styles, the
|
|
6
6
|
sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
|
|
7
7
|
fields and drop-down lists, the loading and status animations and the
|
|
8
|
-
built-in colour themes)
|
|
8
|
+
built-in colour themes), and the third-party harness support (the model
|
|
9
|
+
manifest, CLI flags, prompt injection and ACP handling behind Settings >
|
|
10
|
+
Harnesses), is adapted from T3 Code,
|
|
9
11
|
https://github.com/pingdotgg/t3code. No T3 Code package is a
|
|
10
12
|
dependency; the styles and markup below were ported into this repository and
|
|
11
13
|
rewired to bot-lobby's own data layer. Each adapted file starts with a comment
|
|
@@ -37,6 +39,15 @@ naming the file it came from.
|
|
|
37
39
|
| `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
|
|
38
40
|
| `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
|
|
39
41
|
| `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
|
|
42
|
+
| `src/harness/model-manifest.json` (copied as is) | `apps/server/src/provider/model-manifest.json` |
|
|
43
|
+
| `src/harness/catalog.ts` | `apps/server/src/provider/ClaudeModelCatalog.ts` (effort descriptors and maps), `CodexProvider.ts` (effort levels), `antigravityAuthSupport.ts` (`buildAntigravityAcpSpawnInput`) |
|
|
44
|
+
| `src/harness/cli-args.ts` | `packages/shared/src/cliArgs.ts` (`tokenizeCliArgs`) |
|
|
45
|
+
| `src/harness/instructions.ts` | `packages/provider-core/src/server/runtimeInstructions.ts`, `orchestrationInstructions.ts` (`t3AcpPromptWithInstructions`) |
|
|
46
|
+
| `src/harness/codex.ts` | `apps/server/src/textGeneration/CodexTextGeneration.ts`, `apps/server/src/provider/codexLaunchArgs.ts` |
|
|
47
|
+
| `src/harness/claude.ts` | `apps/server/src/textGeneration/ClaudeTextGeneration.ts`, `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` (auto-compact window) |
|
|
48
|
+
| `src/harness/claude-sdk.ts` | `apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts` (Agent SDK query options) |
|
|
49
|
+
| `src/harness/acp.ts` | `apps/server/src/provider/acp/AntigravityProtocol.ts` (permission answers), `packages/provider-acp/src/server/sessionConfig.ts` (session option categories) |
|
|
50
|
+
| `src/schemas/harness.ts`, `webui/src/tabs/settings/HarnessesGroup.tsx` | `packages/contracts/src/settings.ts` (provider settings fields), `apps/web/src/components/settings/ProviderInstanceCard.tsx` |
|
|
40
51
|
|
|
41
52
|
```
|
|
42
53
|
MIT License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@a-t-h-i/bot-lobby",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.56",
|
|
4
4
|
"description": "Structured multi-agent software engineering orchestrator for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -96,5 +96,8 @@
|
|
|
96
96
|
"typebox": "1.3.27",
|
|
97
97
|
"typescript": "^5.7.0",
|
|
98
98
|
"vite": "8.3.2"
|
|
99
|
+
},
|
|
100
|
+
"optionalDependencies": {
|
|
101
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.306"
|
|
99
102
|
}
|
|
100
103
|
}
|
|
@@ -9,6 +9,7 @@ import { runPiAgent, spawnPiProcess, type ProcessRunner, type RelayAsk } from ".
|
|
|
9
9
|
import { ASK_ENV } from "../ask/relay.ts";
|
|
10
10
|
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
11
11
|
import { ASK_TOOL } from "../ask/types.ts";
|
|
12
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
12
13
|
|
|
13
14
|
export interface AgentContext {
|
|
14
15
|
task: string;
|
|
@@ -75,6 +76,8 @@ export interface AgentRequest {
|
|
|
75
76
|
route?: string;
|
|
76
77
|
/** Lets the agent ask the user (ask_user_question, relayed through the master). */
|
|
77
78
|
onAsk?: RelayAsk;
|
|
79
|
+
/** Run on this third-party harness instead of pi. */
|
|
80
|
+
harness?: HarnessRun;
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
/** The watchdog fields of an AgentRequest, taken from the workflow config. */
|
|
@@ -190,6 +193,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
|
|
|
190
193
|
thinking: request.thinking,
|
|
191
194
|
signal,
|
|
192
195
|
...(request.onAsk ? { onAsk: request.onAsk } : {}),
|
|
196
|
+
...(request.harness ? { harness: request.harness } : {}),
|
|
193
197
|
env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
|
|
194
198
|
stallTimeoutMs: request.stallTimeoutMs,
|
|
195
199
|
toolStallTimeoutMs: request.toolStallTimeoutMs,
|
|
@@ -13,6 +13,8 @@ import { shortDuration } from "../text.ts";
|
|
|
13
13
|
import { readRelayRequest } from "../ask/relay.ts";
|
|
14
14
|
import type { AskQuestion, AskResult } from "../ask/types.ts";
|
|
15
15
|
import type { EffortRouter, RunProfileLike } from "../classifier/effort.ts";
|
|
16
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
17
|
+
import { runHarnessAgent } from "../harness/runner.ts";
|
|
16
18
|
|
|
17
19
|
/** Live control over one running subagent: queue a steering message for its next turn. */
|
|
18
20
|
export interface RunHandle {
|
|
@@ -59,6 +61,8 @@ export interface PiRunOptions {
|
|
|
59
61
|
onStart?: (handle: RunHandle) => void;
|
|
60
62
|
/** Answers the agent's relayed questions; without it they are cancelled like any other dialog. */
|
|
61
63
|
onAsk?: RelayAsk;
|
|
64
|
+
/** Run on this third-party harness instead of pi (`model` is then ignored for the harness's own). */
|
|
65
|
+
harness?: HarnessRun;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
export interface ProcessOutcome {
|
|
@@ -402,10 +406,10 @@ export function isPiLauncher(script: string): boolean {
|
|
|
402
406
|
return base === "pi" || base === "pi.js" || script.includes("pi-coding-agent");
|
|
403
407
|
}
|
|
404
408
|
|
|
405
|
-
const POSIX = process.platform !== "win32";
|
|
409
|
+
export const POSIX = process.platform !== "win32";
|
|
406
410
|
|
|
407
411
|
/** Signal the child's whole process group (POSIX), so grandchildren go too. */
|
|
408
|
-
function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
|
|
412
|
+
export function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
|
|
409
413
|
if (proc.pid === undefined) return;
|
|
410
414
|
if (POSIX) {
|
|
411
415
|
try {
|
|
@@ -422,7 +426,7 @@ function signalGroup(proc: ChildProcess, signal: NodeJS.Signals): void {
|
|
|
422
426
|
}
|
|
423
427
|
}
|
|
424
428
|
|
|
425
|
-
function terminate(proc: ChildProcess, graceMs = 5000): void {
|
|
429
|
+
export function terminate(proc: ChildProcess, graceMs = 5000): void {
|
|
426
430
|
signalGroup(proc, "SIGTERM");
|
|
427
431
|
const escalation = setTimeout(() => signalGroup(proc, "SIGKILL"), graceMs);
|
|
428
432
|
escalation.unref();
|
|
@@ -465,7 +469,7 @@ interface RunState {
|
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
/** Smallest positive limit, used to pick a watchdog cadence that can see it. */
|
|
468
|
-
function watchInterval(...limits: Array<number | undefined>): number {
|
|
472
|
+
export function watchInterval(...limits: Array<number | undefined>): number {
|
|
469
473
|
const positive = limits.filter((limit): limit is number => typeof limit === "number" && limit > 0);
|
|
470
474
|
const smallest = positive.length > 0 ? Math.min(...positive) : 4000;
|
|
471
475
|
return Math.max(25, Math.min(1000, Math.floor(smallest / 4)));
|
|
@@ -651,6 +655,7 @@ function writePromptFile(prompt: string): { file: string; cleanup: () => void }
|
|
|
651
655
|
|
|
652
656
|
/** Run one isolated subagent and normalise its outcome. */
|
|
653
657
|
export async function runPiAgent(given: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
|
|
658
|
+
if (given.harness) return runHarnessAgent({ ...given, harness: given.harness });
|
|
654
659
|
const options = given.autoCompact === false ? { ...given, env: { ...given.env, [AUTO_COMPACT_ENV]: "0" } } : given;
|
|
655
660
|
const host = await delegationHost(options, run);
|
|
656
661
|
let prompt: ReturnType<typeof writePromptFile> | undefined;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Client Protocol (JSON-RPC 2.0 over stdio) for Antigravity, Gemini
|
|
3
|
+
* CLI, OpenCode and any other ACP agent: initialize, open a session in the
|
|
4
|
+
* working folder, pick the model and effort through the session's options,
|
|
5
|
+
* send the task with Bot Lobby's instructions in front, and map the
|
|
6
|
+
* `session/update` notifications onto pi's stream events. The client offers
|
|
7
|
+
* no file system or terminal of its own, so the agent uses its own tools,
|
|
8
|
+
* and its permission requests are answered from the role's allowlist.
|
|
9
|
+
*
|
|
10
|
+
* Adapted from T3 Code: the prompt wrapping from
|
|
11
|
+
* `packages/provider-core/src/server/orchestrationInstructions.ts`, the
|
|
12
|
+
* permission answers from `apps/server/src/provider/acp/AntigravityProtocol.ts`
|
|
13
|
+
* (`selectAntigravityPermissionOptionId`, `isAntigravityUserInputRequest`) and
|
|
14
|
+
* the session option categories from `packages/provider-acp/src/server/sessionConfig.ts`.
|
|
15
|
+
*/
|
|
16
|
+
import { THINKING_LEVELS } from "../schemas/configuration.ts";
|
|
17
|
+
import type { HarnessDriver, HarnessIO } from "./process.ts";
|
|
18
|
+
import type { HarnessAccess, HarnessCollector, HarnessPlan } from "./runner.ts";
|
|
19
|
+
|
|
20
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
interface RpcMessage {
|
|
23
|
+
jsonrpc?: string;
|
|
24
|
+
id?: number | string;
|
|
25
|
+
method?: string;
|
|
26
|
+
params?: Record<string, unknown>;
|
|
27
|
+
result?: Record<string, unknown> | null;
|
|
28
|
+
error?: { code?: number; message?: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PermissionOption {
|
|
32
|
+
optionId: string;
|
|
33
|
+
kind?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ConfigOption {
|
|
38
|
+
id: string;
|
|
39
|
+
category?: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
options?: Array<{ value?: string; name?: string; options?: Array<{ value?: string; name?: string }> }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** pi's tool names for ACP tool kinds, for the activity line and the edit log. */
|
|
45
|
+
const KIND_TOOLS: Record<string, string> = {
|
|
46
|
+
read: "read",
|
|
47
|
+
edit: "edit",
|
|
48
|
+
delete: "write",
|
|
49
|
+
move: "write",
|
|
50
|
+
search: "grep",
|
|
51
|
+
execute: "bash",
|
|
52
|
+
fetch: "fetch_content",
|
|
53
|
+
think: "think",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Whether a role may run a tool of this ACP kind. */
|
|
57
|
+
export function acpKindAllowed(kind: string | undefined, access: HarnessAccess): boolean {
|
|
58
|
+
if (access.writes) return true;
|
|
59
|
+
switch (kind) {
|
|
60
|
+
case "read":
|
|
61
|
+
case "search":
|
|
62
|
+
case "think":
|
|
63
|
+
return true;
|
|
64
|
+
case "fetch":
|
|
65
|
+
return access.web;
|
|
66
|
+
case "execute":
|
|
67
|
+
return access.shell;
|
|
68
|
+
default:
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The option to pick for a permission request, or undefined to cancel it (a question for the user, which nobody can answer here). */
|
|
74
|
+
export function acpPermissionOption(request: { toolCall?: { toolCallId?: string; kind?: string }; options?: PermissionOption[] }, access: HarnessAccess): string | undefined {
|
|
75
|
+
if (request.toolCall?.toolCallId?.startsWith("interaction_")) return undefined;
|
|
76
|
+
const allow = acpKindAllowed(request.toolCall?.kind, access);
|
|
77
|
+
const kinds = allow ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
|
|
78
|
+
for (const kind of kinds) {
|
|
79
|
+
const option = request.options?.find((entry) => entry.kind === kind && entry.optionId.trim());
|
|
80
|
+
if (option) return option.optionId;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function choices(option: ConfigOption): Array<{ value: string; name: string }> {
|
|
86
|
+
return (option.options ?? []).flatMap((entry) => ("options" in entry && Array.isArray(entry.options) ? entry.options : [entry]))
|
|
87
|
+
.filter((entry): entry is { value: string; name: string } => typeof entry.value === "string")
|
|
88
|
+
.map((entry) => ({ value: entry.value, name: entry.name ?? entry.value }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The choice whose id or name is the effort level, or the nearest one below it. */
|
|
92
|
+
export function acpEffortChoice(option: ConfigOption, level: string): string | undefined {
|
|
93
|
+
const list = choices(option);
|
|
94
|
+
const at = THINKING_LEVELS.indexOf(level as (typeof THINKING_LEVELS)[number]);
|
|
95
|
+
for (let index = at; index >= 0; index--) {
|
|
96
|
+
const name = THINKING_LEVELS[index]!;
|
|
97
|
+
const hit = list.find((choice) => choice.value.toLowerCase() === name || choice.name.toLowerCase() === name);
|
|
98
|
+
if (hit) return hit.value;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function blockText(content: unknown): string {
|
|
104
|
+
if (!content || typeof content !== "object") return "";
|
|
105
|
+
const block = content as { type?: string; text?: string };
|
|
106
|
+
return block.type === "text" && typeof block.text === "string" ? block.text : "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toolArgs(update: Record<string, unknown>): Record<string, unknown> {
|
|
110
|
+
const raw = (update.rawInput && typeof update.rawInput === "object" ? update.rawInput : {}) as Record<string, unknown>;
|
|
111
|
+
const locations = Array.isArray(update.locations) ? update.locations as Array<{ path?: string }> : [];
|
|
112
|
+
const path = locations.find((entry) => typeof entry.path === "string")?.path ?? raw.path ?? raw.file_path ?? raw.filePath;
|
|
113
|
+
return { ...raw, ...(typeof path === "string" ? { path } : {}), ...(typeof update.title === "string" ? { title: update.title } : {}) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function acpDriver(io: HarnessIO, plan: HarnessPlan, prompt: string, collector: HarnessCollector, access: HarnessAccess, cwd: string): HarnessDriver {
|
|
117
|
+
let nextId = 1;
|
|
118
|
+
const pending = new Map<number, (message: RpcMessage) => void>();
|
|
119
|
+
const tools = new Map<string, string>();
|
|
120
|
+
const steers: string[] = [];
|
|
121
|
+
let sessionId: string | undefined;
|
|
122
|
+
let message = "";
|
|
123
|
+
let thought = "";
|
|
124
|
+
let prompting = false;
|
|
125
|
+
let finished = false;
|
|
126
|
+
|
|
127
|
+
const request = (method: string, params: Record<string, unknown>, then: (message: RpcMessage) => void) => {
|
|
128
|
+
const id = nextId++;
|
|
129
|
+
pending.set(id, then);
|
|
130
|
+
io.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
131
|
+
};
|
|
132
|
+
const notify = (method: string, params: Record<string, unknown>) => io.write(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
|
133
|
+
const respond = (id: number | string, result: unknown) => io.write(JSON.stringify({ jsonrpc: "2.0", id, result }));
|
|
134
|
+
const fail = (text: string) => {
|
|
135
|
+
if (finished) return;
|
|
136
|
+
finished = true;
|
|
137
|
+
collector.error = text;
|
|
138
|
+
io.done();
|
|
139
|
+
};
|
|
140
|
+
const flushThought = () => {
|
|
141
|
+
if (thought.trim()) io.emit({ type: "thought", text: thought.trim().slice(0, 1500) });
|
|
142
|
+
thought = "";
|
|
143
|
+
};
|
|
144
|
+
const send = (text: string) => {
|
|
145
|
+
prompting = true;
|
|
146
|
+
message = "";
|
|
147
|
+
io.emit({ type: "turn_start" });
|
|
148
|
+
request("session/prompt", { sessionId, prompt: [{ type: "text", text }] }, (reply) => {
|
|
149
|
+
prompting = false;
|
|
150
|
+
flushThought();
|
|
151
|
+
if (message.trim()) collector.text = message;
|
|
152
|
+
if (reply.error) return fail(reply.error.message ?? "the agent refused the prompt");
|
|
153
|
+
const usage = reply.result?.usage as { inputTokens?: number; outputTokens?: number } | undefined;
|
|
154
|
+
if (usage) {
|
|
155
|
+
collector.addUsage(usage.inputTokens ?? 0, usage.outputTokens ?? 0, 0, plan.model || undefined);
|
|
156
|
+
io.emit({ type: "usage", input: usage.inputTokens ?? 0, output: usage.outputTokens ?? 0, cost: 0, ...(plan.model ? { model: plan.model } : {}) });
|
|
157
|
+
}
|
|
158
|
+
const stop = String(reply.result?.stopReason ?? "end_turn");
|
|
159
|
+
if (stop === "cancelled") collector.stopReason = "aborted";
|
|
160
|
+
else if (stop === "refusal") collector.error = "the agent refused the task";
|
|
161
|
+
// A steering message sent mid-turn goes in as the next prompt.
|
|
162
|
+
if (stop !== "cancelled" && steers.length > 0) return send(steers.splice(0).join("\n\n"));
|
|
163
|
+
finished = true;
|
|
164
|
+
io.done();
|
|
165
|
+
});
|
|
166
|
+
};
|
|
167
|
+
const configure = (result: Record<string, unknown>, then: () => void) => {
|
|
168
|
+
const steps: Array<(next: () => void) => void> = [];
|
|
169
|
+
const options = Array.isArray(result.configOptions) ? result.configOptions as ConfigOption[] : [];
|
|
170
|
+
const modelOption = options.find((option) => option.category === "model");
|
|
171
|
+
const effortOption = options.find((option) => option.category === "thought_level");
|
|
172
|
+
if (plan.model && modelOption) steps.push((next) => request("session/set_config_option", { sessionId, configId: modelOption.id, value: plan.model }, next));
|
|
173
|
+
else if (plan.model && result.models) steps.push((next) => request("session/set_model", { sessionId, modelId: plan.model }, next));
|
|
174
|
+
const effort = effortOption ? acpEffortChoice(effortOption, plan.level) : undefined;
|
|
175
|
+
if (effortOption && effort) steps.push((next) => request("session/set_config_option", { sessionId, configId: effortOption.id, value: effort }, next));
|
|
176
|
+
const run = () => {
|
|
177
|
+
const step = steps.shift();
|
|
178
|
+
if (step) step(() => run());
|
|
179
|
+
else then();
|
|
180
|
+
};
|
|
181
|
+
run();
|
|
182
|
+
};
|
|
183
|
+
const onUpdate = (update: Record<string, unknown>) => {
|
|
184
|
+
switch (update.sessionUpdate) {
|
|
185
|
+
case "agent_message_chunk": {
|
|
186
|
+
const text = blockText(update.content);
|
|
187
|
+
if (!text) return;
|
|
188
|
+
flushThought();
|
|
189
|
+
if (!message) io.emit({ type: "writing" });
|
|
190
|
+
message += text;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case "agent_thought_chunk": {
|
|
194
|
+
const text = blockText(update.content);
|
|
195
|
+
if (!text) return;
|
|
196
|
+
if (!thought) io.emit({ type: "thinking" });
|
|
197
|
+
thought += text;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case "tool_call": {
|
|
201
|
+
flushThought();
|
|
202
|
+
if (message.trim()) collector.text = message;
|
|
203
|
+
message = "";
|
|
204
|
+
const id = String(update.toolCallId ?? "");
|
|
205
|
+
const name = KIND_TOOLS[String(update.kind ?? "")] ?? "tool";
|
|
206
|
+
if (id && !tools.has(id)) {
|
|
207
|
+
tools.set(id, name);
|
|
208
|
+
io.emit({ type: "tool_execution_start", toolName: name, args: toolArgs(update) });
|
|
209
|
+
}
|
|
210
|
+
if (update.status === "completed" || update.status === "failed") onUpdate({ ...update, sessionUpdate: "tool_call_update" });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
case "tool_call_update": {
|
|
214
|
+
const id = String(update.toolCallId ?? "");
|
|
215
|
+
const name = tools.get(id);
|
|
216
|
+
if (!name || (update.status !== "completed" && update.status !== "failed")) return;
|
|
217
|
+
tools.delete(id);
|
|
218
|
+
io.emit({ type: "tool_execution_end", toolName: name, isError: update.status === "failed" });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
case "usage_update": {
|
|
222
|
+
const cost = (update.cost as { amount?: number } | undefined)?.amount;
|
|
223
|
+
if (typeof cost === "number" && cost > collector.usage.cost) collector.usage.cost = cost;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const onRequest = (message: RpcMessage) => {
|
|
229
|
+
if (message.id === undefined) return;
|
|
230
|
+
if (message.method === "session/request_permission") {
|
|
231
|
+
const optionId = acpPermissionOption((message.params ?? {}) as Parameters<typeof acpPermissionOption>[0], access);
|
|
232
|
+
respond(message.id, { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
// No file system or terminal was offered; anything else is unknown.
|
|
236
|
+
io.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: `Method not found: ${message.method}` } }));
|
|
237
|
+
};
|
|
238
|
+
return {
|
|
239
|
+
start() {
|
|
240
|
+
request("initialize", { protocolVersion: ACP_PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, clientInfo: { name: "bot-lobby", version: "1" } }, (reply) => {
|
|
241
|
+
if (reply.error) return fail(`the agent did not start: ${reply.error.message ?? "initialize failed"}`);
|
|
242
|
+
request("session/new", { cwd, mcpServers: [] }, (created) => {
|
|
243
|
+
if (created.error || typeof created.result?.sessionId !== "string") return fail(`the agent did not open a session: ${created.error?.message ?? "no session id"}${/auth|key|sign|log ?in/i.test(created.error?.message ?? "") ? " (sign in to it once in a terminal)" : ""}`);
|
|
244
|
+
sessionId = created.result.sessionId;
|
|
245
|
+
collector.session = sessionId;
|
|
246
|
+
configure(created.result, () => send(prompt));
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
steer(text) {
|
|
251
|
+
if (prompting) steers.push(text);
|
|
252
|
+
},
|
|
253
|
+
abort() {
|
|
254
|
+
if (sessionId) notify("session/cancel", { sessionId });
|
|
255
|
+
},
|
|
256
|
+
line(text) {
|
|
257
|
+
let message: RpcMessage;
|
|
258
|
+
try {
|
|
259
|
+
message = JSON.parse(text) as RpcMessage;
|
|
260
|
+
} catch {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (message.method === "session/update") return onUpdate((message.params?.update ?? {}) as Record<string, unknown>);
|
|
264
|
+
if (message.method) return onRequest(message);
|
|
265
|
+
if (typeof message.id === "number") {
|
|
266
|
+
const then = pending.get(message.id);
|
|
267
|
+
pending.delete(message.id);
|
|
268
|
+
then?.(message);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
@@ -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
|
+
}
|