@karma42/agent 0.1.0-beta.5 → 0.1.0-beta.7

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,190 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { rmSync } from 'node:fs';
3
+ import { createWorkController } from '../runtime/work-controller.mjs';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createProvider, CLAUDE_ALLOWED_TOOLS } from '../runtime/providers.mjs';
8
+ import { VERSION } from './version.mjs';
9
+ import { canonicalPath, connectionDir, digest, ensureHome, lstatExists, readPrivate, writePrivate, assertStateOutsideWorkspace } from './state.mjs';
10
+ import { sessionHome } from './session.mjs';
11
+ import { verifyBundle } from './system.mjs';
12
+
13
+ const workerBridges = new WeakMap();
14
+ function workerBridge(bridge) {
15
+ let listening = workerBridges.get(bridge);
16
+ if (!listening) {
17
+ listening = Promise.resolve().then(() => bridge.listen());
18
+ workerBridges.set(bridge, listening);
19
+ listening.catch(() => { if (workerBridges.get(bridge) === listening) workerBridges.delete(bridge); });
20
+ }
21
+ return listening;
22
+ }
23
+ const bounded = (value, label, max = 2000) => {
24
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)) throw new Error(`Provide a clear ${label}.`);
25
+ return value.trim();
26
+ };
27
+ export function validateWorkerWorkspace(folder, record) {
28
+ if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
29
+ const cwd = canonicalPath(folder);
30
+ try { assertStateOutsideWorkspace(record.folder, cwd); } catch { throw new Error('Use separate sibling checkouts or worktrees so the worker and current chat do not edit the same files.'); }
31
+ const git = args => { const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 15000, maxBuffer: 16384, env: { PATH: process.env.PATH, HOME: process.env.HOME, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' } }); return result.status === 0 ? result.stdout.trim() : null; };
32
+ const root = git(['rev-parse', '--show-toplevel']), origin = git(['remote', 'get-url', 'origin']);
33
+ if (!root || canonicalPath(root) !== cwd || !origin || origin.replace(/\.git$/, '').replace(/\/$/, '') !== record.repo.replace(/\/$/, '')) throw new Error('The worker folder must be a Git repository root with the project’s HTTPS GitHub origin. No repository was changed.');
34
+ return cwd;
35
+ }
36
+
37
+ export function createLocalWorkerAdapter({ client, folder, stateDir, bridge, scope, providerFactory = createProvider, spawnModel = spawn }) {
38
+ verifyBundle(); ensureHome(stateDir);
39
+ const configuredEndpoint = client === 'claude' ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL;
40
+ if (configuredEndpoint && (() => { try { const endpoint = new URL(configuredEndpoint); return endpoint.protocol !== 'https:' || endpoint.origin !== (client === 'claude' ? 'https://api.anthropic.com' : 'https://api.openai.com'); } catch { return true; } })()) throw new Error('This worker adapter cannot preserve your custom API endpoint. Use the current-chat MCP mode rather than silently switching your model backend.');
41
+ const runtime = fileURLToPath(new URL('../runtime/', import.meta.url));
42
+ const settings = { claude: 'claude', codex: 'codex', repoDir: folder, permissionMode: 'acceptEdits', allowedTools: CLAUDE_ALLOWED_TOOLS, passEnv: '', turnBudgetUsd: 0 };
43
+ const provider = providerFactory(client, settings);
44
+ provider.preflight();
45
+ const instructions = readPrivate(join(stateDir, 'instructions.md'));
46
+ const promptHash = digest(instructions);
47
+ const sessionFile = join(stateDir, 'model-session.json');
48
+ let session = lstatExists(sessionFile) ? readPrivate(sessionFile, true) : null;
49
+ if (session && (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(session.id ?? '') || session.provider !== client || session.repoDir !== folder || session.promptHash !== promptHash)) throw new Error('Saved worker model session belongs to different approved work. Use a fresh work session.');
50
+ return {
51
+ async run({ prompt, signal, onActivity, recovering = false }) {
52
+ if (signal.aborted) return Promise.resolve({ interrupted: true });
53
+ const resuming = provider.canResume(session);
54
+ session ??= provider.newSession(promptHash, folder);
55
+ const save = () => writePrivate(sessionFile, { ...session, provider: client, promptHash, repoDir: folder });
56
+ save();
57
+ const invocation = provider.invocation({ prompt, instructionsFile: join(stateDir, 'instructions.md'), settingsFile: join(runtime, 'claude-settings.json'), mcpConfig: join(stateDir, 'mcp.json'), bridge, session, resuming, repoDir: folder, clientVersion: VERSION });
58
+ const result = await new Promise(resolve => {
59
+ const child = spawnModel(invocation.command, invocation.args, { cwd: folder, env: provider.environment(invocation.env), stdio: [invocation.rpc ? 'pipe' : 'ignore', 'pipe', 'pipe'], windowsHide: true });
60
+ let done = false, interrupted = false, persistenceFault = false, killTimer, forceTimer, stderr = '';
61
+ const stream = provider.collector({ rpc: invocation.rpc, log: () => {}, action: onActivity, session: id => { session.id = id; try { save(); } catch { persistenceFault = true; child.kill('SIGTERM'); } } });
62
+ const interrupt = () => { interrupted = true; if (invocation.rpc) stream.interrupt(); else child.kill('SIGINT'); killTimer = setTimeout(() => child.kill('SIGTERM'), 3000); forceTimer = setTimeout(() => child.kill('SIGKILL'), 8000); };
63
+ signal.addEventListener('abort', interrupt, { once: true });
64
+ const finish = code => {
65
+ if (done) return; done = true; stream.end(); stream.cleanup?.(); clearTimeout(killTimer); clearTimeout(forceTimer); signal.removeEventListener('abort', interrupt);
66
+ if (stream.sessionId) session.id = stream.sessionId;
67
+ if (stream.tokenTotals) session.tokenTotals = stream.tokenTotals;
68
+ try { save(); } catch { persistenceFault = true; }
69
+ const diagnostics = `${stderr} ${stream.report?.errors?.join(' ') ?? ''} ${stream.report?.result ?? ''}`;
70
+ const failureCode = persistenceFault ? 'private_state_failure' : /(?:not logged in|authentication|unauthorized|login required|invalid api key)/i.test(diagnostics) ? 'authentication_required' : /(?:permission denied|not permitted|approval required|tool.*refused)/i.test(diagnostics) ? 'permission_required' : 'local_execution_failure';
71
+ resolve({ missingSession: !interrupted && !persistenceFault && Boolean(provider.missingSession?.(stderr)), failureCode, retryable: failureCode === 'local_execution_failure', interrupted, failed: !interrupted && (persistenceFault || (invocation.rpc && stream.closed && stream.report ? 0 : code) !== 0 || stream.report?.is_error || !stream.report), text: stream.report?.result ?? stream.finalText ?? '', usage: provider.usage(stream.report, stream.init) });
72
+ };
73
+ child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => { try { stream.feed(chunk); } catch { persistenceFault = true; child.kill('SIGTERM'); } });
74
+ child.stderr.setEncoding('utf8'); child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); child.once('error', () => finish(1)); child.once('close', finish);
75
+ if (invocation.rpc) { child.stdin.on('error', () => {}); stream.attach(child); }
76
+ if (signal.aborted) interrupt();
77
+ });
78
+ if (result.missingSession && !recovering && !signal.aborted) { session = null; rmSync(sessionFile, { force: true }); return this.run({ prompt, signal, onActivity, recovering: true }); }
79
+ if (result.missingSession) { result.failed = true; result.retryable = false; result.failureCode = 'session_recovery_failed'; }
80
+ delete result.missingSession; return result;
81
+ },
82
+ };
83
+ }
84
+
85
+ // A lightweight in-process supervisor, owned by the existing MCP transport and run.
86
+ // Waiting never holds a coding model open. No token or model process is detached.
87
+ export function createProjectWorker({ control, bridge, record, stateDir = connectionDir(sessionHome(), record.id), onState = () => {}, now = Date.now, setTimer = setInterval, clearTimer = clearInterval, adapterFactory = createLocalWorkerAdapter, validateWorkspace = validateWorkerWorkspace, pollMs = 1000, quietIntervalMs = 15 * 60000, random = Math.random, instructionsText } = {}) {
88
+ let phase = 'idle', deadline = null, startedAt = null, adapter, folder, client, scope, turn, abort, timer, stopRequested = false, scheduler, sendDeferred = false, lastResult = '', error = null, sequence = 0, workerConnection, activity = null;
89
+ const steering = [];
90
+ const snapshot = () => ({ state: phase, active: phase === 'starting' || Boolean(turn) || (deadline !== null && !stopRequested), activity, client: client ?? null, folder: folder ?? null, scope: scope ?? null, started_at: startedAt, deadline_at: deadline, remaining_seconds: deadline ? Math.max(0, Math.ceil((deadline - now()) / 1000)) : 0, current_turn: Boolean(turn), last_result: lastResult, error, steering: steering.map(({ id, state }) => ({ id, state })) });
91
+ const notify = () => { try { onState(snapshot()); } catch {} };
92
+ const allowed = () => !control.fatal && !control.closed && control.news?.project?.started === true && control.news.project.paused !== true;
93
+ const journal = () => bridge.journal;
94
+ const previousDeferred = bridge.onDeferred;
95
+ const onDeferred = () => { previousDeferred?.(); if (turn && !stopRequested) { sendDeferred = true; abort?.abort(); } };
96
+ bridge.onDeferred = onDeferred;
97
+ async function launch() {
98
+ if (turn || stopRequested || phase === 'paused' || !allowed() || now() >= deadline) return;
99
+ phase = 'working'; scheduler.beginTurn(control.news); sendDeferred = false; abort = new AbortController();
100
+ const applied = steering.filter(item => item.state === 'received');
101
+ const notices = journal()?.notices?.() ?? []; notify();
102
+ const prompt = `Work only within the human-approved scope: ${scope}.\nRead get_project and get_updates; respect current project state and rules. Reconcile previous work before repeating any external action.\n${applied.length ? 'Operator steering for this turn:\n' + applied.map(item => item.message).join('\n') : 'Continue the saved work session.'}\n${notices.length ? 'Runner deferred-send results (already processed; do not republish): ' + JSON.stringify(notices) + '\n' : ''}The work session ends at ${new Date(deadline).toISOString()}. End with WORK_NEXT: runnable when there is concrete work you can do next, otherwise WORK_NEXT: waiting. Do not create another agent or background process.`;
103
+ const signal = abort.signal;
104
+ turn = (async () => {
105
+ await control.setPhase?.('turn');
106
+ if (signal.aborted || stopRequested || !allowed() || now() >= deadline) return { interrupted: true };
107
+ for (const item of applied) item.state = 'applied';
108
+ notify();
109
+ return adapter.run({ prompt, signal, onActivity: tool => { if (typeof tool === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(tool)) { activity = { tool, at: now() }; control.activity = activity; notify(); } } });
110
+ })();
111
+ try {
112
+ const result = await turn;
113
+ if (result?.interrupted) { scheduler.recordOutcome({ kind: sendDeferred ? 'send_deferred' : 'interrupted', news: control.news, now: now() }); if (!stopRequested && phase !== 'paused') phase = 'waiting'; }
114
+ else if (result?.failed) {
115
+ scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: result.retryable !== false });
116
+ phase = scheduler.status().blocked ? 'blocked' : 'waiting';
117
+ const messages = { authentication_required: 'Sign in to the local model client before resuming.', permission_required: 'The model needs an approved permission before this work can continue.', private_state_failure: 'Private worker state could not be saved. Check local storage before resuming.', session_recovery_failed: 'The saved model session could not be recovered. Start a fresh approved work session.' };
118
+ error = messages[result.failureCode] ?? 'The local model turn failed. Bounded retries stop after three failures.';
119
+ }
120
+ else {
121
+ lastResult = String(result?.text ?? '').slice(0, 2000); error = null;
122
+ scheduler.recordOutcome({ kind: 'ok', news: control.news, now: now(), continuation: /(?:^|\n)WORK_NEXT: runnable\s*$/.test(result?.text ?? '') });
123
+ if (notices.length) journal()?.acknowledge?.(notices);
124
+ if (!stopRequested && phase !== 'paused') phase = 'waiting';
125
+ }
126
+ await control.setPhase?.('idle');
127
+ } catch { if (!stopRequested) { scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: false }); phase = 'blocked'; error = 'The worker stopped on a local execution failure.'; } }
128
+ finally { turn = null; abort = null; notify(); }
129
+ }
130
+ async function tick() {
131
+ if (stopRequested || deadline === null) return;
132
+ if (now() >= deadline) { await stop('completed'); return; }
133
+ if (control.fatal || control.closed) { await stop('stopped'); return; }
134
+ // Control polling briefly clears news before its HTTP reply. Unknown contact blocks
135
+ // new turns, but is not evidence that the project was paused or authorization revoked.
136
+ const observed = control.news?.project;
137
+ if (!observed || typeof observed.started !== 'boolean' || typeof observed.paused !== 'boolean') return;
138
+ if (!observed.started || observed.paused) {
139
+ // Preserve only work that was actually interrupted. A successful waiting result
140
+ // remains waiting; project resume must not invent a reason to start another turn.
141
+ if (turn) abort?.abort();
142
+ if (phase !== 'paused' && phase !== 'blocked') phase = 'waiting';
143
+ notify(); return;
144
+ }
145
+ if (phase === 'paused' || phase === 'blocked' || turn) return;
146
+ if (bridge.fault) { phase = 'blocked'; error = 'Project delivery state requires attention before work can continue.'; notify(); return; }
147
+ const decision = scheduler.decide({ news: control.news, pendingDeliveries: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0, steering: steering.some(item => item.state === 'received'), now: now() });
148
+ if (decision.action === 'stop') { phase = 'blocked'; error ??= decision.reason === 'unacknowledged_inbox' ? 'Three turns left project updates unacknowledged. Review the worker before resuming.' : 'Repeated local model failures require review before resuming.'; notify(); return; }
149
+ if (decision.action === 'run') await launch();
150
+ }
151
+ async function stop(finalPhase = 'stopped') {
152
+ stopRequested = true; phase = turn ? 'stopping' : finalPhase; clearTimer(timer); abort?.abort(); notify();
153
+ if (turn) await turn.catch(() => {});
154
+ phase = finalPhase; if (bridge.onDeferred === onDeferred) bridge.onDeferred = previousDeferred; notify(); return snapshot();
155
+ }
156
+ return {
157
+ status: snapshot,
158
+ async start(options) {
159
+ if (options.approved !== true) throw new Error('Explicit human approval is required for the worker client, scope, folder and duration.');
160
+ if (phase !== 'idle') throw new Error('This work session already started. Pause, resume or stop it; starting cannot extend its deadline.');
161
+ if (!allowed()) throw new Error('Start the project and resume it before starting a worker. The verified connection must still be authorized and available.');
162
+ client = options.client;
163
+ if (!['claude', 'codex'].includes(client)) throw new Error('Supervised workers currently support local Claude Code or Codex CLI. Other models can use the current-chat MCP mode.');
164
+ const minutes = options.minutes ?? 60;
165
+ if (!Number.isInteger(minutes) || minutes < 15 || minutes > 240 || minutes % 15 !== 0) throw new Error('Choose a work duration from 15 to 240 minutes in 15-minute steps.');
166
+ scope = bounded(options.scope, 'approved work scope', 4000); folder = validateWorkspace(options.folder, record);
167
+ phase = 'starting'; notify();
168
+ try {
169
+ assertStateOutsideWorkspace(stateDir, folder); ensureHome(stateDir);
170
+ const workerHome = join(stateDir, 'worker'); ensureHome(workerHome);
171
+ const workerDir = join(workerHome, randomUUID()); ensureHome(workerDir);
172
+ const source = instructionsText ?? verifyBundle().files['AGENT_PROMPT_V2.md'].toString('utf8');
173
+ writePrivate(join(workerDir, 'instructions.md'), `${source}\n\n## Human-approved local work session\nWork only on: ${scope}. Use only this project's supplied MCP connection. Do not start other agents. Your session has a finite deadline; pauses do not extend it. Stop at the deadline or operator cancellation. Native guarded permissions apply; never enable bypass.\nThis approval covers the mission, not just one issue. After completing one task, choose the next useful task within the approved scope without asking what to do next. The mission is done only when its entire approved scope is complete. If a task is blocked, record its concrete blocker and choose another useful authorized task. Wait when all useful authorized work is blocked or there is no actionable work; do not invent work or repeatedly rediscover it. Notify the human of a concrete blocker when their action is needed. End each turn with exactly one line: WORK_NEXT: runnable when concrete authorized work remains, WORK_NEXT: waiting when no authorized action is available, or WORK_NEXT: done only when the entire approved mission is complete.\nDo not create or change CI/CD, deployment, infrastructure, credentials, repository permissions or branch protection unless the approved work scope explicitly authorizes it. If that prevents a task, record the blocker and choose another authorized task. A failing check does not authorize weakening or bypassing it. These instructions constrain your approved scope; they are not an operating-system sandbox.\n`);
174
+ const connection = {};
175
+ adapter = adapterFactory({ client, folder, stateDir: workerDir, bridge: connection, scope });
176
+ workerConnection ??= await workerBridge(bridge);
177
+ if (stopRequested) throw new Error('The MCP session closed before the worker could start.');
178
+ Object.assign(connection, workerConnection);
179
+ writePrivate(join(workerDir, 'mcp.json'), { mcpServers: { pilot: connection } });
180
+ startedAt = now(); deadline = startedAt + minutes * 60000; phase = 'waiting'; scheduler = createWorkController({ quietIntervalMs, now, random, deadline }); notify();
181
+ timer = setTimer(() => { void tick().catch(() => { void stop('stopped'); }); }, pollMs);
182
+ void tick(); return snapshot();
183
+ } catch (failure) { phase = stopRequested ? 'stopped' : 'idle'; adapter = null; notify(); throw failure; }
184
+ },
185
+ async pause() { if (!deadline || stopRequested) throw new Error('There is no active work session.'); phase = 'paused'; abort?.abort(); notify(); if (turn) await turn.catch(() => {}); return snapshot(); },
186
+ async resume() { if (phase !== 'paused' && phase !== 'blocked') throw new Error('Only paused or blocked work can resume.'); if (now() >= deadline) return stop('completed'); error = null; phase = 'waiting'; scheduler.requestContinuation(); notify(); void tick(); return snapshot(); },
187
+ steer({ message }) { if (!deadline || stopRequested) throw new Error('There is no active work session.'); if (steering.length >= 64) throw new Error('Too many steering instructions in this work session.'); steering.push({ id: ++sequence, state: 'received', message: bounded(message, 'steering instruction', 2000) }); notify(); return { id: sequence, state: 'received', note: 'Queued for the next model turn; not applied yet.' }; },
188
+ stop: () => stop('stopped'), close: () => stop('stopped'), tick,
189
+ };
190
+ }