@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one third-party harness process: spawns it in its own process group,
|
|
3
|
+
* feeds its stdout to the protocol driver line by line, and watches it the
|
|
4
|
+
* way `spawnPiProcess` watches pi (a stall kills it, a cancel aborts it, and
|
|
5
|
+
* the run ends when the process exits). There is no time limit.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { POSIX, signalGroup, terminate, watchInterval, type PiStreamEvent, type RunHandle } from "../execution/pi-runner.ts";
|
|
9
|
+
|
|
10
|
+
/** What a driver can do with its process. */
|
|
11
|
+
export interface HarnessIO {
|
|
12
|
+
/** Write one line to the process's stdin. */
|
|
13
|
+
write(line: string): void;
|
|
14
|
+
closeInput(): void;
|
|
15
|
+
/** Forward an agent event to the caller (and to the stall watchdog). */
|
|
16
|
+
emit(event: PiStreamEvent): void;
|
|
17
|
+
/** The turn is over: close stdin and let the process go (killing it after a short grace). */
|
|
18
|
+
done(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** One protocol: what it sends first, how it reads each stdout line, and how it is cancelled or steered. */
|
|
22
|
+
export interface HarnessDriver {
|
|
23
|
+
start(): void;
|
|
24
|
+
line(text: string): void;
|
|
25
|
+
/** Called on cancel before the process is killed (ACP's `session/cancel`). */
|
|
26
|
+
abort?(): void;
|
|
27
|
+
/** A steering message for the running agent; absent when the protocol cannot take one mid-run. */
|
|
28
|
+
steer?(text: string): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HarnessSpawn {
|
|
32
|
+
command: string;
|
|
33
|
+
args: string[];
|
|
34
|
+
cwd: string;
|
|
35
|
+
env: Record<string, string | undefined>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface HarnessProcessOptions {
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
stallTimeoutMs?: number;
|
|
41
|
+
toolStallTimeoutMs?: number;
|
|
42
|
+
onEvent?: (event: PiStreamEvent) => void;
|
|
43
|
+
onStart?: (handle: RunHandle) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HarnessOutcome {
|
|
47
|
+
exitCode: number;
|
|
48
|
+
stderr: string;
|
|
49
|
+
killed: boolean;
|
|
50
|
+
stalled: boolean;
|
|
51
|
+
/** The command could not be started at all (not installed, not executable). */
|
|
52
|
+
spawnError?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MAX_STDERR = 64_000;
|
|
56
|
+
const EXIT_DRAIN_MS = 1500;
|
|
57
|
+
const DONE_GRACE_MS = 2000;
|
|
58
|
+
|
|
59
|
+
export function runHarnessProcess(spec: HarnessSpawn, options: HarnessProcessOptions, makeDriver: (io: HarnessIO) => HarnessDriver): Promise<HarnessOutcome> {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const proc = spawn(spec.command, spec.args, {
|
|
62
|
+
cwd: spec.cwd,
|
|
63
|
+
env: spec.env as NodeJS.ProcessEnv,
|
|
64
|
+
shell: false,
|
|
65
|
+
detached: POSIX,
|
|
66
|
+
windowsHide: true,
|
|
67
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
68
|
+
});
|
|
69
|
+
let stdinOpen = true;
|
|
70
|
+
let stderr = "";
|
|
71
|
+
let partial = "";
|
|
72
|
+
let lastOutput = Date.now();
|
|
73
|
+
let toolsInFlight = 0;
|
|
74
|
+
let killed = false;
|
|
75
|
+
let stalled = false;
|
|
76
|
+
let finished = false;
|
|
77
|
+
let exitCode: number | undefined;
|
|
78
|
+
let spawnError: string | undefined;
|
|
79
|
+
let doneTimer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
proc.stdin?.on("error", () => {
|
|
81
|
+
stdinOpen = false;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const io: HarnessIO = {
|
|
85
|
+
write(line) {
|
|
86
|
+
if (!stdinOpen || !proc.stdin?.writable) return;
|
|
87
|
+
try {
|
|
88
|
+
proc.stdin.write(`${line}\n`);
|
|
89
|
+
} catch {
|
|
90
|
+
stdinOpen = false;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
closeInput() {
|
|
94
|
+
if (!stdinOpen) return;
|
|
95
|
+
stdinOpen = false;
|
|
96
|
+
try {
|
|
97
|
+
proc.stdin?.end();
|
|
98
|
+
} catch {
|
|
99
|
+
// Already closed.
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
emit(event) {
|
|
103
|
+
if (event.type === "tool_execution_start") toolsInFlight += 1;
|
|
104
|
+
if (event.type === "tool_execution_end") toolsInFlight = Math.max(0, toolsInFlight - 1);
|
|
105
|
+
options.onEvent?.(event);
|
|
106
|
+
},
|
|
107
|
+
done() {
|
|
108
|
+
io.closeInput();
|
|
109
|
+
// An ACP agent is a server and stays up after its turn: give it a moment, then end it.
|
|
110
|
+
doneTimer ??= setTimeout(() => terminate(proc, 2000), DONE_GRACE_MS);
|
|
111
|
+
doneTimer.unref();
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
const driver = makeDriver(io);
|
|
115
|
+
|
|
116
|
+
const finish = (code: number) => {
|
|
117
|
+
if (finished) return;
|
|
118
|
+
finished = true;
|
|
119
|
+
clearInterval(watch);
|
|
120
|
+
clearTimeout(doneTimer);
|
|
121
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
122
|
+
if (partial) {
|
|
123
|
+
const rest = partial;
|
|
124
|
+
partial = "";
|
|
125
|
+
driver.line(rest);
|
|
126
|
+
}
|
|
127
|
+
io.closeInput();
|
|
128
|
+
if (POSIX) signalGroup(proc, "SIGTERM");
|
|
129
|
+
resolve({ exitCode: code, stderr, killed, stalled, ...(spawnError ? { spawnError } : {}) });
|
|
130
|
+
};
|
|
131
|
+
const onAbort = () => {
|
|
132
|
+
killed = true;
|
|
133
|
+
driver.abort?.();
|
|
134
|
+
io.closeInput();
|
|
135
|
+
terminate(proc, 3000);
|
|
136
|
+
};
|
|
137
|
+
const watch = setInterval(() => {
|
|
138
|
+
if (killed || finished) return;
|
|
139
|
+
const limit = toolsInFlight > 0 ? options.toolStallTimeoutMs : options.stallTimeoutMs;
|
|
140
|
+
if (limit && limit > 0 && Date.now() - lastOutput >= limit) {
|
|
141
|
+
killed = true;
|
|
142
|
+
stalled = true;
|
|
143
|
+
io.closeInput();
|
|
144
|
+
terminate(proc, 3000);
|
|
145
|
+
}
|
|
146
|
+
}, watchInterval(options.stallTimeoutMs, options.toolStallTimeoutMs));
|
|
147
|
+
watch.unref();
|
|
148
|
+
|
|
149
|
+
proc.stdout?.setEncoding("utf8");
|
|
150
|
+
proc.stderr?.setEncoding("utf8");
|
|
151
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
152
|
+
lastOutput = Date.now();
|
|
153
|
+
const lines = (partial + chunk).split("\n");
|
|
154
|
+
partial = lines.pop() ?? "";
|
|
155
|
+
for (const line of lines) if (line.trim()) driver.line(line);
|
|
156
|
+
});
|
|
157
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
158
|
+
lastOutput = Date.now();
|
|
159
|
+
if (stderr.length < MAX_STDERR) stderr += chunk;
|
|
160
|
+
});
|
|
161
|
+
proc.on("exit", (code, signal) => {
|
|
162
|
+
exitCode = code ?? (signal ? 1 : 0);
|
|
163
|
+
setTimeout(() => finish(exitCode ?? 0), EXIT_DRAIN_MS);
|
|
164
|
+
});
|
|
165
|
+
proc.on("close", (code) => finish(exitCode ?? code ?? 0));
|
|
166
|
+
proc.on("error", (error: NodeJS.ErrnoException) => {
|
|
167
|
+
spawnError = error.code === "ENOENT" ? `${spec.command} was not found; install it or set its path in Settings > Harnesses` : error.message;
|
|
168
|
+
finish(1);
|
|
169
|
+
});
|
|
170
|
+
driver.start();
|
|
171
|
+
options.onStart?.({ steer: (text) => driver.steer?.(text) });
|
|
172
|
+
if (options.signal?.aborted) onAbort();
|
|
173
|
+
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs one subagent step on a third-party harness instead of pi. The caller
|
|
3
|
+
* sees the same `PiRunResult` and stream events either way, so the workflow,
|
|
4
|
+
* the lobby feed and the edit log do not care which harness did the work.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { activityWord } from "../pi/activity.ts";
|
|
10
|
+
import { roleSpec } from "../roles/registry.ts";
|
|
11
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
12
|
+
import { shortDuration } from "../text.ts";
|
|
13
|
+
import type { PiRunOptions, PiRunResult } from "../execution/pi-runner.ts";
|
|
14
|
+
import { acpDriver } from "./acp.ts";
|
|
15
|
+
import { HARNESSES, harnessCommand, harnessEffort, harnessModelId } from "./catalog.ts";
|
|
16
|
+
import { claudeArgs, claudeDriver } from "./claude.ts";
|
|
17
|
+
import { codexArgs, codexDriver } from "./codex.ts";
|
|
18
|
+
import { acpPromptWithInstructions, harnessInstructions } from "./instructions.ts";
|
|
19
|
+
import { runHarnessProcess, type HarnessDriver, type HarnessIO, type HarnessOutcome, type HarnessProcessOptions, type HarnessSpawn } from "./process.ts";
|
|
20
|
+
import { loadClaudeSdk, runClaudeSdk } from "./claude-sdk.ts";
|
|
21
|
+
|
|
22
|
+
/** What one run is allowed to do, from its role's pi tool allowlist (absent: everything). */
|
|
23
|
+
export interface HarnessAccess {
|
|
24
|
+
writes: boolean;
|
|
25
|
+
shell: boolean;
|
|
26
|
+
web: boolean;
|
|
27
|
+
tools?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function accessOf(tools: readonly string[] | undefined): HarnessAccess {
|
|
31
|
+
if (!tools) return { writes: true, shell: true, web: true };
|
|
32
|
+
return {
|
|
33
|
+
writes: tools.includes("edit") || tools.includes("write"),
|
|
34
|
+
shell: tools.includes("bash"),
|
|
35
|
+
web: tools.some((tool) => tool === "web_search" || tool === "fetch_content" || tool === "web_fetch"),
|
|
36
|
+
tools,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One run's harness, model, effort and instructions, resolved from the settings. */
|
|
41
|
+
export interface HarnessPlan {
|
|
42
|
+
harness: HarnessRun;
|
|
43
|
+
/** The id the harness is given; empty leaves its default. */
|
|
44
|
+
model: string;
|
|
45
|
+
/** The harness's own effort value, when it takes one on the command line. */
|
|
46
|
+
effort?: string;
|
|
47
|
+
/** The agent's effort level in pi's names (ACP agents match it against their options). */
|
|
48
|
+
level: string;
|
|
49
|
+
instructions: string;
|
|
50
|
+
autoCompact?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What the stream told us, filled in by the driver. */
|
|
54
|
+
export interface HarnessCollector {
|
|
55
|
+
text: string;
|
|
56
|
+
usage: { input: number; output: number; cost: number; turns: number };
|
|
57
|
+
model?: string;
|
|
58
|
+
session?: string;
|
|
59
|
+
error?: string;
|
|
60
|
+
warning?: string;
|
|
61
|
+
stopReason?: string;
|
|
62
|
+
addUsage(input: number, output: number, cost: number, model?: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collector(): HarnessCollector {
|
|
66
|
+
const out: HarnessCollector = {
|
|
67
|
+
text: "",
|
|
68
|
+
usage: { input: 0, output: 0, cost: 0, turns: 0 },
|
|
69
|
+
addUsage(input, output, cost, model) {
|
|
70
|
+
out.usage.input += input;
|
|
71
|
+
out.usage.output += output;
|
|
72
|
+
out.usage.cost += cost;
|
|
73
|
+
out.usage.turns += 1;
|
|
74
|
+
if (model) out.model = model;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function planHarnessRun(options: Pick<PiRunOptions, "systemPrompt" | "thinking" | "autoCompact" | "agent"> & { harness: HarnessRun }): HarnessPlan {
|
|
81
|
+
const { harness } = options;
|
|
82
|
+
const level = options.thinking ?? "medium";
|
|
83
|
+
const effort = harnessEffort(harness.id, harness.settings, harness.model, level);
|
|
84
|
+
const model = harnessModelId(harness.id, harness.settings, harness.model, level);
|
|
85
|
+
const contract = options.agent ? roleSpec(options.agent.role).contract : undefined;
|
|
86
|
+
return {
|
|
87
|
+
harness,
|
|
88
|
+
model,
|
|
89
|
+
...(effort && HARNESSES[harness.id].effort === "flag" ? { effort } : {}),
|
|
90
|
+
level,
|
|
91
|
+
instructions: harnessInstructions({ harness, ...(options.systemPrompt ? { systemPrompt: options.systemPrompt } : {}), ...(contract ? { contract } : {}), ...(effort ? { effort } : {}) }),
|
|
92
|
+
...(options.autoCompact === false ? { autoCompact: false } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Starts one run and resolves when it ends: a harness process, or Claude Code through the Agent SDK. */
|
|
97
|
+
export type HarnessStart = (collected: HarnessCollector, options: HarnessProcessOptions) => Promise<HarnessOutcome>;
|
|
98
|
+
|
|
99
|
+
/** How one run starts; Claude Code goes through the Agent SDK when it is installed, else its CLI. */
|
|
100
|
+
export function harnessLaunch(plan: HarnessPlan, options: Pick<PiRunOptions, "cwd" | "env"> & { prompt: string; access: HarnessAccess }, promptFile: () => string): HarnessStart {
|
|
101
|
+
const launched = processLaunch(plan, options, promptFile);
|
|
102
|
+
const fromProcess: HarnessStart = (collected, watch) => runHarnessProcess(launched.spawn, watch, (io) => launched.driver(io, collected));
|
|
103
|
+
if (HARNESSES[plan.harness.id].protocol !== "claude-stream") return fromProcess;
|
|
104
|
+
return async (collected, watch) => {
|
|
105
|
+
const query = await loadClaudeSdk();
|
|
106
|
+
if (!query) return fromProcess(collected, watch);
|
|
107
|
+
return runClaudeSdk(query, plan, options.access, { cwd: options.cwd, env: launched.spawn.env, launchArgs: launched.launchArgs, prompt: options.prompt }, collected, watch);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The process to start and the driver that speaks to it. */
|
|
112
|
+
function processLaunch(plan: HarnessPlan, options: Pick<PiRunOptions, "cwd" | "env"> & { prompt: string; access: HarnessAccess }, promptFile: () => string): { spawn: HarnessSpawn; launchArgs: string[]; driver: (io: HarnessIO, collected: HarnessCollector) => HarnessDriver } {
|
|
113
|
+
const { id, settings } = plan.harness;
|
|
114
|
+
const info = HARNESSES[id];
|
|
115
|
+
const { command, args: launchArgs } = harnessCommand(id, settings);
|
|
116
|
+
if (!command) throw new Error(`${info.label} has no command yet: set it in Settings > Harnesses`);
|
|
117
|
+
const { access } = options;
|
|
118
|
+
const env: Record<string, string | undefined> = { ...process.env, ...options.env };
|
|
119
|
+
if (info.homeEnv && settings.homePath) env[info.homeEnv] = settings.homePath;
|
|
120
|
+
if (id === "antigravity" && settings.harnessPath) env.ANTIGRAVITY_HARNESS_PATH = settings.harnessPath;
|
|
121
|
+
const task = options.prompt;
|
|
122
|
+
if (info.protocol === "codex-exec") {
|
|
123
|
+
const built = codexArgs(plan, access, launchArgs);
|
|
124
|
+
const prompt = built.promptPrefix ? acpPromptWithInstructions(task, built.promptPrefix) : task;
|
|
125
|
+
return { spawn: { command, args: built.args, cwd: options.cwd, env }, launchArgs, driver: (io, collected) => codexDriver(io, plan, prompt, collected) };
|
|
126
|
+
}
|
|
127
|
+
if (info.protocol === "claude-stream") {
|
|
128
|
+
const built = claudeArgs(plan, access, launchArgs, plan.instructions ? promptFile() : undefined);
|
|
129
|
+
return { spawn: { command, args: built.args, cwd: options.cwd, env: { ...env, ...built.env } }, launchArgs, driver: (io, collected) => claudeDriver(io, task, collected) };
|
|
130
|
+
}
|
|
131
|
+
const prompt = acpPromptWithInstructions(task, plan.instructions);
|
|
132
|
+
return { spawn: { command, args: launchArgs, cwd: options.cwd, env }, launchArgs, driver: (io, collected) => acpDriver(io, plan, prompt, collected, access, options.cwd) };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toResult(collected: HarnessCollector, outcome: HarnessOutcome, aborted: boolean, options: PiRunOptions, label: string): PiRunResult {
|
|
136
|
+
const base = { output: collected.text, usage: collected.usage, ...(collected.model ? { model: collected.model } : {}) };
|
|
137
|
+
if (outcome.stalled) return { ...base, status: "timeout", stalled: true, error: `stalled: no output for ${shortDuration(options.stallTimeoutMs ?? 0)}` };
|
|
138
|
+
if (outcome.killed || aborted || collected.stopReason === "aborted") return { ...base, status: "cancelled", error: "agent was cancelled" };
|
|
139
|
+
if (outcome.spawnError) return { ...base, status: "failed", error: `${label}: ${outcome.spawnError}` };
|
|
140
|
+
if (collected.error) return { ...base, status: "failed", error: `${label}: ${collected.error}` };
|
|
141
|
+
if (outcome.exitCode !== 0 && !collected.text.trim()) {
|
|
142
|
+
const detail = collected.warning ?? lastLines(outcome.stderr);
|
|
143
|
+
return { ...base, status: "failed", error: `${label}: ${detail || `exited with code ${outcome.exitCode}`}` };
|
|
144
|
+
}
|
|
145
|
+
if (!collected.text.trim()) return { ...base, status: "failed", error: `${label}: the agent produced no usable output${collected.warning ? ` (${collected.warning})` : ""}` };
|
|
146
|
+
return { ...base, status: "success" };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function lastLines(text: string): string {
|
|
150
|
+
return text.trim().split("\n").slice(-3).join(" ").slice(0, 400);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Run one subagent step on its harness and normalise the outcome like a pi run. */
|
|
154
|
+
export async function runHarnessAgent(options: PiRunOptions & { harness: HarnessRun }): Promise<PiRunResult> {
|
|
155
|
+
const empty = { status: "cancelled" as const, output: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
|
|
156
|
+
if (options.signal?.aborted) return empty;
|
|
157
|
+
const plan = planHarnessRun(options);
|
|
158
|
+
const label = HARNESSES[plan.harness.id].label;
|
|
159
|
+
let dir: string | undefined;
|
|
160
|
+
const promptFile = () => {
|
|
161
|
+
dir ??= mkdtempSync(join(tmpdir(), "bot-lobby-harness-"));
|
|
162
|
+
const file = join(dir, "instructions.md");
|
|
163
|
+
writeFileSync(file, plan.instructions, { encoding: "utf8", mode: 0o600 });
|
|
164
|
+
return file;
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
let start: HarnessStart;
|
|
168
|
+
try {
|
|
169
|
+
start = harnessLaunch(plan, { cwd: options.cwd, ...(options.env ? { env: options.env } : {}), prompt: `Task: ${options.task}`, access: accessOf(options.tools) }, promptFile);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return { ...empty, status: "failed", error: (error as Error).message };
|
|
172
|
+
}
|
|
173
|
+
const collected = collector();
|
|
174
|
+
const outcome = await start(collected, {
|
|
175
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
176
|
+
...(options.stallTimeoutMs !== undefined ? { stallTimeoutMs: options.stallTimeoutMs } : {}),
|
|
177
|
+
...(options.toolStallTimeoutMs !== undefined ? { toolStallTimeoutMs: options.toolStallTimeoutMs } : {}),
|
|
178
|
+
...(options.onStart ? { onStart: options.onStart } : {}),
|
|
179
|
+
onEvent: (event) => {
|
|
180
|
+
if (event.type === "tool_execution_start") options.onActivity?.(activityWord(event.toolName));
|
|
181
|
+
options.onEvent?.(event);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
return toResult(collected, outcome, options.signal?.aborted === true, options, label);
|
|
185
|
+
} finally {
|
|
186
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether each harness is installed, and its version, for the settings page.
|
|
3
|
+
* The command is looked up on PATH without running it; only the CLIs with a
|
|
4
|
+
* cheap `--version` are asked for one (Antigravity's ACP server is a large
|
|
5
|
+
* bundle, so it is never started just to probe it, as T3 Code's
|
|
6
|
+
* `AntigravityDriver.ts` also avoids).
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
10
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
11
|
+
import { HARNESS_IDS, type HarnessId, type HarnessesConfig } from "../schemas/harness.ts";
|
|
12
|
+
import { HARNESSES, harnessCommand, harnessModels, type HarnessModel } from "./catalog.ts";
|
|
13
|
+
|
|
14
|
+
export interface HarnessStatus {
|
|
15
|
+
id: HarnessId;
|
|
16
|
+
label: string;
|
|
17
|
+
blurb: string;
|
|
18
|
+
protocol: string;
|
|
19
|
+
/** The command a run starts, as the settings leave it. */
|
|
20
|
+
command: string;
|
|
21
|
+
/** Where it was found; absent when it was not. */
|
|
22
|
+
path?: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
providers: ReadonlyArray<{ value: string; label: string }>;
|
|
25
|
+
effort: "flag" | "variant" | "session";
|
|
26
|
+
models: HarnessModel[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const VERSION_TIMEOUT_MS = 4000;
|
|
30
|
+
const CACHE_MS = 60_000;
|
|
31
|
+
const VERSIONED: ReadonlySet<HarnessId> = new Set(["codex", "claude", "gemini", "opencode"]);
|
|
32
|
+
|
|
33
|
+
function executable(path: string): boolean {
|
|
34
|
+
try {
|
|
35
|
+
if (!statSync(path).isFile()) return false;
|
|
36
|
+
accessSync(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The file a command runs, from PATH (with Windows' extensions) unless it is a path already. */
|
|
44
|
+
export function findCommand(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
45
|
+
if (!command) return undefined;
|
|
46
|
+
if (isAbsolute(command) || command.includes("/")) return executable(command) ? command : undefined;
|
|
47
|
+
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
48
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
49
|
+
if (!dir) continue;
|
|
50
|
+
for (const extension of extensions) {
|
|
51
|
+
const candidate = join(dir, command + extension);
|
|
52
|
+
if (executable(candidate)) return candidate;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const versions = new Map<string, { at: number; version?: string }>();
|
|
59
|
+
|
|
60
|
+
function readVersion(path: string): Promise<string | undefined> {
|
|
61
|
+
const cached = versions.get(path);
|
|
62
|
+
if (cached && Date.now() - cached.at < CACHE_MS) return Promise.resolve(cached.version);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let out = "";
|
|
65
|
+
let settled = false;
|
|
66
|
+
const done = (version?: string) => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
versions.set(path, { at: Date.now(), ...(version ? { version } : {}) });
|
|
70
|
+
resolve(version);
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const proc = spawn(path, ["--version"], { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, NO_COLOR: "1" } });
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
proc.kill("SIGKILL");
|
|
76
|
+
done();
|
|
77
|
+
}, VERSION_TIMEOUT_MS);
|
|
78
|
+
timer.unref();
|
|
79
|
+
proc.stdout?.setEncoding("utf8");
|
|
80
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
81
|
+
if (out.length < 2000) out += chunk;
|
|
82
|
+
});
|
|
83
|
+
proc.on("error", () => done());
|
|
84
|
+
proc.on("close", () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
done(/\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(out)?.[0]);
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
done();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every harness with what the settings page shows about it. */
|
|
95
|
+
export async function harnessStatuses(harnesses: HarnessesConfig): Promise<HarnessStatus[]> {
|
|
96
|
+
return Promise.all(HARNESS_IDS.map(async (id) => {
|
|
97
|
+
const info = HARNESSES[id];
|
|
98
|
+
const settings = harnesses[id];
|
|
99
|
+
const { command } = harnessCommand(id, settings);
|
|
100
|
+
const path = findCommand(command);
|
|
101
|
+
const version = path && VERSIONED.has(id) ? await readVersion(path) : undefined;
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
label: info.label,
|
|
105
|
+
blurb: info.blurb,
|
|
106
|
+
protocol: info.protocol,
|
|
107
|
+
command,
|
|
108
|
+
...(path ? { path } : {}),
|
|
109
|
+
...(version ? { version } : {}),
|
|
110
|
+
providers: info.providers,
|
|
111
|
+
effort: info.effort,
|
|
112
|
+
models: harnessModels(id, settings),
|
|
113
|
+
};
|
|
114
|
+
}));
|
|
115
|
+
}
|
package/src/lobby/host.ts
CHANGED
|
@@ -150,6 +150,8 @@ export interface LobbyService {
|
|
|
150
150
|
models?(): ModelChoice[];
|
|
151
151
|
/** The MCP tools and skills pi offers to grant agents, for the settings page. */
|
|
152
152
|
grants?(): { mcpTools: Array<{ name: string; server: string; description: string; readOnly: boolean }>; skills: Array<{ name: string; description: string; path: string }> };
|
|
153
|
+
/** Each harness's status and models; absent probes the machine (the dev server answers with a fixture). */
|
|
154
|
+
harnesses?(): import("../harness/status.ts").HarnessStatus[];
|
|
153
155
|
/** The classifier models pi can call with its logins, for the classifier's picker. */
|
|
154
156
|
classifierModels?(): Array<{ id: string; label: string }>;
|
|
155
157
|
/** The model this window's session runs on (`provider/id`), when it has one. */
|
package/src/lobby/planner.ts
CHANGED
|
@@ -1008,8 +1008,9 @@ export class PlanningSession {
|
|
|
1008
1008
|
model,
|
|
1009
1009
|
thinking,
|
|
1010
1010
|
mcpTools: profile.mcpTools,
|
|
1011
|
-
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
1012
1011
|
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
1012
|
+
// Seats may run on a harness; the planner itself stays on pi (its settings cannot name one).
|
|
1013
|
+
...(profile.harness ? { harness: profile.harness } : {}),
|
|
1013
1014
|
onChildRun: (child) => {
|
|
1014
1015
|
this.deps.feed?.step(label, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
|
|
1015
1016
|
if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
|
package/src/lobby/pr-review.ts
CHANGED
|
@@ -280,6 +280,7 @@ export class PullReviews {
|
|
|
280
280
|
thinking,
|
|
281
281
|
mcpTools: profile.mcpTools,
|
|
282
282
|
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
283
|
+
...(profile.harness ? { harness: profile.harness } : {}),
|
|
283
284
|
onChildRun: (child) => {
|
|
284
285
|
this.deps.feed?.step(REVIEW_SOURCE, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
|
|
285
286
|
if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
|
package/src/lobby/quickfix.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type { LintExec } from "../execution/lint.ts";
|
|
|
25
25
|
import { scriptsFor, type ScriptPicker } from "../scripts/pick.ts";
|
|
26
26
|
import { dataRoot } from "../state/project.ts";
|
|
27
27
|
import { lintFeed, runLintGate } from "../workflow/lint.ts";
|
|
28
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
28
29
|
|
|
29
30
|
/** A quick fix edits code, so it gets the full coding tool set. */
|
|
30
31
|
export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
@@ -95,6 +96,8 @@ export interface QuickFixProfile {
|
|
|
95
96
|
skills?: string[];
|
|
96
97
|
/** False when its settings turn the agent's auto-compaction off. */
|
|
97
98
|
autoCompact?: boolean;
|
|
99
|
+
/** The third-party harness it runs on; absent runs it on pi. */
|
|
100
|
+
harness?: HarnessRun;
|
|
98
101
|
/** Where the job goes when its model runs out of usage or is unavailable. */
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -343,8 +346,8 @@ export class QuickFixQueue {
|
|
|
343
346
|
model,
|
|
344
347
|
thinking,
|
|
345
348
|
mcpTools: profile.mcpTools,
|
|
346
|
-
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
347
349
|
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
350
|
+
...(profile.harness ? { harness: profile.harness } : {}),
|
|
348
351
|
delegationQuota: quota,
|
|
349
352
|
agent: { runId: job.id, taskId: job.id, domain: "backend", role: "worker" },
|
|
350
353
|
onChildRun: (child) => {
|
package/src/master/master.ts
CHANGED
|
@@ -59,7 +59,7 @@ export interface ScoutRequest {
|
|
|
59
59
|
/** Model, thinking and grants for one run, from settings. */
|
|
60
60
|
function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
|
|
61
61
|
const profile = profileFor(config, resolver, domain, role);
|
|
62
|
-
return { model: profile.model, mcpTools: profile.mcpTools, ...(profile.skills ? { skills: profile.skills } : {}), ...(profile.autoCompact === false ? { autoCompact: false } : {}), thinking: profile.thinking };
|
|
62
|
+
return { model: profile.model, mcpTools: profile.mcpTools, ...(profile.skills ? { skills: profile.skills } : {}), ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.harness ? { harness: profile.harness } : {}), thinking: profile.thinking };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
function scoutInstruction(request: ScoutRequest, domain: Domain): string {
|
|
@@ -198,8 +198,8 @@ async function scoutDomains(request: ScoutRequest, run: ProcessRunner): Promise<
|
|
|
198
198
|
onUpdate: request.onUpdate,
|
|
199
199
|
...watchdogOptions(request.config.workflow),
|
|
200
200
|
}));
|
|
201
|
-
// Scouts think at a fixed level; a trivial one may move to the cheaper model.
|
|
202
|
-
const routes = await Promise.all(requests.map((entry) => routeFor(request.effort, entry.instruction, { ...(entry.model ? { model: entry.model } : {}), thinking: entry.thinking ?? "low" }, { thinkingFixed: true, context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) })));
|
|
201
|
+
// Scouts think at a fixed level; a trivial one may move to the cheaper model (a pi model, so not on another harness).
|
|
202
|
+
const routes = await Promise.all(requests.map((entry) => entry.harness ? undefined : routeFor(request.effort, entry.instruction, { ...(entry.model ? { model: entry.model } : {}), thinking: entry.thinking ?? "low" }, { thinkingFixed: true, context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) })));
|
|
203
203
|
const first = requests.map((entry, index) => (routes[index] ? { ...entry, ...routedFields(routes[index]!) } : entry));
|
|
204
204
|
const runs = await runParallel(first, request.config.workflow.maxParallelScouts, run);
|
|
205
205
|
let outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
|
package/src/master/research.ts
CHANGED
|
@@ -71,6 +71,7 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
|
|
|
71
71
|
mcpTools: profile.mcpTools,
|
|
72
72
|
...(profile.skills ? { skills: profile.skills } : {}),
|
|
73
73
|
...(profile.autoCompact === false ? { autoCompact: false } : {}),
|
|
74
|
+
...(profile.harness ? { harness: profile.harness } : {}),
|
|
74
75
|
thinking: profile.thinking,
|
|
75
76
|
cwd: request.cwd,
|
|
76
77
|
signal: request.signal,
|
package/src/pi/model-support.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type SubagentKind,
|
|
14
14
|
type ThinkingLevelName,
|
|
15
15
|
} from "../schemas/configuration.ts";
|
|
16
|
+
import type { HarnessRun } from "../schemas/harness.ts";
|
|
16
17
|
|
|
17
18
|
/** Find a model by `provider/id` (or bare id); undefined when the registry does not know it. */
|
|
18
19
|
export type ModelLookup = (ref: string) => Model<Api> | undefined;
|
|
@@ -78,6 +79,8 @@ const warned = new Set<string>();
|
|
|
78
79
|
export function createProfileResolver(config: BotLobbyConfig, options: ResolverOptions): ProfileResolver {
|
|
79
80
|
return (domain, role) => {
|
|
80
81
|
const profile = agentProfile(config, domain, role);
|
|
82
|
+
// A harness run picks its own model and effort; pi's model and its thinking clamp do not apply.
|
|
83
|
+
if (profile.harness) return profile;
|
|
81
84
|
const model = profile.model ?? options.sessionModel;
|
|
82
85
|
const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
|
|
83
86
|
if (check.warning) {
|
|
@@ -91,10 +94,11 @@ export function createProfileResolver(config: BotLobbyConfig, options: ResolverO
|
|
|
91
94
|
};
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
type RunProfile = { model?: string; thinking: string; instructions?: string; mcpTools?: string[]; skills?: string[] };
|
|
97
|
+
type RunProfile = { model?: string; thinking: string; instructions?: string; mcpTools?: string[]; skills?: string[]; autoCompact?: boolean; harness?: HarnessRun };
|
|
95
98
|
|
|
96
99
|
/** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
|
|
97
100
|
function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
|
|
101
|
+
if (profile.harness) return profile;
|
|
98
102
|
const model = profile.model ?? options.sessionModel;
|
|
99
103
|
const check = checkThinking(model ? options.lookup(model) : undefined, profile.thinking);
|
|
100
104
|
if (check.warning) {
|
|
@@ -129,7 +133,7 @@ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember,
|
|
|
129
133
|
*/
|
|
130
134
|
export function resolveReviewProfile(config: BotLobbyConfig, options: ResolverOptions): RunProfile {
|
|
131
135
|
const profile = agentProfile(config, "qa", "reviewer");
|
|
132
|
-
return resolveRunProfile({ model: profile.model, thinking: profile.thinking, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.mcpTools ? { mcpTools: profile.mcpTools } : {}), ...(profile.skills ? { skills: profile.skills } : {}) }, kindLabel("qa"), options);
|
|
136
|
+
return resolveRunProfile({ model: profile.model, thinking: profile.thinking, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.mcpTools ? { mcpTools: profile.mcpTools } : {}), ...(profile.skills ? { skills: profile.skills } : {}), ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.harness ? { harness: profile.harness } : {}) }, kindLabel("qa"), options);
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
/** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
|