@karma42/agent 0.1.0-beta.10 → 0.1.0-beta.12
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 +15 -13
- package/package.json +1 -1
- package/runtime/control-v2.mjs +67 -9
- package/runtime/manifest.json +3 -3
- package/runtime/project-tools.json +1 -1
- package/runtime/turn-report-journal.mjs +81 -0
- package/skills/karma42/SKILL.md +22 -4
- package/src/cli.mjs +3 -15
- package/src/layout.mjs +20 -0
- package/src/operation-lock.mjs +46 -0
- package/src/owner-client.mjs +143 -19
- package/src/reusable-stdio.mjs +79 -106
- package/src/session-stdio.mjs +1 -1
- package/src/system.mjs +20 -20
- package/src/version.mjs +3 -1
- package/src/worker-runtime.mjs +10 -6
- package/src/browser-batch.mjs +0 -130
- package/src/browser-pairing.mjs +0 -140
package/src/reusable-stdio.mjs
CHANGED
|
@@ -8,31 +8,26 @@ import { verifyBundle } from './system.mjs';
|
|
|
8
8
|
import { assertStateOutsideWorkspace, canonicalPath, connectionDir, digest, load, lstatExists, readPrivate } from './state.mjs';
|
|
9
9
|
import { sessionHome, sessionId, validateSession } from './session.mjs';
|
|
10
10
|
import { projectIdentity } from './project-identity.mjs';
|
|
11
|
-
import { VERSION } from './version.mjs';
|
|
12
|
-
import { createBrowserOperations } from './browser-pairing.mjs';
|
|
13
|
-
import { createBatchBrowserOperations } from './browser-batch.mjs';
|
|
11
|
+
import { VERSION, GUIDE_REVISION } from './version.mjs';
|
|
14
12
|
import { serveSession } from './session-stdio.mjs';
|
|
15
13
|
import { createProjectWorker } from './worker-runtime.mjs';
|
|
16
14
|
import { workerRecoveryStatus, cancelWorkerRecovery } from './worker-recovery.mjs';
|
|
17
|
-
import { createOwnerClient, OWNER_TOOLS } from './owner-client.mjs';
|
|
15
|
+
import { createOwnerClient, OWNER_TOOLS, startQuestion } from './owner-client.mjs';
|
|
18
16
|
import { createDiscovery, DISCOVERY_TOOLS } from './discovery.mjs';
|
|
19
17
|
|
|
20
18
|
const objectSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
21
|
-
const approvalWaitSchema = { type: 'integer', minimum: 0, maximum: 60, default: 0, description: 'Wait without a model for browser approval, up to this many seconds. Reuse the same reference if still pending; no typed done is required.' };
|
|
22
19
|
const reference = { type: 'string', description: 'Use the opaque connection_ref returned for this exact project agent. There is no default connection.' };
|
|
23
20
|
const GUIDE = readFileSync(new URL('../skills/karma42/SKILL.md', import.meta.url), 'utf8');
|
|
24
21
|
const BOOTSTRAP = [
|
|
25
22
|
{ name:'get_karma42_guide', description:'Read the Karma42 owner/team orchestration skill before managing projects or assembling agents. No sign-in or connection needed.', annotations:{readOnlyHint:true}, inputSchema:objectSchema({},[]) },
|
|
26
|
-
{ name: 'connect_project_agent', description: 'Connect one
|
|
27
|
-
{ name: 'finish_project_agent_connection', description: 'Check this binding’s browser approval and bind when approved. Optional wait_seconds waits within a bounded budget without blocking other agents. Retry the same connection_ref after a lost reply. Never edit client settings or create a new pairing merely to retry.', inputSchema: objectSchema({ connection_ref: reference, wait_seconds: approvalWaitSchema, connection_id: { type: 'string', description: 'Optional crosscheck with the connection ID returned during onboarding.' } }, ['connection_ref']) },
|
|
23
|
+
{ name: 'connect_project_agent', description: 'Connect one existing project agent of your human’s by its exact ID and name. Reuses its saved local connection, or connects it through this computer’s one-time approval; there is no browser step per agent. A computer that is not approved yet is approved once with connect_owner_account. Returns an explicit connection_ref required by every project tool. No client settings, model or permissions are changed. To create a new agent use add_project_agent.', inputSchema: objectSchema({ project_url: { type: 'string' }, agent_id: { type: 'string' }, agent_name: { type: 'string' }, folder: { type: 'string' } }, ['project_url', 'agent_id', 'agent_name', 'folder']) },
|
|
28
24
|
{ name: 'disconnect_project_agent', description: 'Stop and release only this project-agent binding. Other bindings in a shared MCP process remain available. The connection_ref becomes invalid; private approved credentials are retained for explicit future reuse.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
29
25
|
];
|
|
30
26
|
const BATCH_TOOLS = [
|
|
31
|
-
{ name: 'connect_project_agents', description: 'Connect an exact batch of one to ten of your own project agents
|
|
32
|
-
{ name: 'finish_project_agents_connection', description: 'Check the exact batch_ref returned during onboarding and bind each approved agent separately. Optional wait_seconds waits within a bounded budget without blocking other agents. Retry this same batch after a lost reply. No worker is started.', inputSchema: objectSchema({ batch_ref: { type:'string' }, wait_seconds: approvalWaitSchema }, ['batch_ref']) },
|
|
27
|
+
{ name: 'connect_project_agents', description: 'Connect an exact batch of one to ten of your human’s own existing project agents through this computer’s one-time approval; there is no browser step. Each credential and connection_ref remains independent; this does not start workers or grant work authorization.', inputSchema: objectSchema({ project_url: { type: 'string' }, agents: { type: 'array', minItems: 1, maxItems: 10, items: objectSchema({ agent_id: { type:'string' }, agent_name: { type:'string' }, folder: { type:'string' } }, ['agent_id','agent_name','folder']) } }, ['project_url','agents']) },
|
|
33
28
|
];
|
|
34
29
|
const WORKER_TOOLS = [
|
|
35
|
-
{ name: 'start_project_worker', description: 'After your human
|
|
30
|
+
{ name: 'start_project_worker', description: 'After your human approves this work, start one supervised worker for this verified project agent. Uses the existing coordination connection and guarded native permissions. Return promptly; the supervisor can steer, pause or stop through worker controls. An agent added with add_project_agent works in its own folder: leave folder out, after cloning the project repository there. Any other agent requires a separate approved Git checkout/worktree matching the project repository, distinct from the connection/main-chat folder. Agents may self-organize within the shared approved mission; fixed roles are optional.', inputSchema: objectSchema({ connection_ref: reference, recovery_ref: { type: 'string', format: 'uuid', description: 'Only after explicit approval to recover the exact saved mission from get_project_worker. Repeat its saved client/model/scope/folder and omit minutes. Preserves the original deadline; never takes over a surviving supervisor or native process.' }, client: { type: 'string', enum: ['claude', 'codex'] }, model: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,149}$', description: 'Optional human-approved model ID or client alias. Omit for the client default; verify the actual model through worker status.' }, minutes: { type: 'integer', minimum: 15, maximum: 240, multipleOf: 15, default: 60 }, scope: { type: 'string', minLength: 1, maxLength: 4000 }, folder: { type: 'string' }, approved: { type: 'boolean', const: true, description: 'Set true only after the human explicitly approves starting this worker under its limits. Adding or connecting an agent alone is insufficient.' } }, ['connection_ref', 'client', 'scope', 'approved']) },
|
|
36
31
|
{ name: 'get_project_worker', description: 'Read this binding’s actual supervised worker status or saved mission recovery details after its supervisor ended. Reports model, last-turn usage, permission observations, original deadline, requested/applied steering and safe failure stage/code. Saved records are not live process observations. This does not restart work or extend the deadline.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
|
|
37
32
|
{ name: 'control_project_worker', description: 'Steer, pause, resume or stop only this binding’s supervised worker. Steering waits for the next natural model turn without interrupting active work; check requested/applied sequence rather than assuming delivery. Explicit pause/stop still interrupts execution. Resuming preserves the original deadline. Instruction is required only for steer.', inputSchema: objectSchema({ connection_ref: reference, action: { type: 'string', enum: ['pause', 'resume', 'stop', 'steer'] }, instruction: { type: 'string', minLength: 1, maxLength: 2000 } }, ['connection_ref', 'action']) },
|
|
38
33
|
];
|
|
@@ -41,7 +36,7 @@ const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }
|
|
|
41
36
|
|
|
42
37
|
// MCP hosts may share a server across conversations. Every tool carries an explicit binding
|
|
43
38
|
// reference; neither a process nor a shared state file ever has a default active project.
|
|
44
|
-
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process,
|
|
39
|
+
export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), openApproval = () => false, ...boundOptions }) {
|
|
45
40
|
const bundled = verifyBundle();
|
|
46
41
|
const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
|
|
47
42
|
...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
|
|
@@ -53,10 +48,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
53
48
|
let ownerConnected = false;
|
|
54
49
|
const catalog = [...OWNER_TOOLS, ...DISCOVERY_TOOLS, localList, ...BOOTSTRAP, ...BATCH_TOOLS, ...WORKER_TOOLS, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
|
|
55
50
|
home = canonicalPath(home);
|
|
56
|
-
const
|
|
57
|
-
const batchBrowser = batchBrowserFactory({ home, io: { say() {} } });
|
|
58
|
-
const bindings = new Map(), identities = new Map(), batches = new Map();
|
|
51
|
+
const bindings = new Map(), identities = new Map();
|
|
59
52
|
const approvalTasks = new Map();
|
|
53
|
+
let openedOwnerApproval = null;
|
|
60
54
|
let clientName = 'MCP client', stopped = false;
|
|
61
55
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
62
56
|
const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) void closeContext(context).catch(() => {}); };
|
|
@@ -64,7 +58,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
64
58
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.on(signal, stop);
|
|
65
59
|
const send = rpc => { if (!stopped) output.write(`${JSON.stringify(rpc)}\n`); };
|
|
66
60
|
const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
|
|
67
|
-
const state = context => ({ state: context.stopped || context.stopping || context.closing ? 'stopped' : context.verified ? 'connected' : context.record ? 'bound' : context.observedState ?? 'pending', verified: !!context.verified && !context.stopped && !context.stopping && !context.closing, connection_ref: context.ref, connection_id: context.selected.id, agent_id: context.selected.agentId, agent_name: context.selected.agentName, project_url: context.selected.service, folder: context.selected.folder });
|
|
61
|
+
const state = context => ({ state: context.stopped || context.stopping || context.closing ? 'stopped' : context.verified ? 'connected' : context.record ? 'bound' : context.observedState ?? 'pending', verified: !!context.verified && !context.stopped && !context.stopping && !context.closing, connection_ref: context.ref, connection_id: context.selected.id, agent_id: context.selected.agentId, agent_name: context.selected.agentName, project_url: context.selected.service, folder: context.selected.folder, guide_revision: GUIDE_REVISION });
|
|
68
62
|
function validateRecord(record, context) {
|
|
69
63
|
validateSession(record, context.selected.id);
|
|
70
64
|
if (record.service !== context.selected.service || record.agentId !== context.selected.agentId || record.agentName !== context.selected.agentName || record.folder !== context.selected.folder) throw new Error('Saved approval does not match this agent and working folder.');
|
|
@@ -106,10 +100,10 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
106
100
|
for (const pending of context.waiting.values()) pending.reject(new Error('Bound connection stopped.'));
|
|
107
101
|
context.waiting.clear();
|
|
108
102
|
});
|
|
109
|
-
return { ...state(context), next_step: 'Call get_project with this connection_ref and verify your identity, then read the goals, rules, repository and state.
|
|
103
|
+
return { ...state(context), next_step: 'Call get_project with this connection_ref and verify your identity, then read the goals, rules, repository and state. Follow the guide for when work may start.' };
|
|
110
104
|
}
|
|
111
105
|
async function connect(args) {
|
|
112
|
-
if (Object.keys(args).some(key => !['project_url', 'agent_id', 'agent_name', 'folder'
|
|
106
|
+
if (Object.keys(args).some(key => !['project_url', 'agent_id', 'agent_name', 'folder'].includes(key)) || !/^[\w-]{1,200}$/.test(args.agent_id ?? '') || !/^[\w-]{1,64}$/.test(args.agent_name ?? '')) throw new Error('Use the project-specific agent ID and name supplied by your human. A connected agent keeps its connection; there is no replacing it.');
|
|
113
107
|
if (typeof args.project_url !== 'string' || typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Invalid project or folder.');
|
|
114
108
|
const folder = canonicalPath(args.folder);
|
|
115
109
|
assertStateOutsideWorkspace(home, folder);
|
|
@@ -124,24 +118,31 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
124
118
|
if (context) {
|
|
125
119
|
assertLive(context);
|
|
126
120
|
if (context.selected.agentName !== selected.agentName || context.selected.folder !== selected.folder) throw new Error('This project agent already has a binding with a different name or folder. Disconnect its exact connection_ref before changing it.');
|
|
127
|
-
if (context.record)
|
|
128
|
-
if (args.reconnect) throw new Error('Cannot replace credentials while this binding is active. Disconnect its connection_ref before explicitly reconnecting.');
|
|
129
|
-
return state(context);
|
|
130
|
-
}
|
|
121
|
+
if (context.record) return state(context);
|
|
131
122
|
} else {
|
|
132
123
|
context = { ref: randomUUID(), selected, verified: false }; bindings.set(context.ref, context); identities.set(selected.id, context.ref);
|
|
133
124
|
}
|
|
125
|
+
try { return bind(await savedOrApproved(selected, context), context); }
|
|
126
|
+
catch (error) { forget(context); throw error; }
|
|
127
|
+
}
|
|
128
|
+
// A connect that did not complete leaves nothing behind: its reference was never returned to the caller.
|
|
129
|
+
function forget(context) {
|
|
130
|
+
if (context.record || bindings.get(context.ref) !== context) return;
|
|
131
|
+
bindings.delete(context.ref); identities.delete(context.selected.id);
|
|
132
|
+
}
|
|
133
|
+
// The one validation: a connection this computer already saved, or this computer's approval. The approval
|
|
134
|
+
// connects the exact existing agent the caller named; without it the owner client says how to approve the computer.
|
|
135
|
+
async function savedOrApproved(selected, context) {
|
|
134
136
|
const dir = connectionDir(home, selected.id);
|
|
135
|
-
if (lstatExists(join(dir, 'connection.json'))) validateSession(load(dir), selected.id);
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
if (lstatExists(join(dir, 'connection.json'))) return validateSession(load(dir), selected.id);
|
|
138
|
+
const url = new URL(selected.service);
|
|
139
|
+
if (url.origin !== owner.origin) throw new Error('Use a project on the Karma42 site this computer is set up for.');
|
|
140
|
+
const added = await owner.addAgent({ project_id: url.pathname.split('/')[2], agent_name: selected.agentName, folder: selected.folder }, clientName, { exact: selected.agentId, own: false });
|
|
138
141
|
assertLive(context);
|
|
139
|
-
|
|
140
|
-
context.observedState = response.status;
|
|
141
|
-
return { ...state(context), state: response.status, approval_url: response.approvalUrl, request_check: response.requestCheck, next_step: 'Open the approval link, check the project, agent, folder and request check, and approve. Wait for browser approval with finish_project_agent_connection, this connection_ref and wait_seconds:60; no typed done is required.' };
|
|
142
|
+
return added.record;
|
|
142
143
|
}
|
|
143
144
|
async function connectBatch(args) {
|
|
144
|
-
if (Object.keys(args).some(key => !['project_url','agents'
|
|
145
|
+
if (Object.keys(args).some(key => !['project_url','agents'].includes(key)) || !Array.isArray(args.agents) || args.agents.length < 1 || args.agents.length > 10) throw new Error('Use one to ten explicitly selected agents for one project.');
|
|
145
146
|
const selected = args.agents.map(agent => {
|
|
146
147
|
if (!agent || Object.keys(agent).some(key => !['agent_id','agent_name','folder'].includes(key)) || !/^[\w-]{1,200}$/.test(agent.agent_id ?? '') || !/^[\w-]{1,64}$/.test(agent.agent_name ?? '') || typeof agent.folder !== 'string' || !isAbsolute(agent.folder)) throw new Error('Use each agent’s exact ID, name and approved absolute working folder.');
|
|
147
148
|
const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
|
|
@@ -159,80 +160,24 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
159
160
|
const context = contexts[index]; if (!context) continue;
|
|
160
161
|
assertLive(context);
|
|
161
162
|
if (context.selected.agentName !== selected[index].agentName || context.selected.folder !== selected[index].folder) throw new Error('This project agent already has a different name or folder. Disconnect its exact connection_ref first.');
|
|
162
|
-
if (context.record && args.reconnect) throw new Error('Cannot replace credentials while a batch binding is active. Disconnect its exact connection_ref first.');
|
|
163
163
|
}
|
|
164
|
-
|
|
165
|
-
if (contexts.some(context => context?.record)) throw new Error('Cannot replace credentials for active agents in a batch. Select only unbound agents or disconnect their exact connection_ref first.');
|
|
166
|
-
const response = await batchBrowser.connect({ ...args, clientName });
|
|
167
|
-
if (stopped) throw new Error('The MCP session closed before the batch could be bound.');
|
|
168
|
-
for (const context of contexts) if (context) assertLive(context);
|
|
169
|
-
const key = response.id;
|
|
170
|
-
let batch = [...batches.values()].find(candidate => candidate.id === key);
|
|
171
|
-
if (!batch) { batch = { ref:randomUUID(), id:key, contexts:selected.map((agent,index) => {
|
|
164
|
+
const batch = selected.map((agent,index) => {
|
|
172
165
|
const context = contexts[index] ?? { ref:randomUUID(), selected:agent, verified:false };
|
|
173
166
|
bindings.set(context.ref,context); identities.set(agent.id,context.ref); return context;
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Validate every record before binding any, so a mismatch cannot select another agent.
|
|
181
|
-
if (response.records.length !== batch.contexts.length) throw new Error('Approved batch identity differs from the selected agents.');
|
|
182
|
-
for (const context of batch.contexts) validateRecord(response.records.find(record => record.id === context.selected.id),context);
|
|
183
|
-
for (const context of batch.contexts) if (!context.record) bind(response.records.find(record => record.id === context.selected.id),context);
|
|
184
|
-
return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
185
|
-
}
|
|
186
|
-
for (const context of batch.contexts) context.observedState = response.status;
|
|
187
|
-
return { state:response.status, batch_ref:batch.ref, approval_url:response.approvalUrl, request_check:response.requestCheck, agents:batch.contexts.map(state), next_step:'Approve this exact batch of your own agents in Karma42, wait for the result with finish_project_agents_connection, this batch_ref and wait_seconds:60; no typed done is required. Connection approval does not start work.' };
|
|
188
|
-
}
|
|
189
|
-
function queueApprovalWait(rpc, args, once) {
|
|
190
|
-
const key = `${once === finishBatch ? 'batch' : 'agent'}:${args.batch_ref ?? args.connection_ref}`;
|
|
191
|
-
if (approvalTasks.has(key)) throw new Error('Use the existing approval wait for this same reference; it is still running.');
|
|
192
|
-
if (approvalTasks.size >= 20) throw new Error('Approval wait limit reached. Finish or cancel a pending request first.');
|
|
193
|
-
// Waiting for a browser must not block steering or stopping another bound agent.
|
|
194
|
-
const task = waitForApproval(args, once).then(value => send({ jsonrpc:'2.0', id:rpc.id, result:result(value) }), error => {
|
|
195
|
-
const safe = /^(Use |Approval wait|This binding|This project agent|Browser setup|Another operation|Another browser)/.test(error.message ?? '');
|
|
196
|
-
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.'));
|
|
197
|
-
}).finally(() => approvalTasks.delete(key));
|
|
198
|
-
approvalTasks.set(key, task);
|
|
199
|
-
return task;
|
|
200
|
-
}
|
|
201
|
-
async function waitForApproval(args, once) {
|
|
202
|
-
const { wait_seconds = 0, ...selection } = args;
|
|
203
|
-
if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Approval wait must be between 0 and 60 seconds.');
|
|
204
|
-
let remaining = wait_seconds * 1000;
|
|
205
|
-
while (true) {
|
|
206
|
-
const response = await once(selection);
|
|
207
|
-
if (response.state !== 'pending' || remaining <= 0 || stopped) return response;
|
|
208
|
-
const delay = Math.min(5000, remaining);
|
|
209
|
-
await approvalWait(delay); remaining -= delay;
|
|
210
|
-
if (stopped) return response;
|
|
167
|
+
});
|
|
168
|
+
// Each agent is connected and bound on its own; one that fails leaves the others bound, and
|
|
169
|
+
// repeating the same call connects the rest.
|
|
170
|
+
for (const context of batch) {
|
|
171
|
+
assertLive(context);
|
|
172
|
+
if (!context.record) try { bind(await savedOrApproved(context.selected, context), context); } catch (error) { for (const other of batch) forget(other); throw error; }
|
|
211
173
|
}
|
|
212
|
-
|
|
213
|
-
async function finishBatch(args) {
|
|
214
|
-
if (Object.keys(args).some(key => key !== 'batch_ref') || !batches.has(args.batch_ref)) throw new Error('Use the exact batch_ref returned during onboarding in this MCP process.');
|
|
215
|
-
const batch = batches.get(args.batch_ref);
|
|
216
|
-
for (const context of batch.contexts) assertLive(context);
|
|
217
|
-
if (batch.contexts.every(context => context.record)) return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state) };
|
|
218
|
-
return batchResponse(await batchBrowser.prepare({ connection:batch.id }),batch);
|
|
174
|
+
return { state:'bound', agents:batch.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
|
|
219
175
|
}
|
|
220
176
|
function contextFor(args) {
|
|
221
177
|
const context = bindings.get(args.connection_ref);
|
|
222
178
|
if (!context) throw new Error('Use an exact connection_ref returned by connect_project_agent in this MCP process. There is no default connection.');
|
|
223
179
|
return context;
|
|
224
180
|
}
|
|
225
|
-
async function finish(args) {
|
|
226
|
-
if (Object.keys(args).some(key => !['connection_ref', 'connection_id'].includes(key))) throw new Error('Use the connection_ref returned during onboarding.');
|
|
227
|
-
const context = contextFor(args);
|
|
228
|
-
if (args.connection_id !== undefined && args.connection_id !== context.selected.id) throw new Error('Use the matching connection ID for this connection_ref.');
|
|
229
|
-
assertLive(context);
|
|
230
|
-
if (context.record) return state(context);
|
|
231
|
-
const response = await browser.prepare({ connection: context.selected.id });
|
|
232
|
-
assertLive(context);
|
|
233
|
-
context.observedState = response.status;
|
|
234
|
-
return response.schema === 'mcp-connection-1' ? bind(response, context) : { ...state(context), state: response.status, next_step: response.status === 'pending' ? 'Approval is still pending. Ask the human to approve in Karma42; retry this same connection_ref.' : 'The request was cancelled or expired. Ask your human before starting a fresh connection request.' };
|
|
235
|
-
}
|
|
236
181
|
async function closeContext(context) {
|
|
237
182
|
context.closing ??= (async () => {
|
|
238
183
|
// Stop the model process before aborting/releasing its shared coordination run.
|
|
@@ -260,7 +205,8 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
260
205
|
const current = context.worker?.status();
|
|
261
206
|
if (current && (current.state !== 'idle' || current.mission_ref)) return current;
|
|
262
207
|
const recovery = context.record ? workerRecoveryStatus(connectionDir(home, context.record.id), context.record) : null;
|
|
263
|
-
|
|
208
|
+
const status = recovery ? { state: 'recovery_required', active: false, recovery } : current ?? { state: 'not_started', active: false };
|
|
209
|
+
return { ...status, report_delivery: context.ready?.control?.reportDeliveryStatus?.() ?? null };
|
|
264
210
|
}
|
|
265
211
|
async function startWorker(args) {
|
|
266
212
|
if (Object.keys(args).some(key => !['connection_ref', 'client', 'model', 'minutes', 'scope', 'folder', 'approved', 'recovery_ref'].includes(key))) throw new Error('Use only the documented worker options.');
|
|
@@ -268,8 +214,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
268
214
|
if (args.approved !== true) throw new Error('Worker start requires your human’s explicit approval for this worker, duration, folder and work scope. Connection approval is insufficient.');
|
|
269
215
|
if (!['claude', 'codex'].includes(args.client) || !Number.isInteger(minutes) || minutes < 15 || minutes > 240 || minutes % 15 !== 0) throw new Error('Choose a supported worker client and 15–240 minutes in 15-minute increments.');
|
|
270
216
|
if (typeof args.scope !== 'string' || !args.scope.trim() || args.scope.length > 4000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(args.scope)) throw new Error('Use a clear, human-approved work scope of at most 4000 characters.');
|
|
271
|
-
|
|
272
|
-
|
|
217
|
+
const ownFolder = context.record.ownFolder === true ? context.record.folder : undefined;
|
|
218
|
+
if (args.folder === undefined ? !ownFolder : typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Use the approved absolute worker checkout folder.');
|
|
219
|
+
const folder = canonicalPath(args.folder ?? ownFolder); assertStateOutsideWorkspace(home, folder);
|
|
273
220
|
for (const other of bindings.values()) {
|
|
274
221
|
if (other === context || other.stopped || other.closing) continue;
|
|
275
222
|
const candidates = [other.selected.folder, ...(workerOwnsMutations(other) && other.workerFolder ? [other.workerFolder] : [])];
|
|
@@ -342,13 +289,43 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
342
289
|
if (name === 'connect_owner_account') {
|
|
343
290
|
if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
|
|
344
291
|
const account = await owner.connect(clientName); ownerConnected = true;
|
|
345
|
-
|
|
292
|
+
// Open the approval page once per request in this process, as for an agent's approval; the link is still returned.
|
|
293
|
+
let opened = false;
|
|
294
|
+
if (account.status === 'pending' && openedOwnerApproval !== account.approval_url) {
|
|
295
|
+
try { opened = !!openApproval(account.approval_url); } catch { opened = false; }
|
|
296
|
+
if (opened) openedOwnerApproval = account.approval_url;
|
|
297
|
+
}
|
|
298
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, ...(account.status === 'pending' ? { browser_opened: opened, next_step: opened ? 'The approval page is open in their browser. Tell the human so in message text, with a labeled link to approval_url and the request check, then call finish_owner_account_connection with wait_seconds 60 in the same turn.' : 'No browser could be opened. Make a labeled link to approval_url and the request check your whole reply and end your turn, so the human sees it; call finish_owner_account_connection when they say they approved.' } : {}), owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) });
|
|
346
299
|
} else {
|
|
347
300
|
if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
|
|
348
301
|
const { owner_ref, ...fields } = args;
|
|
349
|
-
|
|
302
|
+
if (name === 'add_project_agent') {
|
|
303
|
+
// The saved connection binds exactly as an approved one does; its reference is then verified with get_project.
|
|
304
|
+
const added = await owner.addAgent(fields, clientName);
|
|
305
|
+
const bound = await connect({ project_url: added.record.service, agent_id: added.record.agentId, agent_name: added.record.agentName, folder: added.record.folder });
|
|
306
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...bound, created: added.created, project_name: added.project_name, project_id: added.record.service.split('/p/')[1], work_settings: added.settings, folder: added.record.folder, repository: added.repo, start_question: startQuestion(added.record.agentName, added.project_name, added.settings), next_step: 'Call get_project with this connection_ref and check you.agentName. If project.started is true and project.paused is false, ask start_question with your question tool (AskUserQuestion in Claude Code), using exactly its question and its three options; use a numbered list only when your client has no question tool, and never ask it as a plain sentence. If the project has not started or is paused, ask nothing: say in one line what it is waiting for. Clone the repository into folder only after the human answers Yes. The guide section “Ask once before starting” says what each answer means.', owner_ref: ownerReference }) });
|
|
307
|
+
} else if (name === 'finish_owner_account_connection') {
|
|
308
|
+
const { wait_seconds = 0, ...extra } = fields;
|
|
309
|
+
if (Object.keys(extra).length) throw new Error('Owner finish takes only owner_ref and wait_seconds.');
|
|
310
|
+
if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Use an approval wait between 0 and 60 seconds.');
|
|
311
|
+
if (approvalTasks.has('owner')) throw new Error('Use the existing approval wait for this same reference; it is still running.');
|
|
312
|
+
// Waiting for the browser must not block steering or stopping a bound agent.
|
|
313
|
+
const task = (async () => {
|
|
314
|
+
for (let remaining = wait_seconds * 1000; ; ) {
|
|
315
|
+
const account = await owner.finish();
|
|
316
|
+
if (account.status !== 'pending' || remaining <= 0 || stopped) return account;
|
|
317
|
+
const delay = Math.min(5000, remaining); await approvalWait(delay); remaining -= delay;
|
|
318
|
+
}
|
|
319
|
+
})().then(account => send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) }),
|
|
320
|
+
error => send(fail(rpc.id, -32000, /^Owner /.test(error.message ?? '') ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.')))
|
|
321
|
+
.finally(() => approvalTasks.delete('owner'));
|
|
322
|
+
approvalTasks.set('owner', task);
|
|
323
|
+
await (wait_seconds === 0 ? task : Promise.resolve());
|
|
324
|
+
} else {
|
|
325
|
+
const account = await owner.operation(name, fields);
|
|
350
326
|
if (name === 'disconnect_owner_account') ownerConnected = false;
|
|
351
|
-
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
|
|
327
|
+
send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference, guide_revision: GUIDE_REVISION }) });
|
|
328
|
+
}
|
|
352
329
|
}
|
|
353
330
|
}
|
|
354
331
|
else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
|
|
@@ -361,9 +338,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
361
338
|
send({ jsonrpc: '2.0', id: rpc.id, result: result({ scope: 'this_mcp_process', agents: page.map(context => ({ ...state(context), worker: workerStatus(context) })), next: entries.length > offset + 20 ? page.at(-1).ref : null, active_workers: entries.filter(workerOwnsMutations).length, presence: 'local observation; not service online state' }) });
|
|
362
339
|
}
|
|
363
340
|
else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
|
|
364
|
-
else if (name === 'finish_project_agents_connection') await queueApprovalWait(rpc, args, finishBatch);
|
|
365
341
|
else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
|
|
366
|
-
else if (name === 'finish_project_agent_connection') await queueApprovalWait(rpc, args, finish);
|
|
367
342
|
else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
|
|
368
343
|
else if (name === 'get_project_worker') {
|
|
369
344
|
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
|
|
@@ -374,12 +349,11 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
374
349
|
else if (name === 'disconnect_project_agent') {
|
|
375
350
|
if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
|
|
376
351
|
const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
|
|
377
|
-
for (const [key,batch] of batches) if (batch.contexts.includes(context)) batches.delete(key);
|
|
378
352
|
send({ jsonrpc: '2.0', id: rpc.id, result: result({ state: 'disconnected', connection_ref: context.ref }) });
|
|
379
353
|
} else if (!allowed.has(name)) send(fail(rpc.id, -32602, 'Unknown Karma42 tool.'));
|
|
380
354
|
else {
|
|
381
355
|
const context = contextFor(args);
|
|
382
|
-
if (!context.record) throw new Error('
|
|
356
|
+
if (!context.record) throw new Error('This project agent is not connected. Call connect_project_agent or add_project_agent again and use the connection_ref it returns.');
|
|
383
357
|
if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
|
|
384
358
|
if (context.closing || context.stopping) throw new Error('This binding is stopping. Wait for the worker to stop before reconnecting.');
|
|
385
359
|
if (workerOwnsMutations(context) && !READ_TOOLS.has(name)) throw new Error('This worker owns project mutations. Use control_project_worker to steer or stop it; the supervisor must not mutate concurrently.');
|
|
@@ -396,7 +370,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
396
370
|
}
|
|
397
371
|
}
|
|
398
372
|
} catch (error) {
|
|
399
|
-
const safe = /^(Owner |Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|Private connection state|Use |Saved approval|This project agent|This binding|Cannot replace|Complete browser|Invalid project|Invalid private credential|Tool arguments|No browser setup|Browser setup|Browser approval reply|Unexpected browser|Approved identity|A browser setup|Another operation|Another browser|The connection lock|Unsupported or invalid MCP)/.test(error.message ?? '');
|
|
373
|
+
const safe = /^(Owner |Public discovery|This project is not published|Start the project|The MCP session closed|Only paused or blocked|There is no active work|Too many steering|Call get_project|Worker |Worker start|Choose |Provide a clear|Explicit human approval|Supervised workers|Saved worker model session|The worker folder|This work session|This worker|Sign in locally|Cannot run (claude|codex)|This adapter requires|Cannot inspect local MCP|Invalid local MCP|Unsupported local MCP|Private connection state|Use |Saved approval|This project agent|This binding|This computer is not approved|Adding an agent|An unfinished request|Saved agent request|Cannot replace|Complete browser|Invalid project|Invalid private credential|Tool arguments|No browser setup|Browser setup|Browser approval reply|Unexpected browser|Approved identity|A browser setup|Another operation|Another browser|The connection lock|Unsupported or invalid MCP)/.test(error.message ?? '');
|
|
400
374
|
send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
|
|
401
375
|
}
|
|
402
376
|
}
|
|
@@ -408,10 +382,9 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
|
|
|
408
382
|
const name = rpc.params?.name, args = rpc.params?.arguments;
|
|
409
383
|
if (rpc.method !== 'tools/call' || ['get_karma42_guide', 'list_project_agents', 'get_project_worker'].includes(name)) return { priority: true };
|
|
410
384
|
if (['control_project_worker', 'disconnect_project_agent'].includes(name)) return { priority: true, lane: `control:${args?.connection_ref}` };
|
|
411
|
-
if (OWNER_TOOLS.some(tool => tool.name === name)) return { lane: 'owner' };
|
|
385
|
+
if (OWNER_TOOLS.some(tool => tool.name === name) || ['connect_project_agent', 'connect_project_agents'].includes(name)) return { lane: 'owner' };
|
|
412
386
|
if (DISCOVERY_TOOLS.some(tool => tool.name === name)) return { lane: 'discovery' };
|
|
413
|
-
if (
|
|
414
|
-
if (name === 'finish_project_agent_connection' || name === 'finish_project_agents_connection') return {};
|
|
387
|
+
if (name === 'start_project_worker') return { lane: 'setup' };
|
|
415
388
|
return allowed.has(name) ? { lane: `project:${args?.connection_ref}` } : {};
|
|
416
389
|
}
|
|
417
390
|
function dispatch(rpc) {
|
package/src/session-stdio.mjs
CHANGED
|
@@ -31,7 +31,7 @@ export async function serveSession({ connection, home = sessionHome(), input = p
|
|
|
31
31
|
active = withLock(dir, async () => {
|
|
32
32
|
try {
|
|
33
33
|
requestAbort = new AbortController();
|
|
34
|
-
control = new Control(record.service, token, { takeover: false, requestSignal: requestAbort.signal, onFatal: stop });
|
|
34
|
+
control = new Control(record.service, token, { stateDir: dir, agentId: record.agentId, takeover: false, requestSignal: requestAbort.signal, onFatal: stop });
|
|
35
35
|
const startupTimeout = setTimeout(() => { requestAbort.abort(); control.abort.abort(); }, 30_000);
|
|
36
36
|
try { await control.start(); control.loop?.catch(() => {}); } finally { clearTimeout(startupTimeout); }
|
|
37
37
|
if (stopped) throw new Error('Client disconnected.');
|
package/src/system.mjs
CHANGED
|
@@ -2,31 +2,14 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
|
|
6
|
-
export async function request(service, route, body, token) {
|
|
7
|
-
let response;
|
|
8
|
-
try {
|
|
9
|
-
response = await fetch(`${service}/api/join/${route}`, {
|
|
10
|
-
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
|
|
11
|
-
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
12
|
-
body: JSON.stringify(body),
|
|
13
|
-
});
|
|
14
|
-
} catch { throw new Error('Browser pairing reply unavailable. Retry the same pending connection.'); }
|
|
15
|
-
if (!response.ok) {
|
|
16
|
-
if (response.status === 401 || response.status === 403) throw new Error('This connection is no longer authorized. Check membership and reconnect in My agents.');
|
|
17
|
-
if (response.status === 429) throw new Error('Too many requests. Wait before retrying.');
|
|
18
|
-
throw new Error('The project service could not complete this request.');
|
|
19
|
-
}
|
|
20
|
-
const raw = await response.text();
|
|
21
|
-
if (raw.length > 16_384) throw new Error('Unexpected project service response.');
|
|
22
|
-
try { return JSON.parse(raw); } catch { throw new Error('Unexpected project service response.'); }
|
|
23
|
-
}
|
|
24
7
|
export function verifyBundle() {
|
|
25
8
|
const root = fileURLToPath(new URL('../runtime/', import.meta.url));
|
|
26
9
|
let manifest;
|
|
27
10
|
try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); }
|
|
28
11
|
catch { throw new Error('Packaged runtime missing. Build or reinstall this package before preparing an agent.'); }
|
|
29
|
-
const names = ['control-v2.mjs', 'work-controller.mjs', 'providers.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'project-tools.json'];
|
|
12
|
+
const names = ['control-v2.mjs', 'turn-report-journal.mjs', 'work-controller.mjs', 'providers.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'project-tools.json'];
|
|
30
13
|
if (Object.keys(manifest.files ?? {}).sort().join() !== [...names].sort().join()) throw new Error('Invalid packaged runtime manifest.');
|
|
31
14
|
const files = {};
|
|
32
15
|
for (const name of names) {
|
|
@@ -36,4 +19,21 @@ export function verifyBundle() {
|
|
|
36
19
|
}
|
|
37
20
|
return { files, fingerprint: createHash('sha256').update(JSON.stringify(manifest.files)).digest('hex') };
|
|
38
21
|
}
|
|
39
|
-
|
|
22
|
+
|
|
23
|
+
// Opens only a Karma42 approval page, in the person's default browser. Returns whether an attempt was made;
|
|
24
|
+
// the caller still shows the link, because a launched command is not proof that a page appeared.
|
|
25
|
+
export function openApprovalPage(address, { platform = process.platform, env = process.env, spawnImpl = spawn } = {}) {
|
|
26
|
+
let url;
|
|
27
|
+
try { url = new URL(address); } catch { return false; }
|
|
28
|
+
const karma = url.protocol === 'https:' && ['karma42.ai', 'karma42-beta.web.app'].includes(url.hostname) && !url.port;
|
|
29
|
+
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
30
|
+
if (!(karma || local) || url.username || url.password || !url.pathname.startsWith('/viewer/')) return false;
|
|
31
|
+
// No desktop to open a page on: remote shells, CI, and Linux without a display.
|
|
32
|
+
if (env.KARMA42_NO_BROWSER || env.CI || env.SSH_CONNECTION || env.SSH_TTY || (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)) return false;
|
|
33
|
+
const [command, args] = platform === 'darwin' ? ['open', [url.href]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url.href]] : ['xdg-open', [url.href]];
|
|
34
|
+
try {
|
|
35
|
+
const child = spawnImpl(command, args, { detached: true, stdio: 'ignore' });
|
|
36
|
+
child.on?.('error', () => {}); child.unref?.();
|
|
37
|
+
return true;
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
package/src/version.mjs
CHANGED
package/src/worker-runtime.mjs
CHANGED
|
@@ -27,14 +27,16 @@ const bounded = (value, label, max = 2000) => {
|
|
|
27
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
28
|
return value.trim();
|
|
29
29
|
};
|
|
30
|
-
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', '
|
|
30
|
+
const faultCodes = new Set(['rate_limited', 'project_busy', 'internal_error', 'unauthorized', 'forbidden', 'run_superseded', 'runtime_conflict', 'run_active', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
|
|
31
31
|
const contactIsTransient = failure => failure.fatal === false || failure.name === 'TimeoutError'
|
|
32
32
|
|| (failure instanceof TypeError && failure.message === 'fetch failed')
|
|
33
33
|
|| ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN'].includes(failure.cause?.code);
|
|
34
34
|
export function validateWorkerWorkspace(folder, record) {
|
|
35
35
|
if (typeof folder !== 'string' || !folder.startsWith('/')) throw new Error('Choose a separate absolute Git checkout or worktree for the worker.');
|
|
36
36
|
const cwd = canonicalPath(folder);
|
|
37
|
-
|
|
37
|
+
// An agent added through the approved computer has a folder of its own, which is also its checkout.
|
|
38
|
+
// A connection made from a chat folder still needs a separate one, so the chat and worker never share files.
|
|
39
|
+
if (!(record.ownFolder === true && cwd === record.folder)) 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
40
|
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
41
|
const root = git(['rev-parse', '--show-toplevel']), origin = git(['remote', 'get-url', 'origin']);
|
|
40
42
|
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.');
|
|
@@ -118,7 +120,7 @@ export function createProjectWorker({ control, bridge, record, stateDir = connec
|
|
|
118
120
|
requested_model: requestedModel, model: actualModel, last_turn_usage: lastUsage,
|
|
119
121
|
permission_denials: { count: denialCount, tools: [...denialTools], last_at: lastDenialAt },
|
|
120
122
|
unread_messages: Number.isSafeInteger(news?.pending) ? news.pending : null,
|
|
121
|
-
scheduling, delivery: { pending: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0 },
|
|
123
|
+
scheduling, report_delivery: control.reportDeliveryStatus?.() ?? null, delivery: { pending: journal()?.data?.pending?.length ?? 0, notices: journal()?.data?.notices?.length ?? 0 },
|
|
122
124
|
contact_known: typeof news?.project?.started === 'boolean' && typeof news?.project?.paused === 'boolean',
|
|
123
125
|
work_available: news?.work?.runnable === true && Number.isSafeInteger(news.work.revision)
|
|
124
126
|
&& news.work.revision > (scheduling?.handledRevision ?? -1),
|
|
@@ -172,6 +174,7 @@ export function createProjectWorker({ control, bridge, record, stateDir = connec
|
|
|
172
174
|
failureInfo = null; error = null;
|
|
173
175
|
for (const item of applied) item.state = 'applied';
|
|
174
176
|
failureStage = 'checkout_ownership';
|
|
177
|
+
control.assertReportCapacity?.();
|
|
175
178
|
checkoutClaim.beginTurn(); invokedAt = now(); actualModel = null;
|
|
176
179
|
failureStage = 'native_execution';
|
|
177
180
|
notify();
|
|
@@ -184,8 +187,6 @@ export function createProjectWorker({ control, bridge, record, stateDir = connec
|
|
|
184
187
|
})();
|
|
185
188
|
try {
|
|
186
189
|
const result = await turn;
|
|
187
|
-
failureStage = 'checkout_cleanup';
|
|
188
|
-
checkoutClaim.endTurn();
|
|
189
190
|
let lastTurn;
|
|
190
191
|
if (invokedAt !== undefined) {
|
|
191
192
|
// Sequence belongs to the fenced service run, not this finite worker mission.
|
|
@@ -199,6 +200,9 @@ export function createProjectWorker({ control, bridge, record, stateDir = connec
|
|
|
199
200
|
...lastUsage, ...(lastUsage?.tokens ? { tokens: { input: null, output: null, cacheRead: null, cacheWrite: null, modelCalls: null, ...lastUsage.tokens } } : {}),
|
|
200
201
|
sequence: usageSequence, seconds: Math.max(0, (endedAt - invokedAt) / 1000) } };
|
|
201
202
|
}
|
|
203
|
+
if (lastTurn) { failureStage = 'completion_storage'; lastTurn = control.recordTurn?.(lastTurn) ?? lastTurn; }
|
|
204
|
+
failureStage = 'checkout_cleanup';
|
|
205
|
+
checkoutClaim.endTurn();
|
|
202
206
|
if (result?.interrupted) { scheduler.recordOutcome({ kind: sendDeferred ? 'send_deferred' : 'interrupted', news: control.news, now: now() }); if (!stopRequested && phase !== 'paused') phase = 'waiting'; }
|
|
203
207
|
else if (result?.failed) {
|
|
204
208
|
if (result.failureCode !== 'runtime_contact_failure') {
|
|
@@ -220,7 +224,7 @@ export function createProjectWorker({ control, bridge, record, stateDir = connec
|
|
|
220
224
|
try { await control.setPhase?.('idle', lastTurn); }
|
|
221
225
|
catch (failure) {
|
|
222
226
|
if (failure.fatal) throw failure;
|
|
223
|
-
// Control
|
|
227
|
+
// Control retains the durable queue for its next phase/contact/close request. Retrying
|
|
224
228
|
// telemetry must neither rerun completed model work nor cancel a model turn.
|
|
225
229
|
control.log?.('Worker turn report pending; runtime contact will retry.');
|
|
226
230
|
}
|