Skip to content
16 changes: 11 additions & 5 deletions packages/adapters/antigravity/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ switchboard's supervised child lifecycle.
## Model catalog

The probe reported human-facing names such as `Gemini 3.5 Flash (High)`.
`listModels()` runs `agy models` locally with a fixed argv and timeout, reports
slug-safe ids to Codor, and owns the reverse mapping back to display names at
spawn time. The catalog is never hard-coded here. If two names collapse to the
same slug, discovery fails rather than routing an operator selection to the
wrong model.
`listModels()` runs `agy models` as an async child with a fixed argv, a 20 s budget and
capped output, reports slug-safe ids to Codor, and owns the reverse mapping back to
display names at spawn time. The probe never occupies the event loop, and the child is
confirmed terminated on every path, including the timeout. The 20 s budget matches the
opencode probe; no slow `agy models` run was measured here, so a slower run surfaces as
the same retryable failure. The catalog is never hard-coded
here. If two names collapse to the same slug, discovery fails rather than routing an
operator selection to the wrong model; any failure is recorded per harness (`models_error`,
a controlled category, never raw CLI text) so the dialog offers a retry. The probe keeps
no CLI output on failure, so to diagnose a failing harness run `agy models` directly on
the daemon host.
<!-- harn:end adapters-own-their-model-catalog -->

## Resume boundary
Expand Down
63 changes: 63 additions & 0 deletions packages/adapters/antigravity/src/adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,69 @@ describe('AntigravityAdapter', () => {
await expect(collision.listModels()).rejects.toThrow("collide at slug 'a-b'");
});

it('lets the event loop run while the CLI answers', async () => {
// Requirement: async bounded probe — discovery must not freeze the daemon.
const dir = mkdtempSync(join(tmpdir(), 'codor-antigravity-'));
temporary.push(dir);
const path = join(dir, 'agy');
writeFileSync(path, '#!/usr/bin/env node\nsetTimeout(() => { process.stdout.write("Gemini 3.5 Flash (High)\\n"); }, 1200);\n');
chmodSync(path, 0o755);
const start = Date.now();
let firedAt = -1;
setTimeout(() => { firedAt = Date.now(); }, 20);
const catalog = await new AntigravityAdapter(path).listModels();
expect(catalog.models).toEqual(['gemini-3.5-flash-high']);
expect(firedAt).toBeGreaterThanOrEqual(start);
expect(firedAt - start).toBeLessThan(1000);
});

it('fails rather than buffering unbounded CLI output', async () => {
// Requirement: bounded probe — a runaway listing must fail, not balloon memory.
const dir = mkdtempSync(join(tmpdir(), 'codor-antigravity-'));
temporary.push(dir);
const path = join(dir, 'agy');
writeFileSync(path, "#!/usr/bin/env node\nprocess.stdout.write('x'.repeat(1_200_000));\n");
chmodSync(path, 0o755);
await expect(new AntigravityAdapter(path).listModels()).rejects.toThrow(/exceeded/);
});

it('terminates the child when the budget runs out', async () => {
// Requirement: confirmed cleanup — no orphan survives a timed-out probe.
const dir = mkdtempSync(join(tmpdir(), 'codor-antigravity-'));
temporary.push(dir);
const path = join(dir, 'agy');
const pidFile = join(dir, 'pid');
writeFileSync(path, `#!/usr/bin/env node
require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));
setInterval(() => {}, 1000);
`);
chmodSync(path, 0o755);
await expect(new AntigravityAdapter(path, 2000).listModels()).rejects.toThrow(/timed out/);
const pid = Number(readFileSync(pidFile, 'utf8'));
expect(Number.isInteger(pid)).toBe(true);
let alive = true;
try {
process.kill(pid, 0);
} catch {
alive = false;
}
expect(alive).toBe(false);
});

it('keeps CLI diagnostics out of probe errors', async () => {
// Requirement: harness stderr can carry secrets; it must not enter error text.
const dir = mkdtempSync(join(tmpdir(), 'codor-antigravity-'));
temporary.push(dir);
const path = join(dir, 'agy');
writeFileSync(path, "#!/usr/bin/env node\nprocess.stderr.write('AUTH_TOKEN=hunter2\\n');\nprocess.exit(1);\n");
chmodSync(path, 0o755);
const outcome = await new AntigravityAdapter(path).listModels().then(
() => 'resolved',
(reason: unknown) => (reason instanceof Error ? reason.message : String(reason)),
);
expect(outcome).toBe(`Command failed: ${path} models (exit 1)`);
});

it('classifies missing commands and nonzero exits as failed', async () => {
const missing = new AntigravityAdapter(join(tmpdir(), 'codor-agy-missing'));
expect((await collect(missing, missing.spawn({ cwd: process.cwd() }), 'hello')).at(-1))
Expand Down
110 changes: 98 additions & 12 deletions packages/adapters/antigravity/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,93 @@ function finalOutput(output: string, truncated: boolean): string {
return `${utf8Prefix(output, MAX_OUTPUT_BYTES - markerBytes)}${TRUNCATED}`;
}

/** Budget for the async `agy models` probe: covers the observed worst case
* without ever stalling the daemon, since the child no longer occupies the loop. */
const MODEL_DISCOVERY_TIMEOUT_MS = 20_000;
/** Hard cap on captured `agy models` stdout, in bytes. */
const MODEL_DISCOVERY_MAX_BYTES = 1_000_000;

/** Confirm a discovery child is gone: SIGTERM, then SIGKILL on a finite bound. */
async function stopDiscoveryProbe(child: ChildProcess, label: string): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await new Promise<void>((resolve, reject) => {
const finish = (error?: Error): void => {
clearTimeout(escalate); clearTimeout(deadline); child.off('exit', exited);
if (error) reject(error); else resolve();
};
const exited = (): void => finish();
const escalate = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* exit confirmation below remains required */ }
}, 1_000);
const deadline = setTimeout(() => finish(new Error(`${label} model probe did not exit`)), 2_000);
child.once('exit', exited);
try {
child.kill('SIGTERM');
} catch (error) { finish(error instanceof Error ? error : new Error(`${label} model probe cleanup failed`)); }
});
}

/**
* Run `command models` without occupying the event loop: async spawn, capped
* output, timeout rejection, and confirmed child termination on every path.
*/
async function runBoundedModelsCommand(
command: string,
timeoutMs: number,
maxBytes: number,
): Promise<string> {
let child: ChildProcess | undefined;
let deadline: ReturnType<typeof setTimeout> | undefined;
const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : MODEL_DISCOVERY_TIMEOUT_MS;
const work = async (): Promise<string> => {
const spawned = spawn(command, ['models'], { stdio: ['ignore', 'pipe', 'ignore'] });
child = spawned;
return await new Promise<string>((resolve, reject) => {
let stdout = '';
let stdoutBytes = 0;
let settled = false;
const fail = (error: Error): void => { if (!settled) { settled = true; reject(error); } };
const done = (value: string): void => { if (!settled) { settled = true; resolve(value); } };
spawned.stdout?.setEncoding('utf8');
spawned.stdout?.on('data', (chunk: string) => {
stdoutBytes += Buffer.byteLength(chunk, 'utf8');
if (stdoutBytes > maxBytes) {
fail(new Error(`${command} models output exceeded ${String(maxBytes)} bytes`));
return;
}
stdout += chunk;
});
spawned.once('error', (error) => {
fail(error instanceof Error ? error : new Error(String(error)));
});
spawned.once('close', (code, signal) => {
// No stderr body here: harness diagnostics can carry secrets, and this
// text reaches both the server log and the client category. Exit evidence only.
if (code !== 0) {
fail(new Error(
`Command failed: ${command} models${signal ? ` (signal ${signal})` : ` (exit ${String(code)})`}`,
));
return;
}
done(stdout);
});
});
};
try {
return await Promise.race([
work(),
new Promise<never>((_resolve, reject) => {
deadline = setTimeout(() => {
reject(new Error(`${command} models timed out after ${String(budget)}ms`));
}, budget);
}),
]);
} finally {
clearTimeout(deadline);
if (child) await stopDiscoveryProbe(child, command);
}
}

export interface AntigravityInvocation {
mode: 'plan' | 'accept-edits';
skipPermissions: boolean;
Expand Down Expand Up @@ -116,7 +203,10 @@ export class AntigravityAdapter implements HarnessAdapter {
private readonly children = new WeakMap<Session, ChildProcess>();
private readonly displayNames = new Map<string, string>();

constructor(private readonly command = 'agy') {}
constructor(
private readonly command = 'agy',
private readonly modelDiscoveryTimeoutMs = MODEL_DISCOVERY_TIMEOUT_MS,
) {}

spawn(opts: SpawnOpts): Session {
antigravityMode(opts.policy);
Expand All @@ -132,17 +222,13 @@ export class AntigravityAdapter implements HarnessAdapter {
}

// harn:assume adapters-own-their-model-catalog ref=antigravity-model-catalog
listModels(): Promise<ModelCatalog> {
const result = spawn.sync(this.command, ['models'], {
timeout: 5_000,
maxBuffer: 1_000_000,
encoding: 'utf8',
});
if (result.error) return Promise.reject(result.error);
if (result.status !== 0) {
return Promise.reject(new Error(`Command failed: ${this.command} models`));
}
const displays = String(result.stdout ?? '')
async listModels(): Promise<ModelCatalog> {
const stdout = await runBoundedModelsCommand(
this.command,
this.modelDiscoveryTimeoutMs,
MODEL_DISCOVERY_MAX_BYTES,
);
const displays = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
Expand Down
19 changes: 14 additions & 5 deletions packages/adapters/opencode/NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,26 @@ It is captured once and never regenerated by the normal test suite.
## Model catalog (U3): discovered, not curated

opencode is the one harness that can enumerate its own models, so `listModels()` DISCOVERS
them: it runs `opencode models` with a fixed argument vector (no shell), a 5s timeout and a
capped buffer, and reports `source: 'discovered'`. Nothing is hardcoded.
them: it runs `opencode models` as an async child with a fixed argument vector (no shell),
a 20 s budget and capped output, and reports `source: 'discovered'`. Nothing is hardcoded.
The probe never occupies the event loop, and the child is confirmed terminated on every
path, including the timeout.

This matters because opencode's catalog is per-installation: run on the development host
2026-07-12 (no model spend) it printed 79 models drawn entirely from THAT machine's
configured providers — `openai`, `opencode`, and `synthetic`, with zero Anthropic entries.
Any hardcoded list would have been wrong for somebody.

Discovery is best-effort by design. A missing binary, a non-zero exit, a hang, or output the
daemon cannot validate all degrade silently to no list, and the dialog falls back to its
`Custom…` escape (placeholder `provider/model`, the form opencode's `--model` takes).
Discovery is best-effort by design. A missing binary, a non-zero exit, a hang past the
budget, or output the daemon cannot validate all throw, and the daemon records the failure
per harness (`models_error`, a controlled category) so the dialog offers a retry instead
of reporting no models; a prior catalog is kept when one exists. The 20 s budget covers the
observed 0.9–11.97 s spread of `opencode models`, but upstream allows 10 s per fetch attempt
with retries, so a slower run can still time out — and then surfaces as the same retryable
failure. The dialog falls back to its `Custom…` escape
(placeholder `provider/model`, the form opencode's `--model` takes) only while no catalog
was ever reported. The probe keeps no CLI output on failure, so to diagnose a failing
harness run `opencode models` directly on the daemon host.
Thinking is supported and maps to `run --variant <level>`.
<!-- harn:end adapters-own-their-model-catalog -->

Expand Down
57 changes: 56 additions & 1 deletion packages/adapters/opencode/src/adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -178,6 +178,61 @@ describe('opencode model discovery', () => {
await expect(new OpenCodeAdapter(stub('process.stderr.write("boom\\n"); process.exit(1);')).listModels())
.rejects.toThrow();
});

it('keeps CLI diagnostics out of probe errors', async () => {
// Requirement: harness stderr can carry secrets; it must not enter error text.
const command = stub("process.stderr.write('AUTH_TOKEN=hunter2\\n'); process.exit(1);");
const outcome = await new OpenCodeAdapter(command).listModels().then(
() => 'resolved',
(reason: unknown) => (reason instanceof Error ? reason.message : String(reason)),
);
expect(outcome).toBe(`Command failed: ${command} models (exit 1)`);
});

it('lets the event loop run while the CLI answers', async () => {
// Requirement: async bounded probe — discovery must not freeze the daemon.
const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-models-'));
dirs.push(dir);
const command = join(dir, 'opencode');
writeFileSync(command, '#!/usr/bin/env node\nsetTimeout(() => { console.log("anthropic/claude-sonnet-5\\nopenai/gpt-4o"); }, 1200);\n');
chmodSync(command, 0o755);
const start = Date.now();
let firedAt = -1;
setTimeout(() => { firedAt = Date.now(); }, 20);
const catalog = await new OpenCodeAdapter(command).listModels();
expect(catalog.models).toEqual(['anthropic/claude-sonnet-5', 'openai/gpt-4o']);
expect(firedAt).toBeGreaterThanOrEqual(start);
expect(firedAt - start).toBeLessThan(1000);
});

it('fails rather than buffering unbounded CLI output', async () => {
// Requirement: bounded probe — a runaway listing must fail, not balloon memory.
const command = stub("process.stdout.write('x'.repeat(1_200_000));");
await expect(new OpenCodeAdapter(command).listModels()).rejects.toThrow(/exceeded/);
});

it('terminates the child when the budget runs out', async () => {
// Requirement: confirmed cleanup — no orphan survives a timed-out probe.
const dir = mkdtempSync(join(tmpdir(), 'codor-opencode-models-'));
dirs.push(dir);
const command = join(dir, 'opencode');
const pidFile = join(dir, 'pid');
writeFileSync(command, `#!/usr/bin/env node
require('node:fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));
setInterval(() => {}, 1000);
`);
chmodSync(command, 0o755);
await expect(new OpenCodeAdapter(command, 2000).listModels()).rejects.toThrow(/timed out/);
const pid = Number(readFileSync(pidFile, 'utf8'));
expect(Number.isInteger(pid)).toBe(true);
let alive = true;
try {
process.kill(pid, 0);
} catch {
alive = false;
}
expect(alive).toBe(false);
});
// harn:end windows-cli-adapters-resolve-command-shims
});

Expand Down
Loading