@karma42/agent 0.1.0-beta.3 → 0.1.0-beta.4

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.
@@ -1,591 +0,0 @@
1
- #!/usr/bin/env node
2
- // Keeps one locally operated coding agent in a project without spending model usage on waiting. It
3
- // runs a model turn, then asks the server every 20 seconds (POST /v2/wait) whether the agent
4
- // has events it has not acknowledged, runs a turn when it has, and so on. Needs Node 22 or newer
5
- // and a signed-in Claude Code 2.1.259+ or Codex CLI 0.160.0+.
6
- //
7
- // node runner.mjs <service url> <agent name> <repository folder>
8
- //
9
- // The service URL is the project's, ending in /p/<project id>. The agent's token is kept in
10
- // ~/.agent-projects/<project id>/<agent name>/token (mode 600); the runner asks for it on the
11
- // first start. Claude uses only the kit settings. Codex retains native policy,
12
- // uses workspace-write with automatic review unless PILOT_PERMISSION_MODE=bypassPermissions, and
13
- // disables personal MCPs/plugins/hooks/apps for this invocation. Provider authentication remains local. Optional environment settings:
14
- // PILOT_TAKEOVER explicit v2 takeover of another run (0)
15
- // PILOT_HOME where the runner keeps its files (~/.agent-projects/<project id>)
16
- // PILOT_POLL_SECONDS how often to ask the server for news (20)
17
- // PILOT_TURN_SECONDS the longest model turn; 0 unlimited (0)
18
- // PILOT_TURN_BUDGET_USD the most a turn may spend; 0 unlimited (0)
19
- // PILOT_MAX_TURNS_PER_HOUR turns in any hour; 0 means no limit (0)
20
- // PILOT_WORK_INTERVAL_MINUTES quiet-work turns; 0 turns them off (0)
21
- // PILOT_PROVIDER explicit local client (claude)
22
- // PILOT_CODEX the codex command (codex)
23
- // PILOT_MODEL model override; default resolved by the client
24
- // PILOT_PERMISSION_MODE Claude Code's permission mode (acceptEdits);
25
- // bypassPermissions also runs Codex without its sandbox and approval review
26
- // PILOT_ALLOWED_TOOLS Claude Code's allowed tools, replacing the kit default (CLAUDE_ALLOWED_TOOLS in providers.mjs:
27
- // named git subcommands with origin only, gh issue/pr, gh label list, npm test/run/ci,
28
- // plain npm install, node --check/--test)
29
- // PILOT_CLAUDE the claude command (claude)
30
- // PILOT_PASS_ENV extra variable names for the model client, comma-separated (none);
31
- // it otherwise receives only PATH, HOME, locale/system and provider sign-in
32
- import { KIT_FILES, appliedRelease, fetchRuntime, hash, installRelease, pruneReleases } from './runtime.mjs';
33
- import { CLAUDE_ALLOWED_TOOLS, createProvider } from './providers.mjs';
34
- import { ControlV2, DeferredMcp } from './control-v2.mjs';
35
- import { spawn, spawnSync } from 'node:child_process';
36
- import { createHash, randomUUID } from 'node:crypto';
37
- import {
38
- appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
39
- } from 'node:fs';
40
- import { homedir } from 'node:os';
41
- import { join, resolve } from 'node:path';
42
- import { createInterface } from 'node:readline/promises';
43
- import { setTimeout as sleep } from 'node:timers/promises';
44
-
45
- const USAGE = 'Usage: node runner.mjs <service url> <agent name> <repository folder>';
46
- const FAILURES_BEFORE_STOP = 3;
47
- const HOUR_MS = 3_600_000;
48
- let PROMPT_FILE = join(import.meta.dirname, 'AGENT_PROMPT_V2.md');
49
- const SETTINGS_FILE = join(import.meta.dirname, 'claude-settings.json');
50
-
51
- const [serviceUrl, agentName, repoDirArg] = process.argv.slice(2);
52
- if (!serviceUrl || !agentName || !repoDirArg) {
53
- console.error(USAGE);
54
- process.exit(2);
55
- }
56
- if (!/^[\w-]{1,64}$/.test(agentName)) {
57
- console.error('The agent name may have letters, digits, hyphens and underscores only.');
58
- process.exit(2);
59
- }
60
- const serviceBase = new URL(serviceUrl);
61
- const projectPath = /^\/p\/([a-f0-9]{64})\/?$/.exec(serviceBase.pathname);
62
- if (serviceBase.username || serviceBase.password || serviceBase.search || serviceBase.hash || !projectPath
63
- || !(serviceBase.protocol === 'https:' || serviceBase.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(serviceBase.hostname))) throw new Error('Use the project service URL from its connection instructions.');
64
- const projectId = projectPath[1];
65
- const url = serviceUrl.replace(/\/+$/, '');
66
- const repoDir = resolve(repoDirArg);
67
- if (!existsSync(repoDir) || !statSync(repoDir).isDirectory()) {
68
- console.error(`The repository folder ${repoDir} does not exist.`);
69
- process.exit(2);
70
- }
71
-
72
- const settings = {
73
- home: process.env.PILOT_HOME ?? join(homedir(), '.agent-projects', projectId),
74
- pollSeconds: numberSetting('PILOT_POLL_SECONDS', 20, 1),
75
- turnSeconds: numberSetting('PILOT_TURN_SECONDS', 0),
76
- turnBudgetUsd: numberSetting('PILOT_TURN_BUDGET_USD', 0),
77
- maxTurnsPerHour: numberSetting('PILOT_MAX_TURNS_PER_HOUR', 0),
78
- workIntervalMinutes: numberSetting('PILOT_WORK_INTERVAL_MINUTES', 0),
79
- model: process.env.PILOT_MODEL,
80
- permissionMode: process.env.PILOT_PERMISSION_MODE ?? 'acceptEdits',
81
- allowedTools: process.env.PILOT_ALLOWED_TOOLS ?? CLAUDE_ALLOWED_TOOLS,
82
- claude: process.env.PILOT_CLAUDE ?? 'claude',
83
- passEnv: process.env.PILOT_PASS_ENV ?? '',
84
- codex: process.env.PILOT_CODEX ?? 'codex',
85
- provider: process.env.PILOT_PROVIDER ?? 'claude', repoDir,
86
- };
87
- const provider = createProvider(settings.provider, settings);
88
- const stateDir = join(settings.home, agentName);
89
- const serviceIdentity = join(stateDir, 'service.json');
90
- if (existsSync(serviceIdentity) && JSON.parse(readFileSync(serviceIdentity, 'utf8')).service !== url) throw new Error('This local connection belongs to another project service.');
91
- const files = {
92
- token: join(stateDir, 'token'),
93
- mcpConfig: join(stateDir, 'mcp.json'),
94
- session: join(stateDir, provider.id === 'claude' ? 'session' : 'codex-session'),
95
- log: join(stateDir, 'runner.log'),
96
- lock: join(stateDir, 'lock'),
97
- budget: join(stateDir, 'turn-starts.json'),
98
- sends: join(stateDir, 'deferred-sends.json'),
99
- };
100
- const prompts = {
101
- start: 'Read get_project and the applicable project instructions. If started and not paused, begin useful work toward the actual project goal now. Choose how to investigate, contribute or collaborate according to that guidance; an empty inbox does not mean there is no work. Reconcile prior local/GitHub state and recovery checkpoints before repeating external actions. Handle and acknowledge delivered items; preserve useful unfinished intent or concrete blockers before finishing.',
102
- events: 'Read get_project and your directed inbox with get_updates. Handle relevant requests according to the project instructions, choosing useful work or collaboration when appropriate. Reconcile uncertain prior actions. Acknowledge only handled items and preserve any responsibility that needs later work before finishing.',
103
- work: 'Read get_project, reconcile relevant local/GitHub state and your recovery checkpoints. Continue useful runnable work according to project instructions, or investigate a new contribution when appropriate. Avoid duplicating prior actions. If blocked, report the concrete blocker and finish without polling inside the model turn. Handle and acknowledge relevant updates.',
104
- };
105
-
106
- // Thrown to stop the runner with a message for the person.
107
- class Stop extends Error {}
108
-
109
- let child = null; // the running claude process, if any
110
- let stopping = false;
111
- const lockId = `${process.pid} ${randomUUID()}`; // what this runner writes in the lock
112
- let ownsLock = false; // only the runner that wrote the lock removes it
113
- let repeatedFailures = false; // the stop was the three-failure stop; other local faults report no reason
114
- let control = null;
115
- let deferredMcp = null;
116
- let bridgeConnection = null;
117
- let sendDeferred = false;
118
- let lastTurnOutcome = 'failed';
119
- let continueWork = false;
120
- let usageSequence = 0;
121
- let updateRequested = false;
122
- const installedKitHash = hash(JSON.stringify(KIT_FILES.map(name => [name, hash(readFileSync(join(import.meta.dirname, name), 'utf8'))])));
123
- const initialRuntime = process.env.PILOT_RUNTIME_FILE ? JSON.parse(readFileSync(process.env.PILOT_RUNTIME_FILE, 'utf8')) : undefined;
124
- if (initialRuntime && initialRuntime.kitHash !== installedKitHash) throw new Error('Installed runtime does not match its release metadata.');
125
- let lastTurnUsage;
126
- const turnStarts = []; // when the turns of the last hour started
127
-
128
- function numberSetting(name, fallback, min = 0) {
129
- const raw = process.env[name];
130
- if (raw === undefined || raw === '') return fallback;
131
- const value = Number(raw);
132
- if (!Number.isFinite(value) || value < min) {
133
- console.error(`${name} must be a number of at least ${min}, not ${raw}.`);
134
- process.exit(2);
135
- }
136
- return value;
137
- }
138
-
139
- function log(message) {
140
- const line = `${new Date().toISOString()} ${message}`;
141
- console.info(line);
142
- appendFileSync(files.log, `${line}\n`);
143
- }
144
-
145
- // One runner per agent: a second one would run two model processes on the same events. The lock
146
- // holds the runner's pid and a random id. It is written to a file of its own first and then linked
147
- // into place, which fails if a lock exists, so no runner ever reads a half-written lock.
148
- function acquireLock() {
149
- const draft = `${files.lock}.${process.pid}`;
150
- writeFileSync(draft, lockId);
151
- try {
152
- for (;;) {
153
- try {
154
- linkSync(draft, files.lock);
155
- ownsLock = true;
156
- return;
157
- } catch (error) {
158
- if (error.code !== 'EEXIST') throw error;
159
- }
160
- const held = readLock();
161
- if (held === null) continue; // the other runner just stopped
162
- const pid = Number(held.split(' ')[0]);
163
- if (pid && isRunner(pid)) {
164
- throw new Stop(`Another runner for ${agentName} is already running (process ${pid}). Run only one per agent. `
165
- + `If you are sure none is running, delete ${files.lock} and start again.`);
166
- }
167
- removeStaleLock(held);
168
- }
169
- } finally {
170
- rmSync(draft, { force: true });
171
- }
172
- }
173
-
174
- function readLock() {
175
- try {
176
- return readFileSync(files.lock, 'utf8');
177
- } catch (error) {
178
- if (error.code === 'ENOENT') return null;
179
- throw error;
180
- }
181
- }
182
-
183
- // Removes a lock left by a runner that did not stop cleanly. Two runners can find the same stale
184
- // lock; the second could then remove the lock the first has just taken. So the lock is moved aside
185
- // first and checked: if it is no longer the stale one, it goes back.
186
- function removeStaleLock(stale) {
187
- const aside = `${files.lock}.stale.${process.pid}`;
188
- try {
189
- renameSync(files.lock, aside);
190
- } catch (error) {
191
- if (error.code === 'ENOENT') return;
192
- throw error;
193
- }
194
- try {
195
- if (readFileSync(aside, 'utf8') !== stale) linkSync(aside, files.lock);
196
- } catch (error) {
197
- if (error.code !== 'EEXIST') throw error;
198
- } finally {
199
- rmSync(aside, { force: true });
200
- }
201
- }
202
-
203
- // Whether the process is alive and is a runner. A pid in an old lock may since have been given to
204
- // some other process, after a reboot for example.
205
- function isRunner(pid) {
206
- try {
207
- process.kill(pid, 0);
208
- } catch (error) {
209
- if (error.code !== 'EPERM') return false;
210
- }
211
- const ps = spawnSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' });
212
- if (ps.error) return true; // no ps to ask: assume it is a runner
213
- return ps.stdout.includes('runner.mjs');
214
- }
215
-
216
- async function loadToken() {
217
- if (existsSync(files.token)) return readFileSync(files.token, 'utf8').trim();
218
- if (!process.stdin.isTTY) {
219
- throw new Stop(`There is no token in ${files.token}. Start the runner in a terminal once to enter it.`);
220
- }
221
- const rl = createInterface({ input: process.stdin, output: process.stdout });
222
- const token = (await rl.question(`Paste the token for agent ${agentName} and press Enter: `)).trim();
223
- rl.close();
224
- if (!/^[\w-]{20,}$/.test(token)) throw new Stop('That does not look like a token. Nothing was saved.');
225
- writeFileSync(files.token, `${token}\n`, { mode: 0o600 });
226
- log(`Token saved in ${files.token}.`);
227
- return token;
228
- }
229
-
230
- // Claude Code reads the connection from this file. Only this agent's owner can read it.
231
- function writeMcpConfig(connection) {
232
- writeFileSync(files.mcpConfig, `${JSON.stringify({ mcpServers: { pilot: connection } })}\n`, { mode: 0o600 });
233
- chmodSync(files.mcpConfig, 0o600);
234
- }
235
-
236
- function checkProvider() {
237
- const probe = provider.preflight();
238
- log(`${probe.label}; local provider ${provider.id}; repository folder ${repoDir}; agent ${agentName} at ${url}.`);
239
- }
240
-
241
- // Separate client-owned sessions, bound to this checkout and instruction/tool revision.
242
- function loadSession() {
243
- try {
244
- return JSON.parse(readFileSync(files.session, 'utf8'));
245
- } catch {
246
- return null;
247
- }
248
- }
249
-
250
- function saveSession(session) {
251
- writeFileSync(files.session, `${JSON.stringify(session)}\n`, { mode: 0o600 });
252
- }
253
-
254
- // One model invocation in the repository folder. Returns 'done' when the client reported success,
255
- // 'bounded' when the turn reached its time or budget limit, and 'failed' otherwise.
256
- async function runTurn(kind) {
257
- const promptHash = createHash('sha256').update(readFileSync(PROMPT_FILE)).update(control?.runtime?.mcpRevision ?? '').digest('hex');
258
- let session = loadSession();
259
- if (session && session.promptHash !== promptHash) {
260
- log('AGENT_PROMPT_V2.md changed since the saved session began, so this turn starts a new session.');
261
- session = null;
262
- }
263
- if (session?.repoDir && session.repoDir !== repoDir) throw new Stop('The saved model session belongs to another repository folder.');
264
- const resuming = provider.canResume(session);
265
- if (!resuming) {
266
- session = provider.newSession(promptHash, repoDir);
267
- saveSession(session);
268
- }
269
- const sendNotices = deferredMcp.journal.notices();
270
- const sendContext = sendNotices.length ? `\nRunner deferred-send results (already processed; do not republish): ${JSON.stringify(sendNotices)}.` : '';
271
- const invocation = provider.invocation({ prompt: prompts[kind] + sendContext, instructionsFile: PROMPT_FILE,
272
- settingsFile: SETTINGS_FILE, mcpConfig: files.mcpConfig, bridge: bridgeConnection, session, resuming, repoDir,
273
- clientVersion: `kit-${installedKitHash.slice(0, 12)}` });
274
- log(`Turn (${kind}) starting${resuming ? ', continuing the saved session' : ' in a new session'}. ${settings.turnSeconds > 0 ? `The limit is ${settings.turnSeconds} s.` : 'No time or spending limit is configured.'}`);
275
-
276
- lastTurnOutcome = 'failed';
277
- continueWork = false;
278
- const startedAt = Date.now();
279
- sendDeferred = false;
280
- // If this process dies mid-invocation, a restart must not attribute the interrupted gap
281
- // to its next turn. Keep this invocation's prior baseline only in memory.
282
- saveSession({ ...session, costUsd: null, tokenTotals: null });
283
- deferredMcp.beginContext();
284
- const run = await runModel(invocation, id => saveSession({ ...session, id, tokenTotals: null, costUsd: null }));
285
- const context = { instructionBytes: Buffer.byteLength(readFileSync(PROMPT_FILE)),
286
- turnPromptBytes: Buffer.byteLength(prompts[kind] + sendContext), ...deferredMcp.endContext() };
287
- const seconds = Math.round((Date.now() - startedAt) / 1000);
288
- const { report, init } = run;
289
- if (resuming && !sendDeferred && provider.missingSession(run.stderr)) {
290
- // Nothing ran, so nothing was spent: the turn that was to create the session ended first.
291
- rmSync(files.session, { force: true });
292
- log('Claude Code does not have the saved session, so the turn starts again in a new one.');
293
- return runTurn(kind);
294
- }
295
- const bridgeCalls = context.tools.reduce((n, t) => n + t.calls, 0);
296
- const measured = provider.usage(report, init);
297
- lastTurnUsage = { sequence: ++usageSequence, seconds, ...measured, context,
298
- toolCalls: run.toolCalls > 0 || !bridgeCalls ? run.toolCalls : bridgeCalls,
299
- toolScope: run.truncated || !report || report.is_error ? 'partial' : run.toolCalls > 0 || !bridgeCalls ? 'provider-stream' : 'mcp-bridge' };
300
- const usage = describeUsage(report, lastTurnUsage);
301
- saveSession({ ...session, id: run.sessionId ?? session.id, provider: provider.id, repoDir,
302
- tokenTotals: run.tokenTotals ?? null, costUsd: null });
303
- log(`Coordination context: ${context.instructionBytes} instruction bytes, ${context.turnPromptBytes} turn-prompt bytes; `
304
- + `${context.catalogRequests} catalog responses / ${context.catalogBytes} bytes; `
305
- + `${context.tools.reduce((n, t) => n + t.calls, 0)} tool responses / ${context.tools.reduce((n, t) => n + t.responseTextBytes, 0)} text bytes. Provider context occupancy unknown.`);
306
-
307
- if (sendDeferred) {
308
- log(`Turn (${kind}) ended for a deferred send; retry waits outside the model${usage}.`);
309
- return 'send_deferred';
310
- }
311
- if (run.timedOut) {
312
- log(`Turn (${kind}) stopped at the time limit after ${seconds} s${usage}.`);
313
- lastTurnOutcome = 'timed_out';
314
- return 'bounded';
315
- }
316
- if (report?.subtype === 'error_max_budget_usd') {
317
- log(`Turn (${kind}) stopped at its $${settings.turnBudgetUsd} budget after ${seconds} s${usage}.`);
318
- lastTurnOutcome = 'budget_cap';
319
- return 'bounded';
320
- }
321
- if (run.code !== 0 || !report || report.is_error) {
322
- const detail = report?.errors?.length ? report.errors.map(String).join(' | ')
323
- : report?.result ?? run.stderr.split('\n').filter((line) => line.trim()).slice(-2).join(' | ');
324
- const login = provider.loginHint(`${detail} ${run.stderr}`);
325
- log(`Turn (${kind}) failed after ${seconds} s (exit ${run.code ?? run.signal}${report?.subtype ? `, ${report.subtype}` : ''})${usage}. ${detail}${login}`.trim());
326
- return 'failed';
327
- }
328
- continueWork = /(?:^|\n)WORK_NEXT: runnable\s*$/.test(report.result ?? '');
329
- lastTurnOutcome = 'succeeded';
330
- if (sendNotices.length) deferredMcp.journal.acknowledge(sendNotices);
331
- log(`Turn (${kind}) done in ${seconds} s${usage}.`);
332
- return 'done';
333
- }
334
-
335
- // Unknown provider counters remain unknown; no step count or price is reported.
336
- function describeUsage(report, usage) {
337
- const parts = [`model ${usage.model ?? 'unknown'}`];
338
- const u = usage.tokens; const value = v => v === null || v === undefined ? 'unknown' : v;
339
- parts.push(u ? `tokens ${value(u.input)} in, ${value(u.output)} out, ${value(u.cacheRead)} cache read, ${value(u.cacheWrite)} cache write` : 'tokens unknown');
340
- parts.push(`${usage.toolCalls} observed tool calls (${usage.toolScope})`);
341
- if (report?.permission_denials?.length) parts.push(`${report.permission_denials.length} denied tool calls`);
342
- return `; ${parts.join('; ')}`;
343
- }
344
-
345
- // Runs a turn unless the hour's turns are used up; then it first waits until one is an hour old.
346
- async function turn(kind) {
347
- const prune = () => {
348
- while (turnStarts.length && Date.now() - turnStarts[0] >= HOUR_MS) turnStarts.shift();
349
- };
350
- prune();
351
- if (settings.maxTurnsPerHour > 0 && turnStarts.length >= settings.maxTurnsPerHour) {
352
- const wait = turnStarts[0] + HOUR_MS - Date.now();
353
- log(`${turnStarts.length} turns in the last hour, the most allowed (PILOT_MAX_TURNS_PER_HOUR). The next turn waits ${Math.ceil(wait / 60_000)} min.`);
354
- await control.setPhase('backoff');
355
- await controlledDelay(wait);
356
- prune();
357
- }
358
- if (stopping) return 'deferred';
359
- if (control.fatal) throw control.fatal;
360
- if (!control.news || !control.news.project.started || control.news.project.paused) return 'deferred';
361
- await control.setPhase('turn');
362
- if (stopping || !control.news || !control.news.project.started || control.news.project.paused) return 'deferred';
363
- turnStarts.push(Date.now());
364
- writeFileSync(`${files.budget}.tmp`, JSON.stringify(turnStarts), { mode: 0o600 });
365
- renameSync(`${files.budget}.tmp`, files.budget);
366
- const outcome = await runTurn(kind);
367
- await control.setPhase('idle', { kind, outcome: lastTurnOutcome, ended_at: Date.now(), usage: lastTurnUsage });
368
- return outcome;
369
- }
370
-
371
- // Both local clients use the same contact, deadline and cancellation lifecycle.
372
- function runModel(invocation, onSession) {
373
- return new Promise(resolvePromise => {
374
- const process_ = spawn(invocation.command, invocation.args, { cwd: repoDir,
375
- env: provider.environment(invocation.env), stdio: [invocation.rpc ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
376
- child = process_;
377
- const stream = provider.collector({ rpc: invocation.rpc, log, session: onSession,
378
- action: tool => { if (control) control.activity = { tool, at: Date.now() }; } });
379
- if (invocation.rpc) {
380
- process_.stdin.on('error', () => {});
381
- stream.attach(process_); process_.pilotInterrupt = () => stream.interrupt();
382
- }
383
- let stderr = '', finished = false, timedOut = false;
384
- const timers = [];
385
- if (settings.turnSeconds > 0) timers.push(setTimeout(() => {
386
- timedOut = true; interruptChild(process_);
387
- timers.push(setTimeout(() => process_.kill('SIGTERM'), 30_000));
388
- timers.push(setTimeout(() => process_.kill('SIGKILL'), 40_000));
389
- }, settings.turnSeconds * 1000));
390
- const done = (code, signal) => {
391
- if (finished) return; finished = true; stream.end(); stream.cleanup?.();
392
- for (const timer of timers) clearTimeout(timer); child = null;
393
- resolvePromise({ code: invocation.rpc && stream.closed && stream.report ? 0 : code, signal, stderr, timedOut,
394
- report: stream.report, init: stream.init, sessionId: stream.sessionId, tokenTotals: stream.tokenTotals,
395
- toolCalls: stream.toolCalls, truncated: stream.truncated });
396
- };
397
- process_.stdout.setEncoding('utf8'); process_.stderr.setEncoding('utf8');
398
- process_.stdout.on('data', chunk => stream.feed(chunk));
399
- process_.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-64 * 1024); });
400
- process_.on('error', error => { stderr += error.message; done(null, null); });
401
- process_.on('close', done);
402
- });
403
- }
404
- function interruptChild(process_) { if (process_.pilotInterrupt) process_.pilotInterrupt(); else process_.kill('SIGINT'); }
405
-
406
- async function main() {
407
- mkdirSync(stateDir, { recursive: true, mode: 0o700 });
408
- acquireLock();
409
- pruneReleases(stateDir, import.meta.dirname, import.meta.dirname);
410
- checkProvider();
411
- const token = await loadToken();
412
- try {
413
- const saved = JSON.parse(readFileSync(files.budget, 'utf8'));
414
- if (!Array.isArray(saved) || saved.some((at) => !Number.isFinite(at))) throw new Error('invalid budget state');
415
- turnStarts.push(...saved.filter((at) => Date.now() - at < HOUR_MS));
416
- } catch (error) { if (error.code !== 'ENOENT') throw new Stop(`Cannot read the saved turn limit: ${error.message}`); }
417
- log(`Settings: news every ${settings.pollSeconds} s; time ${settings.turnSeconds > 0 ? `${settings.turnSeconds} s` : 'unlimited'}; spending ${settings.turnBudgetUsd > 0 ? `$${settings.turnBudgetUsd}` : 'unlimited'}; ${settings.maxTurnsPerHour > 0 ? `at most ${settings.maxTurnsPerHour} an hour` : 'any number an hour'
418
- }; quiet-work turns ${
419
- settings.workIntervalMinutes > 0 ? `every ${settings.workIntervalMinutes} min` : 'off'}; provider ${provider.id}; ${provider.id === 'claude' ? `permission mode ${settings.permissionMode}; allowed tools ${settings.allowedTools === CLAUDE_ALLOWED_TOOLS ? 'kit default' : settings.allowedTools}; denied tools and hooks in ${SETTINGS_FILE}` : `${settings.permissionMode === 'bypassPermissions' ? 'full access by operator choice (PILOT_PERMISSION_MODE=bypassPermissions): no sandbox, no approval review, project tools approved' : 'workspace-write sandbox with automatic approval review'}; personal MCPs/plugins/hooks/apps disabled for this invocation`}; model client environment allowlisted${settings.passEnv.trim() ? ` plus ${settings.passEnv.trim()}` : ''}.`);
420
- await runProject(token);
421
- }
422
-
423
- async function controlledDelay(ms) {
424
- const end = Date.now() + ms;
425
- while (!stopping && Date.now() < end) {
426
- if (control.fatal) throw control.fatal;
427
- await sleep(Math.min(1000, end - Date.now()));
428
- }
429
- }
430
-
431
- async function runProject(token) {
432
- control = new ControlV2(url, token, {
433
- pollSeconds: settings.pollSeconds,
434
- takeover: process.env.PILOT_TAKEOVER === '1', runtime: initialRuntime, log,
435
- onFatal: error => {
436
- if (error.code === 'runtime_update_required') updateRequested = true;
437
- if (child) {
438
- const process_ = child;
439
- interruptChild(process_);
440
- const timer = setTimeout(() => process_.kill('SIGKILL'), 5000);
441
- process_.once('close', () => clearTimeout(timer));
442
- }
443
- },
444
- });
445
- await control.start();
446
- deferredMcp = new DeferredMcp(control, files.sends, { log, onDeferred: () => {
447
- if (child && !sendDeferred) {
448
- sendDeferred = true;
449
- const model = child; interruptChild(model);
450
- const timer = setTimeout(() => model.kill('SIGKILL'), 5000);
451
- model.once('close', () => clearTimeout(timer));
452
- }
453
- } });
454
- bridgeConnection = await deferredMcp.listen();
455
- writeMcpConfig(bridgeConnection);
456
- log(`Protocol 2 run ${control.runId}; contact every ${control.pollSeconds} s, including during model work and waiting.`);
457
- let first = true;
458
- let failures = 0;
459
- let handledWorkRevision = -1;
460
- let lastTurnAt = Date.now();
461
- while (!stopping) {
462
- if (control.fatal) throw control.fatal;
463
- if (deferredMcp.fault) throw new Stop('Deferred send journal could not be updated. Inspect it before restarting.');
464
- const news = control.news;
465
- if (!news) { await controlledDelay(1000); continue; }
466
- if (!await adoptRuntime(token)) { await controlledDelay(control.pollSeconds * 1000); continue; }
467
- if (!news.project.started || news.project.paused) {
468
- if (control.phase !== 'paused') await control.setPhase('paused');
469
- await controlledDelay(control.pollSeconds * 1000);
470
- continue;
471
- }
472
- if (control.phase === 'paused') await control.setPhase('idle');
473
- await deferredMcp.retryDue();
474
- if (deferredMcp.journal.data.pending.length) {
475
- if (control.phase !== 'backoff') await control.setPhase('backoff');
476
- await controlledDelay(1000); continue;
477
- }
478
- const workDue = settings.workIntervalMinutes > 0 && Date.now() - lastTurnAt >= settings.workIntervalMinutes * 60_000;
479
- const durableWork = news.work?.runnable === true && Number.isSafeInteger(news.work.revision)
480
- && news.work.revision >= 0 && news.work.revision > handledWorkRevision;
481
- if (first || news.pending > 0 || continueWork || durableWork || workDue || deferredMcp.journal.data.notices.length) {
482
- // The initial turn already discovers the current card and acknowledges kickoff.
483
- // Do not delay first contact with the model behind announcement jitter.
484
- if (!first && !continueWork && !durableWork && news.project_only && !deferredMcp.journal.data.notices.length) {
485
- await control.setPhase('backoff');
486
- await controlledDelay(Math.random() * 120_000);
487
- }
488
- const kind = first ? 'start' : news.pending > 0 ? 'events' : 'work';
489
- const outcome = await turn(kind);
490
- if (outcome === 'deferred') { await controlledDelay(1000); continue; }
491
- if (['done', 'bounded'].includes(outcome) && Number.isSafeInteger(news.work?.revision)) {
492
- handledWorkRevision = Math.max(handledWorkRevision, news.work.revision);
493
- }
494
- first = false;
495
- lastTurnAt = Date.now();
496
- if (outcome === 'send_deferred') { failures = 0; await controlledDelay(1000); continue; }
497
- // Wait for the monitor's next fresh observation rather than issuing an extra, rate-limited wait.
498
- const prior = control.news;
499
- while (!stopping && control.news === prior) await controlledDelay(1000);
500
- const after = control.news;
501
- if (outcome === 'failed' || (news.pending > 0 && after && after.acknowledged <= news.acknowledged)) failures++;
502
- else failures = 0;
503
- if (failures >= FAILURES_BEFORE_STOP) {
504
- repeatedFailures = true;
505
- throw new Stop('Three turns failed or left the inbox unacknowledged. Inspect runner.log before restarting.');
506
- }
507
- if (failures) {
508
- await control.setPhase('backoff');
509
- await controlledDelay(10_000 * 2 ** (failures - 1));
510
- await control.setPhase('idle');
511
- }
512
- } else await controlledDelay(1000);
513
- }
514
- }
515
-
516
- async function adoptRuntime(token) {
517
- const selected = control.news?.runtime?.release;
518
- if (!selected || selected.id === control.runtime?.id) return true;
519
- if (selected.kitHash !== installedKitHash) {
520
- updateRequested = true;
521
- throw new Stop(`Runtime software update selected: ${control.news.runtime.reason}. ${process.env.PILOT_RUNTIME_MANAGED === '1' ? 'The foreground launcher will replace this runner.' : 'Start with the foreground launcher to adopt it automatically.'}`);
522
- }
523
- let latest;
524
- try { latest = await fetchRuntime(url, token); } catch (error) {
525
- if (error.fatal || /Unsupported|integrity|identity mismatch/.test(error.message)) throw error;
526
- log(`Runtime update download failed; no new model invocation until it recovers (${error.message}).`);
527
- return false;
528
- }
529
- const { release, reason } = latest;
530
- if (!release) return true; // Selection was withdrawn before the boundary.
531
- if (release.kitHash !== installedKitHash) {
532
- updateRequested = true;
533
- throw new Stop('Runtime software selection changed. The launcher must refresh the kit.');
534
- }
535
- const kit = installRelease(stateDir, release);
536
- const oldRuntime = control.runtime;
537
- control.runtime = appliedRelease(release);
538
- try {
539
- await control.setPhase('idle', undefined, true);
540
- } catch (error) {
541
- control.runtime = oldRuntime;
542
- if (error.fatal) throw error;
543
- log(`Runtime adoption report failed; retrying before starting work (${error.message}).`);
544
- return false;
545
- }
546
- PROMPT_FILE = join(kit, 'AGENT_PROMPT_V2.md');
547
- pruneReleases(stateDir, kit, import.meta.dirname);
548
- log(`Adopted instructions ${release.instructionHash.slice(0, 12)}, MCP ${release.mcpRevision}, kit ${release.kitHash.slice(0, 12)}: ${reason}. A changed context begins with the next model invocation.`);
549
- return true;
550
- }
551
-
552
- function shutDown(signal) {
553
- if (stopping) return;
554
- stopping = true;
555
- log(`Stopping (${signal}).`);
556
- if (child) {
557
- interruptChild(child);
558
- setTimeout(() => { child?.kill('SIGTERM'); finish(0); }, 15_000).unref();
559
- child.on('close', () => finish(0));
560
- } else {
561
- finish(0);
562
- }
563
- }
564
-
565
- let finishing;
566
- function finish(code) {
567
- return finishing ??= (async () => {
568
- if (deferredMcp) await deferredMcp.close();
569
- if (control) await control.close(stopping ? 'operator' : updateRequested ? 'runtime_update' : repeatedFailures ? 'repeated_failures' : null);
570
- if (ownsLock && readLock() === lockId) rmSync(files.lock, { force: true });
571
- process.exit(code);
572
- })();
573
- }
574
-
575
- process.on('SIGINT', () => shutDown('SIGINT'));
576
- process.on('SIGTERM', () => shutDown('SIGTERM'));
577
- process.on('SIGHUP', () => shutDown('SIGHUP')); // the terminal was closed
578
-
579
- try {
580
- await main();
581
- await finish(0);
582
- } catch (error) {
583
- if (error.code === 'runtime_update_required') updateRequested = true;
584
- if (error instanceof Stop || error.fatal) {
585
- const detail = `${error.code === 'unauthorized' ? 'Service credential refused. ' : ''}${error.message}`;
586
- try { log(`Stopped: ${detail}`); } catch { console.error(`Stopped: ${detail}`); }
587
- await finish(updateRequested ? 75 : 1);
588
- }
589
- try { log(`The runner crashed: ${error.stack ?? error}`); } catch { console.error(error); }
590
- await finish(updateRequested ? 75 : 1);
591
- }
@@ -1,67 +0,0 @@
1
- // Trusted service releases are verified before adoption. Never accept arbitrary filenames/URLs.
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, readdirSync, statSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- export const KIT_FILES = ['runner.mjs', 'control-v2.mjs', 'runtime.mjs', 'claude-settings.json', 'providers.mjs'];
6
- export const hash = text => createHash('sha256').update(text).digest('hex');
7
- export const appliedRelease = release => ({ id: release.id, kitHash: release.kitHash, instructionHash: release.instructionHash, mcpRevision: release.mcpRevision });
8
-
9
- export function verifyRelease(release) {
10
- if (!release || release.protocol !== 2 || release.launcherProtocol !== 2 || !/^[a-f0-9]{64}$/.test(release.id)
11
- || !/^[\w.-]{1,100}$/.test(release.mcpRevision) || typeof release.instructions !== 'string'
12
- || !release.instructions.trim() || Buffer.byteLength(release.instructions) > 64 * 1024
13
- || !release.files || Object.keys(release.files).length !== KIT_FILES.length) throw new Error('Unsupported runtime release. Update the launcher manually.');
14
- for (const name of KIT_FILES) {
15
- const file = release.files[name];
16
- if (!file || typeof file.content !== 'string' || !file.content || Buffer.byteLength(file.content) > 128 * 1024 || hash(file.content) !== file.sha256) throw new Error(`Runtime integrity check failed for ${name}.`);
17
- }
18
- if (hash(JSON.stringify(KIT_FILES.map(name => [name, release.files[name].sha256]))) !== release.kitHash
19
- || hash(release.instructions) !== release.instructionHash
20
- || hash(JSON.stringify([release.kitHash, release.instructionHash, release.mcpRevision])) !== release.id) throw new Error('Runtime release identity mismatch.');
21
- return release;
22
- }
23
-
24
- export async function fetchRuntime(url, token) {
25
- const response = await fetch(`${url}/v2/runtime`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000) });
26
- const data = await response.json();
27
- if (!response.ok) {
28
- const error = new Error(`Runtime selection refused (${response.status}): ${data.message ?? 'unknown error'}`);
29
- error.fatal = response.status >= 400 && response.status < 500 && response.status !== 429;
30
- throw error;
31
- }
32
- if (data.release) verifyRelease(data.release);
33
- return data;
34
- }
35
-
36
- export function installRelease(stateDir, release) {
37
- verifyRelease(release);
38
- const root = join(stateDir, 'runtime-releases');
39
- mkdirSync(root, { recursive: true, mode: 0o700 });
40
- const target = join(root, release.id);
41
- const expected = { ...Object.fromEntries(KIT_FILES.map(name => [name, release.files[name].content])),
42
- 'AGENT_PROMPT_V2.md': release.instructions, 'applied.json': JSON.stringify(appliedRelease(release)) };
43
- if (existsSync(target)) {
44
- for (const [name, text] of Object.entries(expected)) {
45
- if (readFileSync(join(target, name), 'utf8') !== text) throw new Error(`Cached runtime ${release.id} was modified; no files were overwritten.`);
46
- }
47
- return target;
48
- }
49
- const draft = `${target}.download-${process.pid}`;
50
- mkdirSync(draft, { mode: 0o700 });
51
- try {
52
- for (const [name, text] of Object.entries(expected)) writeFileSync(join(draft, name), text, { mode: 0o600 });
53
- renameSync(draft, target);
54
- } finally { rmSync(draft, { recursive: true, force: true }); }
55
- return target;
56
- }
57
-
58
- // Keep the adopted kit, executing kit and one previous cache for rollback. Other generated
59
- // release directories are disposable; session/token/checkout/usage files are never touched.
60
- export function pruneReleases(stateDir, current, executing) {
61
- const root = join(stateDir, 'runtime-releases');
62
- if (!existsSync(root)) return;
63
- const entries = readdirSync(root).filter(id => /^[a-f0-9]{64}$/.test(id)).map(id => join(root, id))
64
- .filter(path => statSync(path).isDirectory()).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
65
- const previous = entries.find(path => path !== current && path !== executing);
66
- for (const path of entries) if (![current, executing, previous].includes(path)) rmSync(path, { recursive: true });
67
- }