@karma42/agent 0.0.0-stage → 0.1.0-beta.2

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.
@@ -0,0 +1,284 @@
1
+ // Provider adapter protocol 1. Local CLI authentication remains owned by the client.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ export const ADAPTER_PROTOCOL = 1;
6
+ export const PROVIDERS = ['claude', 'codex'];
7
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
8
+ const counter = v => Number.isSafeInteger(v) && v >= 0 && v <= 1e15 ? v : null;
9
+ const text = (v, max = 2000) => typeof v === 'string' ? v.slice(0, max) : '';
10
+ const safeName = v => typeof v === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(v) ? v : null;
11
+ const tomlString = value => JSON.stringify(value); // TOML basic strings accept JSON string escaping.
12
+ // Stable identity of the native Codex connection: the kit name and installed kit version, nothing about the operator.
13
+ export const codexClientInfo = version => ({ name: 'karma42_runner', title: 'Karma42 runner', version });
14
+
15
+ // Claude Code runs these without asking. Other Bash commands need approval, which --permission-prompts none refuses;
16
+ // its built-in read-only commands and acceptEdits file edits inside the checkout still run.
17
+ // Named Git subcommands with origin as the only remote, Issue/PR work and the project's own scripts; no npx, inline node
18
+ // or other gh areas. Rules match command text on a best-effort basis (Claude Code documents argument rules as fragile),
19
+ // and they are not a sandbox: tests, package scripts and Git hooks still run repository code the agent can edit.
20
+ export const CLAUDE_ALLOWED_TOOLS = ['mcp__pilot',
21
+ ...['status', 'diff', 'log', 'show', 'branch', 'checkout', 'switch', 'restore', 'reset', 'add', 'rm', 'mv', 'commit', 'stash',
22
+ 'merge', 'rebase', 'revert', 'cherry-pick'].map(v => `Bash(git ${v} *)`),
23
+ ...['log', 'diff', 'show'].map(v => `Bash(git --no-pager ${v} *)`),
24
+ 'Bash(git fetch)', 'Bash(git fetch origin *)', 'Bash(git pull)', 'Bash(git pull origin *)', 'Bash(git pull --ff-only origin *)',
25
+ 'Bash(git push)', 'Bash(git push origin *)', 'Bash(git push -u origin *)', 'Bash(git push --set-upstream origin *)',
26
+ 'Bash(gh issue *)', 'Bash(gh pr *)', 'Bash(gh label list *)',
27
+ 'Bash(npm test *)', 'Bash(npm run *)', 'Bash(npm ci *)', 'Bash(npm install)', 'Bash(npm install --ignore-scripts)', 'Bash(npm i)',
28
+ 'Bash(node --check *)', 'Bash(node --test *)'].join(',');
29
+
30
+ // Model clients and their preflight checks receive named variables only, never the operator's
31
+ // whole shell. This hides unrelated secrets from the model; it does not isolate files or the keychain.
32
+ const COMMON_ENV = ['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG', 'LANGUAGE', 'TZ',
33
+ 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR',
34
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP', 'USERNAME', 'USERPROFILE', 'HOMEDRIVE',
35
+ 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'PROGRAMFILES', 'OS', 'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS'];
36
+ const PROVIDER_ENV = {
37
+ claude: ['CLAUDE_CONFIG_DIR', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_GIT_BASH_PATH'],
38
+ codex: ['CODEX_HOME', 'OPENAI_API_KEY', 'CODEX_API_KEY', 'DBUS_SESSION_BUS_ADDRESS'],
39
+ };
40
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
41
+
42
+ export function passEnvNames(raw = '') {
43
+ const names = String(raw).split(',').map(v => v.trim()).filter(Boolean);
44
+ if (names.length > 64 || names.some(v => !ENV_NAME.test(v))) throw new Error('PILOT_PASS_ENV lists at most 64 variable names separated by commas, for example GH_TOKEN,HTTPS_PROXY.');
45
+ return names;
46
+ }
47
+
48
+ export function childEnv(id, source, extraNames = [], platform = process.platform) {
49
+ const key = platform === 'win32' ? v => v.toUpperCase() : v => v;
50
+ const allowed = new Set([...COMMON_ENV, ...PROVIDER_ENV[id], ...extraNames].map(key));
51
+ return Object.fromEntries(Object.entries(source).filter(([name, value]) => typeof value === 'string'
52
+ && (allowed.has(key(name)) || /^LC_[A-Z]+$/.test(name))));
53
+ }
54
+
55
+ export function createProvider(id, settings) {
56
+ if (!PROVIDERS.includes(id)) throw new Error('Select PILOT_PROVIDER=claude or codex. Providers never switch automatically.');
57
+ const command = id === 'codex' ? settings.codex : settings.claude;
58
+ const newMcpName = () => `project_${randomUUID().replaceAll('-', '')}`;
59
+ const passEnv = passEnvNames(settings.passEnv);
60
+ const environment = (extra = {}) => ({ ...childEnv(id, process.env, passEnv), ...extra });
61
+ // Listed again for every turn: config can change while the runner is up, and disabling a server that is
62
+ // no longer configured makes App Server refuse to start. Plugin-provided servers have no config entry to
63
+ // disable, so plugins are off in both the listing and the invocation.
64
+ const mcpServers = cwd => {
65
+ const list = spawnSync(command, ['-c', 'features.plugins=false', 'mcp', 'list', '--json'], { cwd, env: environment(), encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
66
+ if (list.error || list.status !== 0) throw new Error('Cannot inspect local MCP configuration before isolating the project connection.');
67
+ let servers; try { servers = JSON.parse(list.stdout); } catch { throw new Error('Invalid local MCP configuration listing.'); }
68
+ if (!Array.isArray(servers) || servers.length > 128 || servers.some(v => typeof v?.name !== 'string' || !/^[A-Za-z0-9_-]{1,150}$/.test(v.name))) throw new Error('Unsupported local MCP configuration listing.');
69
+ return servers.map(v => v.name);
70
+ };
71
+ return {
72
+ id, protocol: ADAPTER_PROTOCOL, environment,
73
+ preflight() {
74
+ if (id === 'codex' && settings.turnBudgetUsd > 0) throw new Error('Codex CLI cannot enforce a dollar cap. Remove PILOT_TURN_BUDGET_USD or choose Claude explicitly.');
75
+ if (id === 'codex' && ![undefined, 'acceptEdits', 'bypassPermissions'].includes(settings.permissionMode)) throw new Error('Codex accepts PILOT_PERMISSION_MODE=bypassPermissions only; leave it unset (default acceptEdits) for the workspace-write sandbox with automatic review.');
76
+ const probe = spawnSync(command, ['--version'], { env: environment(), encoding: 'utf8', timeout: 15_000 });
77
+ if (probe.error || probe.status !== 0) throw new Error(`Cannot run ${id} --version. Install the local client or set PILOT_${id.toUpperCase()}.`);
78
+ if (id === 'codex') {
79
+ const version = /codex-cli (\d+)\.(\d+)\.(\d+)/.exec(probe.stdout);
80
+ if (!version || +version[1] === 0 && +version[2] < 160) throw new Error('This adapter requires Codex CLI 0.160.0 or newer.');
81
+ const login = spawnSync(command, ['login', 'status'], { env: environment(), encoding: 'utf8', timeout: 15_000 });
82
+ if (login.error || login.status !== 0) throw new Error('Sign in locally with codex login, then start the runner again. No credentials were copied.');
83
+ mcpServers(settings.repoDir);
84
+ }
85
+ return { label: text(probe.stdout.trim(), 150) };
86
+ },
87
+ newSession(promptHash, repoDir) {
88
+ return { id: id === 'claude' ? randomUUID() : null, provider: id, promptHash, repoDir, costUsd: null, ...(id === 'codex' ? { mcpName: newMcpName() } : {}) };
89
+ },
90
+ canResume(session) { return id === 'claude' ? typeof session?.id === 'string' : session?.provider === id && UUID.test(session?.id ?? ''); },
91
+ invocation({ prompt, instructionsFile, settingsFile, mcpConfig, bridge, session, resuming, repoDir, clientVersion }) {
92
+ if (id === 'claude') {
93
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--append-system-prompt-file', instructionsFile,
94
+ '--setting-sources=', '--settings', settingsFile, '--disable-slash-commands', '--mcp-config', mcpConfig, '--strict-mcp-config',
95
+ '--permission-mode', settings.permissionMode, '--permission-prompts', 'none', '--allowedTools', settings.allowedTools,
96
+ resuming ? '--resume' : '--session-id', session.id];
97
+ if (settings.turnBudgetUsd > 0) args.push('--max-budget-usd', String(settings.turnBudgetUsd));
98
+ if (settings.model) args.push('--model', settings.model);
99
+ return { command, args };
100
+ }
101
+ const endpoint = new URL(bridge.url);
102
+ const token = bridge.headers?.Authorization?.replace(/^Bearer /, '');
103
+ if (endpoint.protocol !== 'http:' || endpoint.hostname !== '127.0.0.1' || !token) throw new Error('Codex requires the authenticated local project bridge.');
104
+ if (typeof clientVersion !== 'string' || !clientVersion) throw new Error('Codex requires the installed kit version.');
105
+ // PILOT_PERMISSION_MODE=bypassPermissions is the operator's explicit choice of full access, never the default:
106
+ // no Codex sandbox and no approval review, so the project tools must be approved or approval policy never refuses them.
107
+ const fullAccess = settings.permissionMode === 'bypassPermissions';
108
+ const access = fullAccess ? { sandbox: 'danger-full-access', approvalPolicy: 'never' }
109
+ : { sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'auto_review' };
110
+ const config = {
111
+ model_provider: '"openai"', sandbox_mode: `"${access.sandbox}"`, approval_policy: `"${access.approvalPolicy}"`,
112
+ ...(fullAccess ? {} : { approvals_reviewer: '"auto_review"' }),
113
+ developer_instructions: tomlString(readFileSync(instructionsFile, 'utf8')),
114
+ 'features.hooks': 'false', 'features.apps': 'false', 'features.plugins': 'false', 'features.multi_agent': 'false', 'features.goals': 'false',
115
+ 'memories.generate_memories': 'false', 'memories.use_memories': 'false',
116
+ 'shell_environment_policy.filters.PILOT_BRIDGE_TOKEN': '"exclude"',
117
+ };
118
+ // App Server exposes resolved model identity, reroutes and full token-usage notifications.
119
+ // Disable the configured personal MCPs in this invocation only; never edit client config. The project server is
120
+ // set key by key: a whole mcp_servers table override would replace these disables and re-enable personal servers.
121
+ const configured = mcpServers(repoDir);
122
+ for (const name of configured) config[`mcp_servers.${name}.enabled`] = 'false';
123
+ // The session keeps the project server's name, which the runner saves with it: a thread resumed after a runner
124
+ // restart remembers its tools by that name. A new name is chosen only if a configured server now uses it.
125
+ if (!/^project_[0-9a-f]{32}$/.test(session.mcpName ?? '')) session.mcpName = newMcpName();
126
+ while (configured.includes(session.mcpName)) session.mcpName = newMcpName();
127
+ const mcpName = session.mcpName;
128
+ Object.assign(config, { [`mcp_servers.${mcpName}.url`]: tomlString(bridge.url),
129
+ [`mcp_servers.${mcpName}.bearer_token_env_var`]: '"PILOT_BRIDGE_TOKEN"', [`mcp_servers.${mcpName}.required`]: 'true',
130
+ ...(fullAccess ? { [`mcp_servers.${mcpName}.default_tools_approval_mode`]: '"approve"' } : {}) });
131
+ const args = ['app-server', '--stdio', '--strict-config', ...Object.entries(config).flatMap(([key, value]) => ['-c', `${key}=${value}`])];
132
+ const startParams = { cwd: repoDir, modelProvider: 'openai', ...access,
133
+ ...(settings.model ? { model: settings.model } : {}),
134
+ ...(resuming ? { threadId: session.id } : {}) };
135
+ return { command, args, env: { PILOT_BRIDGE_TOKEN: token }, rpc: { prompt, resuming, startParams, clientInfo: codexClientInfo(clientVersion),
136
+ baseline: resuming ? session.tokenTotals ?? null : [0, 0, 0, 0] } };
137
+ },
138
+ collector(options) { return id === 'codex' ? new CodexStream(options) : new ProviderStream(id, options); },
139
+ usage(report, init) {
140
+ const u = report?.usage;
141
+ let tokens = null;
142
+ if (u && !(id === 'claude' && report?.is_error && report.num_turns === 0)) {
143
+ const source = id === 'codex'
144
+ ? [u.input_tokens, u.output_tokens, u.cached_input_tokens, u.cache_write_input_tokens]
145
+ : [u.input_tokens, u.output_tokens, u.cache_read_input_tokens, u.cache_creation_input_tokens];
146
+ if (source.every(v => v === undefined || counter(v) !== null)) {
147
+ const measured = source.map(counter);
148
+ if (measured.some(v => v !== null)) tokens = { input: measured[0], output: measured[1], cacheRead: measured[2], cacheWrite: measured[3], modelCalls: null };
149
+ }
150
+ }
151
+ return { provider: id, inputSemantics: id === 'codex' ? 'includes-cache' : 'uncached',
152
+ model: safeName(init?.model), modelSource: init?.modelSource ?? (init?.model ? 'provider-report' : 'unknown'), tokens };
153
+ },
154
+ loginHint(detail) { return /not logged in|\/login|invalid api key|authentication/i.test(detail)
155
+ ? id === 'codex' ? ' Sign in locally with codex login.' : ' Sign in locally with claude /login.' : ''; },
156
+ missingSession(stderr) { return id === 'claude' && stderr.includes('No conversation found'); },
157
+ };
158
+ }
159
+
160
+ // Retain only session identity, final text, numeric usage and bounded failure metadata.
161
+ // Tool arguments/results and reasoning are never retained or rendered by this collector.
162
+ export class ProviderStream {
163
+ constructor(provider, { log = () => {}, action = () => {}, session = () => {} } = {}) {
164
+ this.provider = provider; this.log = log; this.action = action; this.session = session;
165
+ this.init = null; this.report = null; this.finalText = ''; this.sessionId = null;
166
+ this.pending = ''; this.dropping = false; this.truncated = false; this.toolCalls = 0; this.toolIds = new Set();
167
+ }
168
+ feed(chunk) {
169
+ for (const part of String(chunk).split(/(?<=\n)/)) {
170
+ if (!this.dropping && Buffer.byteLength(this.pending) + Buffer.byteLength(part) <= 1024 * 1024) this.pending += part;
171
+ else { this.pending = ''; this.dropping = true; this.truncated = true; }
172
+ if (part.endsWith('\n')) {
173
+ if (!this.dropping) this.line(this.pending);
174
+ this.pending = ''; this.dropping = false;
175
+ }
176
+ }
177
+ }
178
+ end() { if (!this.dropping && this.pending.trim()) this.line(this.pending); this.pending = ''; }
179
+ identify(value) { if (UUID.test(value ?? '')) { this.sessionId = value; this.session(value); } }
180
+ show(value, label = 'Agent') { if (typeof value === 'string' && value.trim()) this.log(`${label}: ${value.slice(0, 4000)}`); }
181
+ count(id) { if (!id || this.toolIds.has(id)) return; if (this.toolIds.size >= 4096) { this.truncated = true; return; } this.toolIds.add(id); this.toolCalls++; }
182
+ act(value) { const name = safeName(value); if (name) { this.log(`Agent action: ${name}`); this.action(name); } }
183
+ line(line) {
184
+ let events;
185
+ try { const parsed = JSON.parse(line); events = Array.isArray(parsed) ? parsed : [parsed]; } catch { return; }
186
+ for (const e of events) {
187
+ if (!e || typeof e !== 'object') continue;
188
+ if (this.provider === 'claude') {
189
+ if (e.type === 'system' && e.subtype === 'init') this.init = { model: safeName(e.model), effort: safeName(e.effort) };
190
+ if (e.type === 'assistant') for (const block of e.message?.content ?? []) {
191
+ if (block.type === 'text') this.show(block.text);
192
+ if (block.type === 'tool_use') { this.count(block.id ?? `${e.message?.id ?? e.uuid ?? 'unknown'}:${block.name}`); this.act(block.name); }
193
+ }
194
+ if (e.type === 'result') {
195
+ this.identify(e.session_id);
196
+ this.report = { is_error: e.is_error === true, subtype: safeName(e.subtype), num_turns: counter(e.num_turns),
197
+ session_id: this.sessionId, usage: e.usage ? { input_tokens: e.usage.input_tokens, output_tokens: e.usage.output_tokens,
198
+ cache_read_input_tokens: e.usage.cache_read_input_tokens, cache_creation_input_tokens: e.usage.cache_creation_input_tokens } : null,
199
+ result: text(e.result, 32 * 1024), errors: Array.isArray(e.errors) ? e.errors.slice(0, 5).map(v => text(v)) : [],
200
+ permission_denials: Array.isArray(e.permission_denials) ? e.permission_denials.slice(0, 100).map(v => ({ tool_name: safeName(v?.tool_name) })) : [] };
201
+ this.show(e.result, 'Agent result');
202
+ }
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // App Server protocol: initialize → start/resume → turn/start → ordered notifications.
209
+ // Only this thread's scalar metadata survives parsing; no conversation history is requested.
210
+ class CodexStream extends ProviderStream {
211
+ constructor(options) { super('codex', options); this.rpc = options.rpc; this.threadId = null; this.turnId = null;
212
+ this.tokenTotals = null; this.closed = false; this.nextId = 10; this.interrupted = false; }
213
+ attach(process_) {
214
+ this.process = process_;
215
+ this.send({ id: 1, method: 'initialize', params: { clientInfo: this.rpc.clientInfo } });
216
+ this.setupTimer = setTimeout(() => { this.fail('Codex initialization timed out before starting project work.'); }, 45_000);
217
+ }
218
+ send(value) { if (!this.closed && !this.process.stdin.destroyed) this.process.stdin.write(JSON.stringify(value) + '\n'); }
219
+ cleanup() { clearTimeout(this.setupTimer); clearTimeout(this.closeTimer); }
220
+ finish() {
221
+ if (this.closed) return; this.closed = true; this.cleanup(); this.process.stdin.end();
222
+ this.closeTimer = setTimeout(() => this.process.kill('SIGKILL'), 5000);
223
+ }
224
+ fail(message) { this.report = { is_error: true, usage: null, result: '', errors: [text(message)] }; this.finish(); }
225
+ interrupt() {
226
+ this.interrupted = true;
227
+ if (this.threadId && this.turnId) this.send({ id: this.nextId++, method: 'turn/interrupt', params: { threadId: this.threadId, turnId: this.turnId } });
228
+ else this.process.kill('SIGINT');
229
+ }
230
+ line(line) {
231
+ let e; try { e = JSON.parse(line); } catch { return; }
232
+ if (e.id === 1) {
233
+ if (e.error) return this.fail(e.error.message);
234
+ this.send({ method: 'initialized', params: {} });
235
+ this.send({ id: 2, method: this.rpc.resuming ? 'thread/resume' : 'thread/start', params: this.rpc.startParams });
236
+ return;
237
+ }
238
+ if (e.id === 2) {
239
+ if (e.error) return this.fail(e.error.message);
240
+ const r = e.result;
241
+ if (!UUID.test(r?.thread?.id ?? '') || r.modelProvider !== 'openai') return this.fail('Codex returned an unsupported thread/provider identity.');
242
+ this.threadId = r.thread.id; this.identify(this.threadId);
243
+ this.init = { model: safeName(r.model), modelSource: 'client-selected' };
244
+ this.send({ id: 3, method: 'turn/start', params: { threadId: this.threadId, input: [{ type: 'text', text: this.rpc.prompt }] } });
245
+ return;
246
+ }
247
+ if (e.id === 3) {
248
+ if (e.error) return this.fail(e.error.message);
249
+ this.turnId ??= e.result?.turn?.id; clearTimeout(this.setupTimer);
250
+ if (this.interrupted) this.interrupt(); return;
251
+ }
252
+ if (e.method && e.id !== undefined) {
253
+ // Automatic review handles supported approval requests. Never approve an unexpected
254
+ // interactive request or keep an unattended turn waiting for a human response.
255
+ this.send({ id: e.id, error: { code: -32601, message: 'This foreground adapter cannot answer interactive requests.' } });
256
+ return this.fail(`Codex requested interactive input (${safeName(e.method) ?? 'unknown'}); review the local execution policy.`);
257
+ }
258
+ const v = e.params;
259
+ if (!v || v.threadId !== this.threadId) return;
260
+ if (e.method === 'turn/started') { this.turnId = v.turn?.id; clearTimeout(this.setupTimer); }
261
+ if (v.turnId && this.turnId && v.turnId !== this.turnId) return;
262
+ if (e.method === 'model/rerouted') this.init = { model: safeName(v.toModel), modelSource: 'provider-rerouted' };
263
+ if (e.method === 'thread/tokenUsage/updated' && this.turnId) {
264
+ const t = v.tokenUsage?.total;
265
+ if (t) this.tokenTotals = [t.inputTokens, t.outputTokens, t.cachedInputTokens, t.cacheWriteInputTokens].map(counter);
266
+ }
267
+ if (e.method === 'item/started' || e.method === 'item/completed') {
268
+ const item = v.item;
269
+ if (['commandExecution', 'mcpToolCall', 'fileChange', 'webSearch', 'dynamicToolCall', 'imageGeneration'].includes(item?.type)) {
270
+ this.count(item.id); if (e.method === 'item/started') this.act(item.type);
271
+ }
272
+ if (e.method === 'item/completed' && item?.type === 'agentMessage') { this.finalText = text(item.text, 32 * 1024); this.show(this.finalText); }
273
+ }
274
+ if (e.method === 'turn/completed') {
275
+ if (this.turnId && v.turn?.id !== this.turnId) return;
276
+ const baseline = this.rpc.baseline;
277
+ const values = this.tokenTotals?.map((n, i) => n !== null && baseline?.[i] !== null && counter(baseline?.[i]) !== null && n >= baseline[i] ? n - baseline[i] : undefined);
278
+ this.report = { is_error: v.turn?.status !== 'completed', result: this.finalText,
279
+ usage: values ? { input_tokens: values[0], output_tokens: values[1], cached_input_tokens: values[2], cache_write_input_tokens: values[3] } : null,
280
+ errors: v.turn?.error?.message ? [text(v.turn.error.message)] : [] };
281
+ this.finish();
282
+ }
283
+ }
284
+ }