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

@@ -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
// 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_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 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([
['.asdf/shims'],
['.bun/bin'],
['.opencode/bin'],
['.local/share/pnpm'],
])('resolves a CLI installed under the well-known dir %s', (rel) => {
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).
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)', () => {
@@ -201,6 +230,28 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns
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 () => {
const home = scratch();
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');
});
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', () => {
for (const agent of ['codex', 'claude', 'hermes']) {
for (const agent of ['codex', 'claude', 'hermes', 'opencode', 'omp']) {
const cfg = config.getLLMConfig('local-agent', agent);
expect(cfg.provider).toBe('local-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', () => {
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"
// =============================================================================
// 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):
// - 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;
}
export type LocalAgentId = 'claude' | 'codex' | 'hermes';
export type LocalAgentId = 'claude' | 'codex' | 'hermes' | 'opencode' | 'omp';
/** Apply an authoritative bulk selection; single-agent connects remain additive. */
export function syncLocalAgentSelection<T>(
@@ -164,6 +164,30 @@ const SPECS: AgentSpec[] = [
authArtifacts: ['~/.hermes/.env', "~/.hermes/auth.json", localAppData('hermes/.env'), localAppData('hermes/auth.json')],
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 {
@@ -181,6 +205,13 @@ export function hermesYoloEnabled(): boolean {
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 {
id: LocalAgentId;
label: string;
@@ -213,6 +244,7 @@ const NEWLINE_RE = new RegExp('\\r?\\n');
function wellKnownBinDirs(home: string): string[] {
const dirs = [
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, '.asdf', 'shims'), // asdf
'/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 maxChars = opts.maxChars ?? 4000;
// 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;
return new Promise((resolve) => {
// 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
* that returns ONLY the model's reply text (no CLI banner). Per-agent invocation: claude/codex feed
* 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
* 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
* 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.
*/
export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise<string> {
const spec = getSpec(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).
const env = childEnv();
const env = agentChildEnv(id);
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);
@@ -478,6 +510,12 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
outFile = join(workDir, 'reply.txt');
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
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
viaStdin = false;
+18 -2
View File
@@ -638,7 +638,7 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
],
'local-agent': [
// 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',
name: 'Codex (local CLI)',
@@ -663,6 +663,22 @@ export const AVAILABLE_MODELS: Record<LLMProvider, ModelInfo[]> = {
maxOutput: 8192,
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;
case 'local-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
// underlying model after "::" (for example "claude::opus").
actualModel = model || 'claude';
+2 -2
View File
@@ -1353,7 +1353,7 @@ function estimateUsage(promptText: string, completionText: string): LLMResponse[
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`,
// 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).
@@ -1371,7 +1371,7 @@ class LocalAgentAdapter implements LLMProviderAdapter {
return { agentId, agentModel };
}
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 {
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);
const effectiveKey = missionLLMConfig.apiKey;
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;
}
if (missionLLMConfig.provider === 'local-agent') {
@@ -6818,8 +6818,7 @@ function resolveGeneralLLMConfig(provider: string | undefined, model: string | u
} {
const defaultConfig = config.getLLMConfig();
const selectedProvider = provider || defaultConfig.provider;
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
// Local-agent backends use their own CLI login and need no T3MP3ST API key.
if (selectedProvider === 'local-agent') {
return {
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
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see