@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.
@@ -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 human-selected project agent. Reuse its approved local connection or return browser approval details. Returns an explicit connection_ref required by every project tool. No client settings, model or permissions are changed. Use the human-selected project/folder and exact agent IDs/names provided by the human or returned by the owner team tools.', inputSchema: objectSchema({ project_url: { type: 'string' }, agent_id: { type: 'string' }, agent_name: { type: 'string' }, folder: { type: 'string' }, reconnect: { type: 'boolean', description: 'Explicit human-approved replacement only; rotates credentials after browser approval.' } }, ['project_url', 'agent_id', 'agent_name', 'folder']) },
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. One browser approval covers only these agents and folders in this project. Never approve another participant’s agents. 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']) }, reconnect: { type:'boolean', description:'Explicit human-approved replacement only.' } }, ['project_url','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 explicitly 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. 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 this worker, duration, workspace and work scope. Connection approval alone is insufficient.' } }, ['connection_ref', 'client', 'scope', 'folder', 'approved']) },
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, browserFactory = createBrowserOperations, batchBrowserFactory = createBatchBrowserOperations, boundServer = serveSession, workerFactory, discoveryOptions, ownerFactory = createOwnerClient, ownerOptions, approvalWait = ms => new Promise(resolve => setTimeout(resolve, ms)), ...boundOptions }) {
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 browser = browserFactory({ home, io: { say() {} } });
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. Ask your human before starting work.' };
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', 'reconnect'].includes(key)) || !/^[\w-]{1,200}$/.test(args.agent_id ?? '') || !/^[\w-]{1,64}$/.test(args.agent_name ?? '') || (args.reconnect !== undefined && typeof args.reconnect !== 'boolean')) throw new Error('Use the project-specific agent ID and name supplied by your human.');
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
- if (!args.reconnect && !lstatExists(join(dir, 'pending-browser.json')) && lstatExists(join(dir, 'connection.json'))) return bind(load(dir), context);
137
- const response = await browser.connect({ clientName, folder, 'project-url': selected.service, 'agent-id': selected.agentId, reconnect: args.reconnect });
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
- if (response.schema === 'mcp-connection-1') return bind(response, context);
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','reconnect'].includes(key)) || !Array.isArray(args.agents) || args.agents.length < 1 || args.agents.length > 10 || (args.reconnect !== undefined && typeof args.reconnect !== 'boolean')) throw new Error('Use one to ten explicitly selected agents for one project.');
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
- if (contexts.every(context => context?.record)) return { state:'bound', agents:contexts.map(state), next_step:'Verify each agent with get_project using its separate connection_ref. Work requires separate approval.' };
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
- }) }; batches.set(batch.ref,batch); }
175
- return batchResponse(response,batch);
176
- }
177
- function batchResponse(response,batch) {
178
- for (const context of batch.contexts) assertLive(context);
179
- if (response.status === 'saved') {
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
- return recovery ? { state: 'recovery_required', active: false, recovery } : current ?? { state: 'not_started', active: false };
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
- if (typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Use the approved absolute worker checkout folder.');
272
- const folder = canonicalPath(args.folder); assertStateOutsideWorkspace(home, folder);
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
- send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
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
- const account = name === 'finish_owner_account_connection' ? (Object.keys(fields).length ? (() => { throw new Error('Owner finish takes only owner_ref.'); })() : await owner.finish()) : await owner.operation(name, fields);
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('Complete browser approval for this connection_ref before using project tools.');
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 (['connect_project_agent', 'connect_project_agents', 'start_project_worker'].includes(name)) return { lane: 'setup' };
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) {
@@ -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
- export const system = { request, verifyBundle, now: () => new Date().toISOString() };
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
@@ -1 +1,3 @@
1
- export const VERSION = '0.1.0-beta.10';
1
+ export const VERSION = '0.1.0-beta.12';
2
+ // Raised when the bundled guide gains rules a website prompt depends on; prompts name the minimum they need.
3
+ export const GUIDE_REVISION = 5;
@@ -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', 'runtime_update_required', 'runtime_conflict', 'run_active', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
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
- 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.'); }
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 keeps the report for its next phase/contact/close request. Retrying
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
  }