autodev-cli 1.4.86 → 1.4.94
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/media/profile/00-identity.md +35 -0
- package/media/profile/01-learning.md +23 -0
- package/media/profile/02-memory-mcp.md +49 -0
- package/media/profile/03-living-docs.md +18 -0
- package/media/profile/04-skill-files.md +9 -0
- package/media/profile/05-skill-creation.md +18 -0
- package/media/profile/06-core-rules.md +58 -0
- package/media/profile/07-core-loop.md +31 -0
- package/media/profile/08-thinking.md +30 -0
- package/media/profile/09-parallel-panel.md +29 -0
- package/media/profile/10-codebase-verification.md +19 -0
- package/media/profile/11-git-debug-security.md +16 -0
- package/media/profile/12-todo-format.md +20 -0
- package/media/profile/13-workflow-principles.md +17 -0
- package/media/profile/14-contracts.md +16 -0
- package/media/profile/15-soul.md +12 -0
- package/media/profile/16-journal.md +38 -0
- package/media/profile/17-issue-tracking.md +19 -0
- package/media/profile/18-knowledgebase.md +13 -0
- package/media/profile/19-subagent-context-management.md +510 -0
- package/media/profile/20-project-graph.md +56 -0
- package/out/agentBackup/layout.js +1 -0
- package/out/agentBackup/layout.js.map +1 -1
- package/out/commands/mcpOperate.js +544 -28
- package/out/commands/mcpOperate.js.map +1 -1
- package/out/configManager.d.ts +7 -0
- package/out/configManager.js +36 -0
- package/out/configManager.js.map +1 -1
- package/out/core/modelInfo.d.ts +28 -0
- package/out/core/modelInfo.js +97 -0
- package/out/core/modelInfo.js.map +1 -0
- package/out/core/projectSkills.d.ts +44 -0
- package/out/core/projectSkills.js +266 -0
- package/out/core/projectSkills.js.map +1 -0
- package/out/core/redactSecrets.d.ts +15 -0
- package/out/core/redactSecrets.js +110 -0
- package/out/core/redactSecrets.js.map +1 -0
- package/out/core/settingsLoader.d.ts +13 -1
- package/out/core/settingsLoader.js +1 -0
- package/out/core/settingsLoader.js.map +1 -1
- package/out/graphStore.d.ts +153 -0
- package/out/graphStore.js +412 -0
- package/out/graphStore.js.map +1 -0
- package/out/mcpInstallCheck.d.ts +2 -1
- package/out/mcpInstallCheck.js.map +1 -1
- package/out/profileBuilder.js +26 -1
- package/out/profileBuilder.js.map +1 -1
- package/out/taskLoop.d.ts +8 -0
- package/out/taskLoop.js +55 -2
- package/out/taskLoop.js.map +1 -1
- package/out/webSocketPoller.d.ts +3 -0
- package/out/webSocketPoller.js +11 -0
- package/out/webSocketPoller.js.map +1 -1
- package/out/webhookPoller.d.ts +1 -0
- package/out/webhookPoller.js +5 -0
- package/out/webhookPoller.js.map +1 -1
- package/package.json +4 -3
|
@@ -45,6 +45,7 @@ const readline = __importStar(require("readline"));
|
|
|
45
45
|
const child_process_1 = require("child_process");
|
|
46
46
|
const url_1 = require("url");
|
|
47
47
|
const settingsLoader_1 = require("../core/settingsLoader");
|
|
48
|
+
const modelInfo_1 = require("../core/modelInfo");
|
|
48
49
|
const officeSocket_1 = require("../officeSocket");
|
|
49
50
|
const presenceGuard_1 = require("../presenceGuard");
|
|
50
51
|
const fileBrowser_1 = require("../fileBrowser");
|
|
@@ -52,9 +53,12 @@ const gitRequest_1 = require("../git/gitRequest");
|
|
|
52
53
|
const manager_1 = require("../vnc/manager");
|
|
53
54
|
const manager_2 = require("../rdp/manager");
|
|
54
55
|
const projectMcp_1 = require("../core/projectMcp");
|
|
56
|
+
const projectSkills_1 = require("../core/projectSkills");
|
|
55
57
|
const configManager_1 = require("../configManager");
|
|
56
58
|
const version_1 = require("../version");
|
|
57
59
|
const liveNarration_1 = require("../core/liveNarration");
|
|
60
|
+
const redactSecrets_1 = require("../core/redactSecrets");
|
|
61
|
+
const graphStore_1 = require("../graphStore");
|
|
58
62
|
/**
|
|
59
63
|
* `autodev mcp-operate` — run a local stdio MCP server that lets a pure MCP
|
|
60
64
|
* client (Claude Desktop/Code, etc.) operate a pixel-office character with NO
|
|
@@ -67,8 +71,12 @@ const liveNarration_1 = require("../core/liveNarration");
|
|
|
67
71
|
* single command — no remote-server approval friction, and the key/url stay in
|
|
68
72
|
* autodev config instead of being pasted into the client.
|
|
69
73
|
*
|
|
70
|
-
* claude mcp add pixel-office -- autodev mcp-operate --key <api_key> --url <…/api/office-mcp>
|
|
74
|
+
* claude mcp add pixel-office-<agent-slug> -- autodev mcp-operate --key <api_key> --url <…/api/office-mcp>
|
|
71
75
|
* # or, inside a bound workspace, just: autodev mcp-operate
|
|
76
|
+
*
|
|
77
|
+
* Name each server pixel-office-<agent-slug> (not a bare "pixel-office"): one MCP
|
|
78
|
+
* client / Claude session can then operate SEVERAL office characters at once, each
|
|
79
|
+
* under its own server name, instead of every agent colliding on one shared name.
|
|
72
80
|
*/
|
|
73
81
|
/** Derive the operator-MCP URL from a bound workspace's serverBaseUrl. */
|
|
74
82
|
function officeMcpUrl(serverBaseUrl) {
|
|
@@ -169,15 +177,18 @@ function proxy(endpoint, key, body) {
|
|
|
169
177
|
function mcpOperateCommand(program) {
|
|
170
178
|
program
|
|
171
179
|
.command('mcp-operate [path]')
|
|
172
|
-
.description('Run
|
|
180
|
+
.description('Run an MCP server that operates a pixel-office agent (bridges to …/api/office-mcp). Adds two bridge-synthesized tools on top of the office tools: wait_for_events (block on live office activity) and ask_user (ask the user a decision / multi-step wizard and BLOCK for their answer). Speaks stdio by default; add --http-port to run a PERSISTENT streamable-HTTP sidecar an opencode `type: remote` MCP can attach to. Add stdio with: claude mcp add pixel-office-<agent-slug> -- autodev mcp-operate --key <api_key> --url <url> (per-agent name lets one client run several characters at once). All outbound agent content (hook events, tool output, assistant narration, task results / A2A replies) is secret-redacted before it leaves the machine.')
|
|
173
181
|
.option('--url <url>', 'Operator MCP URL (…/api/office-mcp). Default: derived from the workspace binding.')
|
|
174
182
|
.option('--key <apiKey>', 'The character api_key (Bearer). Default: the workspace serverApiKey.')
|
|
183
|
+
.option('--http-port <port>', 'Run a persistent MCP-over-HTTP (Streamable HTTP) server on this port instead of stdio. Point an opencode `type: remote` MCP at http://<host>:<port>/mcp.', (v) => parseInt(v, 10))
|
|
184
|
+
.option('--http-host <host>', 'Bind address for --http-port (default 127.0.0.1).')
|
|
175
185
|
.option('--no-socket', 'Do not open the presence WebSocket (stay on poll-based presence only).')
|
|
176
186
|
.option('--file-browser', 'Serve the office file browser for this MCP-only agent (read/write files in the workspace over the office file browser).')
|
|
177
187
|
.option('--git', 'Serve the office git panel for this MCP-only agent (status/diff/stage/commit/branch in the workspace over the office git panel).')
|
|
178
188
|
.option('--vnc', 'Serve office VNC remote-desktop sessions for this MCP-only agent (input forwarding + framebuffer streaming).')
|
|
179
189
|
.option('--rdp', 'Serve office RDP remote-desktop sessions for this MCP-only agent (input forwarding + framebuffer streaming).')
|
|
180
190
|
.option('--mcp-update', 'Honor mcp_update frames: sync remote-supplied MCP config into the workspace (relaunch to pick up spawn changes).')
|
|
191
|
+
.option('--skill-update', 'Honor skill_update frames: sync remote-supplied Claude Code skills into the workspace (.claude/skills/<slug>/SKILL.md; live-reloads, no relaunch).')
|
|
181
192
|
.action(async (workspacePath, opts) => {
|
|
182
193
|
const cwd = workspacePath ? path.resolve(workspacePath) : process.cwd();
|
|
183
194
|
let settings = (0, settingsLoader_1.loadSettingsForRoot)(cwd);
|
|
@@ -191,6 +202,7 @@ function mcpOperateCommand(program) {
|
|
|
191
202
|
let vncEnabled = opts.vnc === true || settings.vncEnabled === true;
|
|
192
203
|
let rdpEnabled = opts.rdp === true || settings.rdpEnabled === true;
|
|
193
204
|
let mcpUpdateEnabled = opts.mcpUpdate === true || settings.mcpUpdateEnabled === true;
|
|
205
|
+
let skillUpdateEnabled = opts.skillUpdate === true || settings.skillUpdateEnabled === true;
|
|
194
206
|
if (!endpoint || !key) {
|
|
195
207
|
process.stderr.write('autodev mcp-operate: need --url and --key (or run inside a workspace bound to an office).\n');
|
|
196
208
|
process.exit(1);
|
|
@@ -229,6 +241,195 @@ function mcpOperateCommand(program) {
|
|
|
229
241
|
description: "Block until new office activity arrives over the live socket (or a timeout), then return it. Teammates' messages, status changes, task assignments and tool activity stream in as they happen — call this in a loop to react in real time. Returns any buffered events immediately.",
|
|
230
242
|
inputSchema: { type: 'object', properties: { timeout_seconds: { type: 'integer', description: 'Max seconds to wait for the next event (default 25, max 55).' } }, required: [] },
|
|
231
243
|
};
|
|
244
|
+
// ── Blocking user-decision tool (ask_user) ───────────────────────────────
|
|
245
|
+
// Ask the user to make a real decision — a single question OR a multi-step
|
|
246
|
+
// wizard — and BLOCK until they answer in the pixel-office chat, then return
|
|
247
|
+
// their answer so the model continues. Bridge-synthesized like WAIT_TOOL, so
|
|
248
|
+
// it reaches every provider (claude/opencode/grok/copilot) uniformly. The
|
|
249
|
+
// office holds the request state; this tool just creates it and polls.
|
|
250
|
+
const ASK_USER_TOOL = {
|
|
251
|
+
name: 'ask_user',
|
|
252
|
+
description: "Ask the user to make a decision and BLOCK for their answer. Use for genuine either/or choices the user must make. Provide clear options; set allow_other to let them type a custom answer; use steps for a multi-step wizard. Single question: pass { question, options, allow_other?, multi_select? }. Wizard: pass { title?, steps: [{ question, options, allow_other?, multi_select? }] }. Each option is a string OR an object { label, description }. IMPORTANT: if the call returns 'Still waiting...', call ask_user AGAIN with the SAME { request_id } it gives you (do NOT create a new question) — keep re-calling until you get the user's answer.",
|
|
253
|
+
inputSchema: {
|
|
254
|
+
type: 'object',
|
|
255
|
+
properties: {
|
|
256
|
+
question: { type: 'string', description: 'The decision to ask (single-question form).' },
|
|
257
|
+
options: { type: 'array', description: 'Choices for the single question — strings, or { label, description } objects.', items: {} },
|
|
258
|
+
allow_other: { type: 'boolean', description: 'Let the user type a custom free-text answer in addition to the listed options.' },
|
|
259
|
+
multi_select: { type: 'boolean', description: 'Let the user pick more than one option.' },
|
|
260
|
+
title: { type: 'string', description: 'Optional title shown above a multi-step wizard.' },
|
|
261
|
+
steps: { type: 'array', description: 'Wizard steps (multi-step form) — each { question, options, allow_other?, multi_select? }.', items: { type: 'object' } },
|
|
262
|
+
request_id: { type: 'string', description: 'Resume waiting for an EXISTING request (do NOT create a new one). Pass the id from a prior "Still waiting" ask_user result to keep polling until the user answers.' },
|
|
263
|
+
},
|
|
264
|
+
required: [],
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
// ── Project graph (durable, shared, cross-session memory) ────────────────
|
|
268
|
+
// A typed knowledge/work graph persisted under `<workspace>/.autodev/graph/`.
|
|
269
|
+
// DISTINCT from prose memory: structured, sourced, versioned facts that many
|
|
270
|
+
// agents read and write across sessions. The graph tools below are
|
|
271
|
+
// bridge-synthesized (handled locally, never proxied to the office) so they
|
|
272
|
+
// reach every provider uniformly — like wait_for_events / ask_user.
|
|
273
|
+
const graphRunId = `run-${new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)}-${crypto.randomBytes(2).toString('hex')}`;
|
|
274
|
+
const graphIdentity = {
|
|
275
|
+
agentId: String(settings.agentName
|
|
276
|
+
|| settings.characterName
|
|
277
|
+
|| settings.name
|
|
278
|
+
|| path.basename(cwd) || 'agent'),
|
|
279
|
+
runId: graphRunId,
|
|
280
|
+
};
|
|
281
|
+
const graph = new graphStore_1.GraphStore(cwd, graphIdentity);
|
|
282
|
+
const GRAPH_TOOLS = [
|
|
283
|
+
{
|
|
284
|
+
name: 'graph_add_node',
|
|
285
|
+
description: `Write a typed fact to the durable PROJECT GRAPH (\`.autodev/graph/\`, shared across agents and sessions — the graph persists after your context is gone). This is NOT prose memory; it is a structured, sourced, versioned graph. Node types: ${graphStore_1.NODE_TYPES.join(' | ')}. Provenance (your run + agent id) is attached automatically. INVARIANTS enforced: a 'claim' needs source:"file:line"/url/source-node-id OR inference:true; an 'artifact' needs version; an 'evaluation' needs rubric. Entities dedupe by name (idempotent — re-adding merges aliases). Returns the node id to link with graph_add_edge.`,
|
|
286
|
+
inputSchema: { type: 'object', properties: {
|
|
287
|
+
type: { type: 'string', description: `One of: ${graphStore_1.NODE_TYPES.join(', ')}` },
|
|
288
|
+
name: { type: 'string', description: 'Short label / surface form of the fact.' },
|
|
289
|
+
id: { type: 'string', description: 'Pin an id to upsert an existing node (optional; entities auto-dedupe by name).' },
|
|
290
|
+
body: { type: 'string', description: 'Fuller detail (optional).' },
|
|
291
|
+
source: { type: 'string', description: 'Where the fact came from: a file:line, url, or a source-node id. Required for claims unless inference:true.' },
|
|
292
|
+
inference: { type: 'boolean', description: 'Set true for a claim that is your reasoning, not a cited source.' },
|
|
293
|
+
version: { type: 'string', description: 'For artifacts: the version/commit/semver this node describes.' },
|
|
294
|
+
rubric: { type: 'string', description: 'For evaluations: the criteria judged against.' },
|
|
295
|
+
aliases: { type: 'array', description: 'Other names for this entity.', items: { type: 'string' } },
|
|
296
|
+
props: { type: 'object', description: 'Arbitrary structured attributes.' },
|
|
297
|
+
}, required: ['type', 'name'] },
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: 'graph_add_edge',
|
|
301
|
+
description: `Link two project-graph nodes with a typed, optionally-sourced edge. Edge types: ${graphStore_1.EDGE_TYPES.join(' | ')}. Pass the from/to node ids returned by graph_add_node or graph_query.`,
|
|
302
|
+
inputSchema: { type: 'object', properties: {
|
|
303
|
+
type: { type: 'string', description: `One of: ${graphStore_1.EDGE_TYPES.join(', ')}` },
|
|
304
|
+
from: { type: 'string', description: 'Source node id.' },
|
|
305
|
+
to: { type: 'string', description: 'Target node id.' },
|
|
306
|
+
source: { type: 'string', description: 'Evidence for this relationship (optional).' },
|
|
307
|
+
props: { type: 'object' },
|
|
308
|
+
}, required: ['type', 'from', 'to'] },
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: 'graph_query',
|
|
312
|
+
description: 'Search the project graph for nodes by type and/or free text. Returns matching nodes (freshest first) with ids to expand or link. Superseded nodes are hidden unless include_superseded is set.',
|
|
313
|
+
inputSchema: { type: 'object', properties: {
|
|
314
|
+
type: { type: 'string', description: 'Filter by node type.' },
|
|
315
|
+
text: { type: 'string', description: 'Case-insensitive substring over name/body/aliases.' },
|
|
316
|
+
id: { type: 'string', description: 'Fetch one node by id.' },
|
|
317
|
+
include_superseded: { type: 'boolean' },
|
|
318
|
+
limit: { type: 'integer', description: 'Max results (default 30, max 200).' },
|
|
319
|
+
}, required: [] },
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: 'graph_neighbors',
|
|
323
|
+
description: "Build task context FROM the graph (not a dump): resolve a node/entity by id or name, expand 1-3 hops over allowed edge types, and return a small, citable subgraph. Call this at the START of a task to recall what the swarm already knows before acting.",
|
|
324
|
+
inputSchema: { type: 'object', properties: {
|
|
325
|
+
id: { type: 'string', description: 'Center node id (or use name).' },
|
|
326
|
+
name: { type: 'string', description: 'Center node by name/alias (resolved to the node).' },
|
|
327
|
+
hops: { type: 'integer', description: 'Expansion depth 1-3 (default 1).' },
|
|
328
|
+
edge_types: { type: 'array', description: 'Restrict traversal to these edge types.', items: { type: 'string' } },
|
|
329
|
+
limit: { type: 'integer', description: 'Max edges to return (default 40).' },
|
|
330
|
+
}, required: [] },
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: 'graph_supersede',
|
|
334
|
+
description: "Version a fact instead of deleting it: create a replacement node, link it with a 'supersedes' edge, and flag the old node (which stays addressable). Use when a stored fact changed.",
|
|
335
|
+
inputSchema: { type: 'object', properties: {
|
|
336
|
+
old_id: { type: 'string', description: 'Id of the node being replaced.' },
|
|
337
|
+
name: { type: 'string', description: 'Name of the replacement node.' },
|
|
338
|
+
body: { type: 'string' }, source: { type: 'string' }, inference: { type: 'boolean' },
|
|
339
|
+
version: { type: 'string' }, rubric: { type: 'string' }, props: { type: 'object' },
|
|
340
|
+
}, required: ['old_id', 'name'] },
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: 'graph_stats',
|
|
344
|
+
description: 'Health of the project graph: node/edge counts by type, superseded count, isolated (unlinked) nodes, contradictions, and open questions. Use to spot gaps worth filling.',
|
|
345
|
+
inputSchema: { type: 'object', properties: {}, required: [] },
|
|
346
|
+
},
|
|
347
|
+
];
|
|
348
|
+
const graphToolNames = new Set(GRAPH_TOOLS.map((t) => t.name));
|
|
349
|
+
// Serialize a node/edge compactly for the model — stable ids for citation.
|
|
350
|
+
const fmtNode = (n) => {
|
|
351
|
+
const tags = [];
|
|
352
|
+
if (n.source) {
|
|
353
|
+
tags.push(`src=${n.source}`);
|
|
354
|
+
}
|
|
355
|
+
if (n.version) {
|
|
356
|
+
tags.push(`v=${n.version}`);
|
|
357
|
+
}
|
|
358
|
+
if (n.inference) {
|
|
359
|
+
tags.push('inference');
|
|
360
|
+
}
|
|
361
|
+
if (n.supersededBy) {
|
|
362
|
+
tags.push(`superseded→${n.supersededBy}`);
|
|
363
|
+
}
|
|
364
|
+
const body = n.body ? ` — ${n.body.replace(/\s+/g, ' ').slice(0, 160)}` : '';
|
|
365
|
+
return `- [${n.id}] ${n.type} "${n.name}"${tags.length ? ' (' + tags.join(', ') + ')' : ''}${body}`;
|
|
366
|
+
};
|
|
367
|
+
const handleGraphTool = (name, a) => {
|
|
368
|
+
try {
|
|
369
|
+
if (name === 'graph_add_node') {
|
|
370
|
+
const { node, created } = graph.addNode({
|
|
371
|
+
type: String(a.type ?? ''), name: String(a.name ?? ''),
|
|
372
|
+
id: a.id ? String(a.id) : undefined, body: a.body ? String(a.body) : undefined,
|
|
373
|
+
source: a.source ? String(a.source) : undefined,
|
|
374
|
+
inference: a.inference === true || undefined,
|
|
375
|
+
version: a.version ? String(a.version) : undefined,
|
|
376
|
+
rubric: a.rubric ? String(a.rubric) : undefined,
|
|
377
|
+
aliases: Array.isArray(a.aliases) ? a.aliases.map(String) : undefined,
|
|
378
|
+
props: (a.props && typeof a.props === 'object') ? a.props : undefined,
|
|
379
|
+
});
|
|
380
|
+
return `${created ? 'Created' : 'Updated'} node [${node.id}] ${node.type} "${node.name}". Link it with graph_add_edge.`;
|
|
381
|
+
}
|
|
382
|
+
if (name === 'graph_add_edge') {
|
|
383
|
+
const e = graph.addEdge({
|
|
384
|
+
type: String(a.type ?? ''), from: String(a.from ?? ''), to: String(a.to ?? ''),
|
|
385
|
+
source: a.source ? String(a.source) : undefined,
|
|
386
|
+
props: (a.props && typeof a.props === 'object') ? a.props : undefined,
|
|
387
|
+
});
|
|
388
|
+
return `Added edge [${e.id}]: (${e.from}) -${e.type}-> (${e.to}).`;
|
|
389
|
+
}
|
|
390
|
+
if (name === 'graph_query') {
|
|
391
|
+
const rows = graph.query({
|
|
392
|
+
type: a.type ? String(a.type) : undefined, text: a.text ? String(a.text) : undefined,
|
|
393
|
+
id: a.id ? String(a.id) : undefined, includeSuperseded: a.include_superseded === true,
|
|
394
|
+
limit: a.limit ? Number(a.limit) : undefined,
|
|
395
|
+
});
|
|
396
|
+
return rows.length ? `${rows.length} node(s):\n${rows.map(fmtNode).join('\n')}` : 'No matching nodes. Add facts with graph_add_node.';
|
|
397
|
+
}
|
|
398
|
+
if (name === 'graph_neighbors') {
|
|
399
|
+
const res = graph.neighbors({
|
|
400
|
+
idOrName: String(a.id ?? a.name ?? ''), hops: a.hops ? Number(a.hops) : undefined,
|
|
401
|
+
edgeTypes: Array.isArray(a.edge_types) ? a.edge_types.map(String) : undefined,
|
|
402
|
+
limit: a.limit ? Number(a.limit) : undefined,
|
|
403
|
+
});
|
|
404
|
+
if (!res) {
|
|
405
|
+
return `No node matches "${String(a.id ?? a.name ?? '')}". Try graph_query first.`;
|
|
406
|
+
}
|
|
407
|
+
const edgeLines = res.edges.map((e) => `- (${e.from}) -${e.type}-> (${e.to})${e.source ? ` [src=${e.source}]` : ''}`);
|
|
408
|
+
return `Context around [${res.center.id}] "${res.center.name}":\nNODES (${res.nodes.length}):\n${res.nodes.map(fmtNode).join('\n')}\nEDGES (${res.edges.length}):\n${edgeLines.join('\n') || '(none)'}`;
|
|
409
|
+
}
|
|
410
|
+
if (name === 'graph_supersede') {
|
|
411
|
+
const { oldId, node } = graph.supersede(String(a.old_id ?? ''), {
|
|
412
|
+
type: '', name: String(a.name ?? ''), body: a.body ? String(a.body) : undefined,
|
|
413
|
+
source: a.source ? String(a.source) : undefined, inference: a.inference === true || undefined,
|
|
414
|
+
version: a.version ? String(a.version) : undefined, rubric: a.rubric ? String(a.rubric) : undefined,
|
|
415
|
+
props: (a.props && typeof a.props === 'object') ? a.props : undefined,
|
|
416
|
+
});
|
|
417
|
+
return `Superseded [${oldId}] with [${node.id}] "${node.name}". The old node stays addressable.`;
|
|
418
|
+
}
|
|
419
|
+
if (name === 'graph_stats') {
|
|
420
|
+
const s = graph.stats();
|
|
421
|
+
const byType = (m) => Object.entries(m).map(([k, v]) => `${k}:${v}`).join(', ') || '(none)';
|
|
422
|
+
return `Project graph @ ${graph.path}\nnodes=${s.nodeCount} (${byType(s.nodesByType)})\nedges=${s.edgeCount} (${byType(s.edgesByType)})\nsuperseded=${s.superseded} · isolated=${s.isolated} · contradictions=${s.contradictions} · openQuestions=${s.openQuestions}`;
|
|
423
|
+
}
|
|
424
|
+
return `unknown graph tool: ${name}`;
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
if (e instanceof graphStore_1.GraphInvariantError) {
|
|
428
|
+
return `Rejected (graph invariant): ${e.message}`;
|
|
429
|
+
}
|
|
430
|
+
return `graph error: ${e?.message ?? String(e)}`;
|
|
431
|
+
}
|
|
432
|
+
};
|
|
232
433
|
// ── Presence WebSocket (optional; --no-socket disables) ──────────────────
|
|
233
434
|
// Holds a live connection so the office shows this MCP agent genuinely
|
|
234
435
|
// online, and surfaces task/message pushes to the client as MCP
|
|
@@ -257,7 +458,20 @@ function mcpOperateCommand(program) {
|
|
|
257
458
|
const id = body.id !== undefined && body.id !== null ? body.id : `wsreq-${++wsReqSeq}`;
|
|
258
459
|
const k = String(id);
|
|
259
460
|
const timer = setTimeout(() => { wsPending.delete(k); reject(new Error('operator_request timed out')); }, 20_000);
|
|
260
|
-
wsPending.set(k, (resp) => {
|
|
461
|
+
wsPending.set(k, (resp) => {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
// A JSON-RPC error with no result (e.g. a stale long-running WS server
|
|
464
|
+
// missing a newly-added office tool) → REJECT so callOffice fails over
|
|
465
|
+
// to the HTTP proxy, which php-fpm always serves with fresh code. A
|
|
466
|
+
// normal result resolves. Without this, a stale WS silently returns an
|
|
467
|
+
// empty result and callers like ask_user hard-fail on JSON.parse('').
|
|
468
|
+
if (resp && resp['error'] && (resp['result'] === undefined || resp['result'] === null)) {
|
|
469
|
+
reject(new Error(String(resp['error']?.message ?? 'operator_response error')));
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
resolve(resp);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
261
475
|
socket.sendFrame({ type: 'operator_request', id, method: body.method, params: body.params ?? {} });
|
|
262
476
|
});
|
|
263
477
|
// Prefer the socket when ready; fall back to HTTP otherwise (startup handshake,
|
|
@@ -299,6 +513,7 @@ function mcpOperateCommand(program) {
|
|
|
299
513
|
vncEnabled = opts.vnc === true || settings.vncEnabled === true;
|
|
300
514
|
rdpEnabled = opts.rdp === true || settings.rdpEnabled === true;
|
|
301
515
|
mcpUpdateEnabled = opts.mcpUpdate === true || settings.mcpUpdateEnabled === true;
|
|
516
|
+
skillUpdateEnabled = opts.skillUpdate === true || settings.skillUpdateEnabled === true;
|
|
302
517
|
applyRemoteDesktopSettings();
|
|
303
518
|
};
|
|
304
519
|
// ── Live MCP-config reload (mcp_update frame) ────────────────────────────
|
|
@@ -333,6 +548,33 @@ function mcpOperateCommand(program) {
|
|
|
333
548
|
logErr(`⚠️ MCP update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
334
549
|
}
|
|
335
550
|
};
|
|
551
|
+
// ── Live skill reload (skill_update frame) ───────────────────────────────
|
|
552
|
+
// The office pushes the agent's FULL effective skill set. Skills live-reload
|
|
553
|
+
// (Claude re-reads .claude/skills each run), so there is nothing to relaunch —
|
|
554
|
+
// sanitize, full-replace on disk, fold prose for non-Claude providers, report.
|
|
555
|
+
const handleSkillUpdate = (skills) => {
|
|
556
|
+
reloadBridgeSettings();
|
|
557
|
+
if (!skillUpdateEnabled) {
|
|
558
|
+
logErr('🔒 skill_update ignored — skillUpdateEnabled is off (set it in .autodev/settings.json or pass --skill-update to allow)');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
logErr('🧩 skill_update received — validating and writing .claude/skills…');
|
|
562
|
+
const { safe, rejected } = (0, projectSkills_1.sanitizeRemoteSkills)(cwd, skills);
|
|
563
|
+
if (rejected.length) {
|
|
564
|
+
logErr(`⚠️ skill_update dropped ${rejected.length} entr${rejected.length === 1 ? 'y' : 'ies'}: ${rejected.map(r => `${r.name} (${r.reason})`).join(', ')}`);
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
const { written, removed } = (0, projectSkills_1.saveProjectSkills)(cwd, safe);
|
|
568
|
+
if (!(0, projectSkills_1.providerConsumesSkills)(settings.provider)) {
|
|
569
|
+
(0, projectSkills_1.foldSkillsIntoProfile)(cwd, safe);
|
|
570
|
+
}
|
|
571
|
+
void configManager_1.ConfigManager.reportProjectSkills(cwd, written, logErr);
|
|
572
|
+
logErr(`✅ skills synced — wrote ${written.length}, removed ${removed.length} (.claude/skills). Live-reloads on the next run — no relaunch.`);
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
logErr(`⚠️ skill update failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
336
578
|
// ── Presence socket lifecycle (runtime-driven, self-healing) ─────────────
|
|
337
579
|
// Whether the bridge holds the presence socket is decided at RUNTIME from
|
|
338
580
|
// ACTUAL loop presence, NOT a static config flag. A co-located `autodev
|
|
@@ -377,6 +619,10 @@ function mcpOperateCommand(program) {
|
|
|
377
619
|
log: (l) => process.stderr.write(l + '\n'),
|
|
378
620
|
meta: {
|
|
379
621
|
provider: 'mcp-operator', cliVersion: version_1.CLI_VERSION,
|
|
622
|
+
// The MCP-only agent's worker provider drives real turns; announce its
|
|
623
|
+
// effective model (undefined when it runs an account default) so the
|
|
624
|
+
// office can badge the model. 'mcp-operator' is only the transport marker.
|
|
625
|
+
model: (0, modelInfo_1.resolveConfiguredModel)(settings),
|
|
380
626
|
fileBrowserEnabled, gitEnabled, vncEnabled, rdpEnabled,
|
|
381
627
|
// Announce the desktop host/port too (parity with the loop's meta) so
|
|
382
628
|
// the office persists the real target instead of defaulting to :5900.
|
|
@@ -458,6 +704,14 @@ function mcpOperateCommand(program) {
|
|
|
458
704
|
}
|
|
459
705
|
return;
|
|
460
706
|
}
|
|
707
|
+
// Live skill reload frame from the office (full effective skill set).
|
|
708
|
+
if (msgType === 'skill_update') {
|
|
709
|
+
const skills = msg['skills'];
|
|
710
|
+
if (Array.isArray(skills)) {
|
|
711
|
+
handleSkillUpdate(skills);
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
461
715
|
const notice = describePush(msg);
|
|
462
716
|
if (notice) {
|
|
463
717
|
// Buffer for wait_for_events (the reliable real-time path)…
|
|
@@ -586,7 +840,9 @@ function mcpOperateCommand(program) {
|
|
|
586
840
|
let transcriptOffset = 0;
|
|
587
841
|
const seenAsst = new Set(); // assistant-entry uuids already forwarded
|
|
588
842
|
const forwardAssistant = (text) => {
|
|
589
|
-
|
|
843
|
+
// Redact secrets from the assistant prose BEFORE it leaves the machine
|
|
844
|
+
// — this bubble is stored + rendered in the office chat.
|
|
845
|
+
const msg = (0, redactSecrets_1.redactSecrets)(text.trim());
|
|
590
846
|
if (!msg || !socket) {
|
|
591
847
|
return;
|
|
592
848
|
}
|
|
@@ -721,7 +977,9 @@ function mcpOperateCommand(program) {
|
|
|
721
977
|
continue;
|
|
722
978
|
}
|
|
723
979
|
ev._session_name = sessionNameForHooks; // so the office can label it
|
|
724
|
-
|
|
980
|
+
// Redact secrets from the whole hook object (tool_input.command,
|
|
981
|
+
// tool_response, file contents, …) before it leaves the machine.
|
|
982
|
+
socket.sendFrame({ type: 'hook_event', data: (0, redactSecrets_1.redactDeep)(ev) });
|
|
725
983
|
const name = ev['hook_event_name'] || ev['hook'] || ev['event'] || '';
|
|
726
984
|
if (WORKING_HOOKS.has(name)) {
|
|
727
985
|
sawWork = true;
|
|
@@ -906,7 +1164,8 @@ function mcpOperateCommand(program) {
|
|
|
906
1164
|
}
|
|
907
1165
|
await officeTool('start_task', { task_id: t.id });
|
|
908
1166
|
const result = await runWorker(`You are working as the office agent in this workspace. Complete this task using your own tools (edit/create files as needed), then briefly summarize what you did.\n\nTASK: ${t.title}`);
|
|
909
|
-
|
|
1167
|
+
// The worker summary is stored + shown in the office task feed — redact secrets.
|
|
1168
|
+
await officeTool('complete_task', { task_id: t.id, result: (0, redactSecrets_1.redactSecrets)((result || 'Done.').slice(0, 1500)) });
|
|
910
1169
|
logErr(`🤖 autonomy: completed task ${t.id} (${t.title.slice(0, 40)})`);
|
|
911
1170
|
}
|
|
912
1171
|
// A2A: reply to direct messages (check_messages). The worker composes a
|
|
@@ -920,7 +1179,8 @@ function mcpOperateCommand(program) {
|
|
|
920
1179
|
}
|
|
921
1180
|
const reply = (await runWorker(`A teammate "${m.from}" sent you this message in the office:\n\n"${m.body}"\n\nIf a reply is warranted, output ONLY the reply text — concise, and do NOT ask a question back unless truly essential. If the message needs work in this workspace, do it, then reply with what you did. If no reply is needed, output exactly: NO_REPLY`)).trim();
|
|
922
1181
|
if (reply && !/^NO_REPLY/i.test(reply)) {
|
|
923
|
-
|
|
1182
|
+
// The A2A reply is delivered + shown in the office chat — redact secrets.
|
|
1183
|
+
await officeTool('send_message', { to: m.from, message: (0, redactSecrets_1.redactSecrets)(reply.slice(0, 1000)) });
|
|
924
1184
|
logErr(`🤖 autonomy: replied to ${m.from}`);
|
|
925
1185
|
replied++;
|
|
926
1186
|
}
|
|
@@ -974,20 +1234,15 @@ function mcpOperateCommand(program) {
|
|
|
974
1234
|
process.stdout.write('', () => process.exit(0));
|
|
975
1235
|
}
|
|
976
1236
|
};
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
req = JSON.parse(t);
|
|
985
|
-
}
|
|
986
|
-
catch {
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
1237
|
+
// ── Single JSON-RPC request path (shared by stdio + HTTP) ────────────────
|
|
1238
|
+
// Process ONE inbound MCP message and return the response object to send
|
|
1239
|
+
// back, or null for a notification / message with no reply. Both the stdio
|
|
1240
|
+
// line reader and the HTTP handler call this, so wait_for_events blocking,
|
|
1241
|
+
// tools/list augmentation, and notification forwarding all live in ONE place
|
|
1242
|
+
// regardless of transport.
|
|
1243
|
+
const dispatch = async (req) => {
|
|
989
1244
|
// Notifications (no id) are one-way — nothing to reply. Forward the
|
|
990
|
-
// handshake ones opportunistically but never
|
|
1245
|
+
// handshake ones opportunistically but never return a response for them.
|
|
991
1246
|
if (req.id === undefined || req.id === null) {
|
|
992
1247
|
if (typeof req.method === 'string' && req.method.startsWith('notifications/')) {
|
|
993
1248
|
// Fire-and-forget over the socket when ready, else HTTP.
|
|
@@ -998,7 +1253,7 @@ function mcpOperateCommand(program) {
|
|
|
998
1253
|
proxy(endpoint, key, req).catch(() => { });
|
|
999
1254
|
}
|
|
1000
1255
|
}
|
|
1001
|
-
return;
|
|
1256
|
+
return null;
|
|
1002
1257
|
}
|
|
1003
1258
|
// Local tool: wait_for_events — stream office activity from the socket
|
|
1004
1259
|
// instead of proxying to the server. Blocks until an event (or timeout).
|
|
@@ -1014,13 +1269,160 @@ function mcpOperateCommand(program) {
|
|
|
1014
1269
|
const text = events.length
|
|
1015
1270
|
? `Office activity (${events.length} event${events.length === 1 ? '' : 's'}):\n${events.join('\n')}\n\nUse get_events / check_messages for detail, then call wait_for_events again to keep listening.`
|
|
1016
1271
|
: `No new events in ${secs}s. Call wait_for_events again to keep listening.`;
|
|
1017
|
-
|
|
1272
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text }] } };
|
|
1273
|
+
}
|
|
1274
|
+
finally {
|
|
1275
|
+
inflight--;
|
|
1276
|
+
maybeExit();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// Local tools: graph_* — read/write the durable project graph on local
|
|
1280
|
+
// disk (.autodev/graph/). Handled here, never proxied to the office, so
|
|
1281
|
+
// every provider gets the same persistent shared memory. Synchronous and
|
|
1282
|
+
// fast (a JSONL append / in-memory replay), so no inflight bookkeeping.
|
|
1283
|
+
if (req.method === 'tools/call' && typeof params?.name === 'string' && graphToolNames.has(params.name)) {
|
|
1284
|
+
const args = (req.params?.arguments) ?? {};
|
|
1285
|
+
const text = handleGraphTool(params.name, args);
|
|
1286
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text }] } };
|
|
1287
|
+
}
|
|
1288
|
+
// Local blocking tool: ask_user — create a user-decision request in the
|
|
1289
|
+
// office and BLOCK until the user answers in pixel-office chat (or a cap
|
|
1290
|
+
// elapses). Poll get_choice_answer in SHORT calls (never one long call:
|
|
1291
|
+
// the WS proxy caps at 20s, HTTP at 120s) and keep the agent's status
|
|
1292
|
+
// fresh so it doesn't flip to idle while blocked. inflight++/maybeExit
|
|
1293
|
+
// exactly like wait_for_events keeps the process alive while waiting.
|
|
1294
|
+
if (req.method === 'tools/call' && params?.name === 'ask_user') {
|
|
1295
|
+
inflight++;
|
|
1296
|
+
// Tunables (mirror wait_for_events' small consts).
|
|
1297
|
+
const ASK_INVOCATION_MS = 50_000; // cap ONE call under opencode's ~60s remote-MCP client timeout; the model re-calls with request_id to keep waiting
|
|
1298
|
+
const ASK_POLL_MS = 3_000; // short poll cadence (office is fast)
|
|
1299
|
+
const ASK_HEARTBEAT_MS = 60_000; // refresh 'waiting' status well under the 120s idle flip
|
|
1300
|
+
try {
|
|
1301
|
+
const a = (req.params?.arguments) ?? {};
|
|
1302
|
+
// Call an office tool over the same WS/HTTP channel and read its first
|
|
1303
|
+
// text-content block (same extraction as officeTool / wait_for_events).
|
|
1304
|
+
const askOfficeTool = async (name, args) => {
|
|
1305
|
+
const r = await callOffice({ id: `ask-${name}-${++wsReqSeq}`, method: 'tools/call', params: { name, arguments: args } });
|
|
1306
|
+
return (r['result']?.content?.[0]?.text) ?? '';
|
|
1307
|
+
};
|
|
1308
|
+
// Build create_choice_request arguments: pass through single OR wizard
|
|
1309
|
+
// form untouched (the office validates the shape).
|
|
1310
|
+
const createArgs = {};
|
|
1311
|
+
const isWizard = Array.isArray(a['steps']);
|
|
1312
|
+
if (isWizard) {
|
|
1313
|
+
createArgs.steps = a['steps'];
|
|
1314
|
+
if (typeof a['title'] === 'string') {
|
|
1315
|
+
createArgs.title = a['title'];
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
else {
|
|
1319
|
+
if (a['question'] !== undefined) {
|
|
1320
|
+
createArgs.question = a['question'];
|
|
1321
|
+
}
|
|
1322
|
+
if (a['options'] !== undefined) {
|
|
1323
|
+
createArgs.options = a['options'];
|
|
1324
|
+
}
|
|
1325
|
+
if (a['allow_other'] !== undefined) {
|
|
1326
|
+
createArgs.allow_other = a['allow_other'];
|
|
1327
|
+
}
|
|
1328
|
+
if (a['multi_select'] !== undefined) {
|
|
1329
|
+
createArgs.multi_select = a['multi_select'];
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
// Format the office's answer array into readable text for the model.
|
|
1333
|
+
const fmtOne = (ans) => {
|
|
1334
|
+
const other = ans?.otherText ? String(ans.otherText) : '';
|
|
1335
|
+
if (Array.isArray(ans?.values) && ans.values.length > 0) {
|
|
1336
|
+
const v = ans.values.map((x) => `"${String(x)}"`).join(', ');
|
|
1337
|
+
return other ? `${v} (other: "${other}")` : v;
|
|
1338
|
+
}
|
|
1339
|
+
const hasValue = ans?.value !== undefined && ans?.value !== null && String(ans.value) !== '';
|
|
1340
|
+
if (hasValue) {
|
|
1341
|
+
return other ? `"${String(ans.value)}" (other: "${other}")` : `"${String(ans.value)}"`;
|
|
1342
|
+
}
|
|
1343
|
+
// Free-text only (no preset option chosen): show the typed answer directly.
|
|
1344
|
+
return other ? `"${other}"` : '"(no answer)"';
|
|
1345
|
+
};
|
|
1346
|
+
const formatAnswers = (answers) => {
|
|
1347
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
1348
|
+
return 'The user answered, but no selection was returned.';
|
|
1349
|
+
}
|
|
1350
|
+
if (!isWizard && answers.length === 1) {
|
|
1351
|
+
return `The user chose: ${fmtOne(answers[0])}`;
|
|
1352
|
+
}
|
|
1353
|
+
const steps = a['steps'] ?? [];
|
|
1354
|
+
const lines = answers.map((ans) => {
|
|
1355
|
+
const idx = typeof ans?.stepIndex === 'number' ? ans.stepIndex : 0;
|
|
1356
|
+
const q = steps[idx]?.question ?? `step ${idx + 1}`;
|
|
1357
|
+
return `Step ${idx + 1} (${q}): ${fmtOne(ans)}`;
|
|
1358
|
+
});
|
|
1359
|
+
return `The user completed the wizard:\n${lines.join('\n')}`;
|
|
1360
|
+
};
|
|
1361
|
+
// 1) Create the request — UNLESS resuming an existing one (request_id
|
|
1362
|
+
// passed back after a prior "Still waiting" return). Resuming avoids
|
|
1363
|
+
// re-asking and lets a long wait span several short sub-60s calls.
|
|
1364
|
+
let requestId = typeof a['request_id'] === 'string' ? String(a['request_id']).trim() : '';
|
|
1365
|
+
if (!requestId) {
|
|
1366
|
+
try {
|
|
1367
|
+
const createText = await askOfficeTool('create_choice_request', createArgs);
|
|
1368
|
+
const parsed = JSON.parse(createText);
|
|
1369
|
+
requestId = (parsed?.request_id ?? '').toString();
|
|
1370
|
+
}
|
|
1371
|
+
catch (e) {
|
|
1372
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'Could not create the user-decision request: ' + (e?.message ?? String(e)) + '. Nothing was shown to the user.' }] } };
|
|
1373
|
+
}
|
|
1374
|
+
if (!requestId) {
|
|
1375
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'The office did not return a request id — the user-decision request was not created, so no question reached the user.' }] } };
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
// 2) BLOCK: re-poll get_choice_answer in short calls; heartbeat status.
|
|
1379
|
+
const started = Date.now();
|
|
1380
|
+
let lastBeat = Date.now();
|
|
1381
|
+
while (!closed) {
|
|
1382
|
+
// Return BEFORE the client's request timeout (opencode's remote MCP
|
|
1383
|
+
// caps a single call at ~60s). The request stays open server-side;
|
|
1384
|
+
// the model re-calls ask_user with this request_id to keep waiting.
|
|
1385
|
+
if (Date.now() - started > ASK_INVOCATION_MS) {
|
|
1386
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: `Still waiting for the user to answer — the question is live in the office chat. To keep waiting, call ask_user AGAIN with { "request_id": "${requestId}" } (do NOT create a new question).` }] } };
|
|
1387
|
+
}
|
|
1388
|
+
let statusText = '';
|
|
1389
|
+
try {
|
|
1390
|
+
statusText = await askOfficeTool('get_choice_answer', { request_id: requestId });
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
statusText = '';
|
|
1394
|
+
}
|
|
1395
|
+
let status = 'pending';
|
|
1396
|
+
let answers = null;
|
|
1397
|
+
if (statusText) {
|
|
1398
|
+
try {
|
|
1399
|
+
const p = JSON.parse(statusText);
|
|
1400
|
+
status = p.status ?? 'pending';
|
|
1401
|
+
answers = p.answers ?? null;
|
|
1402
|
+
}
|
|
1403
|
+
catch { /* keep pending */ }
|
|
1404
|
+
}
|
|
1405
|
+
if (status === 'answered') {
|
|
1406
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: formatAnswers(answers) }] } };
|
|
1407
|
+
}
|
|
1408
|
+
if (status === 'cancelled') {
|
|
1409
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'The user dismissed the request without answering.' }] } };
|
|
1410
|
+
}
|
|
1411
|
+
// Heartbeat: keep the agent marked 'waiting' (best-effort) so it does
|
|
1412
|
+
// not flip to idle after ~120s while genuinely blocked on the user.
|
|
1413
|
+
if (Date.now() - lastBeat > ASK_HEARTBEAT_MS) {
|
|
1414
|
+
lastBeat = Date.now();
|
|
1415
|
+
askOfficeTool('set_status', { status: 'waiting' }).catch(() => { });
|
|
1416
|
+
}
|
|
1417
|
+
await new Promise((res) => { const t = setTimeout(res, ASK_POLL_MS); t.unref?.(); });
|
|
1418
|
+
}
|
|
1419
|
+
// Tool torn down (stdin closed / shutdown) before the user answered.
|
|
1420
|
+
return { jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text: 'The ask_user request was interrupted before the user answered.' }] } };
|
|
1018
1421
|
}
|
|
1019
1422
|
finally {
|
|
1020
1423
|
inflight--;
|
|
1021
1424
|
maybeExit();
|
|
1022
1425
|
}
|
|
1023
|
-
return;
|
|
1024
1426
|
}
|
|
1025
1427
|
inflight++;
|
|
1026
1428
|
try {
|
|
@@ -1029,20 +1431,134 @@ function mcpOperateCommand(program) {
|
|
|
1029
1431
|
if (req.method === 'tools/list') {
|
|
1030
1432
|
const result = res['result'];
|
|
1031
1433
|
if (result && Array.isArray(result.tools)) {
|
|
1032
|
-
result.tools.push(WAIT_TOOL);
|
|
1434
|
+
result.tools.push(WAIT_TOOL, ASK_USER_TOOL, ...GRAPH_TOOLS);
|
|
1033
1435
|
}
|
|
1034
1436
|
}
|
|
1035
|
-
|
|
1437
|
+
return res;
|
|
1036
1438
|
}
|
|
1037
1439
|
catch (e) {
|
|
1038
|
-
|
|
1440
|
+
return { jsonrpc: '2.0', id: req.id, error: { code: -32603, message: 'proxy error: ' + (e?.message ?? String(e)) } };
|
|
1039
1441
|
}
|
|
1040
1442
|
finally {
|
|
1041
1443
|
inflight--;
|
|
1042
1444
|
maybeExit();
|
|
1043
1445
|
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1446
|
+
};
|
|
1447
|
+
// ── Transport: persistent HTTP (Streamable HTTP) OR stdio ────────────────
|
|
1448
|
+
// opencode's `type: remote` MCP client (MCP.connectRemote) tries Streamable
|
|
1449
|
+
// HTTP first — POST JSON-RPC to the configured `url`, Accept
|
|
1450
|
+
// `application/json, text/event-stream` — then falls back to legacy SSE. It
|
|
1451
|
+
// also opens a standalone GET SSE stream after `initialized` but TOLERATES a
|
|
1452
|
+
// 405 there. So the persistent sidecar needs only: POST → single JSON-RPC
|
|
1453
|
+
// reply as `application/json` (202 for notification-only bodies), GET → 405
|
|
1454
|
+
// (no server-initiated stream; office activity reaches the model via the
|
|
1455
|
+
// wait_for_events tool, exactly as over stdio). This lets ONE always-on
|
|
1456
|
+
// bridge serve both `opencode serve` and every `opencode run` with no
|
|
1457
|
+
// stdio-spawn dependency and no flap.
|
|
1458
|
+
if (opts.httpPort) {
|
|
1459
|
+
const httpHost = opts.httpHost || '127.0.0.1';
|
|
1460
|
+
const sessionId = crypto.randomUUID();
|
|
1461
|
+
const httpServer = http.createServer((hreq, hres) => {
|
|
1462
|
+
// No standalone server→client SSE stream: the client tolerates 405 on
|
|
1463
|
+
// the GET it opens after `initialized` (if(status===405)return).
|
|
1464
|
+
if (hreq.method === 'GET') {
|
|
1465
|
+
hres.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
1466
|
+
hres.end('Method Not Allowed');
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
// Session teardown — acknowledge and move on (stateless bridge).
|
|
1470
|
+
if (hreq.method === 'DELETE') {
|
|
1471
|
+
hres.writeHead(204);
|
|
1472
|
+
hres.end();
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (hreq.method !== 'POST') {
|
|
1476
|
+
hres.writeHead(405, { 'Content-Type': 'text/plain' });
|
|
1477
|
+
hres.end('Method Not Allowed');
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
let body = '';
|
|
1481
|
+
hreq.on('data', (c) => { body += c; if (body.length > 8 * 1024 * 1024) {
|
|
1482
|
+
hreq.destroy();
|
|
1483
|
+
} });
|
|
1484
|
+
hreq.on('end', async () => {
|
|
1485
|
+
let parsed;
|
|
1486
|
+
try {
|
|
1487
|
+
parsed = JSON.parse(body);
|
|
1488
|
+
}
|
|
1489
|
+
catch {
|
|
1490
|
+
hres.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1491
|
+
hres.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const batch = Array.isArray(parsed);
|
|
1495
|
+
const msgs = (batch ? parsed : [parsed]);
|
|
1496
|
+
const isInit = msgs.some((m) => m && m.method === 'initialize');
|
|
1497
|
+
const responses = [];
|
|
1498
|
+
for (const m of msgs) {
|
|
1499
|
+
try {
|
|
1500
|
+
const r = await dispatch(m);
|
|
1501
|
+
if (r) {
|
|
1502
|
+
responses.push(r);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
catch (e) {
|
|
1506
|
+
if (m && m.id !== undefined && m.id !== null) {
|
|
1507
|
+
responses.push({ jsonrpc: '2.0', id: m.id, error: { code: -32603, message: 'bridge error: ' + (e?.message ?? String(e)) } });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
// Notification/response-only body → 202 Accepted, no content.
|
|
1512
|
+
if (responses.length === 0) {
|
|
1513
|
+
hres.writeHead(202, isInit ? { 'Mcp-Session-Id': sessionId } : {});
|
|
1514
|
+
hres.end();
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1518
|
+
if (isInit) {
|
|
1519
|
+
headers['Mcp-Session-Id'] = sessionId;
|
|
1520
|
+
}
|
|
1521
|
+
hres.writeHead(200, headers);
|
|
1522
|
+
hres.end(JSON.stringify(batch ? responses : responses[0]));
|
|
1523
|
+
});
|
|
1524
|
+
hreq.on('error', () => { try {
|
|
1525
|
+
hres.writeHead(400);
|
|
1526
|
+
hres.end();
|
|
1527
|
+
}
|
|
1528
|
+
catch { /* ignore */ } });
|
|
1529
|
+
});
|
|
1530
|
+
httpServer.on('error', (e) => {
|
|
1531
|
+
process.stderr.write(`autodev mcp-operate: HTTP server error — ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
});
|
|
1534
|
+
httpServer.listen(opts.httpPort, httpHost, () => {
|
|
1535
|
+
process.stderr.write(`autodev mcp-operate: persistent Streamable-HTTP MCP listening on http://${httpHost}:${opts.httpPort}/mcp (point an opencode \`type: remote\` MCP at this URL).\n`);
|
|
1536
|
+
});
|
|
1537
|
+
// No stdin client in HTTP mode — do NOT read/close on stdin (a detached
|
|
1538
|
+
// sidecar has stdin closed, which would otherwise trip maybeExit). The
|
|
1539
|
+
// HTTP server keeps the process alive.
|
|
1540
|
+
rl.close();
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
rl.on('line', async (line) => {
|
|
1544
|
+
const t = line.trim();
|
|
1545
|
+
if (!t) {
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
let req;
|
|
1549
|
+
try {
|
|
1550
|
+
req = JSON.parse(t);
|
|
1551
|
+
}
|
|
1552
|
+
catch {
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
const resp = await dispatch(req);
|
|
1556
|
+
if (resp) {
|
|
1557
|
+
send(resp);
|
|
1558
|
+
}
|
|
1559
|
+
});
|
|
1560
|
+
rl.on('close', () => { closed = true; maybeExit(); });
|
|
1561
|
+
}
|
|
1046
1562
|
});
|
|
1047
1563
|
}
|
|
1048
1564
|
//# sourceMappingURL=mcpOperate.js.map
|