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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/THIRD_PARTY_NOTICES.md +12 -1
  2. package/package.json +4 -1
  3. package/src/execution/agent-runner.ts +7 -0
  4. package/src/execution/delegation.ts +3 -1
  5. package/src/execution/pi-runner.ts +14 -6
  6. package/src/harness/acp.ts +272 -0
  7. package/src/harness/catalog.ts +257 -0
  8. package/src/harness/claude-sdk.ts +169 -0
  9. package/src/harness/claude.ts +188 -0
  10. package/src/harness/cli-args.ts +62 -0
  11. package/src/harness/codex.ts +155 -0
  12. package/src/harness/instructions.ts +45 -0
  13. package/src/harness/model-manifest.json +829 -0
  14. package/src/harness/process.ts +175 -0
  15. package/src/harness/runner.ts +188 -0
  16. package/src/harness/status.ts +115 -0
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/planner.ts +3 -2
  19. package/src/lobby/pr-review.ts +2 -1
  20. package/src/lobby/quickfix.ts +6 -2
  21. package/src/master/goal-check.ts +1 -0
  22. package/src/master/master.ts +3 -3
  23. package/src/master/research.ts +2 -1
  24. package/src/pi/auto-compaction.ts +23 -5
  25. package/src/pi/delegation.ts +5 -3
  26. package/src/pi/grants.ts +10 -0
  27. package/src/pi/model-support.ts +6 -2
  28. package/src/pi/quiet.ts +2 -0
  29. package/src/schemas/configuration.ts +67 -23
  30. package/src/schemas/harness.ts +122 -0
  31. package/src/webui/api/index.ts +2 -1
  32. package/src/webui/api/settings.ts +7 -1
  33. package/src/webui/dev/fake-service.ts +13 -0
  34. package/src/webui/protocol.ts +9 -0
  35. package/webui/dist/assets/{TerminalView-CIfjkb5H.js → TerminalView-C8VC7KXy.js} +1 -1
  36. package/webui/dist/assets/index-Bs1_77_1.js +114 -0
  37. package/webui/dist/assets/index-DOLQ4FDd.css +2 -0
  38. package/webui/dist/build.json +16 -12
  39. package/webui/dist/index.html +2 -2
  40. package/webui/dist/assets/index-BkXhtB6A.css +0 -2
  41. package/webui/dist/assets/index-DIM2-FUQ.js +0 -114
@@ -0,0 +1,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. */
@@ -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
- ...(profile.autoCompact === false ? { autoCompact: false } : {}),
1011
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
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");
@@ -279,7 +279,8 @@ export class PullReviews {
279
279
  model,
280
280
  thinking,
281
281
  mcpTools: profile.mcpTools,
282
- ...(profile.autoCompact === false ? { autoCompact: false } : {}),
282
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
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");
@@ -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,9 @@ 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;
101
+ compactAt?: number;
98
102
  /** Where the job goes when its model runs out of usage or is unavailable. */
99
103
  }
100
104
 
@@ -343,8 +347,8 @@ export class QuickFixQueue {
343
347
  model,
344
348
  thinking,
345
349
  mcpTools: profile.mcpTools,
346
- ...(profile.autoCompact === false ? { autoCompact: false } : {}),
347
- ...(profile.autoCompact === false ? { autoCompact: false } : {}),
350
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
351
+ ...(profile.harness ? { harness: profile.harness } : {}),
348
352
  delegationQuota: quota,
349
353
  agent: { runId: job.id, taskId: job.id, domain: "backend", role: "worker" },
350
354
  onChildRun: (child) => {
@@ -101,6 +101,7 @@ export async function runGoalCheck(request: GoalCheckRequest, run: ProcessRunner
101
101
  instruction: goalCheckInstruction(request),
102
102
  context: { task: request.request },
103
103
  systemPrompt: goalCheckPrompt(profile.instructions),
104
+ mcpTools: profile.mcpTools,
104
105
  ...(profile.model ? { model: profile.model } : {}),
105
106
  thinking: profile.thinking,
106
107
  cwd: request.cwd,
@@ -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.compactAt ? { compactAt: profile.compactAt } : {}), ...(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));
@@ -70,7 +70,8 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
70
70
  model: profile.model,
71
71
  mcpTools: profile.mcpTools,
72
72
  ...(profile.skills ? { skills: profile.skills } : {}),
73
- ...(profile.autoCompact === false ? { autoCompact: false } : {}),
73
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}), ...(profile.compactAt ? { compactAt: profile.compactAt } : {}),
74
+ ...(profile.harness ? { harness: profile.harness } : {}),
74
75
  thinking: profile.thinking,
75
76
  cwd: request.cwd,
76
77
  signal: request.signal,
@@ -1,7 +1,8 @@
1
1
  import { compact, CONFIG_DIR_NAME, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, type ExtensionAPI, type ExtensionContext, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
2
2
  import { contextMarks, freshContextOn, staleCompaction } from "./fresh-context.ts";
3
- import { AUTO_COMPACT_ENV, isSubagentProcess } from "./quiet.ts";
3
+ import { AUTO_COMPACT_ENV, COMPACT_AT_ENV, isSubagentProcess } from "./quiet.ts";
4
4
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
5
+ import { normalizeCompactAt } from "../schemas/configuration.ts";
5
6
  import { describeShared, DIGEST_INSTRUCTION, parseDigest, shareDigest, type DigestItem } from "../knowledge/oracle-digest.ts";
6
7
 
7
8
  const CHECKPOINT = "Write a concise checkpoint with: Requirements and constraints; Decisions; Files changed; Verification and evidence; Failed approaches (do not repeat); Unfinished work and next action; Outstanding children and their assignments. Preserve concrete file references and distinguish verified facts from guesses. The parent remains responsible for integration and verification.";
@@ -9,6 +10,8 @@ const CHECKPOINT = "Write a concise checkpoint with: Requirements and constraint
9
10
  export interface AutoCompactionOptions {
10
11
  /** Whether this agent compacts on its own: a subagent by its environment, the oracle by its settings. */
11
12
  enabled?: () => boolean;
13
+ /** The token count set in this agent's settings to compact at; by default its environment, or the oracle's settings. */
14
+ target?: () => number | undefined;
12
15
  /** Where the oracle's shared knowledge goes; by default the project's knowledge files. */
13
16
  share?: (items: DigestItem[], ctx: ExtensionContext) => string;
14
17
  }
@@ -22,6 +25,15 @@ function enabledBySettings(): boolean {
22
25
  }
23
26
  }
24
27
 
28
+ function targetBySettings(): number | undefined {
29
+ if (isSubagentProcess()) return normalizeCompactAt(Number(process.env[COMPACT_AT_ENV]));
30
+ try {
31
+ return loadConfig().master.compactAt;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
25
37
  function shareWithAgents(items: DigestItem[], ctx: ExtensionContext): string {
26
38
  const root = detectProjectRoot(ctx.cwd, CONFIG_DIR_NAME);
27
39
  return describeShared(shareDigest(dataRoot(root, CONFIG_DIR_NAME), items));
@@ -29,18 +41,24 @@ function shareWithAgents(items: DigestItem[], ctx: ExtensionContext): string {
29
41
 
30
42
  export const CONTEXT_TARGET = 64_000;
31
43
 
32
- export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens): number {
33
- return Math.max(1, Math.min(CONTEXT_TARGET, Math.floor(window * 0.6), window - Math.min(reserve, Math.floor(window / 2))));
44
+ /**
45
+ * When to compact: a threshold set in Settings, else 64K or 60% of the window, whichever is smaller. Either way it
46
+ * stays below the window less pi's reserve, so the checkpoint still has room to be written.
47
+ */
48
+ export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION_SETTINGS.reserveTokens, target?: number): number {
49
+ const room = window - Math.min(reserve, Math.floor(window / 2));
50
+ return Math.max(1, Math.min(target ?? Math.min(CONTEXT_TARGET, Math.floor(window * 0.6)), room));
34
51
  }
35
52
 
36
53
  /**
37
54
  * Compact at a settled tool boundary, below pi's own threshold (pi 1.3 still sets that only per model in settings, and
38
- * ctx.compact() would abort the turn we need to continue). Each agent's settings can turn it off. Compactions pi
55
+ * ctx.compact() would abort the turn we need to continue). Each agent's settings can turn it off or set the token count it starts at. Compactions pi
39
56
  * starts itself get the same checkpoint through `session_before_compact`. The oracle's checkpoint also lists what it learned that is worth keeping, tagged by agent, and
40
57
  * that goes into the knowledge base: its own, and the designer's, backend's and QA's where it concerns them.
41
58
  */
42
59
  export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compact = compact, options: AutoCompactionOptions = {}): void {
43
60
  const enabled = options.enabled ?? enabledBySettings;
61
+ const target = options.target ?? targetBySettings;
44
62
  const share = options.share ?? shareWithAgents;
45
63
  const oracle = !isSubagentProcess();
46
64
  let busy = false;
@@ -53,7 +71,7 @@ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compa
53
71
  const configured = pi.getSettings().compaction;
54
72
  const override = configured?.modelOverrides?.[`${ctx.model.provider}/${ctx.model.id}`];
55
73
  const reserve = Math.min(override?.reserveTokens ?? configured?.reserveTokens ?? DEFAULT_COMPACTION_SETTINGS.reserveTokens, Math.floor(ctx.model.contextWindow / 2));
56
- const threshold = compactionThreshold(ctx.model.contextWindow, reserve);
74
+ const threshold = compactionThreshold(ctx.model.contextWindow, reserve, target());
57
75
  const estimate = event.context.contextMessages.reduce((total, message) => total + estimateTokens(message), 0) + Math.ceil(ctx.getSystemPrompt().length / 4);
58
76
  const usage = ctx.getContextUsage();
59
77
  if (Math.max(estimate, usage?.tokens ?? 0) < threshold) return;