@karma42/agent 0.1.0-beta.1 → 0.1.0-beta.10
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.
- package/README.md +81 -55
- package/package.json +5 -3
- package/runtime/AGENT_PROMPT_V2.md +8 -0
- package/runtime/control-v2.mjs +4 -3
- package/runtime/manifest.json +5 -6
- package/runtime/project-tools.json +510 -0
- package/runtime/providers.mjs +25 -5
- package/runtime/work-controller.mjs +55 -0
- package/skills/karma42/SKILL.md +38 -0
- package/src/browser-batch.mjs +130 -0
- package/src/browser-pairing.mjs +140 -0
- package/src/checkout-ownership.mjs +115 -0
- package/src/cli.mjs +84 -51
- package/src/discovery.mjs +42 -0
- package/src/mcp-configuration.mjs +41 -0
- package/src/model-guard-process.mjs +73 -0
- package/src/model-guard.mjs +31 -0
- package/src/owner-client.mjs +109 -0
- package/src/project-identity.mjs +15 -0
- package/src/reusable-stdio.mjs +444 -0
- package/src/session-stdio.mjs +102 -0
- package/src/session.mjs +14 -0
- package/src/state.mjs +25 -37
- package/src/system.mjs +4 -40
- package/src/version.mjs +1 -0
- package/src/worker-recovery.mjs +82 -0
- package/src/worker-runtime.mjs +328 -0
- package/runtime/launcher.mjs +0 -55
- package/runtime/runner.mjs +0 -591
- package/runtime/runtime.mjs +0 -67
- package/src/connection.mjs +0 -214
- package/src/doctor.mjs +0 -28
- package/src/plan.mjs +0 -88
- package/src/supervisor.mjs +0 -17
- package/src/terminal.mjs +0 -33
package/src/state.mjs
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
|
-
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync
|
|
2
|
-
import { join, dirname, basename, resolve } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { constants, lstatSync, realpathSync, fstatSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename, resolve, relative, isAbsolute } from 'node:path';
|
|
4
3
|
import { randomUUID, createHash } from 'node:crypto';
|
|
5
4
|
|
|
6
|
-
export const defaultHome = () => join(homedir(), '.karma42', 'connections');
|
|
7
5
|
export function canonicalPath(value) {
|
|
8
6
|
let path = resolve(value);
|
|
9
7
|
const tail = [];
|
|
10
8
|
while (!lstatExists(path)) { tail.unshift(basename(path)); path = dirname(path); }
|
|
11
9
|
return join(realpathSync(path), ...tail);
|
|
12
10
|
}
|
|
11
|
+
// Physical paths matter: a symlink spelling must not allow credentials inside a checkout.
|
|
12
|
+
export function assertStateOutsideWorkspace(home, folder) {
|
|
13
|
+
const state = canonicalPath(home), workspace = canonicalPath(folder);
|
|
14
|
+
const inside = (root, path) => {
|
|
15
|
+
const part = relative(root, path);
|
|
16
|
+
return part === '' || (part !== '..' && !part.startsWith('../') && !isAbsolute(part));
|
|
17
|
+
};
|
|
18
|
+
if (inside(workspace, state) || inside(state, workspace)) throw new Error('Private connection state and the project workspace must be separate sibling paths. Choose storage outside the checkout.');
|
|
19
|
+
}
|
|
13
20
|
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
14
21
|
export function privateDir(path) {
|
|
15
22
|
try { mkdirSync(path, { mode: 0o700 }); }
|
|
@@ -23,14 +30,20 @@ export function ensureHome(home) {
|
|
|
23
30
|
privateDir(home);
|
|
24
31
|
}
|
|
25
32
|
export function readPrivate(path, json = false) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
34
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
35
|
+
try {
|
|
36
|
+
const stat = fstatSync(fd);
|
|
37
|
+
if (!stat.isFile() || (stat.mode & 0o077) || stat.nlink > 1) throw new Error('Connection files must be private regular files.');
|
|
38
|
+
// Atomic replacement can unlink the inode between open and fstat. Reopen
|
|
39
|
+
// the named record; never read an unlinked inode or relax private-file checks.
|
|
40
|
+
if (stat.nlink === 0 && attempt < 2) continue;
|
|
41
|
+
if (stat.nlink !== 1) throw new Error('Connection files must be private regular files.');
|
|
42
|
+
const value = readFileSync(fd, 'utf8');
|
|
43
|
+
if (!json) return value;
|
|
44
|
+
try { return JSON.parse(value); } catch { throw new Error('Invalid private connection record. Restore its backup or reconnect.'); }
|
|
45
|
+
} finally { closeSync(fd); }
|
|
46
|
+
}
|
|
34
47
|
}
|
|
35
48
|
export function writePrivate(path, value) {
|
|
36
49
|
try {
|
|
@@ -54,9 +67,6 @@ export function connectionDir(home, id) {
|
|
|
54
67
|
}
|
|
55
68
|
export function save(dir, record) { writePrivate(join(dir, 'connection.json'), record); }
|
|
56
69
|
export function load(dir) { return readPrivate(join(dir, 'connection.json'), true); }
|
|
57
|
-
export function binding(record) {
|
|
58
|
-
return digest([record.schema, record.id, record.service, record.agentId, record.agentName, record.repo, record.folder, record.provider, record.mode, record.copyVersion, record.runtimePolicy, record.bundleHash]);
|
|
59
|
-
}
|
|
60
70
|
export function withLock(dir, action) {
|
|
61
71
|
const lock = join(dir, 'operation.lock');
|
|
62
72
|
let fd;
|
|
@@ -68,26 +78,4 @@ export function withLock(dir, action) {
|
|
|
68
78
|
writeFileSync(fd, `${process.pid}\n`); closeSync(fd);
|
|
69
79
|
return Promise.resolve().then(action).finally(() => unlinkSync(lock));
|
|
70
80
|
}
|
|
71
|
-
export function list(home) {
|
|
72
|
-
if (!lstatExists(home)) return [];
|
|
73
|
-
ensureHome(home);
|
|
74
|
-
const result = [];
|
|
75
|
-
for (const project of readdirSync(home)) {
|
|
76
|
-
if (!/^[a-f0-9]{64}$/.test(project)) continue;
|
|
77
|
-
privateDir(join(home, project));
|
|
78
|
-
for (const agent of readdirSync(join(home, project))) {
|
|
79
|
-
if (!/^[\w-]{1,200}$/.test(agent)) continue;
|
|
80
|
-
const dir = connectionDir(home, `${project}/${agent}`);
|
|
81
|
-
if (lstatExists(join(dir, 'connection.json'))) result.push(load(dir));
|
|
82
|
-
else if (lstatExists(join(dir, 'pending-pair.json'))) {
|
|
83
|
-
const { token, ...pending } = readPrivate(join(dir, 'pending-pair.json'), true);
|
|
84
|
-
result.push({ ...pending, phase: 'paired', consent: null });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return result;
|
|
89
|
-
}
|
|
90
81
|
export function lstatExists(path) { try { lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
91
|
-
export function cleanEnvironment(input = process.env) {
|
|
92
|
-
return Object.fromEntries(Object.entries(input).filter(([key]) => !key.startsWith('PILOT_')));
|
|
93
|
-
}
|
package/src/system.mjs
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
|
-
import { spawnSync, spawn } from 'node:child_process';
|
|
2
1
|
import { createHash } from 'node:crypto';
|
|
3
2
|
import { readFileSync } from 'node:fs';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
4
|
import { join } from 'node:path';
|
|
6
|
-
import { doctor } from './doctor.mjs';
|
|
7
|
-
import { cleanEnvironment, privateDir, readPrivate, writePrivate } from './state.mjs';
|
|
8
5
|
|
|
9
|
-
export function run(command, args, cwd) {
|
|
10
|
-
const result = spawnSync(command, args, { cwd, shell: false, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024, env: cleanEnvironment(), windowsHide: true });
|
|
11
|
-
// Do not print raw subprocess errors/output: they can contain credentials or terminal escapes.
|
|
12
|
-
if (result.error || result.status !== 0) return null;
|
|
13
|
-
return result.stdout.trim();
|
|
14
|
-
}
|
|
15
6
|
export async function request(service, route, body, token) {
|
|
16
7
|
let response;
|
|
17
8
|
try {
|
|
@@ -20,9 +11,9 @@ export async function request(service, route, body, token) {
|
|
|
20
11
|
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
21
12
|
body: JSON.stringify(body),
|
|
22
13
|
});
|
|
23
|
-
} catch { throw new Error(
|
|
14
|
+
} catch { throw new Error('Browser pairing reply unavailable. Retry the same pending connection.'); }
|
|
24
15
|
if (!response.ok) {
|
|
25
|
-
if (response.status === 401 || response.status === 403) throw new Error(
|
|
16
|
+
if (response.status === 401 || response.status === 403) throw new Error('This connection is no longer authorized. Check membership and reconnect in My agents.');
|
|
26
17
|
if (response.status === 429) throw new Error('Too many requests. Wait before retrying.');
|
|
27
18
|
throw new Error('The project service could not complete this request.');
|
|
28
19
|
}
|
|
@@ -35,7 +26,7 @@ export function verifyBundle() {
|
|
|
35
26
|
let manifest;
|
|
36
27
|
try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); }
|
|
37
28
|
catch { throw new Error('Packaged runtime missing. Build or reinstall this package before preparing an agent.'); }
|
|
38
|
-
const names = ['
|
|
29
|
+
const names = ['control-v2.mjs', 'work-controller.mjs', 'providers.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'project-tools.json'];
|
|
39
30
|
if (Object.keys(manifest.files ?? {}).sort().join() !== [...names].sort().join()) throw new Error('Invalid packaged runtime manifest.');
|
|
40
31
|
const files = {};
|
|
41
32
|
for (const name of names) {
|
|
@@ -45,31 +36,4 @@ export function verifyBundle() {
|
|
|
45
36
|
}
|
|
46
37
|
return { files, fingerprint: createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex') };
|
|
47
38
|
}
|
|
48
|
-
export
|
|
49
|
-
const bundle = verifyBundle();
|
|
50
|
-
const kit = join(state, 'kit'); privateDir(kit);
|
|
51
|
-
for (const [name, content] of Object.entries(bundle.files)) writePrivate(join(kit, name), content.toString('utf8'));
|
|
52
|
-
return bundle.fingerprint;
|
|
53
|
-
}
|
|
54
|
-
export function verifyInstalled(state) {
|
|
55
|
-
const bundle = verifyBundle();
|
|
56
|
-
for (const [name, content] of Object.entries(bundle.files)) {
|
|
57
|
-
if (readPrivate(join(state, 'kit', name)) !== content.toString('utf8')) throw new Error('Prepared runtime files changed. Run prepare before starting.');
|
|
58
|
-
}
|
|
59
|
-
return bundle.fingerprint;
|
|
60
|
-
}
|
|
61
|
-
export function launch(dir, record) {
|
|
62
|
-
return new Promise((resolve, reject) => {
|
|
63
|
-
const child = spawn(process.execPath, [fileURLToPath(new URL('./supervisor.mjs', import.meta.url)), dir], {
|
|
64
|
-
shell: false, stdio: 'inherit', env: cleanEnvironment(),
|
|
65
|
-
});
|
|
66
|
-
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
67
|
-
let stopping = false;
|
|
68
|
-
const handlers = Object.fromEntries(signals.map(signal => [signal, () => { stopping = true; child.kill(signal); }]));
|
|
69
|
-
for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
|
|
70
|
-
function cleanup() { for (const [signal, handler] of Object.entries(handlers)) process.off(signal, handler); }
|
|
71
|
-
child.once('error', () => { cleanup(); reject(new Error('Could not launch the foreground supervisor.')); });
|
|
72
|
-
child.once('exit', (code, signal) => { cleanup(); resolve(stopping && signal ? 0 : code ?? 1); });
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
export const system = { doctor, run, request, installBundle, verifyBundle, verifyInstalled, launch, now: () => new Date().toISOString() };
|
|
39
|
+
export const system = { request, verifyBundle, now: () => new Date().toISOString() };
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const VERSION = '0.1.0-beta.10';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { lstatExists, readPrivate, writePrivate } from './state.mjs';
|
|
3
|
+
import { claimWorkerCheckout } from './checkout-ownership.mjs';
|
|
4
|
+
|
|
5
|
+
const uuid = value => typeof value === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value);
|
|
6
|
+
const terminal = new Set(['stopped', 'completed', 'startup_failed']);
|
|
7
|
+
const phases = new Set(['starting', 'working', 'waiting', 'paused', 'blocked', 'stopping', 'stopped', 'completed', 'startup_failed']);
|
|
8
|
+
const pathFor = dir => join(dir, 'worker-mission.json');
|
|
9
|
+
const bounded = (value, max) => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
10
|
+
const ownerAlive = pid => {
|
|
11
|
+
try { process.kill(pid, 0); return true; }
|
|
12
|
+
catch (error) { return error.code !== 'ESRCH'; } // Unknown/EPERM/reused PID: do not take over.
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function readWorkerMission(dir, record) {
|
|
16
|
+
const path = pathFor(dir);
|
|
17
|
+
if (!lstatExists(path)) return null;
|
|
18
|
+
const value = readPrivate(path, true);
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.schema !== 1 || !uuid(value.id) || value.connectionId !== record.id || value.service !== record.service
|
|
20
|
+
|| value.repo !== record.repo || !uuid(value.workerDir) || !Number.isSafeInteger(value.ownerPid) || value.ownerPid <= 0
|
|
21
|
+
|| !['claude', 'codex'].includes(value.client) || !bounded(value.folder, 4000) || !value.folder.startsWith('/')
|
|
22
|
+
|| !bounded(value.scope, 4000) || !(value.model === null || (typeof value.model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$/.test(value.model)))
|
|
23
|
+
|| !Number.isSafeInteger(value.startedAt) || !Number.isSafeInteger(value.deadline) || value.deadline <= value.startedAt
|
|
24
|
+
|| value.deadline - value.startedAt < 15 * 60000 || value.deadline - value.startedAt > 240 * 60000
|
|
25
|
+
|| (value.deadline - value.startedAt) % (15 * 60000) !== 0 || !phases.has(value.phase)
|
|
26
|
+
|| !/^[a-f0-9]{64}$/.test(value.instructionsHash ?? '')
|
|
27
|
+
|| !Array.isArray(value.steering) || value.steering.length > 64
|
|
28
|
+
|| value.steering.some(item => !item || typeof item !== 'object' || !Number.isSafeInteger(item.id) || item.id < 1 || !bounded(item.message, 2000) || !['received', 'applied'].includes(item.state))
|
|
29
|
+
|| new Set(value.steering.map(item => item.id)).size !== value.steering.length
|
|
30
|
+
|| !Number.isSafeInteger(value.sequence) || value.sequence < Math.max(0, ...value.steering.map(item => item.id))
|
|
31
|
+
|| typeof value.turnInFlight !== 'boolean' || !Array.isArray(value.inflightSteering)
|
|
32
|
+
|| value.inflightSteering.some(id => !value.steering.some(item => item.id === id))) {
|
|
33
|
+
throw new Error('Worker recovery record does not match this approved connection or is invalid. Inspect private storage before starting work.');
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function workerRecoveryStatus(dir, record, now = Date.now()) {
|
|
39
|
+
const mission = readWorkerMission(dir, record);
|
|
40
|
+
if (!mission || terminal.has(mission.phase)) return null;
|
|
41
|
+
return { recovery_ref: mission.id, state: ownerAlive(mission.ownerPid) ? 'supervisor_present' : now >= mission.deadline ? 'expired' : 'approval_required',
|
|
42
|
+
client: mission.client, model: mission.model, folder: mission.folder, scope: mission.scope,
|
|
43
|
+
started_at: mission.startedAt, deadline_at: mission.deadline, previous_phase: mission.phase,
|
|
44
|
+
previous_turn_outcome: mission.turnInFlight ? 'unknown' : 'not_in_flight',
|
|
45
|
+
steering: mission.steering.map(({ id, state }) => ({ id, state: mission.turnInFlight && mission.inflightSteering.includes(id) ? 'unconfirmed' : state })),
|
|
46
|
+
note: 'Saved local mission, not live model status. Recovery needs explicit approval, a verified project connection and exclusive checkout ownership. An unfinished turn must reconcile existing changes before repeating actions.' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function selectWorkerMission(dir, record, options, now = Date.now()) {
|
|
50
|
+
const mission = readWorkerMission(dir, record);
|
|
51
|
+
if (options.recovery_ref !== undefined) {
|
|
52
|
+
if (!mission || terminal.has(mission.phase) || options.recovery_ref !== mission.id) throw new Error('Worker recovery reference is no longer available. Read worker status again.');
|
|
53
|
+
if (ownerAlive(mission.ownerPid)) throw new Error('Worker recovery cannot take over a supervisor that may still be running. No process was stopped.');
|
|
54
|
+
if (now >= mission.deadline) throw new Error('Worker recovery deadline has expired. Approve a new work session instead.');
|
|
55
|
+
if (options.minutes !== undefined || options.client !== mission.client || (options.model ?? null) !== mission.model
|
|
56
|
+
|| options.scope !== mission.scope || options.folder !== mission.folder) throw new Error('Worker recovery must use the saved client, model, scope and folder, without minutes. The original deadline cannot be extended.');
|
|
57
|
+
return mission;
|
|
58
|
+
}
|
|
59
|
+
if (mission && !terminal.has(mission.phase) && (ownerAlive(mission.ownerPid) || now < mission.deadline)) {
|
|
60
|
+
throw new Error('Worker recovery is pending for this connection. Read get_project_worker and explicitly recover its saved mission instead of replacing it.');
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveWorkerMission(dir, record, mission, expectedId) {
|
|
66
|
+
const existing = readWorkerMission(dir, record);
|
|
67
|
+
if ((existing?.id ?? null) !== expectedId) throw new Error('Worker recovery record changed during startup or work. No replacement mission was overwritten.');
|
|
68
|
+
writePrivate(pathFor(dir), mission);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function cancelWorkerRecovery(dir, record, claimCheckout = claimWorkerCheckout) {
|
|
72
|
+
const mission = readWorkerMission(dir, record);
|
|
73
|
+
if (!mission || terminal.has(mission.phase)) return { state: 'not_started', active: false };
|
|
74
|
+
if (ownerAlive(mission.ownerPid)) throw new Error('Worker recovery cannot cancel a supervisor that may still be running. Stop it in its owning session. No process was stopped.');
|
|
75
|
+
// Cancellation is a record change, not permission to kill an orphan. The same
|
|
76
|
+
// checkout arbitration must prove no surviving native process owns these files.
|
|
77
|
+
const claim = await claimCheckout({ folder: mission.folder, agentId: record.agentId, project: record.service });
|
|
78
|
+
try {
|
|
79
|
+
saveWorkerMission(dir, record, { ...mission, phase: 'stopped' }, mission.id);
|
|
80
|
+
return { state: 'stopped', active: false, mission_ref: mission.id, note: 'Saved mission cancelled. No native process was started or killed; existing contributions remain.' };
|
|
81
|
+
} finally { claim.release(); }
|
|
82
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { 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
|
+
import { claimWorkerCheckout } from './checkout-ownership.mjs';
|
|
13
|
+
import { readWorkerMission, selectWorkerMission, saveWorkerMission } from './worker-recovery.mjs';
|
|
14
|
+
import { spawnSupervisedModel } from './model-guard.mjs';
|
|
15
|
+
|
|
16
|
+
const workerBridges = new WeakMap();
|
|
17
|
+
function workerBridge(bridge) {
|
|
18
|
+
let listening = workerBridges.get(bridge);
|
|
19
|
+
if (!listening) {
|
|
20
|
+
listening = Promise.resolve().then(() => bridge.listen());
|
|
21
|
+
workerBridges.set(bridge, listening);
|
|
22
|
+
listening.catch(() => { if (workerBridges.get(bridge) === listening) workerBridges.delete(bridge); });
|
|
23
|
+
}
|
|
24
|
+
return listening;
|
|
25
|
+
}
|
|
26
|
+
const bounded = (value, label, max = 2000) => {
|
|
27
|
+
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}.`);
|
|
28
|
+
return value.trim();
|
|
29
|
+
};
|
|
30
|
+
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', 'runtime_update_required', 'runtime_conflict', 'run_active', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
|
|
31
|
+
const contactIsTransient = failure => failure.fatal === false || failure.name === 'TimeoutError'
|
|
32
|
+
|| (failure instanceof TypeError && failure.message === 'fetch failed')
|
|
33
|
+
|| ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(failure.cause?.code);
|
|
34
|
+
export function validateWorkerWorkspace(folder, record) {
|
|
35
|
+
if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
|
|
36
|
+
const cwd = canonicalPath(folder);
|
|
37
|
+
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.'); }
|
|
38
|
+
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; };
|
|
39
|
+
const root = git(['rev-parse', '--show-toplevel']), origin = git(['remote', 'get-url', 'origin']);
|
|
40
|
+
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.');
|
|
41
|
+
return cwd;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createLocalWorkerAdapter({ client, model, folder, stateDir, bridge, scope, providerFactory = createProvider, spawnModel = spawnSupervisedModel }) {
|
|
45
|
+
verifyBundle(); ensureHome(stateDir);
|
|
46
|
+
const configuredEndpoint = client === 'claude' ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL;
|
|
47
|
+
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.');
|
|
48
|
+
const runtime = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
49
|
+
const settings = { claude: 'claude', codex: 'codex', model, repoDir: folder, permissionMode: 'acceptEdits', allowedTools: CLAUDE_ALLOWED_TOOLS, passEnv: '', turnBudgetUsd: 0 };
|
|
50
|
+
const provider = providerFactory(client, settings);
|
|
51
|
+
provider.preflight();
|
|
52
|
+
const instructions = readPrivate(join(stateDir, 'instructions.md'));
|
|
53
|
+
const promptHash = digest(instructions);
|
|
54
|
+
const sessionFile = join(stateDir, 'model-session.json');
|
|
55
|
+
let session = lstatExists(sessionFile) ? readPrivate(sessionFile, true) : null;
|
|
56
|
+
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.');
|
|
57
|
+
return {
|
|
58
|
+
async run({ prompt, signal, deadline, onActivity, onMetadata, onPermission, onModelStarting, onModelStarted, recovering = false }) {
|
|
59
|
+
if (signal.aborted) return Promise.resolve({ interrupted: true });
|
|
60
|
+
const resuming = provider.canResume(session);
|
|
61
|
+
session ??= provider.newSession(promptHash, folder);
|
|
62
|
+
const save = () => writePrivate(sessionFile, { ...session, provider: client, promptHash, repoDir: folder });
|
|
63
|
+
save();
|
|
64
|
+
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 });
|
|
65
|
+
onModelStarting?.();
|
|
66
|
+
const result = await new Promise(resolve => {
|
|
67
|
+
const child = spawnModel(invocation.command, invocation.args, { cwd: folder, env: provider.environment(invocation.env), stdio: [invocation.rpc ? 'pipe' : 'ignore', 'pipe', 'pipe'], windowsHide: true, deadline });
|
|
68
|
+
let done = false, interrupted = false, persistenceFault = false, killTimer, forceTimer, stderr = '';
|
|
69
|
+
child.on('message', value => { if (value?.guardStop === 'deadline') interrupted = true; });
|
|
70
|
+
if (child.pid) { try { onModelStarted?.(child.pid); } catch { persistenceFault = true; child.kill('SIGTERM'); forceTimer = setTimeout(() => child.kill('SIGKILL'), 8000); } }
|
|
71
|
+
const stream = provider.collector({ rpc: invocation.rpc, log: () => {}, action: onActivity, metadata: onMetadata, permission: onPermission, session: id => { session.id = id; try { save(); } catch { persistenceFault = true; child.kill('SIGTERM'); } } });
|
|
72
|
+
// The supervised spawn maps force-stop requests to native group cleanup;
|
|
73
|
+
// the guard remains alive until that cleanup has completed.
|
|
74
|
+
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); };
|
|
75
|
+
signal.addEventListener('abort', interrupt, { once: true });
|
|
76
|
+
const finish = code => {
|
|
77
|
+
if (done) return; done = true; stream.end(); stream.cleanup?.(); clearTimeout(killTimer); clearTimeout(forceTimer); signal.removeEventListener('abort', interrupt);
|
|
78
|
+
if (stream.sessionId) session.id = stream.sessionId;
|
|
79
|
+
if (stream.tokenTotals) session.tokenTotals = stream.tokenTotals;
|
|
80
|
+
try { save(); } catch { persistenceFault = true; }
|
|
81
|
+
const diagnostics = `${stderr} ${stream.report?.errors?.join(' ') ?? ''} ${stream.report?.result ?? ''}`;
|
|
82
|
+
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';
|
|
83
|
+
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: {
|
|
84
|
+
...provider.usage(stream.report, stream.init), toolCalls: stream.toolCalls,
|
|
85
|
+
toolScope: interrupted || stream.truncated || !stream.report || stream.report.is_error ? 'partial' : 'provider-stream',
|
|
86
|
+
} });
|
|
87
|
+
};
|
|
88
|
+
child.stdout.setEncoding('utf8'); child.stdout.on('data', chunk => { try { stream.feed(chunk); } catch { persistenceFault = true; child.kill('SIGTERM'); } });
|
|
89
|
+
child.stderr.setEncoding('utf8'); child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-16384); }); child.once('error', () => finish(1)); child.once('close', finish);
|
|
90
|
+
if (invocation.rpc) { child.stdin.on('error', () => {}); stream.attach(child); }
|
|
91
|
+
if (signal.aborted) interrupt();
|
|
92
|
+
});
|
|
93
|
+
if (result.missingSession && !recovering && !signal.aborted) { session = null; rmSync(sessionFile, { force: true }); return this.run({ prompt, signal, deadline, onActivity, onMetadata, onPermission, onModelStarting, onModelStarted, recovering: true }); }
|
|
94
|
+
if (result.missingSession) { result.failed = true; result.retryable = false; result.failureCode = 'session_recovery_failed'; }
|
|
95
|
+
delete result.missingSession; return result;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// A lightweight in-process supervisor, owned by the existing MCP transport and run.
|
|
101
|
+
// Waiting never holds a coding model open. Each native turn has a bounded process
|
|
102
|
+
// guard; loss of this controller stops that turn rather than leaving it orphaned.
|
|
103
|
+
export function createProjectWorker({ control, bridge, record, stateDir = connectionDir(sessionHome(), record.id), onState = () => {}, now = Date.now, setTimer = setInterval, clearTimer = clearInterval, adapterFactory = createLocalWorkerAdapter, validateWorkspace = validateWorkerWorkspace, claimCheckout = claimWorkerCheckout, pollMs = 1000, quietIntervalMs = 15 * 60000, random = Math.random, instructionsText } = {}) {
|
|
104
|
+
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;
|
|
105
|
+
const steering = [];
|
|
106
|
+
let requestedModel = null, actualModel = null, lastUsage = null, denialCount = 0, lastDenialAt = null;
|
|
107
|
+
let usageSequence = 0;
|
|
108
|
+
let ownership;
|
|
109
|
+
let failureInfo = null, contactRetryAt = null;
|
|
110
|
+
let mission = null, persistedMission = null, inflightSteering = [];
|
|
111
|
+
let turnSettled = null;
|
|
112
|
+
const denialTools = new Set();
|
|
113
|
+
const snapshot = () => {
|
|
114
|
+
const scheduling = scheduler?.status() ?? null, news = control.news;
|
|
115
|
+
return {
|
|
116
|
+
state: phase, active: phase === 'starting' || Boolean(turn) || (deadline !== null && !stopRequested),
|
|
117
|
+
checkout_owned: Boolean(ownership), activity, client: client ?? null,
|
|
118
|
+
requested_model: requestedModel, model: actualModel, last_turn_usage: lastUsage,
|
|
119
|
+
permission_denials: { count: denialCount, tools: [...denialTools], last_at: lastDenialAt },
|
|
120
|
+
unread_messages: Number.isSafeInteger(news?.pending) ? news.pending : null,
|
|
121
|
+
scheduling, delivery: { pending: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0 },
|
|
122
|
+
contact_known: typeof news?.project?.started === 'boolean' && typeof news?.project?.paused === 'boolean',
|
|
123
|
+
work_available: news?.work?.runnable === true && Number.isSafeInteger(news.work.revision)
|
|
124
|
+
&& news.work.revision > (scheduling?.handledRevision ?? -1),
|
|
125
|
+
folder: folder ?? null, scope: scope ?? null, started_at: startedAt, deadline_at: deadline,
|
|
126
|
+
remaining_seconds: deadline ? Math.max(0, Math.ceil((deadline - now()) / 1000)) : 0,
|
|
127
|
+
current_turn: Boolean(turn), last_result: lastResult, error, failure: failureInfo,
|
|
128
|
+
mission_ref: mission?.id ?? null, recovered: mission?.recovered === true,
|
|
129
|
+
steering: steering.map(({ id, state }) => ({ id, state })),
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
const notify = () => {
|
|
133
|
+
if (mission) {
|
|
134
|
+
const next = { ...mission, phase, turnInFlight: Boolean(turn) || phase === 'working' || phase === 'stopping',
|
|
135
|
+
inflightSteering, steering: steering.map(item => ({ ...item })), sequence };
|
|
136
|
+
const serialized = JSON.stringify(next);
|
|
137
|
+
// Persist lifecycle/steering transitions, not every tool activity or status poll.
|
|
138
|
+
if (serialized !== persistedMission) { saveWorkerMission(stateDir, record, next, mission.id); mission = next; persistedMission = serialized; }
|
|
139
|
+
}
|
|
140
|
+
try { onState(snapshot()); } catch {}
|
|
141
|
+
};
|
|
142
|
+
const allowed = () => !control.fatal && !control.closed && control.news?.project?.started === true && control.news.project.paused !== true;
|
|
143
|
+
const journal = () => bridge.journal;
|
|
144
|
+
const previousDeferred = bridge.onDeferred;
|
|
145
|
+
const onDeferred = () => { previousDeferred?.(); if (turn && !stopRequested) sendDeferred = true; };
|
|
146
|
+
bridge.onDeferred = onDeferred;
|
|
147
|
+
async function launch() {
|
|
148
|
+
if (turn || stopRequested || phase === 'paused' || !allowed() || now() >= deadline) return;
|
|
149
|
+
phase = 'working'; scheduler.beginTurn(control.news); sendDeferred = false; abort = new AbortController();
|
|
150
|
+
const applied = steering.filter(item => item.state === 'received');
|
|
151
|
+
inflightSteering = applied.map(item => item.id);
|
|
152
|
+
const notices = journal()?.notices?.() ?? []; notify();
|
|
153
|
+
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${mission?.recovered ? 'This mission was recovered after its supervisor ended. The last turn may have completed actions without recording its result. Inspect the checkout, saved work intents and project updates before choosing further work; do not blindly replay edits, commits or messages.\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.`;
|
|
154
|
+
const signal = abort.signal;
|
|
155
|
+
const checkoutClaim = ownership;
|
|
156
|
+
let finishTurn;
|
|
157
|
+
turnSettled = new Promise(resolve => { finishTurn = resolve; });
|
|
158
|
+
let invokedAt;
|
|
159
|
+
let failureStage = 'runtime_contact';
|
|
160
|
+
turn = (async () => {
|
|
161
|
+
try { await control.setPhase?.('turn'); }
|
|
162
|
+
catch (failure) {
|
|
163
|
+
const retryable = failure.fatal !== true && contactIsTransient(failure);
|
|
164
|
+
failureInfo = { stage: 'runtime_contact', code: faultCodes.has(failure.code) ? failure.code : faultCodes.has(failure.cause?.code) ? failure.cause.code : 'unknown', retryable };
|
|
165
|
+
if (!retryable) throw failure;
|
|
166
|
+
const retryAfter = Number(failure.retryAfter);
|
|
167
|
+
contactRetryAt = now() + (Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 3600) * 1000 : 0);
|
|
168
|
+
return { failed: true, retryable: true, failureCode: 'runtime_contact_failure' };
|
|
169
|
+
}
|
|
170
|
+
contactRetryAt = null;
|
|
171
|
+
if (signal.aborted || stopRequested || !allowed() || now() >= deadline) return { interrupted: true };
|
|
172
|
+
failureInfo = null; error = null;
|
|
173
|
+
for (const item of applied) item.state = 'applied';
|
|
174
|
+
failureStage = 'checkout_ownership';
|
|
175
|
+
checkoutClaim.beginTurn(); invokedAt = now(); actualModel = null;
|
|
176
|
+
failureStage = 'native_execution';
|
|
177
|
+
notify();
|
|
178
|
+
return adapter.run({ prompt, signal, deadline,
|
|
179
|
+
onModelStarting: () => checkoutClaim.beginTurn(), onModelStarted: pid => checkoutClaim.modelStarted(pid),
|
|
180
|
+
onActivity: tool => { if (typeof tool === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(tool)) { activity = { tool, at: now() }; control.activity = activity; notify(); } },
|
|
181
|
+
onMetadata: value => { if (typeof value?.model === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(value.model)) { actualModel = value.model; notify(); } },
|
|
182
|
+
onPermission: value => { if (typeof value?.tool === 'string' && /^[A-Za-z0-9_:.-]{1,150}$/.test(value.tool)) { denialCount++; lastDenialAt = now(); if (denialTools.size < 64) denialTools.add(value.tool); notify(); } },
|
|
183
|
+
});
|
|
184
|
+
})();
|
|
185
|
+
try {
|
|
186
|
+
const result = await turn;
|
|
187
|
+
failureStage = 'checkout_cleanup';
|
|
188
|
+
checkoutClaim.endTurn();
|
|
189
|
+
let lastTurn;
|
|
190
|
+
if (invokedAt !== undefined) {
|
|
191
|
+
// Sequence belongs to the fenced service run, not this finite worker mission.
|
|
192
|
+
// A replacement worker on the same control must not restart at sequence one.
|
|
193
|
+
usageSequence = Math.max(usageSequence, control.lastTurn?.usage?.sequence ?? 0) + 1;
|
|
194
|
+
lastUsage = result?.usage ?? null; actualModel = lastUsage?.model ?? null;
|
|
195
|
+
const endedAt = now();
|
|
196
|
+
const outcome = result?.interrupted && endedAt >= deadline ? 'timed_out' : result?.interrupted || result?.failed ? 'failed' : 'succeeded';
|
|
197
|
+
lastTurn = { kind: 'work', outcome, ended_at: endedAt,
|
|
198
|
+
usage: { provider: client, inputSemantics: client === 'codex' ? 'includes-cache' : 'uncached', model: null, modelSource: 'unknown', tokens: null,
|
|
199
|
+
...lastUsage, ...(lastUsage?.tokens ? { tokens: { input: null, output: null, cacheRead: null, cacheWrite: null, modelCalls: null, ...lastUsage.tokens } } : {}),
|
|
200
|
+
sequence: usageSequence, seconds: Math.max(0, (endedAt - invokedAt) / 1000) } };
|
|
201
|
+
}
|
|
202
|
+
if (result?.interrupted) { scheduler.recordOutcome({ kind: sendDeferred ? 'send_deferred' : 'interrupted', news: control.news, now: now() }); if (!stopRequested && phase !== 'paused') phase = 'waiting'; }
|
|
203
|
+
else if (result?.failed) {
|
|
204
|
+
if (result.failureCode !== 'runtime_contact_failure') {
|
|
205
|
+
const codes = ['authentication_required', 'permission_required', 'private_state_failure', 'session_recovery_failed', 'local_execution_failure'];
|
|
206
|
+
failureInfo = { stage: 'native_execution', code: codes.includes(result.failureCode) ? result.failureCode : 'unknown', retryable: result.retryable !== false };
|
|
207
|
+
}
|
|
208
|
+
scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: result.retryable !== false });
|
|
209
|
+
phase = scheduler.status().blocked ? 'blocked' : 'waiting';
|
|
210
|
+
const messages = { runtime_contact_failure: 'Project contact failed before model work. Bounded retries wait for fresh contact and stop after three failures.', 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.' };
|
|
211
|
+
error = messages[result.failureCode] ?? 'The local model turn failed. Bounded retries stop after three failures.';
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
lastResult = String(result?.text ?? '').slice(0, 2000); error = null; failureInfo = null;
|
|
215
|
+
scheduler.recordOutcome({ kind: 'ok', news: control.news, now: now(), continuation: sendDeferred || /(?:^|\n)WORK_NEXT: runnable\s*$/.test(result?.text ?? '') });
|
|
216
|
+
if (notices.length) journal()?.acknowledge?.(notices);
|
|
217
|
+
if (!stopRequested && phase !== 'paused') phase = 'waiting';
|
|
218
|
+
}
|
|
219
|
+
failureStage = 'runtime_report';
|
|
220
|
+
try { await control.setPhase?.('idle', lastTurn); }
|
|
221
|
+
catch (failure) {
|
|
222
|
+
if (failure.fatal) throw failure;
|
|
223
|
+
// Control keeps the report for its next phase/contact/close request. Retrying
|
|
224
|
+
// telemetry must neither rerun completed model work nor cancel a model turn.
|
|
225
|
+
control.log?.('Worker turn report pending; runtime contact will retry.');
|
|
226
|
+
}
|
|
227
|
+
} catch (failure) { if (!stopRequested) { failureInfo = { stage: failureStage, code: faultCodes.has(failure.code) ? failure.code : 'unknown', retryable: false }; scheduler.recordOutcome({ kind: 'failed', news: control.news, now: now(), retryable: false }); phase = 'blocked'; error = /^(Worker checkout|Worker ownership)/.test(failure.message ?? '') ? failure.message : 'The worker stopped on a local execution failure.'; } }
|
|
228
|
+
finally {
|
|
229
|
+
turn = null; abort = null; inflightSteering = [];
|
|
230
|
+
try { notify(); } finally { turnSettled = null; finishTurn(); }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async function tick() {
|
|
234
|
+
if (stopRequested || deadline === null) return;
|
|
235
|
+
if (now() >= deadline) { await stop('completed'); return; }
|
|
236
|
+
if (control.fatal || control.closed) { await stop('stopped'); return; }
|
|
237
|
+
// Control polling briefly clears news before its HTTP reply. Unknown contact blocks
|
|
238
|
+
// new turns, but is not evidence that the project was paused or authorization revoked.
|
|
239
|
+
const observed = control.news?.project;
|
|
240
|
+
if (!observed || typeof observed.started !== 'boolean' || typeof observed.paused !== 'boolean') return;
|
|
241
|
+
if (!observed.started || observed.paused) {
|
|
242
|
+
// Preserve only work that was actually interrupted. A successful waiting result
|
|
243
|
+
// remains waiting; project resume must not invent a reason to start another turn.
|
|
244
|
+
if (turn) abort?.abort();
|
|
245
|
+
if (phase !== 'paused' && phase !== 'blocked') phase = 'waiting';
|
|
246
|
+
notify(); return;
|
|
247
|
+
}
|
|
248
|
+
// Communication never cancels a model turn. The agent can read project updates
|
|
249
|
+
// during its work; queued steering is supplied at the next natural turn boundary.
|
|
250
|
+
if (phase === 'paused' || phase === 'blocked' || turn) return;
|
|
251
|
+
if (contactRetryAt !== null && now() < contactRetryAt) return;
|
|
252
|
+
if (bridge.fault) { phase = 'blocked'; error = 'Project delivery state requires attention before work can continue.'; notify(); return; }
|
|
253
|
+
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() });
|
|
254
|
+
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; }
|
|
255
|
+
if (decision.action === 'run') await launch();
|
|
256
|
+
}
|
|
257
|
+
async function stop(finalPhase = 'stopped') {
|
|
258
|
+
stopRequested = true; phase = turn ? 'stopping' : finalPhase; clearTimer(timer); abort?.abort(); notify();
|
|
259
|
+
if (turnSettled) await turnSettled;
|
|
260
|
+
try { ownership?.release(); ownership = null; }
|
|
261
|
+
catch (failure) { phase = 'blocked'; error = failure.message; notify(); throw failure; }
|
|
262
|
+
phase = finalPhase; if (bridge.onDeferred === onDeferred) bridge.onDeferred = previousDeferred; notify(); return snapshot();
|
|
263
|
+
}
|
|
264
|
+
const requestTick = () => { void tick().catch(() => { void stop('stopped').catch(() => { /* stop already exposes the blocked state and retains ownership. */ }); }); };
|
|
265
|
+
return {
|
|
266
|
+
status: snapshot,
|
|
267
|
+
async start(options) {
|
|
268
|
+
if (options.approved !== true) throw new Error('Explicit human approval is required for the worker client, scope, folder and duration.');
|
|
269
|
+
if (phase !== 'idle') throw new Error('This work session already started. Pause, resume or stop it; starting cannot extend its deadline.');
|
|
270
|
+
if (!allowed()) throw new Error('Start the project and resume it before starting a worker. The verified connection must still be authorized and available.');
|
|
271
|
+
client = options.client;
|
|
272
|
+
if (options.model !== undefined && (typeof options.model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$/.test(options.model))) throw new Error('Choose a valid model identifier for the approved client.');
|
|
273
|
+
requestedModel = options.model ?? null;
|
|
274
|
+
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.');
|
|
275
|
+
const minutes = options.minutes ?? 60;
|
|
276
|
+
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.');
|
|
277
|
+
scope = bounded(options.scope, 'approved work scope', 4000); folder = validateWorkspace(options.folder, record);
|
|
278
|
+
selectWorkerMission(stateDir, record, { ...options, scope, folder }, now());
|
|
279
|
+
phase = 'starting'; notify();
|
|
280
|
+
try {
|
|
281
|
+
ownership = await claimCheckout({ folder, agentId: record.agentId, project: record.service });
|
|
282
|
+
if (stopRequested) { ownership.release(); ownership = null; throw new Error('The MCP session closed before the worker could start.'); }
|
|
283
|
+
assertStateOutsideWorkspace(stateDir, folder); ensureHome(stateDir);
|
|
284
|
+
const workerHome = join(stateDir, 'worker'); ensureHome(workerHome);
|
|
285
|
+
const saved = selectWorkerMission(stateDir, record, { ...options, scope, folder }, now());
|
|
286
|
+
const previousId = readWorkerMission(stateDir, record)?.id ?? null;
|
|
287
|
+
const workerDirId = saved?.workerDir ?? randomUUID();
|
|
288
|
+
const workerDir = join(workerHome, workerDirId); ensureHome(workerDir);
|
|
289
|
+
const source = instructionsText ?? verifyBundle().files['AGENT_PROMPT_V2.md'].toString('utf8');
|
|
290
|
+
const executionGuide = client === 'claude' ? '\nGuarded execution: project npm scripts (`npm test`, `npm run <script>`) and `node --test`/`node --check` are supported within this approved mission. Git fetch/push use origin or the configured default remote; quiet forms are supported. File edits use native Read/Write/Edit. Arbitrary node scripts, inline code, outside-workspace reads and browser startup may require human permission. A denied operation is local to this worker and does not prove another worker is blocked. Do not disguise or repackage a denied action to evade its decision; report the operation and needed approval, and continue other useful approved work.\n' : '';
|
|
291
|
+
if (!saved) writePrivate(join(workerDir, 'instructions.md'), `${source}${executionGuide}\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`);
|
|
292
|
+
const instructionsHash = digest(readPrivate(join(workerDir, 'instructions.md')));
|
|
293
|
+
if (saved && instructionsHash !== saved.instructionsHash) throw new Error('Worker recovery instructions changed. Inspect the saved mission before resuming.');
|
|
294
|
+
const connection = {};
|
|
295
|
+
adapter = adapterFactory({ client, model: options.model, folder, stateDir: workerDir, bridge: connection, scope });
|
|
296
|
+
workerConnection ??= await workerBridge(bridge);
|
|
297
|
+
if (stopRequested) throw new Error('The MCP session closed before the worker could start.');
|
|
298
|
+
Object.assign(connection, workerConnection);
|
|
299
|
+
writePrivate(join(workerDir, 'mcp.json'), { mcpServers: { pilot: connection } });
|
|
300
|
+
startedAt = saved?.startedAt ?? now(); deadline = saved?.deadline ?? startedAt + minutes * 60000;
|
|
301
|
+
if (now() >= deadline) throw new Error('Worker recovery deadline has expired during startup. No model was started.');
|
|
302
|
+
sequence = saved?.sequence ?? 0;
|
|
303
|
+
steering.push(...(saved?.steering ?? []).map(item => ({ ...item, state: saved.turnInFlight && saved.inflightSteering.includes(item.id) ? 'received' : item.state })));
|
|
304
|
+
const nextMission = { schema: 1, id: saved?.id ?? randomUUID(), workerDir: workerDirId, connectionId: record.id, service: record.service, repo: record.repo,
|
|
305
|
+
ownerPid: process.pid, client, model: requestedModel, scope, folder, startedAt, deadline, instructionsHash, recovered: Boolean(saved),
|
|
306
|
+
phase: 'waiting', sequence, steering: steering.map(item => ({ ...item })), turnInFlight: false, inflightSteering: [] };
|
|
307
|
+
// Compare against the exact record observed under exclusive checkout ownership.
|
|
308
|
+
saveWorkerMission(stateDir, record, nextMission, previousId);
|
|
309
|
+
mission = nextMission;
|
|
310
|
+
persistedMission = JSON.stringify(mission);
|
|
311
|
+
phase = 'waiting'; scheduler = createWorkController({ quietIntervalMs, now, random, deadline }); notify();
|
|
312
|
+
timer = setTimer(requestTick, pollMs);
|
|
313
|
+
requestTick(); return snapshot();
|
|
314
|
+
} catch (failure) {
|
|
315
|
+
let cleanupFailure;
|
|
316
|
+
try { ownership?.release(); ownership = null; } catch (cleanup) { cleanupFailure = cleanup; }
|
|
317
|
+
phase = cleanupFailure ? 'blocked' : stopRequested ? 'stopped' : 'idle'; adapter = null;
|
|
318
|
+
if (mission && !cleanupFailure) { mission.phase = 'startup_failed'; saveWorkerMission(stateDir, record, mission, mission.id); mission = null; persistedMission = null; }
|
|
319
|
+
if (cleanupFailure) { error = cleanupFailure.message; failure.cause ??= cleanupFailure; }
|
|
320
|
+
notify(); throw failure;
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
async pause() { if (!deadline || stopRequested) throw new Error('There is no active work session.'); phase = 'paused'; abort?.abort(); notify(); if (turnSettled) await turnSettled; return snapshot(); },
|
|
324
|
+
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(); requestTick(); return snapshot(); },
|
|
325
|
+
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 natural model turn; current work is not interrupted. Check applied state; the deadline is unchanged.' }; },
|
|
326
|
+
stop: () => stop('stopped'), close: () => stop('stopped'), tick,
|
|
327
|
+
};
|
|
328
|
+
}
|