feat(local-agent): support OpenCode and Oh My Pi (#144)

This commit is contained in:
Joseph Magly authored and GitHub committed 2026-08-01 20:15:42 -04:00
1 parent d471da6603
commit 8f658891f2
8 files changed
+150 -24

No files matched your search

+3 -3
View File
@@ -24,7 +24,7 @@
**Your AI coding agent is already a hacker — T3MP3ST hands it an arsenal.** **Your AI coding agent is already a hacker — T3MP3ST hands it an arsenal.**
Point it at an authorized target and the kill chain runs itself: **recon → exploit → report**, from a browser War Room or the CLI, driven by the agent you're *already* signed into — Claude Code, Codex, Hermes — or a model you run **fully offline** (Ollama, LM Studio, vLLM). No new API keys, no cloud tenant, no second bill. Your agent is the brain; T3MP3ST is the war machine bolted around it. **Self-hosted storm. Keyless warfare.** ⚡ Point it at an authorized target and the kill chain runs itself: **recon → exploit → report**, from a browser War Room or the CLI, driven by the agent you're *already* signed into — Claude Code, Codex, Hermes, OpenCode, Oh My Pi — or a model you run **fully offline** (Ollama, LM Studio, vLLM). No new API keys, no cloud tenant, no second bill. Your agent is the brain; T3MP3ST is the war machine bolted around it. **Self-hosted storm. Keyless warfare.** ⚡
And it won't ask you to take its word for it. On **XBOW's own 104-challenge suite it scores 90.1% pass@1** — above XBOW's self-reported 85% — alongside hint-free CTF solves and a **cold hunt on real, post-cutoff CVEs the model had never seen**. Every number in this README recomputes from committed data with one command (`npm run verify-claims`). Loud about the mission, honest about the build — the [status table](#what-ships-today) says exactly what's live, what's scaffolding, and what's still roadmap; full receipts in [Benchmarks](#benchmarks). And it won't ask you to take its word for it. On **XBOW's own 104-challenge suite it scores 90.1% pass@1** — above XBOW's self-reported 85% — alongside hint-free CTF solves and a **cold hunt on real, post-cutoff CVEs the model had never seen**. Every number in this README recomputes from committed data with one command (`npm run verify-claims`). Loud about the mission, honest about the build — the [status table](#what-ships-today) says exactly what's live, what's scaffolding, and what's still roadmap; full receipts in [Benchmarks](#benchmarks).
@@ -68,7 +68,7 @@ npm install
npm run server # War Room → http://127.0.0.1:3333/ui/ npm run server # War Room → http://127.0.0.1:3333/ui/
``` ```
In the War Room, open **Settings** and connect a local agent (Claude Code / Codex / Hermes). Then describe a target to **Op Admiral** in plain English and launch. The agent you connected is the brain. No key required. In the War Room, open **Settings** and connect a local agent (Claude Code / Codex / Hermes / OpenCode / Oh My Pi). Then describe a target to **Op Admiral** in plain English and launch. The agent you connected is the brain. No key required.
Prefer to bring a key? Set one and skip the connect step: Prefer to bring a key? Set one and skip the connect step:
@@ -296,7 +296,7 @@ Operators map to MITRE ATT&CK and Cyber Kill Chain phases (recon is live; later
| **Coordinator** | Command & Control | TA0011 | mission control, orchestration | | **Coordinator** | Command & Control | TA0011 | mission control, orchestration |
| **Analyst** | Analysis | — | pattern analysis, reporting | | **Analyst** | Analysis | — | pattern analysis, reporting |
**Providers:** OpenRouter, Venice, Anthropic, OpenAI, or a keyless local agent (Claude Code / Codex / Hermes). Set `OPENROUTER_API_KEY` / `VENICE_API_KEY` / `ANTHROPIC_API_KEY`, or connect an agent in Settings. **Providers:** OpenRouter, Venice, Anthropic, OpenAI, or a keyless local agent (Claude Code / Codex / Hermes / OpenCode / Oh My Pi). Set `OPENROUTER_API_KEY` / `VENICE_API_KEY` / `ANTHROPIC_API_KEY`, or connect an agent in Settings.
**Integrations:** `node dist/mcp-server.js` exposes `security_recon` to MCP-aware agents. `npm run server` starts the HTTP API (`POST /api/mission/start`, `GET /api/mission/status`, and more). Full reference in [docs/](docs/). **Integrations:** `node dist/mcp-server.js` exposes `security_recon` to MCP-aware agents. `npm run server` starts the HTTP API (`POST /api/mission/start`, `GET /api/mission/status`, and more). Full reference in [docs/](docs/).
+4 -4
View File
@@ -26303,9 +26303,9 @@ Be specific and actionable. Format as structured JSON.`;
}); });
</script> </script>
<script> <script>
// ═══════════ Connect Local Agents — detect + enlist already-authed CLIs (Claude Code / Codex / Hermes) ═══════════ // ═══════════ Connect Local Agents — detect + enlist already-authed CLIs ═══════════
(function(){ (function(){
const ICONS = { claude: '🟠', codex: '🟢', hermes: '🔵' }; const ICONS = { claude: '🟠', codex: '🟢', hermes: '🔵', opencode: '🟣', omp: '🟡' };
let lastDetected = []; let lastDetected = [];
let connectedIds = []; let connectedIds = [];
const pingStatus = {}; // id -> { ok, latencyMs, error } from a real round-trip const pingStatus = {}; // id -> { ok, latencyMs, error } from a real round-trip
@@ -26536,9 +26536,9 @@ Be specific and actionable. Format as structured JSON.`;
} }
window.t3mpHasBackend = hasBackend; window.t3mpHasBackend = hasBackend;
// Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and // Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and
// prefer codex/hermes over claude (claude's standalone CLI is often auth-stale). Returns id | null. // Use a stable preference order when the operator has not pinned a connected agent. Returns id | null.
function preferredAgent(){ function preferredAgent(){
var order = ['codex','hermes','claude']; var order = ['codex','opencode','omp','hermes','claude'];
var connected = (connectedIds || []).slice(); var connected = (connectedIds || []).slice();
var live = connected.filter(function(id){ return pingStatus[id] && pingStatus[id].ok; }); var live = connected.filter(function(id){ return pingStatus[id] && pingStatus[id].ok; });
var pool = live; var pool = live;
@@ -24,6 +24,16 @@ const FAKE_CLI = '#!/bin/sh\necho "1.2.3"\n';
// For the chat path, which writes the prompt to the child's stdin: drain it before replying so the // For the chat path, which writes the prompt to the child's stdin: drain it before replying so the
// parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply. // parent's write()/end() completes cleanly (no EPIPE races), then emit a clean, non-error reply.
const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n'; const FAKE_CLI_STDIN = '#!/bin/sh\ncat >/dev/null 2>&1\necho "1.2.3"\n';
const FAKE_OPENCODE = `#!/bin/sh
if [ "$1" = "--version" ]; then echo "opencode 1.2.3"; exit 0; fi
prompt=$(cat)
printf '%s\\n' "$OPENCODE_PERMISSION|$*|$prompt"
`;
const FAKE_OMP = `#!/bin/sh
if [ "$1" = "--version" ]; then echo "omp 1.2.3"; exit 0; fi
prompt=$(cat)
printf '%s\\n' "$*|$prompt"
`;
const tmpDirs: string[] = []; const tmpDirs: string[] = [];
const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME }; const savedEnv = { PATH: process.env.PATH, AGENT_HOME: process.env.T3MP3ST_AGENT_HOME };
@@ -63,6 +73,7 @@ describe('resolveBin — macOS/POSIX well-known-dir resolution (issue #78)', ()
it.each([ it.each([
['.asdf/shims'], ['.asdf/shims'],
['.bun/bin'], ['.bun/bin'],
['.opencode/bin'],
['.local/share/pnpm'], ['.local/share/pnpm'],
])('resolves a CLI installed under the well-known dir %s', (rel) => { ])('resolves a CLI installed under the well-known dir %s', (rel) => {
const home = scratch(); const home = scratch();
@@ -185,6 +196,24 @@ describe('detectLocalAgents — wiring: a well-known-dir CLI is reported install
// codex/hermes are absent from this temp home → still not installed (no false positives). // codex/hermes are absent from this temp home → still not installed (no false positives).
expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false); expect(agents.find((a) => a.id === 'codex')?.installed).toBe(false);
}); });
it('detects authenticated OpenCode and Oh My Pi installations (#136)', async () => {
const home = scratch();
const binDir = join(home, '.local', 'bin');
putExe(binDir, 'opencode', FAKE_OPENCODE);
putExe(binDir, 'omp', FAKE_OMP);
mkdirSync(join(home, '.local', 'share', 'opencode'), { recursive: true });
writeFileSync(join(home, '.local', 'share', 'opencode', 'auth.json'), '{}');
mkdirSync(join(home, '.omp', 'agent'), { recursive: true });
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), 'fixture');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
delete process.env.T3MP3ST_DISABLE_LOCAL_AGENTS;
const agents = await detectLocalAgents();
expect(agents.find((a) => a.id === 'opencode')).toMatchObject({ installed: true, authed: true, ready: true, version: '1.2.3' });
expect(agents.find((a) => a.id === 'omp')).toMatchObject({ installed: true, authed: true, ready: true, version: '1.2.3' });
});
}); });
describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => { describe('spawn call-sites use the resolved path (issue #78 — detected-but-unspawnable would be worse)', () => {
@@ -201,6 +230,28 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns
expect(res.output).toContain('1.2.3'); expect(res.output).toContain('1.2.3');
}); });
it('keeps OpenCode tools denied on the one-shot ping/dispatch path (#136)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'opencode', FAKE_OPENCODE);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const res = await runLocalAgent('opencode', 'ping', { model: 'openai/gpt-5', timeoutMs: 4000 });
expect(res.ok).toBe(true);
expect(res.output).toBe('{"*":"deny"}|run --model openai/gpt-5 ping|');
});
it('keeps Oh My Pi tools disabled on the one-shot ping/dispatch path (#136)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'omp', FAKE_OMP);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
const res = await runLocalAgent('omp', 'ping', { model: 'anthropic/claude-sonnet-4-5', timeoutMs: 4000 });
expect(res.ok).toBe(true);
expect(res.output).toBe('--no-tools -p --model anthropic/claude-sonnet-4-5 ping|');
});
it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => { it('localAgentChat launches the well-known-dir CLI (not the bare name)', async () => {
const home = scratch(); const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN); putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_STDIN);
@@ -209,4 +260,26 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns
await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3'); await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).resolves.toContain('1.2.3');
}); });
it('drives OpenCode through stdin with model routing and all internal tools denied (#136)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'opencode', FAKE_OPENCODE);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
await expect(localAgentChat('opencode', 'long planning prompt', {
model: 'anthropic/claude-sonnet-4-5', timeoutMs: 4000,
})).resolves.toBe('{"*":"deny"}|run --model anthropic/claude-sonnet-4-5|long planning prompt');
});
it('drives Oh My Pi through stdin with model routing and internal tools disabled (#136)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'omp', FAKE_OMP);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';
await expect(localAgentChat('omp', 'long planning prompt', {
model: 'openai-codex/gpt-5', timeoutMs: 4000,
})).resolves.toBe('--no-tools --model openai-codex/gpt-5|long planning prompt');
});
}); });
+2 -2
View File
@@ -15,7 +15,7 @@ describe("local-agent provider wiring (#118)", () => {
}); });
it('preserves any supported agent id passed as the model', () => { it('preserves any supported agent id passed as the model', () => {
for (const agent of ['codex', 'claude', 'hermes']) { for (const agent of ['codex', 'claude', 'hermes', 'opencode', 'omp']) {
const cfg = config.getLLMConfig('local-agent', agent); const cfg = config.getLLMConfig('local-agent', agent);
expect(cfg.provider).toBe('local-agent'); expect(cfg.provider).toBe('local-agent');
expect(cfg.model).toBe(agent); expect(cfg.model).toBe(agent);
@@ -38,7 +38,7 @@ describe("local-agent provider wiring (#118)", () => {
it('surfaces the connected-agent ids as available local-agent models', () => { it('surfaces the connected-agent ids as available local-agent models', () => {
expect(AVAILABLE_MODELS['local-agent']?.map(m => m.id)).toEqual( expect(AVAILABLE_MODELS['local-agent']?.map(m => m.id)).toEqual(
expect.arrayContaining(['codex', 'claude', 'hermes']), expect.arrayContaining(['codex', 'claude', 'hermes', 'opencode', 'omp']),
); );
}); });
}); });
+45 -7
View File
@@ -2,7 +2,7 @@
// LOCAL AGENT CONNECTORS — "bring your own already-authed agent" // LOCAL AGENT CONNECTORS — "bring your own already-authed agent"
// ============================================================================= // =============================================================================
// Detect + connect agent CLIs that already live, authenticated, on the operator's machine // Detect + connect agent CLIs that already live, authenticated, on the operator's machine
// (Claude Code, Codex, Hermes) and drive them headlessly as t3mp3st operators. // (Claude Code, Codex, Hermes, OpenCode, Oh My Pi) and drive them headlessly as t3mp3st operators.
// //
// SECURITY POSTURE (important): // SECURITY POSTURE (important):
// - We NEVER read, print, log, or transmit credential contents. Auth is detected purely by the // - We NEVER read, print, log, or transmit credential contents. Auth is detected purely by the
@@ -80,7 +80,7 @@ function childEnv(): NodeJS.ProcessEnv {
return env; return env;
} }
export type LocalAgentId = 'claude' | 'codex' | 'hermes'; export type LocalAgentId = 'claude' | 'codex' | 'hermes' | 'opencode' | 'omp';
/** Apply an authoritative bulk selection; single-agent connects remain additive. */ /** Apply an authoritative bulk selection; single-agent connects remain additive. */
export function syncLocalAgentSelection<T>( export function syncLocalAgentSelection<T>(
@@ -164,6 +164,30 @@ const SPECS: AgentSpec[] = [
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')], authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')],
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])], oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
}, },
{
id: 'opencode',
label: 'OpenCode',
vendor: 'Anomaly',
bin: 'opencode',
blurb: 'Open-source coding agent CLI',
invokeHint: 'opencode run "<prompt>" (internal tools denied by T3MP3ST)',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.local/share/opencode/auth.json', '~/.config/opencode/auth.json'],
oneShot: (p, m) => ['run', ...(m ? ['--model', m] : []), p],
},
{
id: 'omp',
label: 'Oh My Pi',
vendor: 'Oh My Pi',
bin: 'omp',
blurb: 'Oh My Pi coding agent CLI',
invokeHint: 'omp --no-tools -p "<prompt>"',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.omp/agent/agent.db'],
oneShot: (p, m) => ['--no-tools', '-p', ...(m ? ['--model', m] : []), p],
},
]; ];
export function getSpec(id: string): AgentSpec | undefined { export function getSpec(id: string): AgentSpec | undefined {
@@ -181,6 +205,13 @@ export function hermesYoloEnabled(): boolean {
return /^(1|true|yes|on)$/i.test((process.env.T3MP3ST_HERMES_YOLO || '').trim()); return /^(1|true|yes|on)$/i.test((process.env.T3MP3ST_HERMES_YOLO || '').trim());
} }
/** OpenCode remains a planning backbone; Arsenal owns every executable tool action. */
function agentChildEnv(id: string): NodeJS.ProcessEnv {
const env = childEnv();
if (id === 'opencode') env.OPENCODE_PERMISSION = JSON.stringify({ '*': 'deny' });
return env;
}
export interface AgentDetection { export interface AgentDetection {
id: LocalAgentId; id: LocalAgentId;
label: string; label: string;
@@ -213,6 +244,7 @@ const NEWLINE_RE = new RegExp('\\r?\\n');
function wellKnownBinDirs(home: string): string[] { function wellKnownBinDirs(home: string): string[] {
const dirs = [ const dirs = [
join(home, '.local', 'bin'), // Claude Code native installer, pipx, mise join(home, '.local', 'bin'), // Claude Code native installer, pipx, mise
join(home, '.opencode', 'bin'), // OpenCode native installer
join(home, '.local', 'share', 'mise', 'shims'), // mise join(home, '.local', 'share', 'mise', 'shims'), // mise
join(home, '.asdf', 'shims'), // asdf join(home, '.asdf', 'shims'), // asdf
'/opt/homebrew/bin', // Homebrew (Apple Silicon) '/opt/homebrew/bin', // Homebrew (Apple Silicon)
@@ -410,7 +442,7 @@ export function runLocalAgent(
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
const maxChars = opts.maxChars ?? 4000; const maxChars = opts.maxChars ?? 4000;
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv(); const env = agentChildEnv(id);
const resolvedBin = resolveBin(spec.bin) || spec.bin; const resolvedBin = resolveBin(spec.bin) || spec.bin;
return new Promise((resolve) => { return new Promise((resolve) => {
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait). // stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
@@ -452,16 +484,16 @@ export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number):
/** /**
* Drive a connected agent as the LLM BACKEND for the mission/operator flow — a long-prompt one-shot * Drive a connected agent as the LLM BACKEND for the mission/operator flow — a long-prompt one-shot
* that returns ONLY the model's reply text (no CLI banner). Per-agent invocation: claude/codex feed * that returns ONLY the model's reply text (no CLI banner). Claude, Codex, OpenCode, and Oh My Pi feed
* the prompt via STDIN (robust for long planning prompts), codex uses --output-last-message for a * the prompt via STDIN (robust for long planning prompts); Codex uses --output-last-message for a
* clean reply, hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own * clean reply, while Hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own
* login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires. * login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires.
*/ */
export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise<string> { export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise<string> {
const spec = getSpec(id); const spec = getSpec(id);
if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`)); if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`));
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv(); const env = agentChildEnv(id);
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined; const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000);
@@ -478,6 +510,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-')); workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
outFile = join(workDir, 'reply.txt'); outFile = join(workDir, 'reply.txt');
args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])]; args = ['exec', '--ephemeral', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
} else if (id === 'opencode') {
args = ['run', ...(model ? ['--model', model] : [])];
} else if (id === 'omp') {
// Piped stdin selects OMP's one-shot print mode. Its own tools stay disabled so the model may
// request actions through T3MP3ST's text contract while Arsenal remains the execution boundary.
args = ['--no-tools', ...(model ? ['--model', model] : [])];
} else { // hermes — takes the prompt as an arg } else { // hermes — takes the prompt as an arg
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])]; args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
viaStdin = false; viaStdin = false;
+18 -2
View File
@@ -638,7 +638,7 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
], ],
'local-agent': [ 'local-agent': [
// Connected local agent CLIs used AS the LLM backend — no API key (each uses its own login). // Connected local agent CLIs used AS the LLM backend — no API key (each uses its own login).
// The chosen agent id (codex|claude|hermes) travels in the `model` field. // The chosen agent id (codex|claude|hermes|opencode|omp) travels in the `model` field.
{ {
id: 'codex', id: 'codex',
name: 'Codex (local CLI)', name: 'Codex (local CLI)',
@@ -663,6 +663,22 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
maxOutput: 8192, maxOutput: 8192,
capabilities: ['reasoning', 'code', 'agents', 'local-cli'], capabilities: ['reasoning', 'code', 'agents', 'local-cli'],
}, },
{
id: 'opencode',
name: 'OpenCode (local CLI)',
provider: 'LocalAgent',
contextWindow: 200000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'local-cli'],
},
{
id: 'omp',
name: 'Oh My Pi (local CLI)',
provider: 'LocalAgent',
contextWindow: 200000,
maxOutput: 8192,
capabilities: ['reasoning', 'code', 'analysis', 'agents', 'local-cli'],
},
], ],
}; };
@@ -975,7 +991,7 @@ class ConfigManager {
break; break;
case 'local-agent': case 'local-agent':
// Keyless backbone: the mission is routed through a connected local CLI agent // Keyless backbone: the mission is routed through a connected local CLI agent
// (Claude Code / Codex / Hermes), each using its own login — no API key or base // (Claude Code / Codex / Hermes / OpenCode / Oh My Pi), each using its own login — no API key or base
// URL. The chosen agent id travels in `model`, optionally with the selected // URL. The chosen agent id travels in `model`, optionally with the selected
// underlying model after "::" (for example "claude::opus"). // underlying model after "::" (for example "claude::opus").
actualModel = model || 'claude'; actualModel = model || 'claude';
+2 -2
View File
@@ -1353,7 +1353,7 @@ function estimateUsage(promptText: string, completionText: string): LLMResponse[
return { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens }; return { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
} }
// Generic adapter that drives a CONNECTED local agent CLI (Claude Code / Codex / Hermes) as the LLM // Generic adapter that drives a connected local agent CLI as the LLM
// backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`, // backend — NO API key needed; each CLI uses its own login. The agent id travels in `config.model`,
// optionally with an underlying model after a `::` separator ("claude::opus", "claude::claude-opus-4-8") // optionally with an underlying model after a `::` separator ("claude::opus", "claude::claude-opus-4-8")
// so the operator can pick WHICH model the local CLI runs (passed through as the CLI's --model/-m flag). // so the operator can pick WHICH model the local CLI runs (passed through as the CLI's --model/-m flag).
@@ -1371,7 +1371,7 @@ class LocalAgentAdapter implements LLMProviderAdapter {
return { agentId, agentModel }; return { agentId, agentModel };
} }
validateConfig(): { valid: boolean; error?: string } { validateConfig(): { valid: boolean; error?: string } {
return this.config.model ? { valid: true } : { valid: false, error: 'local-agent requires the agent id in `model` (codex|claude|hermes)' }; return this.config.model ? { valid: true } : { valid: false, error: 'local-agent requires the agent id in `model` (codex|claude|hermes|opencode|omp)' };
} }
private formatPrompt(messages: LLMMessage[], options?: ChatOptions): string { private formatPrompt(messages: LLMMessage[], options?: ChatOptions): string {
const parts = [ const parts = [
+3 -4
View File
@@ -6290,7 +6290,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
: resolveGeneralLLMConfig(provider, model, apiKey, baseUrl); : resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
const effectiveKey = missionLLMConfig.apiKey; const effectiveKey = missionLLMConfig.apiKey;
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) { if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' }); res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a supported local agent' });
return; return;
} }
if (missionLLMConfig.provider === 'local-agent') { if (missionLLMConfig.provider === 'local-agent') {
@@ -6818,8 +6818,7 @@ function resolveGeneralLLMConfig(provider: string | undefined, model: string | u
} { } {
const defaultConfig = config.getLLMConfig(); const defaultConfig = config.getLLMConfig();
const selectedProvider = provider || defaultConfig.provider; const selectedProvider = provider || defaultConfig.provider;
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the // Local-agent backends use their own CLI login and need no T3MP3ST API key.
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
if (selectedProvider === 'local-agent') { if (selectedProvider === 'local-agent') {
return { return {
provider: 'local-agent' as any, provider: 'local-agent' as any,
@@ -7805,7 +7804,7 @@ app.get('/api/bounty/credentials', (_req: Request, res: Response) => {
}); });
// ============================================================================= // =============================================================================
// LOCAL AGENT CONNECTORS — bring-your-own already-authed CLIs (Claude Code / Codex / Hermes) // LOCAL AGENT CONNECTORS — bring-your-own already-authed CLIs
// ============================================================================= // =============================================================================
// Detect agent CLIs that are already installed + logged-in on this machine and enlist them as // Detect agent CLIs that are already installed + logged-in on this machine and enlist them as
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see // operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see