@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 +7 -5
- package/package.json +1 -1
- package/src/reusable-stdio.mjs +128 -77
- package/src/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Karma42 MCP connector
|
|
2
2
|
|
|
3
|
-
`0.1.0-beta.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
package/src/reusable-stdio.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (rpc
|
|
302
|
-
if (rpc.
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
else {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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([...
|
|
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.
|
|
1
|
+
export const VERSION = '0.1.0-beta.9';
|