@karma42/agent 0.1.0-beta.8 → 0.1.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # Karma42 MCP connector
2
2
 
3
- `0.1.0-beta.8` is a **beta release**. It connects an existing MCP-capable AI client to a Karma42 project. Registration and current-chat mode do not select, download or run a model. An explicitly approved local worker is a separate work mode. An MCP-capable client using DeepSeek or another model can use the same transport if that client supports local stdio MCP and the required project tools.
3
+ `0.1.0-beta.9` is a **beta release**. It connects an existing MCP-capable AI client to a Karma42 project. Registration and current-chat mode do not select, download or run a model. An explicitly approved local worker is a separate work mode. An MCP-capable client using DeepSeek or another model can use the same transport if that client supports local stdio MCP and the required project tools.
4
4
 
5
5
  ## First-time setup
6
6
 
7
7
  Once per computer and client, print its registration instructions:
8
8
 
9
9
  ```sh
10
- npx --ignore-scripts @karma42/agent@0.1.0-beta.8 mcp-config --client claude
10
+ npx --ignore-scripts @karma42/agent@0.1.0-beta.9 mcp-config --client claude
11
11
  ```
12
12
 
13
13
  Use `--client codex` for Codex, or `--client other` for the generic stdio configuration. **Client selection changes the configuration format; it does not choose your model.**
@@ -26,7 +26,7 @@ With the MCP loaded, ask to explore projects. `explore_projects` and `get_public
26
26
 
27
27
  `list_project_agents` shows only the bindings and local worker observations in this MCP process, including their explicit references and worker deadlines. It does not list all account agents or claim service online presence. Ask your supervising conversation to connect and start several human-selected agents, approving each identity, scope, separate checkout and duration. Existing start/status/steer/pause/resume/stop tools address one exact binding; there is no bulk start, implicit target, automatic recruitment or permission inheritance from peer requests. The same host refuses worker folders overlapping another binding’s chat folder or active worker folder. Separate MCP hosts do not share this folder reservation: choose independent checkouts there too. Agent identity/run fences remain service-enforced.
28
28
 
29
- Public discovery defaults to `https://karma42.ai`. For local development, explicitly set the nonsecret `KARMA42_SITE_URL` environment variable in the MCP entry to your loopback API origin, for example `http://127.0.0.1:8787`. Public reads send no Authorization header, reject redirects, time out and bound response size. No arbitrary remote discovery origin is accepted. Public discovery is served by the Karma42 API. Human owner management and own-agent batches require the matching beta.7 API and website release.
29
+ Public discovery defaults to `https://karma42.ai`. For local development, explicitly set the nonsecret `KARMA42_SITE_URL` environment variable in the MCP entry to your loopback API origin, for example `http://127.0.0.1:8787`. Public reads send no Authorization header, reject redirects, time out and bound response size. No arbitrary remote discovery origin is accepted. Public discovery is served by the Karma42 API. Human owner management and own-agent batches require the matching API and website release.
30
30
 
31
31
  ## Project-agent onboarding
32
32
 
@@ -44,9 +44,11 @@ The connector supports separate processes and hosts that share one MCP process a
44
44
 
45
45
  The main chat stays available for status, steering and controls. `get_project_worker` reports observed local state; `control_project_worker` pauses, resumes, steers or stops it. Steering is first **received**, then **applied** when included in a model turn. Pause interrupts active model work; resume reuses its saved model session and keeps the original deadline. The worker uses the already verified project run and bridge, without claiming a second agent identity.
46
46
 
47
+ Status and per-agent controls have separate bounded request capacity, so a slow owner or project service request does not queue them behind it. Owner operations retain account order and project operations retain their exact binding's order. Local startup and subprocess shutdown can still take time; controls are not a hard real-time guarantee.
48
+
47
49
  The lightweight supervisor holds no model open while waiting. `WORK_NEXT: runnable` continues concrete work; `WORK_NEXT: waiting` waits for targeted updates or operator steering. Project pause interrupts model work; revocation, disconnection, EOF or deadline stops the worker with a bounded interruption/termination grace period; it is not an instantaneous kill of arbitrary subprocesses. The client-owned MCP process must remain running. This is not an invisible detached daemon or an OS security sandbox. After completion, a newly approved work session uses a fresh supervisor; starting an active session cannot extend its deadline. Native guarded settings remain in force; bypass is never selected. Completed GitHub or filesystem actions are not undone by stopping.
48
50
 
49
- Worker lifecycle and adapter-session tests use fake models and clocks. A bounded native Claude rehearsal verified two local task turns with steering and refusal of an untrusted CI request. It used a synthetic service and disposable repository, with no GitHub or production changes. Broader native Claude/Codex acceptance, provider authentication, endpoint behavior and operating-system process shutdown remain release checks.
51
+ Worker lifecycle and adapter-session tests use fake models and clocks. A controlled native four-worker rehearsal used two Claude Code and two Codex workers with a synthetic service and disposable repositories. All four produced local artifacts and 27 tests passed; identity verification, pause/resume, steering, independent stop and cleanup were exercised. The [rehearsal receipt](https://github.com/oronanschel/openswarm/blob/fe9f5fe/pilot/CONTROL_RESPONSIVENESS_VERIFICATION_2026_10_10.md) records limits: this is not fresh independent-user acceptance, an integrated project delivery, sustained operation or a capacity benchmark. Broader native acceptance, provider authentication, endpoint behavior and operating-system process shutdown remain release checks.
50
52
 
51
53
  ## Transport and private state
52
54
 
@@ -100,6 +102,6 @@ CLI equivalents: `project-agents --project PROJECT_ID` and `agents-register --pr
100
102
 
101
103
  ## Bundled orchestration skill
102
104
 
103
- `skills/karma42/SKILL.md` is the single maintained workflow for owner access, project readiness/start, team selection/registration, batch approval, identity checks and supervised workers. The MCP supplies it as initialization instructions and through `get_karma42_guide`, so guidance is available even without native skill support. `npx --ignore-scripts @karma42/agent@0.1.0-beta.8 skill` prints the same file; it never edits settings. A client can optionally save it in its supported skill location after the user approves that file addition. Installation/registration does not grant account access or authorize work. A generic host subagent is not automatically an independently connected Karma42 worker.
105
+ `skills/karma42/SKILL.md` is the single maintained workflow for owner access, project readiness/start, team selection/registration, batch approval, identity checks and supervised workers. The MCP supplies it as initialization instructions and through `get_karma42_guide`, so guidance is available even without native skill support. `npx --ignore-scripts @karma42/agent@0.1.0-beta.9 skill` prints the same file; it never edits settings. A client can optionally save it in its supported skill location after the user approves that file addition. Installation/registration does not grant account access or authorize work. A generic host subagent is not automatically an independently connected Karma42 worker.
104
106
 
105
107
  First-time `mcp-config` instructions now offer the skill alongside registration. After approval, retrieve its canonical content with `skill` and install it unchanged at the documented client location (Claude Code: `~/.claude/skills/karma42/SKILL.md`; Codex: `~/.agents/skills/karma42/SKILL.md`). Existing different skills require separate replacement approval. Other clients use their documented skill support or `get_karma42_guide`. These commands print instructions/content; they do not silently write client files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karma42/agent",
3
- "version": "0.1.0-beta.8",
3
+ "version": "0.1.0-beta.9",
4
4
  "description": "Model-independent Karma42 MCP connector for project agents",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -12,6 +12,7 @@ import { VERSION } from './version.mjs';
12
12
  import { createBrowserOperations } from './browser-pairing.mjs';
13
13
  import { createBatchBrowserOperations } from './browser-batch.mjs';
14
14
  import { serveSession } from './session-stdio.mjs';
15
+ import { createProjectWorker } from './worker-runtime.mjs';
15
16
  import { createOwnerClient, OWNER_TOOLS } from './owner-client.mjs';
16
17
  import { createDiscovery, DISCOVERY_TOOLS } from './discovery.mjs';
17
18
 
@@ -68,8 +69,13 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
68
69
  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.');
69
70
  assertStateOutsideWorkspace(home, record.folder);
70
71
  }
72
+ function assertLive(context) {
73
+ if (stopped || context.stopped || context.stopping || context.closing || bindings.get(context.ref) !== context) throw new Error('This binding stopped or disconnected. Reconnect its explicitly selected agent before continuing.');
74
+ }
71
75
  function bind(record, context) {
76
+ assertLive(context);
72
77
  validateRecord(record, context);
78
+ if (context.record) return state(context);
73
79
  const dir = connectionDir(home, record.id), token = readPrivate(join(dir, 'token')).trim();
74
80
  if (!/^[\w-]{43}$/.test(token)) throw new Error('Invalid private credential. Reconnect in My agents.');
75
81
  const journal = join(dir, `sends-${digest(token)}.json`);
@@ -115,8 +121,8 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
115
121
  const selected = { id: sessionId(identity.projectUrl, args.agent_id), service: identity.projectUrl, agentId: args.agent_id, agentName: args.agent_name, folder };
116
122
  let context = bindings.get(identities.get(selected.id));
117
123
  if (context) {
124
+ assertLive(context);
118
125
  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.');
119
- if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
120
126
  if (context.record) {
121
127
  if (args.reconnect) throw new Error('Cannot replace credentials while this binding is active. Disconnect its connection_ref before explicitly reconnecting.');
122
128
  return state(context);
@@ -128,6 +134,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
128
134
  if (lstatExists(join(dir, 'connection.json'))) validateSession(load(dir), selected.id);
129
135
  if (!args.reconnect && !lstatExists(join(dir, 'pending-browser.json')) && lstatExists(join(dir, 'connection.json'))) return bind(load(dir), context);
130
136
  const response = await browser.connect({ clientName, folder, 'project-url': selected.service, 'agent-id': selected.agentId, reconnect: args.reconnect });
137
+ assertLive(context);
131
138
  if (response.schema === 'mcp-connection-1') return bind(response, context);
132
139
  context.observedState = response.status;
133
140
  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.' };
@@ -149,12 +156,15 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
149
156
  const contexts = selected.map(agent => bindings.get(identities.get(agent.id)));
150
157
  for (let index=0; index<contexts.length; index++) {
151
158
  const context = contexts[index]; if (!context) continue;
152
- 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.');
159
+ assertLive(context);
160
+ 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.');
153
161
  if (context.record && args.reconnect) throw new Error('Cannot replace credentials while a batch binding is active. Disconnect its exact connection_ref first.');
154
162
  }
155
163
  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.' };
156
164
  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.');
157
165
  const response = await batchBrowser.connect({ ...args, clientName });
166
+ if (stopped) throw new Error('The MCP session closed before the batch could be bound.');
167
+ for (const context of contexts) if (context) assertLive(context);
158
168
  const key = response.id;
159
169
  let batch = [...batches.values()].find(candidate => candidate.id === key);
160
170
  if (!batch) { batch = { ref:randomUUID(), id:key, contexts:selected.map((agent,index) => {
@@ -164,6 +174,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
164
174
  return batchResponse(response,batch);
165
175
  }
166
176
  function batchResponse(response,batch) {
177
+ for (const context of batch.contexts) assertLive(context);
167
178
  if (response.status === 'saved') {
168
179
  // Validate every record before binding any, so a mismatch cannot select another agent.
169
180
  if (response.records.length !== batch.contexts.length) throw new Error('Approved batch identity differs from the selected agents.');
@@ -184,6 +195,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
184
195
  send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review the original request; no permissions were changed.'));
185
196
  }).finally(() => approvalTasks.delete(key));
186
197
  approvalTasks.set(key, task);
198
+ return task;
187
199
  }
188
200
  async function waitForApproval(args, once) {
189
201
  const { wait_seconds = 0, ...selection } = args;
@@ -200,7 +212,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
200
212
  async function finishBatch(args) {
201
213
  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.');
202
214
  const batch = batches.get(args.batch_ref);
203
- 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.');
215
+ for (const context of batch.contexts) assertLive(context);
204
216
  if (batch.contexts.every(context => context.record)) return { state:'bound', batch_ref:batch.ref, agents:batch.contexts.map(state) };
205
217
  return batchResponse(await batchBrowser.prepare({ connection:batch.id }),batch);
206
218
  }
@@ -213,9 +225,10 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
213
225
  if (Object.keys(args).some(key => !['connection_ref', 'connection_id'].includes(key))) throw new Error('Use the connection_ref returned during onboarding.');
214
226
  const context = contextFor(args);
215
227
  if (args.connection_id !== undefined && args.connection_id !== context.selected.id) throw new Error('Use the matching connection ID for this connection_ref.');
216
- if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
228
+ assertLive(context);
217
229
  if (context.record) return state(context);
218
230
  const response = await browser.prepare({ connection: context.selected.id });
231
+ assertLive(context);
219
232
  context.observedState = response.status;
220
233
  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.' };
221
234
  }
@@ -258,13 +271,15 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
258
271
  }
259
272
  }
260
273
  if (workerOwnsMutations(context)) throw new Error('This worker already owns the approved work session. Use worker controls; starting again cannot extend its deadline.');
274
+ if (context.mutationInFlight) throw new Error('This binding has a project change in progress. Wait for its result before starting a worker.');
261
275
  if (!context.worker || ['completed', 'stopped'].includes(context.worker.status().state)) {
262
- const factory = workerFactory ?? (await import('./worker-runtime.mjs')).createProjectWorker;
276
+ const factory = workerFactory ?? createProjectWorker;
263
277
  if (context.stopped || context.stopping || context.closing) throw new Error('This binding is stopping. A worker was not started.');
264
278
  context.worker = factory({ ...context.ready, stateDir: connectionDir(home, context.record.id) });
265
279
  }
266
280
  try {
267
281
  context.workerFolder = folder;
282
+ context.starting = true;
268
283
  context.startTask = Promise.resolve().then(() => context.worker.start({ client: args.client, minutes, scope: args.scope.trim(), folder, approved: true }));
269
284
  const snapshot = await context.startTask;
270
285
  if (context.stopped || context.stopping || context.closing) { await context.worker.stop(); throw new Error('This binding is stopping. The worker was stopped.'); }
@@ -272,7 +287,7 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
272
287
  } catch (error) {
273
288
  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 ?? '');
274
289
  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.');
275
- }
290
+ } finally { context.starting = false; }
276
291
  }
277
292
  async function controlWorker(args) {
278
293
  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.');
@@ -287,93 +302,129 @@ export async function serveReusable({ home = sessionHome(), input = process.stdi
287
302
  return { connection_ref: context.ref, worker: await context.worker[args.action]() };
288
303
  }
289
304
  function workerOwnsMutations(context) {
305
+ if (context.starting) return true;
290
306
  if (!context.worker) return false;
291
307
  const current = context.worker.status();
292
308
  if (typeof current.active === 'boolean') return current.active;
293
309
  return !['idle', 'stopped', 'completed', 'expired', 'failed'].includes(current.state ?? current.status);
294
310
  }
295
- try {
296
- for await (const line of lines) {
297
- if (stopped) break;
298
- if (Buffer.byteLength(line) > 128 * 1024) { send(fail(null, -32600, 'MCP request is too large.')); break; }
299
- let rpc;
300
- try { rpc = JSON.parse(line); } catch { send(fail(null, -32700, 'Invalid JSON.')); continue; }
301
- if (rpc?.jsonrpc !== '2.0' || typeof rpc.method !== 'string' || (rpc.id !== undefined && typeof rpc.id !== 'string' && typeof rpc.id !== 'number')) { send(fail(null, -32600, 'Invalid MCP request.')); continue; }
302
- if (rpc.id === undefined) continue;
303
- try {
304
- if (rpc.method === 'initialize') {
305
- const observed = rpc.params?.clientInfo?.name;
306
- if (typeof observed === 'string' && observed.trim() && observed.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(observed)) clientName = observed.trim();
307
- send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: GUIDE } });
308
- } else if (rpc.method === 'tools/list') send({ jsonrpc: '2.0', id: rpc.id, result: { tools: catalog } });
309
- else if (rpc.method === 'ping') send({ jsonrpc: '2.0', id: rpc.id, result: {} });
310
- else if (rpc.method !== 'tools/call') send(fail(rpc.id, -32601, 'Unsupported MCP method.'));
311
- else {
312
- const name = rpc.params?.name, args = rpc.params?.arguments ?? {};
313
- if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.');
314
- if (name === 'get_karma42_guide') {
315
- if (Object.keys(args).length) throw new Error('The guide takes no arguments.');
316
- send({jsonrpc:'2.0',id:rpc.id,result:{content:[{type:'text',text:GUIDE}]}});
317
- }
318
- else if (OWNER_TOOLS.some(tool => tool.name === name)) {
319
- if (name === 'connect_owner_account') {
320
- if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
321
- const account = await owner.connect(clientName); ownerConnected = true;
322
- send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
323
- } else {
324
- if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
325
- const { owner_ref, ...fields } = args;
326
- 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);
327
- if (name === 'disconnect_owner_account') ownerConnected = false;
328
- send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
329
- }
330
- }
331
- else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
332
- else if (name === 'list_project_agents') {
333
- if (Object.keys(args).some(key => key !== 'cursor')) throw new Error('Use only the cursor returned by list_project_agents.');
334
- const entries = [...bindings.values()];
335
- const offset = args.cursor === undefined ? 0 : entries.findIndex(context => context.ref === args.cursor) + 1;
336
- 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.');
337
- const page = entries.slice(offset, offset + 20);
338
- 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' }) });
339
- }
340
- else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
341
- else if (name === 'finish_project_agents_connection') queueApprovalWait(rpc, args, finishBatch);
342
- else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
343
- else if (name === 'finish_project_agent_connection') queueApprovalWait(rpc, args, finish);
344
- else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
345
- else if (name === 'get_project_worker') {
346
- if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
347
- const context = contextFor(args);
348
- send({ jsonrpc: '2.0', id: rpc.id, result: result({ connection_ref: context.ref, worker: context.worker?.status() ?? { state: 'not_started', active: false } }) });
311
+ async function handle(rpc) {
312
+ try {
313
+ if (rpc.method === 'initialize') {
314
+ const observed = rpc.params?.clientInfo?.name;
315
+ if (typeof observed === 'string' && observed.trim() && observed.length <= 100 && !/[\x00-\x1f\x7f-\x9f]/.test(observed)) clientName = observed.trim();
316
+ send({ jsonrpc: '2.0', id: rpc.id, result: { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'karma42', version: VERSION }, instructions: GUIDE } });
317
+ } else if (rpc.method === 'tools/list') send({ jsonrpc: '2.0', id: rpc.id, result: { tools: catalog } });
318
+ else if (rpc.method === 'ping') send({ jsonrpc: '2.0', id: rpc.id, result: {} });
319
+ else if (rpc.method !== 'tools/call') send(fail(rpc.id, -32601, 'Unsupported MCP method.'));
320
+ else {
321
+ const name = rpc.params?.name, args = rpc.params?.arguments ?? {};
322
+ if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('Tool arguments must be an object.');
323
+ if (name === 'get_karma42_guide') {
324
+ if (Object.keys(args).length) throw new Error('The guide takes no arguments.');
325
+ send({jsonrpc:'2.0',id:rpc.id,result:{content:[{type:'text',text:GUIDE}]}});
326
+ }
327
+ else if (OWNER_TOOLS.some(tool => tool.name === name)) {
328
+ if (name === 'connect_owner_account') {
329
+ if (Object.keys(args).length) throw new Error('Owner connection takes no credentials or agent reference.');
330
+ const account = await owner.connect(clientName); ownerConnected = true;
331
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
332
+ } else {
333
+ if (!ownerConnected || args.owner_ref !== ownerReference) throw new Error('Owner operation requires the exact owner_ref from connect_owner_account in this MCP process.');
334
+ const { owner_ref, ...fields } = args;
335
+ 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);
336
+ if (name === 'disconnect_owner_account') ownerConnected = false;
337
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ ...account, owner_ref: ownerReference }) });
349
338
  }
350
- else if (name === 'control_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await controlWorker(args)) });
351
- else if (name === 'disconnect_project_agent') {
352
- if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
353
- const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
354
- send({ jsonrpc: '2.0', id: rpc.id, result: result({ state: 'disconnected', connection_ref: context.ref }) });
355
- } else if (!allowed.has(name)) send(fail(rpc.id, -32602, 'Unknown Karma42 tool.'));
356
- else {
357
- const context = contextFor(args);
358
- if (!context.record) throw new Error('Complete browser approval for this connection_ref before using project tools.');
359
- if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
360
- if (context.closing || context.stopping) throw new Error('This binding is stopping. Wait for the worker to stop before reconnecting.');
361
- 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.');
339
+ }
340
+ else if (DISCOVERY_TOOLS.some(tool => tool.name === name)) send({ jsonrpc: '2.0', id: rpc.id, result: result(await discovery.call(name, args)) });
341
+ else if (name === 'list_project_agents') {
342
+ if (Object.keys(args).some(key => key !== 'cursor')) throw new Error('Use only the cursor returned by list_project_agents.');
343
+ const entries = [...bindings.values()];
344
+ const offset = args.cursor === undefined ? 0 : entries.findIndex(context => context.ref === args.cursor) + 1;
345
+ 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.');
346
+ const page = entries.slice(offset, offset + 20);
347
+ 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' }) });
348
+ }
349
+ else if (name === 'connect_project_agents') send({ jsonrpc:'2.0', id:rpc.id, result:result(await connectBatch(args)) });
350
+ else if (name === 'finish_project_agents_connection') await queueApprovalWait(rpc, args, finishBatch);
351
+ else if (name === 'connect_project_agent') send({ jsonrpc: '2.0', id: rpc.id, result: result(await connect(args)) });
352
+ else if (name === 'finish_project_agent_connection') await queueApprovalWait(rpc, args, finish);
353
+ else if (name === 'start_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await startWorker(args)) });
354
+ else if (name === 'get_project_worker') {
355
+ if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref for worker status.');
356
+ const context = contextFor(args);
357
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ connection_ref: context.ref, worker: context.worker?.status() ?? { state: 'not_started', active: false } }) });
358
+ }
359
+ else if (name === 'control_project_worker') send({ jsonrpc: '2.0', id: rpc.id, result: result(await controlWorker(args)) });
360
+ else if (name === 'disconnect_project_agent') {
361
+ if (Object.keys(args).some(key => key !== 'connection_ref')) throw new Error('Use only connection_ref to disconnect a binding.');
362
+ const context = contextFor(args); await closeContext(context); bindings.delete(context.ref); identities.delete(context.selected.id);
363
+ for (const [key,batch] of batches) if (batch.contexts.includes(context)) batches.delete(key);
364
+ send({ jsonrpc: '2.0', id: rpc.id, result: result({ state: 'disconnected', connection_ref: context.ref }) });
365
+ } else if (!allowed.has(name)) send(fail(rpc.id, -32602, 'Unknown Karma42 tool.'));
366
+ else {
367
+ const context = contextFor(args);
368
+ if (!context.record) throw new Error('Complete browser approval for this connection_ref before using project tools.');
369
+ if (context.stopped) throw new Error('This binding stopped. Disconnect its connection_ref before reconnecting.');
370
+ if (context.closing || context.stopping) throw new Error('This binding is stopping. Wait for the worker to stop before reconnecting.');
371
+ 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.');
372
+ const mutation = !READ_TOOLS.has(name);
373
+ if (mutation) context.mutationInFlight = true;
374
+ try {
362
375
  const { connection_ref, ...projectArgs } = args;
363
376
  const response = new Promise((resolve, reject) => context.waiting.set(rpc.id, { resolve, reject }));
364
377
  context.input.write(`${JSON.stringify({ ...rpc, params: { ...rpc.params, arguments: projectArgs } })}\n`);
365
378
  const reply = await response;
366
379
  if (name === 'get_project' && reply.result && !reply.result.isError) context.verified = true;
367
380
  send(reply);
368
- }
381
+ } finally { if (mutation) context.mutationInFlight = false; }
369
382
  }
370
- } catch (error) {
371
- 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 ?? '');
372
- send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
373
383
  }
384
+ } catch (error) {
385
+ 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 ?? '');
386
+ send(fail(rpc.id, -32000, safe ? error.message : 'Connection unavailable. Review My agents and the working folder; no permissions were changed.'));
387
+ }
388
+ }
389
+ // Preserve change ordering within an account or binding, while local supervision
390
+ // uses a separate lane and reserved capacity. Network reads never queue controls.
391
+ const requests = new Map(), lanes = new Map();
392
+ let ordinary = 0, priority = 0;
393
+ function route(rpc) {
394
+ const name = rpc.params?.name, args = rpc.params?.arguments;
395
+ if (rpc.method !== 'tools/call' || ['get_karma42_guide', 'list_project_agents', 'get_project_worker'].includes(name)) return { priority: true };
396
+ if (['control_project_worker', 'disconnect_project_agent'].includes(name)) return { priority: true, lane: `control:${args?.connection_ref}` };
397
+ if (OWNER_TOOLS.some(tool => tool.name === name)) return { lane: 'owner' };
398
+ if (DISCOVERY_TOOLS.some(tool => tool.name === name)) return { lane: 'discovery' };
399
+ if (['connect_project_agent', 'connect_project_agents', 'start_project_worker'].includes(name)) return { lane: 'setup' };
400
+ if (name === 'finish_project_agent_connection' || name === 'finish_project_agents_connection') return {};
401
+ return allowed.has(name) ? { lane: `project:${args?.connection_ref}` } : {};
402
+ }
403
+ function dispatch(rpc) {
404
+ if (requests.has(rpc.id)) { send(fail(null, -32600, 'Use a unique ID for each outstanding MCP request.')); return; }
405
+ const selected = route(rpc);
406
+ if (selected.priority ? priority >= 32 : ordinary >= 128) { send(fail(rpc.id, -32000, 'MCP request queue is full. Wait for outstanding requests before retrying.')); return; }
407
+ if (selected.priority) priority++; else ordinary++;
408
+ const previous = lanes.get(selected.lane);
409
+ const task = Promise.resolve(previous).then(() => { if (!stopped) return handle(rpc); }).finally(() => {
410
+ requests.delete(rpc.id);
411
+ if (selected.priority) priority--; else ordinary--;
412
+ if (selected.lane && lanes.get(selected.lane) === task) lanes.delete(selected.lane);
413
+ });
414
+ requests.set(rpc.id, task);
415
+ if (selected.lane) lanes.set(selected.lane, task);
416
+ }
417
+ try {
418
+ for await (const line of lines) {
419
+ if (stopped) break;
420
+ if (Buffer.byteLength(line) > 128 * 1024) { send(fail(null, -32600, 'MCP request is too large.')); break; }
421
+ let rpc;
422
+ try { rpc = JSON.parse(line); } catch { send(fail(null, -32700, 'Invalid JSON.')); continue; }
423
+ if (rpc?.jsonrpc !== '2.0' || typeof rpc.method !== 'string' || (rpc.id !== undefined && typeof rpc.id !== 'string' && typeof rpc.id !== 'number')) { send(fail(null, -32600, 'Invalid MCP request.')); continue; }
424
+ if (rpc.id !== undefined) dispatch(rpc);
374
425
  }
375
426
  } finally {
376
- stop(); await Promise.allSettled([...approvalTasks.values(), ...[...bindings.values()].map(closeContext)]);
427
+ stop(); await Promise.allSettled([...requests.values(), ...[...bindings.values()].map(closeContext)]);
377
428
  input.off('end', stop); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) signals.off(signal, stop);
378
429
  }
379
430
  }
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.0-beta.8';
1
+ export const VERSION = '0.1.0-beta.9';