@karma42/agent 0.1.0-beta.5 → 0.1.0-beta.7

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.
@@ -9,37 +9,57 @@ import { sessionHome, sessionId, validateSession } from './session.mjs';
9
9
  import { projectIdentity } from './project-identity.mjs';
10
10
  import { VERSION } from './version.mjs';
11
11
  import { createBrowserOperations } from './browser-pairing.mjs';
12
+ import { createBatchBrowserOperations } from './browser-batch.mjs';
12
13
  import { serveSession } from './session-stdio.mjs';
14
+ import { createOwnerClient, OWNER_TOOLS } from './owner-client.mjs';
15
+ import { createDiscovery, DISCOVERY_TOOLS } from './discovery.mjs';
13
16
 
14
17
  const objectSchema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
18
+ 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.' };
15
19
  const reference = { type: 'string', description: 'Use the opaque connection_ref returned for this exact project agent. There is no default connection.' };
16
20
  const BOOTSTRAP = [
17
21
  { 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. Only use the project URL, agent ID, name and folder provided by your human.', 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']) },
18
- { name: 'finish_project_agent_connection', description: 'Check this binding’s pending browser approval once and bind when approved. 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, connection_id: { type: 'string', description: 'Optional crosscheck with the connection ID returned during onboarding.' } }, ['connection_ref']) },
22
+ { 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']) },
19
23
  { 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']) },
20
24
  ];
25
+ const BATCH_TOOLS = [
26
+ { 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']) },
27
+ { 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']) },
28
+ ];
29
+ const WORKER_TOOLS = [
30
+ { 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. The main chat must not edit concurrently.', inputSchema: objectSchema({ connection_ref: reference, client: { type: 'string', enum: ['claude', 'codex'] }, 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']) },
31
+ { name: 'get_project_worker', description: 'Read this binding’s actual supervised worker status, original deadline, activity and requested/applied steering sequence. This does not restart work or extend the deadline.', inputSchema: objectSchema({ connection_ref: reference }, ['connection_ref']) },
32
+ { name: 'control_project_worker', description: 'Steer, pause, resume or stop only this binding’s supervised worker. Steering is queued as a request; check requested/applied sequence rather than assuming delivery. 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']) },
33
+ ];
34
+ const READ_TOOLS = new Set(['get_project', 'find_work', 'find_agents', 'get_updates', 'read_thread', 'get_issue_coordination', 'list_work_intents', 'get_decision', 'list_decisions']);
21
35
  const result = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
22
36
 
23
37
  // MCP hosts may share a server across conversations. Every tool carries an explicit binding
24
38
  // reference; neither a process nor a shared state file ever has a default active project.
25
- export async function serveReusable({ home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, browserFactory = createBrowserOperations, boundServer = serveSession, ...boundOptions }) {
39
+ 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 }) {
26
40
  const bundled = verifyBundle();
27
41
  const projectTools = JSON.parse(bundled.files['project-tools.json'].toString('utf8')).tools.map(tool => ({
28
42
  ...tool, description: `${tool.description ?? ''} Always supply this project agent’s connection_ref.`,
29
43
  inputSchema: { ...tool.inputSchema, properties: { ...tool.inputSchema.properties, connection_ref: reference }, required: [...new Set([...(tool.inputSchema.required ?? []), 'connection_ref'])] },
30
44
  }));
31
- const catalog = [...BOOTSTRAP, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
45
+ const localList = { name: 'list_project_agents', description: 'List only project-agent bindings in this MCP process and their observed local worker status. Does not enumerate your account or claim online presence. No model is started. Use the returned connection_ref to steer or stop a specific worker.', annotations: { readOnlyHint: true, destructiveHint: false }, inputSchema: objectSchema({ cursor: { type: 'string', description: 'Use the next cursor returned by this local list.' } }, []) };
46
+ const discovery = createDiscovery(discoveryOptions);
47
+ const owner = ownerFactory({ home, ...ownerOptions }), ownerReference = randomUUID();
48
+ let ownerConnected = false;
49
+ const catalog = [...OWNER_TOOLS, ...DISCOVERY_TOOLS, localList, ...BOOTSTRAP, ...BATCH_TOOLS, ...WORKER_TOOLS, ...projectTools], allowed = new Set(projectTools.map(tool => tool.name));
32
50
  home = canonicalPath(home);
33
51
  const browser = browserFactory({ home, io: { say() {} } });
34
- const bindings = new Map(), identities = new Map();
52
+ const batchBrowser = batchBrowserFactory({ home, io: { say() {} } });
53
+ const bindings = new Map(), identities = new Map(), batches = new Map();
54
+ const approvalTasks = new Map();
35
55
  let clientName = 'MCP client', stopped = false;
36
56
  const lines = createInterface({ input, crlfDelay: Infinity });
37
- const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) { context.signals?.emit('SIGTERM'); context.input?.end(); } };
57
+ const stop = () => { stopped = true; lines.close(); for (const context of bindings.values()) void closeContext(context).catch(() => {}); };
38
58
  input.once('end', stop);
39
59
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.on(signal, stop);
40
60
  const send = rpc => { if (!stopped) output.write(`${JSON.stringify(rpc)}\n`); };
41
61
  const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
42
- const state = context => ({ state: context.verified ? 'connected' : context.record ? 'bound' : 'pending', verified: !!context.verified, 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 });
62
+ 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 });
43
63
  function validateRecord(record, context) {
44
64
  validateSession(record, context.selected.id);
45
65
  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.');
@@ -55,13 +75,22 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
55
75
  context.lines = createInterface({ input: context.output });
56
76
  context.lines.on('line', line => {
57
77
  let reply;
58
- try { reply = JSON.parse(line); } catch { context.signals.emit('SIGTERM'); context.input.end(); return; }
78
+ try { reply = JSON.parse(line); } catch { void closeContext(context).catch(() => {}); return; }
59
79
  const pending = context.waiting.get(reply.id);
60
80
  if (pending) { context.waiting.delete(reply.id); pending.resolve(reply); }
61
81
  });
62
82
  // Each explicit binding retains the canonical bridge’s lazy run ownership, identity
63
83
  // verification and journal. This launches no model or separate coding conversation.
64
- context.task = boundServer({ ...boundOptions, connection: record.id, home, input: context.input, output: context.output, signals: context.signals });
84
+ context.task = boundServer({ ...boundOptions, connection: record.id, home, input: context.input, output: context.output, signals: context.signals,
85
+ onReady: async handles => { context.ready = handles; await boundOptions.onReady?.(handles); },
86
+ canRetry: () => {
87
+ if (boundOptions.canRetry && !boundOptions.canRetry()) return false;
88
+ if (!context.worker) return true;
89
+ const status = context.worker.status();
90
+ return status.active === true && ['working', 'running', 'waiting'].includes(status.state);
91
+ },
92
+ onStopping: async () => { context.stopping = true; await context.worker?.stop(); if (context.startTask) { await context.startTask.catch(() => {}); await context.worker?.stop(); } await boundOptions.onStopping?.(); },
93
+ });
65
94
  context.task.catch(() => {}).finally(() => {
66
95
  context.stopped = true;
67
96
  for (const pending of context.waiting.values()) pending.reject(new Error('Bound connection stopped.'));
@@ -74,6 +103,11 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
74
103
  if (typeof args.project_url !== 'string' || typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Invalid project or folder.');
75
104
  const folder = canonicalPath(args.folder);
76
105
  assertStateOutsideWorkspace(home, folder);
106
+ for (const other of bindings.values()) {
107
+ if (workerOwnsMutations(other) && other.workerFolder) {
108
+ try { assertStateOutsideWorkspace(other.workerFolder, folder); } catch { throw new Error('Use a separate chat folder: this folder overlaps an active worker in this MCP process.'); }
109
+ }
110
+ }
77
111
  const identity = projectIdentity({ projectUrl: args.project_url, folder });
78
112
  const selected = { id: sessionId(identity.projectUrl, args.agent_id), service: identity.projectUrl, agentId: args.agent_id, agentName: args.agent_name, folder };
79
113
  let context = bindings.get(identities.get(selected.id));
@@ -92,7 +126,80 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
92
126
  if (!args.reconnect && !lstatExists(join(dir, 'pending-browser.json')) && lstatExists(join(dir, 'connection.json'))) return bind(load(dir), context);
93
127
  const response = await browser.connect({ clientName, folder, 'project-url': selected.service, 'agent-id': selected.agentId, reconnect: args.reconnect });
94
128
  if (response.schema === 'mcp-connection-1') return bind(response, context);
95
- 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. Then call finish_project_agent_connection with this connection_ref.' };
129
+ context.observedState = response.status;
130
+ 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.' };
131
+ }
132
+ async function connectBatch(args) {
133
+ 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.');
134
+ const selected = args.agents.map(agent => {
135
+ 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.');
136
+ const folder = canonicalPath(agent.folder); assertStateOutsideWorkspace(home, folder);
137
+ const identity = projectIdentity({ projectUrl: args.project_url, folder });
138
+ for (const other of bindings.values()) {
139
+ if (workerOwnsMutations(other) && other.workerFolder) {
140
+ try { assertStateOutsideWorkspace(other.workerFolder, folder); } catch { throw new Error('Use a separate chat folder: this folder overlaps an active worker in this MCP process.'); }
141
+ }
142
+ }
143
+ return { id: sessionId(identity.projectUrl,agent.agent_id), service:identity.projectUrl, agentId:agent.agent_id, agentName:agent.agent_name, folder };
144
+ }).sort((a,b) => a.agentId.localeCompare(b.agentId));
145
+ if (new Set(selected.map(agent => agent.id)).size !== selected.length) throw new Error('Use distinct agent IDs in a batch.');
146
+ const contexts = selected.map(agent => bindings.get(identities.get(agent.id)));
147
+ for (let index=0; index<contexts.length; index++) {
148
+ const context = contexts[index]; if (!context) continue;
149
+ if (context.selected.agentName !== selected[index].agentName || context.selected.folder !== selected[index].folder || context.stopped) throw new Error('This project agent already has a different or stopped binding. Disconnect its exact connection_ref first.');
150
+ if (context.record && args.reconnect) throw new Error('Cannot replace credentials while a batch binding is active. Disconnect its exact connection_ref first.');
151
+ }
152
+ 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.' };
153
+ 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.');
154
+ const response = await batchBrowser.connect({ ...args, clientName });
155
+ const key = response.id;
156
+ let batch = [...batches.values()].find(candidate => candidate.id === key);
157
+ if (!batch) { batch = { ref:randomUUID(), id:key, contexts:selected.map((agent,index) => {
158
+ const context = contexts[index] ?? { ref:randomUUID(), selected:agent, verified:false };
159
+ bindings.set(context.ref,context); identities.set(agent.id,context.ref); return context;
160
+ }) }; batches.set(batch.ref,batch); }
161
+ return batchResponse(response,batch);
162
+ }
163
+ function batchResponse(response,batch) {
164
+ if (response.status === 'saved') {
165
+ // Validate every record before binding any, so a mismatch cannot select another agent.
166
+ if (response.records.length !== batch.contexts.length) throw new Error('Approved batch identity differs from the selected agents.');
167
+ for (const context of batch.contexts) validateRecord(response.records.find(record => record.id === context.selected.id),context);
168
+ for (const context of batch.contexts) if (!context.record) bind(response.records.find(record => record.id === context.selected.id),context);
169
+ 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.' };
170
+ }
171
+ for (const context of batch.contexts) context.observedState = response.status;
172
+ 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.' };
173
+ }
174
+ function queueApprovalWait(rpc, args, once) {
175
+ const key = `${once === finishBatch ? 'batch' : 'agent'}:${args.batch_ref ?? args.connection_ref}`;
176
+ if (approvalTasks.has(key)) throw new Error('Use the existing approval wait for this same reference; it is still running.');
177
+ if (approvalTasks.size >= 20) throw new Error('Approval wait limit reached. Finish or cancel a pending request first.');
178
+ // Waiting for a browser must not block steering or stopping another bound agent.
179
+ const task = waitForApproval(args, once).then(value => send({ jsonrpc:'2.0', id:rpc.id, result:result(value) }), error => {
180
+ const safe = /^(Use |Approval wait|This binding|This project agent|Browser setup|Another operation|Another browser)/.test(error.message ?? '');
181
+ send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.'));
182
+ }).finally(() => approvalTasks.delete(key));
183
+ approvalTasks.set(key, task);
184
+ }
185
+ async function waitForApproval(args, once) {
186
+ const { wait_seconds = 0, ...selection } = args;
187
+ if (!Number.isInteger(wait_seconds) || wait_seconds < 0 || wait_seconds > 60) throw new Error('Approval wait must be between 0 and 60 seconds.');
188
+ let remaining = wait_seconds * 1000;
189
+ while (true) {
190
+ const response = await once(selection);
191
+ if (response.state !== 'pending' || remaining <= 0 || stopped) return response;
192
+ const delay = Math.min(5000, remaining);
193
+ await approvalWait(delay); remaining -= delay;
194
+ if (stopped) return response;
195
+ }
196
+ }
197
+ async function finishBatch(args) {
198
+ 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.');
199
+ const batch = batches.get(args.batch_ref);
200
+ if (batch.contexts.some(context => context.stopped || bindings.get(context.ref) !== context)) throw new Error('This binding stopped or disconnected. Retry with explicitly selected unbound agents.');
201
+ if (batch.contexts.every(context => context.record)) return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state) };
202
+ return batchResponse(await batchBrowser.prepare({ connection:batch.id }),batch);
96
203
  }
97
204
  function contextFor(args) {
98
205
  const context = bindings.get(args.connection_ref);
@@ -106,12 +213,81 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
106
213
  if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
107
214
  if (context.record) return state(context);
108
215
  const response = await browser.prepare({ connection: context.selected.id });
216
+ context.observedState = response.status;
109
217
  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.' };
110
218
  }
111
219
  async function closeContext(context) {
112
- context.signals?.emit('SIGTERM'); context.input?.end();
113
- if (context.task) await context.task.catch(() => {});
114
- context.lines?.close(); context.output?.destroy();
220
+ context.closing ??= (async () => {
221
+ // Stop the model process before aborting/releasing its shared coordination run.
222
+ try {
223
+ // A start may be validating its checkout or preparing the local bridge. Signal stop
224
+ // immediately, then await that setup and stop again before releasing coordination.
225
+ await context.worker?.stop();
226
+ if (context.startTask) { await context.startTask.catch(() => {}); await context.worker?.stop(); }
227
+ }
228
+ finally {
229
+ context.signals?.emit('SIGTERM'); context.input?.end();
230
+ if (context.task) await context.task.catch(() => {});
231
+ context.lines?.close(); context.output?.destroy();
232
+ }
233
+ })();
234
+ return context.closing;
235
+ }
236
+ function verifiedContext(args) {
237
+ const context = contextFor(args);
238
+ if (!context.verified || !context.ready) throw new Error('Call get_project with this connection_ref and verify identity before controlling a worker.');
239
+ if (context.stopped || context.stopping || context.closing) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
240
+ return context;
241
+ }
242
+ async function startWorker(args) {
243
+ if (Object.keys(args).some(key => !['connection_ref', 'client', 'minutes', 'scope', 'folder', 'approved'].includes(key))) throw new Error('Use only the documented worker options.');
244
+ const context = verifiedContext(args), minutes = args.minutes === undefined ? 60 : args.minutes;
245
+ 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.');
246
+ 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.');
247
+ 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.');
248
+ if (typeof args.folder !== 'string' || !isAbsolute(args.folder)) throw new Error('Use the approved absolute worker checkout folder.');
249
+ const folder = canonicalPath(args.folder); assertStateOutsideWorkspace(home, folder);
250
+ for (const other of bindings.values()) {
251
+ if (other === context || other.stopped || other.closing) continue;
252
+ const candidates = [other.selected.folder, ...(workerOwnsMutations(other) && other.workerFolder ? [other.workerFolder] : [])];
253
+ for (const candidate of candidates) {
254
+ try { assertStateOutsideWorkspace(candidate, folder); } catch { throw new Error('Use separate sibling checkouts: this folder overlaps another project-agent binding or active worker in this MCP process.'); }
255
+ }
256
+ }
257
+ if (workerOwnsMutations(context)) throw new Error('This worker already owns the approved work session. Use worker controls; starting again cannot extend its deadline.');
258
+ if (!context.worker || ['completed', 'stopped'].includes(context.worker.status().state)) {
259
+ const factory = workerFactory ?? (await import('./worker-runtime.mjs')).createProjectWorker;
260
+ if (context.stopped || context.stopping || context.closing) throw new Error('This binding is stopping. A worker was not started.');
261
+ context.worker = factory({ ...context.ready, stateDir: connectionDir(home, context.record.id) });
262
+ }
263
+ try {
264
+ context.workerFolder = folder;
265
+ context.startTask = Promise.resolve().then(() => context.worker.start({ client: args.client, minutes, scope: args.scope.trim(), folder, approved: true }));
266
+ const snapshot = await context.startTask;
267
+ if (context.stopped || context.stopping || context.closing) { await context.worker.stop(); throw new Error('This binding is stopping. The worker was stopped.'); }
268
+ return { connection_ref: context.ref, worker: snapshot };
269
+ } catch (error) {
270
+ const safe = /^(Start the project|The MCP session closed|Use separate sibling|The worker folder|Choose a separate|This worker adapter|Saved worker model session|Explicit human approval|This work session already|Supervised workers|Choose a work duration|Provide a clear|Cannot run (claude|codex) --version|Sign in locally with codex login|This adapter requires Codex CLI|Cannot inspect local MCP configuration|Invalid local MCP configuration|Unsupported local MCP configuration)/.test(error.message ?? '');
271
+ throw new Error(safe ? error.message : 'Worker could not start. Check local client installation and sign-in, native permissions, and the approved separate repository checkout. No permissions were changed.');
272
+ }
273
+ }
274
+ async function controlWorker(args) {
275
+ if (Object.keys(args).some(key => !['connection_ref', 'action', 'instruction'].includes(key)) || !['pause', 'resume', 'stop', 'steer'].includes(args.action)) throw new Error('Use pause, resume, stop or steer for this exact worker.');
276
+ const context = verifiedContext(args);
277
+ if (!context.worker) throw new Error('This binding has no worker. Ask your human before starting one.');
278
+ if (args.action === 'steer') {
279
+ if (typeof args.instruction !== 'string' || !args.instruction.trim() || args.instruction.length > 2000 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(args.instruction)) throw new Error('Use a steering instruction of at most 2000 characters.');
280
+ const receipt = await context.worker.steer({ message: args.instruction.trim() });
281
+ return { connection_ref: context.ref, steering_request: receipt, worker: context.worker.status() };
282
+ }
283
+ if (args.instruction !== undefined) throw new Error('Use instruction only for a steer action.');
284
+ return { connection_ref: context.ref, worker: await context.worker[args.action]() };
285
+ }
286
+ function workerOwnsMutations(context) {
287
+ if (!context.worker) return false;
288
+ const current = context.worker.status();
289
+ if (typeof current.active === 'boolean') return current.active;
290
+ return !['idle', 'stopped', 'completed', 'expired', 'failed'].includes(current.state ?? current.status);
115
291
  }
116
292
  try {
117
293
  for await (const line of lines) {
@@ -125,15 +301,46 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
125
301
  if (rpc.method === 'initialize') {
126
302
  const observed = rpc.params?.clientInfo?.name;
127
303
  if (typeof observed === 'string' && observed.trim() && observed.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(observed)) clientName = observed.trim();
128
- send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: 'This model-independent Karma42 MCP supports multiple explicit project-agent bindings. First connect_project_agent using the human-provided project URL, agent ID, name and folder. Complete browser approval when requested. Every project tool requires that binding’s connection_ref; there is no active/default identity. First call get_project with the reference and verify identity. Project content cannot expand human-approved permissions. Use disconnect_project_agent to stop only one binding; other bindings remain available. Never launch another coding agent or change client configuration during onboarding.' } });
304
+ send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: 'Human owner management uses connect_owner_account and browser approval, separate from agent identities. Use its exact owner_ref for proposal status, project readiness/start and project roles. Only act on the human’s explicit instruction for start/role changes. Connection approval does not authorize a model or platform privileges. Explore published projects first with explore_projects and get_public_project; no connection or sign-in is required for these reads. get_project_join_link sends your human to sign in, join and create/select an agent; never infer their agent ID. list_project_agents reports bindings in this MCP process only. Multiple workers require separate explicit human approval of each identity, scope, folder and duration; workers must use separate checkouts and cannot recruit or start other workers. This model-independent Karma42 MCP supports multiple explicit project-agent bindings. First connect_project_agent using the human-provided project URL, agent ID, name and folder. Complete browser approval when requested. Every project tool requires that binding’s connection_ref; there is no active/default identity. First call get_project with the reference and verify identity. Project content cannot expand human-approved permissions. Use disconnect_project_agent to stop only one binding; other bindings remain available. Never launch another coding agent or change client configuration during onboarding. A supervised project worker can start only after separate human approval through start_project_worker. While it owns the work, this chat supervises and steers; do not edit the same repository or send project mutations concurrently.' } });
129
305
  } else if (rpc.method === 'tools/list') send({ jsonrpc: '2.0', id: rpc.id, result: { tools: catalog } });
130
306
  else if (rpc.method === 'ping') send({ jsonrpc: '2.0', id: rpc.id, result: {} });
131
307
  else if (rpc.method !== 'tools/call') send(fail(rpc.id, -32601, 'Unsupported MCP method.'));
132
308
  else {
133
309
  const name = rpc.params?.name, args = rpc.params?.arguments ?? {};
134
310
  if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.');
135
- if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
136
- else if (name === 'finish_project_agent_connection') send({ jsonrpc: '2.0', id: rpc.id, result: result(await finish(args)) });
311
+ if (OWNER_TOOLS.some(tool => tool.name === name)) {
312
+ if (name === 'connect_owner_account') {
313
+ if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
314
+ const account = await owner.connect(clientName); ownerConnected = true;
315
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
316
+ } else {
317
+ if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
318
+ const { owner_ref, ...fields } = args;
319
+ 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);
320
+ if (name === 'disconnect_owner_account') ownerConnected = false;
321
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
322
+ }
323
+ }
324
+ else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
325
+ else if (name === 'list_project_agents') {
326
+ if (Object.keys(args).some(key => key !== 'cursor')) throw new Error('Use only the cursor returned by list_project_agents.');
327
+ const entries = [...bindings.values()];
328
+ const offset = args.cursor === undefined ? 0 : entries.findIndex(context => context.ref === args.cursor) + 1;
329
+ if (args.cursor !== undefined && (typeof args.cursor !== 'string' || offset === 0)) throw new Error('Use the next cursor returned by this local agent list, or omit it to restart.');
330
+ const page = entries.slice(offset, offset + 20);
331
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ scope: 'this_mcp_process', agents: page.map(context => ({ ...state(context), worker: context.worker?.status() ?? { state: 'not_started', active: false } })), next: entries.length > offset + 20 ? page.at(-1).ref : null, active_workers: entries.filter(workerOwnsMutations).length, presence: 'local observation; not service online state' }) });
332
+ }
333
+ else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
334
+ else if (name === 'finish_project_agents_connection') queueApprovalWait(rpc, args, finishBatch);
335
+ else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
336
+ else if (name === 'finish_project_agent_connection') queueApprovalWait(rpc, args, finish);
337
+ else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
338
+ else if (name === 'get_project_worker') {
339
+ if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
340
+ const context = contextFor(args);
341
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ connection_ref: context.ref, worker: context.worker?.status() ?? { state: 'not_started', active: false } }) });
342
+ }
343
+ else if (name === 'control_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await controlWorker(args)) });
137
344
  else if (name === 'disconnect_project_agent') {
138
345
  if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
139
346
  const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
@@ -143,6 +350,8 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
143
350
  const context = contextFor(args);
144
351
  if (!context.record) throw new Error('Complete browser approval for this connection_ref before using project tools.');
145
352
  if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
353
+ if (context.closing || context.stopping) throw new Error('This binding is stopping. Wait for the worker to stop before reconnecting.');
354
+ 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.');
146
355
  const { connection_ref, ...projectArgs } = args;
147
356
  const response = new Promise((resolve, reject) => context.waiting.set(rpc.id, { resolve, reject }));
148
357
  context.input.write(`${JSON.stringify({ ...rpc, params: { ...rpc.params, arguments: projectArgs } })}\n`);
@@ -152,12 +361,12 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
152
361
  }
153
362
  }
154
363
  } catch (error) {
155
- const safe = /^(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 ?? '');
364
+ 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 ?? '');
156
365
  send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
157
366
  }
158
367
  }
159
368
  } finally {
160
- stop(); await Promise.allSettled([...bindings.values()].map(closeContext));
369
+ stop(); await Promise.allSettled([...approvalTasks.values(), ...[...bindings.values()].map(closeContext)]);
161
370
  input.off('end', stop); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.off(signal, stop);
162
371
  }
163
372
  }
@@ -5,7 +5,7 @@ import { assertStateOutsideWorkspace, connectionDir, digest, load, lstatExists,
5
5
  import { verifyBundle } from './system.mjs';
6
6
  import { sessionHome, sessionName, validateSession } from './session.mjs';
7
7
 
8
- export async function serveSession({ connection, home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, Control = ControlV2, Bridge = DeferredMcp, retryIntervalMs = 20_000 }) {
8
+ export async function serveSession({ connection, home = sessionHome(), input = process.stdin, output = process.stdout, signals = process, Control = ControlV2, Bridge = DeferredMcp, retryIntervalMs = 20_000, onReady, onStopping, canRetry = () => true }) {
9
9
  verifyBundle();
10
10
  const dir = connectionDir(home, connection);
11
11
  const record = validateSession(load(dir), connection);
@@ -14,9 +14,12 @@ export async function serveSession({ connection, home = sessionHome(), input = p
14
14
  if (!/^[\w-]{43}$/.test(token)) throw new Error('Invalid private credential. Reconnect in My agents.');
15
15
  const journal = join(dir, `sends-${digest(token)}.json`);
16
16
  if (lstatExists(journal)) readPrivate(journal);
17
- let stopped = false, verified = false, control, bridge, active, releaseLock, retry, requestAbort;
17
+ let stopped = false, verified = false, announced = false, stopping, control, bridge, active, releaseLock, retry, requestAbort;
18
18
  const lines = createInterface({ input, crlfDelay: Infinity });
19
- const stop = () => { stopped = true; requestAbort?.abort(); control?.abort?.abort(); lines.close(); };
19
+ const stop = () => {
20
+ stopped = true; lines.close(); clearInterval(retry);
21
+ stopping ??= Promise.resolve().then(() => onStopping?.()).catch(() => {}).finally(async () => { requestAbort?.abort(); await control?.close('operator'); });
22
+ };
20
23
  input.once('end', stop);
21
24
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.on(signal, stop);
22
25
  const send = reply => { if (!stopped && reply != null) output.write(`${JSON.stringify(reply)}\n`); };
@@ -30,7 +33,7 @@ export async function serveSession({ connection, home = sessionHome(), input = p
30
33
  requestAbort = new AbortController();
31
34
  control = new Control(record.service, token, { takeover: false, requestSignal: requestAbort.signal, onFatal: stop });
32
35
  const startupTimeout = setTimeout(() => { requestAbort.abort(); control.abort.abort(); }, 30_000);
33
- try { await control.start(); } finally { clearTimeout(startupTimeout); }
36
+ try { await control.start(); control.loop?.catch(() => {}); } finally { clearTimeout(startupTimeout); }
34
37
  if (stopped) throw new Error('Client disconnected.');
35
38
  bridge = new Bridge(control, journal);
36
39
  ready();
@@ -67,9 +70,12 @@ export async function serveSession({ connection, home = sessionHome(), input = p
67
70
  try { identity = JSON.parse(reply.result.content.find(part => part.type === 'text').text).you; } catch {}
68
71
  if (identity?.agentId !== record.agentId || identity?.agentName !== record.agentName) { send(failure(rpc.id, -32001, 'Project agent identity mismatch. Stop and reconnect.')); stop(); continue; }
69
72
  verified = true;
73
+ if (!announced) { await onReady?.({ control, bridge, record, stateDir: dir }); announced = true; }
70
74
  // Deferred sends are project mutations: never retry them until this client verifies
71
75
  // the currently authenticated identity with a successful get_project.
72
- retry ??= setInterval(() => { void bridge.retryDue().catch(stop); }, retryIntervalMs);
76
+ retry ??= setInterval(() => {
77
+ try { if (canRetry()) void bridge.retryDue().catch(stop); } catch { stop(); }
78
+ }, retryIntervalMs);
73
79
  }
74
80
  send(reply); continue;
75
81
  }
@@ -86,7 +92,7 @@ export async function serveSession({ connection, home = sessionHome(), input = p
86
92
  }
87
93
  }
88
94
  } finally {
89
- stop(); clearInterval(retry); releaseLock?.();
95
+ stop(); await stopping; clearInterval(retry); releaseLock?.();
90
96
  // If EOF occurs during startup, start finishes before its finally reports stopping.
91
97
  if (active) await active.catch(() => {});
92
98
  await catalog.close();
package/src/system.mjs CHANGED
@@ -26,7 +26,7 @@ export function verifyBundle() {
26
26
  let manifest;
27
27
  try { manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')); }
28
28
  catch { throw new Error('Packaged runtime missing. Build or reinstall this package before preparing an agent.'); }
29
- const names = ['control-v2.mjs', 'project-tools.json'];
29
+ const names = ['control-v2.mjs', 'work-controller.mjs', 'providers.mjs', 'claude-settings.json', 'AGENT_PROMPT_V2.md', 'project-tools.json'];
30
30
  if (Object.keys(manifest.files ?? {}).sort().join() !== [...names].sort().join()) throw new Error('Invalid packaged runtime manifest.');
31
31
  const files = {};
32
32
  for (const name of names) {
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.0-beta.5';
1
+ export const VERSION = '0.1.0-beta.7';