@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.
- package/README.md +60 -4
- package/package.json +1 -1
- package/runtime/AGENT_PROMPT_V2.md +115 -0
- package/runtime/claude-settings.json +82 -0
- package/runtime/manifest.json +4 -0
- package/runtime/providers.mjs +284 -0
- package/runtime/work-controller.mjs +55 -0
- package/src/browser-batch.mjs +130 -0
- package/src/browser-pairing.mjs +2 -2
- package/src/cli.mjs +51 -4
- package/src/discovery.mjs +42 -0
- package/src/mcp-configuration.mjs +1 -1
- package/src/owner-client.mjs +102 -0
- package/src/reusable-stdio.mjs +226 -17
- package/src/session-stdio.mjs +12 -6
- package/src/system.mjs +1 -1
- package/src/version.mjs +1 -1
- package/src/worker-runtime.mjs +190 -0
package/src/reusable-stdio.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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())
|
|
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.
|
|
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
|
-
|
|
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.
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
136
|
-
|
|
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
|
}
|
package/src/session-stdio.mjs
CHANGED
|
@@ -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 = () => {
|
|
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(() => {
|
|
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.
|
|
1
|
+
export const VERSION = '0.1.0-beta.7';
|