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.
Files changed (57) hide show
  1. package/media/profile/00-identity.md +35 -0
  2. package/media/profile/01-learning.md +23 -0
  3. package/media/profile/02-memory-mcp.md +49 -0
  4. package/media/profile/03-living-docs.md +18 -0
  5. package/media/profile/04-skill-files.md +9 -0
  6. package/media/profile/05-skill-creation.md +18 -0
  7. package/media/profile/06-core-rules.md +58 -0
  8. package/media/profile/07-core-loop.md +31 -0
  9. package/media/profile/08-thinking.md +30 -0
  10. package/media/profile/09-parallel-panel.md +29 -0
  11. package/media/profile/10-codebase-verification.md +19 -0
  12. package/media/profile/11-git-debug-security.md +16 -0
  13. package/media/profile/12-todo-format.md +20 -0
  14. package/media/profile/13-workflow-principles.md +17 -0
  15. package/media/profile/14-contracts.md +16 -0
  16. package/media/profile/15-soul.md +12 -0
  17. package/media/profile/16-journal.md +38 -0
  18. package/media/profile/17-issue-tracking.md +19 -0
  19. package/media/profile/18-knowledgebase.md +13 -0
  20. package/media/profile/19-subagent-context-management.md +510 -0
  21. package/media/profile/20-project-graph.md +56 -0
  22. package/out/agentBackup/layout.js +1 -0
  23. package/out/agentBackup/layout.js.map +1 -1
  24. package/out/commands/mcpOperate.js +544 -28
  25. package/out/commands/mcpOperate.js.map +1 -1
  26. package/out/configManager.d.ts +7 -0
  27. package/out/configManager.js +36 -0
  28. package/out/configManager.js.map +1 -1
  29. package/out/core/modelInfo.d.ts +28 -0
  30. package/out/core/modelInfo.js +97 -0
  31. package/out/core/modelInfo.js.map +1 -0
  32. package/out/core/projectSkills.d.ts +44 -0
  33. package/out/core/projectSkills.js +266 -0
  34. package/out/core/projectSkills.js.map +1 -0
  35. package/out/core/redactSecrets.d.ts +15 -0
  36. package/out/core/redactSecrets.js +110 -0
  37. package/out/core/redactSecrets.js.map +1 -0
  38. package/out/core/settingsLoader.d.ts +13 -1
  39. package/out/core/settingsLoader.js +1 -0
  40. package/out/core/settingsLoader.js.map +1 -1
  41. package/out/graphStore.d.ts +153 -0
  42. package/out/graphStore.js +412 -0
  43. package/out/graphStore.js.map +1 -0
  44. package/out/mcpInstallCheck.d.ts +2 -1
  45. package/out/mcpInstallCheck.js.map +1 -1
  46. package/out/profileBuilder.js +26 -1
  47. package/out/profileBuilder.js.map +1 -1
  48. package/out/taskLoop.d.ts +8 -0
  49. package/out/taskLoop.js +55 -2
  50. package/out/taskLoop.js.map +1 -1
  51. package/out/webSocketPoller.d.ts +3 -0
  52. package/out/webSocketPoller.js +11 -0
  53. package/out/webSocketPoller.js.map +1 -1
  54. package/out/webhookPoller.d.ts +1 -0
  55. package/out/webhookPoller.js +5 -0
  56. package/out/webhookPoller.js.map +1 -1
  57. 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 a stdio MCP server that operates a pixel-office agent (bridges to …/api/office-mcp). Add it with: claude mcp add pixel-office -- autodev mcp-operate --key <api_key> --url <url>')
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) => { clearTimeout(timer); resolve(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
- const msg = text.trim();
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
- socket.sendFrame({ type: 'hook_event', data: ev });
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
- await officeTool('complete_task', { task_id: t.id, result: (result || 'Done.').slice(0, 1500) });
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
- await officeTool('send_message', { to: m.from, message: reply.slice(0, 1000) });
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
- rl.on('line', async (line) => {
978
- const t = line.trim();
979
- if (!t) {
980
- return;
981
- }
982
- let req;
983
- try {
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 write a response for them.
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
- send({ jsonrpc: '2.0', id: req.id, result: { content: [{ type: 'text', text }] } });
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
- send(res);
1437
+ return res;
1036
1438
  }
1037
1439
  catch (e) {
1038
- send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: 'proxy error: ' + (e?.message ?? String(e)) } });
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
- rl.on('close', () => { closed = true; maybeExit(); });
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