@karma42/agent 0.0.0-stage → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +102 -2
- package/package.json +32 -4
- package/runtime/AGENT_PROMPT_V2.md +115 -0
- package/runtime/claude-settings.json +82 -0
- package/runtime/control-v2.mjs +358 -0
- package/runtime/launcher.mjs +55 -0
- package/runtime/manifest.json +12 -0
- package/runtime/providers.mjs +284 -0
- package/runtime/runner.mjs +591 -0
- package/runtime/runtime.mjs +67 -0
- package/src/cli.mjs +89 -0
- package/src/connection.mjs +214 -0
- package/src/doctor.mjs +28 -0
- package/src/plan.mjs +88 -0
- package/src/session-stdio.mjs +93 -0
- package/src/session.mjs +110 -0
- package/src/state.mjs +93 -0
- package/src/supervisor.mjs +17 -0
- package/src/system.mjs +75 -0
- package/src/terminal.mjs +33 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Runtime control stays outside model turns. One request at a time; fatal refusals stop the model.
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
|
|
4
|
+
export class ControlV2 {
|
|
5
|
+
constructor(url, token, { pollSeconds = 20, takeover = false, runtime, requestSignal, onFatal = () => {}, log = () => {} } = {}) {
|
|
6
|
+
Object.assign(this, { url, token, pollSeconds: Math.max(10, pollSeconds), takeover, runtime, requestSignal, onFatal, log });
|
|
7
|
+
this.phase = 'idle';
|
|
8
|
+
this.lastPhaseAt = 0;
|
|
9
|
+
this.lastTurn = undefined;
|
|
10
|
+
this.news = null;
|
|
11
|
+
this.projectState = null;
|
|
12
|
+
this.fatal = null;
|
|
13
|
+
this.closed = false;
|
|
14
|
+
this.queue = Promise.resolve();
|
|
15
|
+
this.abort = new AbortController();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async request(path, body) {
|
|
19
|
+
const response = await fetch(`${this.url}/v2${path}`, {
|
|
20
|
+
method: body ? 'POST' : 'GET',
|
|
21
|
+
headers: { authorization: `Bearer ${this.token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
22
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
23
|
+
signal: this.requestSignal && !path.endsWith('/phase')
|
|
24
|
+
? AbortSignal.any([AbortSignal.timeout(30_000), this.requestSignal]) : AbortSignal.timeout(30_000),
|
|
25
|
+
});
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const error = new Error(`${data.error ?? response.status}: ${data.message ?? 'Runtime request refused.'}`);
|
|
29
|
+
error.code = data.error;
|
|
30
|
+
error.fatal = response.status === 401 || response.status === 409 || (response.status >= 400 && response.status < 500 && response.status !== 429);
|
|
31
|
+
error.retryAfter = Number(data.retry_after_seconds ?? response.headers.get('retry-after') ?? 1);
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start() {
|
|
38
|
+
const run = await this.request('/runs', { runner_version: this.runtime ? `kit-${this.runtime.kitHash.slice(0, 12)}` : 'kit-v2', takeover: this.takeover, ...(this.runtime ? { runtime: this.runtime } : {}) });
|
|
39
|
+
if (run.protocol !== 2) throw new Error('Protocol mismatch: this runner needs protocol 2.');
|
|
40
|
+
this.runId = run.run_id;
|
|
41
|
+
this.pollSeconds = Math.max(this.pollSeconds, run.poll_seconds);
|
|
42
|
+
while (!this.closed && !this.news) {
|
|
43
|
+
const delay = await this.observe();
|
|
44
|
+
if (!this.news) await sleep(delay * 1000, undefined, { signal: this.abort.signal });
|
|
45
|
+
}
|
|
46
|
+
if (!this.closed) this.loop = this.monitor();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
serialize(action) {
|
|
50
|
+
const task = this.queue.then(action);
|
|
51
|
+
this.queue = task.catch(() => {});
|
|
52
|
+
return task;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async cycle() {
|
|
56
|
+
return this.serialize(async () => {
|
|
57
|
+
this.news = null; // Fail closed while contact is uncertain: no new model turn.
|
|
58
|
+
const reportDue = Date.now() - this.lastPhaseAt >= 60_000;
|
|
59
|
+
const news = await this.request('/wait', {
|
|
60
|
+
run_id: this.runId,
|
|
61
|
+
...(reportDue ? { report: { phase: this.phase, ...(this.lastTurn ? { last_turn: this.lastTurn } : {}),
|
|
62
|
+
...(this.activity ? { activity: this.activity } : {}) } } : {}),
|
|
63
|
+
});
|
|
64
|
+
// Advance only after the combined request succeeds. A lost response retries the report;
|
|
65
|
+
// the server deduplicates its usage sequence and checks the run on every request.
|
|
66
|
+
if (reportDue) this.lastPhaseAt = Date.now();
|
|
67
|
+
const state = `started=${news.project.started}, paused=${news.project.paused}`;
|
|
68
|
+
if (state !== this.projectState) {
|
|
69
|
+
this.log(`Project state: ${state}.`);
|
|
70
|
+
this.projectState = state;
|
|
71
|
+
}
|
|
72
|
+
this.news = news;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Startup and ongoing contact share the same refusal/retry policy. A run can be
|
|
77
|
+
// registered while its first wait is rate-limited after a recent restart.
|
|
78
|
+
async observe() {
|
|
79
|
+
try {
|
|
80
|
+
await this.cycle();
|
|
81
|
+
return this.pollSeconds;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.news = null;
|
|
84
|
+
if (error.fatal) {
|
|
85
|
+
this.fatal = error;
|
|
86
|
+
this.onFatal(error);
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
this.log(`Runtime contact failed (${error.message}); no new turn until contact recovers.`);
|
|
90
|
+
return Math.max(this.pollSeconds, Number.isFinite(error.retryAfter) ? error.retryAfter : 0);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async monitor() {
|
|
95
|
+
let delay = this.pollSeconds;
|
|
96
|
+
while (!this.closed) {
|
|
97
|
+
try {
|
|
98
|
+
await sleep(delay * 1000, undefined, { signal: this.abort.signal });
|
|
99
|
+
if (this.closed) break;
|
|
100
|
+
delay = await this.observe();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (this.closed || error.fatal) break;
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async setPhase(phase, lastTurn, reportRuntime = false) {
|
|
109
|
+
if (lastTurn) this.lastTurn = lastTurn;
|
|
110
|
+
if (this.fatal) throw this.fatal;
|
|
111
|
+
this.phase = phase;
|
|
112
|
+
return this.serialize(async () => {
|
|
113
|
+
await this.request(`/runs/${this.runId}/phase`, {
|
|
114
|
+
phase, ...(this.lastTurn ? { last_turn: this.lastTurn } : {}), ...(this.activity ? { activity: this.activity } : {}), ...(reportRuntime && this.runtime ? { runtime: this.runtime } : {}),
|
|
115
|
+
});
|
|
116
|
+
this.lastPhaseAt = Date.now();
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A null reason is a local fault the server records as unknown.
|
|
121
|
+
async close(reason = 'operator') {
|
|
122
|
+
if (this.closed) return;
|
|
123
|
+
this.closed = true;
|
|
124
|
+
this.abort.abort();
|
|
125
|
+
if (this.runId && (!this.fatal || this.fatal.code === 'runtime_update_required')) {
|
|
126
|
+
try {
|
|
127
|
+
await this.serialize(() => this.request(`/runs/${this.runId}/phase`, { phase: 'stopping', ...(reason ? { reason } : {}), ...(this.lastTurn ? { last_turn: this.lastTurn } : {}) }));
|
|
128
|
+
} catch (error) { this.log(`Final stop report could not be saved: ${error.message}`); }
|
|
129
|
+
}
|
|
130
|
+
await this.loop;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Single-writer local intent journal, owned by the foreground runner (OPE-20).
|
|
135
|
+
// The MCP child accesses it through an authenticated loopback bridge, never by writing the file.
|
|
136
|
+
import { createServer } from 'node:http';
|
|
137
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
138
|
+
import { existsSync, readFileSync, statSync, writeFileSync, renameSync, openSync, fsyncSync, closeSync, rmSync } from 'node:fs';
|
|
139
|
+
import { dirname } from 'node:path';
|
|
140
|
+
|
|
141
|
+
const JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
142
|
+
const JOURNAL_SLOTS = 128;
|
|
143
|
+
const SEND_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
144
|
+
const digest = text => createHash('sha256').update(text).digest('hex');
|
|
145
|
+
const refusalResult = text => ({ content: [{ type: 'text', text }], isError: true });
|
|
146
|
+
function normalizedSend(args) {
|
|
147
|
+
if (!args || typeof args.client_message_id !== 'string' || !SEND_ID.test(args.client_message_id) || !Array.isArray(args.to) || args.to.length < 1 || args.to.length > 20
|
|
148
|
+
|| args.to.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(id)) || typeof args.body !== 'string'
|
|
149
|
+
|| !args.body.trim() || args.body.length > 2000 || (args.thread_id !== undefined && (typeof args.thread_id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(args.thread_id)))
|
|
150
|
+
|| Object.keys(args).some(key => !['to', 'body', 'thread_id', 'client_message_id'].includes(key))) throw new TypeError('Invalid deferred send arguments.');
|
|
151
|
+
return { to: [...new Set(args.to)].sort(), body: args.body, ...(args.thread_id === undefined ? {} : { thread_id: args.thread_id }), client_message_id: args.client_message_id };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class DeferredSends {
|
|
155
|
+
constructor(file, identity, now = Date.now) {
|
|
156
|
+
Object.assign(this, { file, identity, now });
|
|
157
|
+
this.data = { version: 1, identity, pending: [], notices: [] };
|
|
158
|
+
if (existsSync(file)) {
|
|
159
|
+
if (statSync(file).size > JOURNAL_BYTES) throw new Error('Deferred send journal is too large; inspect it before restarting.');
|
|
160
|
+
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
161
|
+
if (data.version !== 1 || data.identity !== identity || !Array.isArray(data.pending) || !Array.isArray(data.notices)
|
|
162
|
+
|| data.pending.length + data.notices.length > JOURNAL_SLOTS) throw new Error('Deferred sends belong to another connection or have an unsupported format.');
|
|
163
|
+
const ids = new Set();
|
|
164
|
+
for (const entry of data.pending) {
|
|
165
|
+
if (ids.has(entry.id) || normalizedSend(entry.args).client_message_id !== entry.id || entry.fingerprint !== digest(JSON.stringify(normalizedSend(entry.args)))
|
|
166
|
+
|| !Number.isSafeInteger(entry.nextAt) || entry.nextAt < 0 || !Number.isSafeInteger(entry.attempts) || entry.attempts < 0 || typeof entry.deferred !== 'boolean') throw new Error('Invalid deferred send journal.');
|
|
167
|
+
ids.add(entry.id); entry.deferred = true; // an unfinished pre-crash request has an ambiguous result
|
|
168
|
+
}
|
|
169
|
+
for (const note of data.notices) if (!SEND_ID.test(note.id) || !['delivered', 'failed'].includes(note.status)
|
|
170
|
+
|| !Number.isSafeInteger(note.at) || note.at < 0 || typeof note.code !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(note.code)) throw new Error('Invalid deferred send notice.');
|
|
171
|
+
this.data = data;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
save() {
|
|
175
|
+
const text = JSON.stringify(this.data);
|
|
176
|
+
if (Buffer.byteLength(text) > JOURNAL_BYTES) throw new Error('Deferred send journal is full.');
|
|
177
|
+
const draft = `${this.file}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
178
|
+
try {
|
|
179
|
+
const fd = openSync(draft, 'wx', 0o600);
|
|
180
|
+
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
181
|
+
renameSync(draft, this.file);
|
|
182
|
+
const directory = openSync(dirname(this.file), 'r');
|
|
183
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
184
|
+
} finally { rmSync(draft, { force: true }); }
|
|
185
|
+
}
|
|
186
|
+
add(args) {
|
|
187
|
+
args = normalizedSend(args);
|
|
188
|
+
const fingerprint = digest(JSON.stringify(args));
|
|
189
|
+
const prior = this.data.pending.find(entry => entry.id === args.client_message_id);
|
|
190
|
+
if (prior) {
|
|
191
|
+
if (prior.fingerprint !== fingerprint) throw new TypeError('This pending client message ID already has different content.');
|
|
192
|
+
return { entry: prior, existing: true };
|
|
193
|
+
}
|
|
194
|
+
if (this.data.pending.length + this.data.notices.length >= JOURNAL_SLOTS) throw new TypeError('Deferred send journal is full; handle its pending results first.');
|
|
195
|
+
const entry = { id: args.client_message_id, args, fingerprint, nextAt: this.now(), attempts: 0, deferred: false };
|
|
196
|
+
this.data.pending.push(entry); this.save();
|
|
197
|
+
return { entry, existing: false };
|
|
198
|
+
}
|
|
199
|
+
defer(entry, seconds) {
|
|
200
|
+
entry.deferred = true;
|
|
201
|
+
entry.nextAt = this.now() + Math.ceil(seconds * 1000);
|
|
202
|
+
this.save();
|
|
203
|
+
}
|
|
204
|
+
complete(entry, status, code) {
|
|
205
|
+
this.data.pending = this.data.pending.filter(item => item.id !== entry.id);
|
|
206
|
+
if (entry.deferred) this.data.notices.push({ id: entry.id, status, code, at: this.now() });
|
|
207
|
+
this.save();
|
|
208
|
+
}
|
|
209
|
+
notices() { return this.data.notices.map(note => ({ ...note })); }
|
|
210
|
+
acknowledge(notes) {
|
|
211
|
+
const seen = new Set(notes.map(note => JSON.stringify(note)));
|
|
212
|
+
this.data.notices = this.data.notices.filter(note => !seen.has(JSON.stringify(note)));
|
|
213
|
+
this.save();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Fixed categories keep telemetry bounded and prevent arbitrary tool names/arguments from
|
|
218
|
+
// becoming stored metadata. Only numeric sizes cross the runtime reporting boundary.
|
|
219
|
+
const contextCategory = name => {
|
|
220
|
+
if (name === 'get_project') return 'project';
|
|
221
|
+
if (['find_agents', 'find_work'].includes(name)) return 'discovery';
|
|
222
|
+
if (name === 'get_updates') return 'updates';
|
|
223
|
+
if (name === 'read_thread') return 'threads';
|
|
224
|
+
if (name === 'send_message') return 'messaging';
|
|
225
|
+
if (['record_work_intent', 'list_work_intents'].includes(name)) return 'checkpoints';
|
|
226
|
+
if (['get_issue_coordination', 'coordinate_issue'].includes(name)) return 'coordination';
|
|
227
|
+
return 'other';
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export class DeferredMcp {
|
|
231
|
+
constructor(control, file, { now = Date.now, onDeferred = () => {}, log = () => {} } = {}) {
|
|
232
|
+
Object.assign(this, { control, now, onDeferred, log });
|
|
233
|
+
this.journal = new DeferredSends(file, digest(JSON.stringify([control.url, control.token])), now);
|
|
234
|
+
this.secret = randomBytes(32).toString('hex'); this.queue = Promise.resolve(); this.closed = false; this.fault = null;
|
|
235
|
+
}
|
|
236
|
+
beginContext() { this.context = { catalogRequests: 0, catalogBytes: 0, tools: new Map() }; }
|
|
237
|
+
endContext() {
|
|
238
|
+
const context = this.context; this.context = null;
|
|
239
|
+
return { catalogRequests: context?.catalogRequests ?? 0, catalogBytes: context?.catalogBytes ?? 0,
|
|
240
|
+
tools: [...(context?.tools.values() ?? [])].map(item => ({ ...item })) };
|
|
241
|
+
}
|
|
242
|
+
recordContext(context, rpc, reply) {
|
|
243
|
+
if (!context || context !== this.context) return; // completed outside this model boundary
|
|
244
|
+
if (rpc.method === 'tools/list' && Array.isArray(reply?.result?.tools)) {
|
|
245
|
+
context.catalogRequests++; context.catalogBytes += Buffer.byteLength(JSON.stringify(reply.result.tools));
|
|
246
|
+
}
|
|
247
|
+
if (rpc.method !== 'tools/call') return;
|
|
248
|
+
const category = contextCategory(rpc.params?.name);
|
|
249
|
+
const item = context.tools.get(category) ?? { category, calls: 0, argumentBytes: 0, responseTextBytes: 0, refusals: 0 };
|
|
250
|
+
item.calls++; item.argumentBytes += Buffer.byteLength(JSON.stringify(rpc.params?.arguments ?? {}));
|
|
251
|
+
const content = reply?.result?.content;
|
|
252
|
+
if (Array.isArray(content)) item.responseTextBytes += content.reduce((n, part) => n + (part.type === 'text' && typeof part.text === 'string' ? Buffer.byteLength(part.text) : 0), 0);
|
|
253
|
+
if (reply?.result?.isError || reply?.error) item.refusals++;
|
|
254
|
+
context.tools.set(category, item);
|
|
255
|
+
}
|
|
256
|
+
serialize(action) { const task = this.queue.then(() => { if (this.fault) throw this.fault; return action(); }); this.queue = task.catch(error => { if (!this.closed) { this.fault = error; this.onDeferred(); } }); return task; }
|
|
257
|
+
canSend() { return !this.closed && !this.control.closed && !this.control.fatal && this.control.news?.project?.started && !this.control.news.project.paused; }
|
|
258
|
+
pendingResult(entry) { return { content: [{ type: 'text', text: JSON.stringify({ status: 'pending', client_message_id: entry.id, retry_at: entry.nextAt, instruction: 'The runner saved this send and will retry without model usage. Finish this turn; do not send a replacement message.' }) }] }; }
|
|
259
|
+
async forward(rpc) {
|
|
260
|
+
const response = await fetch(`${this.control.url}/v2/mcp`, {
|
|
261
|
+
method: 'POST', headers: { authorization: `Bearer ${this.control.token}`, 'x-pilot-run': this.control.runId,
|
|
262
|
+
'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
|
263
|
+
body: JSON.stringify(rpc), signal: AbortSignal.timeout(15_000),
|
|
264
|
+
});
|
|
265
|
+
let text = '', bytes = 0; const decoder = new TextDecoder();
|
|
266
|
+
for await (const chunk of response.body ?? []) {
|
|
267
|
+
bytes += chunk.byteLength;
|
|
268
|
+
if (bytes > JOURNAL_BYTES) throw new Error('MCP response exceeds the local bridge bound.');
|
|
269
|
+
text += decoder.decode(chunk, { stream: true });
|
|
270
|
+
}
|
|
271
|
+
text += decoder.decode();
|
|
272
|
+
if (!text && response.ok && rpc.id === undefined) return null;
|
|
273
|
+
const data = JSON.parse(text.split('\n').find(line => line.startsWith('data:'))?.slice(5) ?? text);
|
|
274
|
+
if (!response.ok || typeof data.error === 'string') {
|
|
275
|
+
if (rpc.method === 'tools/call') return { jsonrpc: '2.0', id: rpc.id, result: refusalResult(`${data.error ?? 'internal_error'}: ${data.message ?? 'Remote call refused.'} (retry_after_seconds: ${data.retry_after_seconds ?? 20})`) };
|
|
276
|
+
throw new Error('Remote MCP request refused.');
|
|
277
|
+
}
|
|
278
|
+
return data;
|
|
279
|
+
}
|
|
280
|
+
async attempt(entry, rpc, observed = () => true) {
|
|
281
|
+
if (!this.canSend()) {
|
|
282
|
+
this.journal.defer(entry, this.control.pollSeconds ?? 20); this.onDeferred();
|
|
283
|
+
return this.pendingResult(entry);
|
|
284
|
+
}
|
|
285
|
+
entry.attempts = Math.min(Number.MAX_SAFE_INTEGER, entry.attempts + 1); this.journal.save(); // preserve identity before an ambiguous network boundary
|
|
286
|
+
let reply;
|
|
287
|
+
try { reply = await this.forward(rpc); } catch {
|
|
288
|
+
this.journal.defer(entry, Math.min(3600, 20 * 2 ** Math.min(entry.attempts - 1, 7))); this.onDeferred();
|
|
289
|
+
return this.pendingResult(entry);
|
|
290
|
+
}
|
|
291
|
+
if (!observed()) entry.deferred = true; // the model disconnected before receiving the result
|
|
292
|
+
const result = reply?.result;
|
|
293
|
+
const text = result?.content?.find(item => item.type === 'text')?.text ?? '';
|
|
294
|
+
const code = text.split(':')[0];
|
|
295
|
+
if (result?.isError && ['rate_limited', 'project_busy', 'recipient_backlog', 'internal_error', 'paused', 'not_started'].includes(code)) {
|
|
296
|
+
const seconds = Number(/retry_after_seconds:\s*(\d+(?:\.\d+)?)/.exec(text)?.[1] ?? 20);
|
|
297
|
+
this.journal.defer(entry, Math.max(1, Math.min(86400, seconds))); this.onDeferred();
|
|
298
|
+
return this.pendingResult(entry);
|
|
299
|
+
}
|
|
300
|
+
if (result?.isError) { this.journal.complete(entry, 'failed', /^[A-Za-z0-9_-]{1,100}$/.test(code) ? code : 'refused'); return result; }
|
|
301
|
+
let value;
|
|
302
|
+
try { value = JSON.parse(text); } catch { /* ambiguous protocol success is retried using the same id */ }
|
|
303
|
+
if (!value?.message || typeof value.message.id !== 'string') {
|
|
304
|
+
this.journal.defer(entry, 20); this.onDeferred(); return this.pendingResult(entry);
|
|
305
|
+
}
|
|
306
|
+
const wasDeferred = entry.deferred;
|
|
307
|
+
this.journal.complete(entry, 'delivered', 'accepted');
|
|
308
|
+
if (wasDeferred) this.log('Deferred send delivered.');
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
async call(rpc, observed = () => true) {
|
|
312
|
+
if (rpc.method !== 'tools/call' || rpc.params?.name !== 'send_message') return this.forward(rpc);
|
|
313
|
+
return this.serialize(async () => {
|
|
314
|
+
if (this.closed) throw new Error('Local MCP bridge stopped.');
|
|
315
|
+
let added;
|
|
316
|
+
try { added = this.journal.add(rpc.params.arguments); } catch (error) {
|
|
317
|
+
if (!(error instanceof TypeError)) throw error;
|
|
318
|
+
return { jsonrpc: '2.0', id: rpc.id, result: refusalResult(`bad_input: ${error.message}`) };
|
|
319
|
+
}
|
|
320
|
+
const result = added.existing ? (this.onDeferred(), this.pendingResult(added.entry)) : await this.attempt(added.entry, { ...rpc, params: { ...rpc.params, arguments: added.entry.args } }, observed);
|
|
321
|
+
return { jsonrpc: '2.0', id: rpc.id, result };
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
async retryDue() {
|
|
325
|
+
return this.serialize(async () => {
|
|
326
|
+
for (const entry of [...this.journal.data.pending]) {
|
|
327
|
+
if (!this.canSend()) break;
|
|
328
|
+
if (entry.nextAt > this.now()) continue;
|
|
329
|
+
await this.attempt(entry, { jsonrpc: '2.0', id: `retry-${entry.id}`, method: 'tools/call', params: { name: 'send_message', arguments: entry.args } });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
async listen() {
|
|
334
|
+
this.server = createServer(async (req, res) => {
|
|
335
|
+
if (this.closed || req.url !== '/mcp' || req.headers.authorization !== `Bearer ${this.secret}`) { res.writeHead(401); res.end(); return; }
|
|
336
|
+
if (req.method !== 'POST') { res.writeHead(405, { Allow: 'POST' }); res.end(); return; }
|
|
337
|
+
try {
|
|
338
|
+
let text = '', bytes = 0; req.setEncoding('utf8');
|
|
339
|
+
for await (const chunk of req) { bytes += Buffer.byteLength(chunk); if (bytes > 128 * 1024) { res.writeHead(413); res.end(); return; } text += chunk.toString('utf8'); }
|
|
340
|
+
const rpc = JSON.parse(text);
|
|
341
|
+
if (rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') throw new Error('Invalid MCP request.');
|
|
342
|
+
const context = this.context;
|
|
343
|
+
const reply = await this.call(rpc, () => !res.destroyed);
|
|
344
|
+
if (!res.destroyed) this.recordContext(context, rpc, reply);
|
|
345
|
+
if (reply === null || rpc.id === undefined) { res.writeHead(204); res.end(); }
|
|
346
|
+
else { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(reply)); }
|
|
347
|
+
} catch { if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Local MCP bridge could not complete the request.' } })); }
|
|
348
|
+
});
|
|
349
|
+
this.server.requestTimeout = 30_000; this.server.headersTimeout = 10_000;
|
|
350
|
+
await new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(0, '127.0.0.1', resolve); });
|
|
351
|
+
return { type: 'http', url: `http://127.0.0.1:${this.server.address().port}/mcp`, headers: { Authorization: `Bearer ${this.secret}` } };
|
|
352
|
+
}
|
|
353
|
+
async close() {
|
|
354
|
+
this.closed = true;
|
|
355
|
+
if (this.server) { this.server.closeAllConnections(); await new Promise(resolve => this.server.close(resolve)); }
|
|
356
|
+
await this.queue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Small foreground supervisor. Its protocol is explicit; incompatible updates require a
|
|
2
|
+
// launcher update, never an unbounded chain of downloaded bootstrap replacements.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
7
|
+
import { fetchRuntime, installRelease } from './runtime.mjs';
|
|
8
|
+
|
|
9
|
+
export async function supervise({ service, agentName, repoDir, home, env = {} }) {
|
|
10
|
+
const state = join(home, agentName);
|
|
11
|
+
let child, stopped = false;
|
|
12
|
+
const abort = new AbortController();
|
|
13
|
+
const stop = signal => { stopped = true; abort.abort(); child?.kill(signal); };
|
|
14
|
+
const handlers = Object.fromEntries(['SIGINT', 'SIGTERM', 'SIGHUP'].map(signal => [signal, () => stop(signal)]));
|
|
15
|
+
for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
|
|
16
|
+
let failures = 0;
|
|
17
|
+
try {
|
|
18
|
+
while (!stopped) {
|
|
19
|
+
let selected;
|
|
20
|
+
try {
|
|
21
|
+
const token = readFileSync(join(state, 'token'), 'utf8').trim();
|
|
22
|
+
selected = await fetchRuntime(service, token);
|
|
23
|
+
failures = 0;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (stopped) break;
|
|
26
|
+
if (error.fatal || /Unsupported|integrity|identity mismatch/.test(error.message)) throw error;
|
|
27
|
+
console.info(`Runtime selection unavailable; retrying before starting work (${error.message}).`);
|
|
28
|
+
await sleep(Math.min(60, 10 * 2 ** Math.min(failures++, 3)) * 1000, undefined, { signal: abort.signal });
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (stopped) break;
|
|
32
|
+
const kit = selected.release ? installRelease(state, selected.release) : join(state, 'kit');
|
|
33
|
+
if (selected.release) console.info(`Runtime release ${selected.release.id.slice(0, 12)}: ${selected.reason}`);
|
|
34
|
+
const provider = env.PILOT_PROVIDER ?? process.env.PILOT_PROVIDER ?? 'claude';
|
|
35
|
+
if (!['claude', 'codex'].includes(provider)) throw new Error('Select the local provider explicitly: claude or codex.');
|
|
36
|
+
const code = await new Promise((resolve, reject) => {
|
|
37
|
+
child = spawn(process.execPath, [join(kit, 'runner.mjs'), service, agentName, repoDir], {
|
|
38
|
+
stdio: 'inherit', env: { ...process.env, ...env, PILOT_HOME: home,
|
|
39
|
+
PILOT_RUNTIME_FILE: selected.release ? join(kit, 'applied.json') : '', PILOT_RUNTIME_MANAGED: '1' },
|
|
40
|
+
});
|
|
41
|
+
child.once('error', reject);
|
|
42
|
+
child.once('exit', (code, signal) => resolve(signal && stopped ? 0 : code ?? 1));
|
|
43
|
+
});
|
|
44
|
+
child = null;
|
|
45
|
+
if (stopped || code !== 75) return stopped ? 0 : code;
|
|
46
|
+
console.info('Adopting the selected runtime; repository and project remain in place.');
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (stopped && error.name === 'AbortError') return 0;
|
|
51
|
+
console.error(error.message); return 1;
|
|
52
|
+
} finally {
|
|
53
|
+
for (const [signal, handler] of Object.entries(handlers)) process.off(signal, handler);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "pilot/agent-kit",
|
|
3
|
+
"files": {
|
|
4
|
+
"runner.mjs": "984d2eabc54f2561397a04c851c29f8362d83e9521063222c2a3ce2725e3e06f",
|
|
5
|
+
"control-v2.mjs": "02e325b642669b8f29c7b25582b1afaf4b74cbc63d2b463c11bf02abac90ca12",
|
|
6
|
+
"runtime.mjs": "bceb586f369ca373f919ab848ace6968518d15c49057798778c6bfed3e76d8fc",
|
|
7
|
+
"launcher.mjs": "4254a649e02597f21b5f3c1b146490554a19cc9919933a3dfdc55113cbf140aa",
|
|
8
|
+
"claude-settings.json": "bf3bfaaf38de37cda60a7dc48cb8c186c1864df61bd5ff500715ee2d1db4333e",
|
|
9
|
+
"AGENT_PROMPT_V2.md": "1a4f28e8105db39d384b63ef32cf06a9b14057da2e12fd2d0a0c0f3f1ca02d80",
|
|
10
|
+
"providers.mjs": "90e70acd1d2363f28584c8ccbc5cce7f9851c4aae1864309ff408822050a10ef"
|
|
11
|
+
}
|
|
12
|
+
}
|