@smartmemory/compose 0.1.0

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1014 -0
  3. package/bin/compose.js +1515 -0
  4. package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
  5. package/dist/assets/arc-SxJ2J1sh.js +1 -0
  6. package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
  7. package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
  8. package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
  9. package/dist/assets/channel-DGElom1e.js +1 -0
  10. package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
  11. package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
  12. package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
  13. package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
  14. package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
  15. package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
  16. package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
  17. package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
  18. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
  19. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
  20. package/dist/assets/clone-DUJKJXd7.js +1 -0
  21. package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
  22. package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
  23. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  24. package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
  25. package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
  26. package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
  27. package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
  28. package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
  29. package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
  30. package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
  31. package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
  32. package/dist/assets/graph-D0Cfv00Y.js +1 -0
  33. package/dist/assets/index-CUd6pFGF.css +1 -0
  34. package/dist/assets/index-DReRlzZI.js +1144 -0
  35. package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
  36. package/dist/assets/init-Gi6I4Gst.js +1 -0
  37. package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
  38. package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
  39. package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
  40. package/dist/assets/katex-DkKDou_j.js +257 -0
  41. package/dist/assets/layout-Bj72wOEB.js +1 -0
  42. package/dist/assets/linear-BRFo114D.js +1 -0
  43. package/dist/assets/min-GCHnKlJS.js +1 -0
  44. package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
  45. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  46. package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
  47. package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
  48. package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
  49. package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
  50. package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
  51. package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
  52. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
  53. package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
  54. package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
  55. package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
  56. package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
  57. package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
  58. package/dist/index.html +30 -0
  59. package/lib/agent-chains.js +65 -0
  60. package/lib/agent-string.js +86 -0
  61. package/lib/budget-ledger.js +86 -0
  62. package/lib/build-all.js +162 -0
  63. package/lib/build-dag.js +120 -0
  64. package/lib/build-stream-writer.js +190 -0
  65. package/lib/build.js +2997 -0
  66. package/lib/capability-checker.js +53 -0
  67. package/lib/cert-inject.js +38 -0
  68. package/lib/cli-progress.js +483 -0
  69. package/lib/constants.js +69 -0
  70. package/lib/cross-layer-audit.js +84 -0
  71. package/lib/debug-discipline.js +173 -0
  72. package/lib/feature-json.js +106 -0
  73. package/lib/gate-prompt.js +291 -0
  74. package/lib/gate-tiers.js +194 -0
  75. package/lib/health-history.js +119 -0
  76. package/lib/health-score.js +227 -0
  77. package/lib/ideabox.js +570 -0
  78. package/lib/import.js +244 -0
  79. package/lib/migrate-roadmap.js +94 -0
  80. package/lib/model-pricing.js +67 -0
  81. package/lib/new.js +413 -0
  82. package/lib/pipeline-cli.js +489 -0
  83. package/lib/plan-parser.js +103 -0
  84. package/lib/qa-scoping.js +474 -0
  85. package/lib/questionnaire.js +200 -0
  86. package/lib/resolve-port.js +7 -0
  87. package/lib/result-normalizer.js +349 -0
  88. package/lib/review-lenses.js +166 -0
  89. package/lib/roadmap-gen.js +210 -0
  90. package/lib/roadmap-parser.js +176 -0
  91. package/lib/server-probe.js +23 -0
  92. package/lib/staleness.js +87 -0
  93. package/lib/step-prompt.js +260 -0
  94. package/lib/step-validator.js +49 -0
  95. package/lib/stratum-mcp-client.js +365 -0
  96. package/lib/team-flag.js +46 -0
  97. package/lib/test-bootstrap.js +401 -0
  98. package/lib/triage.js +274 -0
  99. package/lib/vision-writer.js +391 -0
  100. package/package.json +111 -0
  101. package/pipelines/bug-fix.stratum.yaml +230 -0
  102. package/pipelines/build.stratum.yaml +498 -0
  103. package/pipelines/content.stratum.yaml +112 -0
  104. package/pipelines/coverage-sweep.stratum.yaml +52 -0
  105. package/pipelines/refactor.stratum.yaml +169 -0
  106. package/pipelines/research.stratum.yaml +88 -0
  107. package/pipelines/review-fix.stratum.yaml +109 -0
  108. package/presets/team-feature.stratum.yaml +105 -0
  109. package/presets/team-research.stratum.yaml +108 -0
  110. package/presets/team-review.stratum.yaml +106 -0
  111. package/scripts/agent-activity-hook.sh +31 -0
  112. package/scripts/agent-error-hook.sh +28 -0
  113. package/scripts/analyze-orphans.mjs +50 -0
  114. package/scripts/find-orphans.mjs +26 -0
  115. package/scripts/fix-phases.mjs +49 -0
  116. package/scripts/generate-stratum-spec.mjs +137 -0
  117. package/scripts/import-roadmap.mjs +116 -0
  118. package/scripts/phase-audit.mjs +33 -0
  119. package/scripts/run-pipeline.mjs +314 -0
  120. package/scripts/session-end-hook.sh +18 -0
  121. package/scripts/session-start-hook.sh +38 -0
  122. package/scripts/vision-hook.sh +104 -0
  123. package/scripts/vision-track.mjs +554 -0
  124. package/scripts/wire-all-orphans.mjs +108 -0
  125. package/scripts/wire-orphans.mjs +164 -0
  126. package/server/activity-routes.js +123 -0
  127. package/server/agent-health.js +197 -0
  128. package/server/agent-hooks.js +102 -0
  129. package/server/agent-mcp.js +10 -0
  130. package/server/agent-registry.js +95 -0
  131. package/server/agent-server.js +290 -0
  132. package/server/agent-spawn.js +251 -0
  133. package/server/agent-templates.js +77 -0
  134. package/server/artifact-manager.js +247 -0
  135. package/server/artifact-templates/architecture.md +28 -0
  136. package/server/artifact-templates/blueprint.md +21 -0
  137. package/server/artifact-templates/design.md +36 -0
  138. package/server/artifact-templates/plan.md +25 -0
  139. package/server/artifact-templates/prd.md +43 -0
  140. package/server/artifact-templates/report.md +40 -0
  141. package/server/block-tracker.js +90 -0
  142. package/server/build-stream-bridge.js +502 -0
  143. package/server/coalescing-buffer.js +46 -0
  144. package/server/compose-mcp-tools.js +479 -0
  145. package/server/compose-mcp.js +324 -0
  146. package/server/connectors/agent-connector.js +78 -0
  147. package/server/connectors/claude-sdk-connector.js +198 -0
  148. package/server/connectors/codex-connector.js +240 -0
  149. package/server/connectors/connector-discovery.js +18 -0
  150. package/server/connectors/connector-runtime.js +13 -0
  151. package/server/connectors/opencode-connector.js +200 -0
  152. package/server/design-routes.js +540 -0
  153. package/server/design-session.js +161 -0
  154. package/server/feature-scan.js +593 -0
  155. package/server/file-watcher.js +284 -0
  156. package/server/find-root.js +29 -0
  157. package/server/graph-export.js +343 -0
  158. package/server/ideabox-cache.js +77 -0
  159. package/server/ideabox-routes.js +294 -0
  160. package/server/index.js +156 -0
  161. package/server/model-tiers.js +49 -0
  162. package/server/pipeline-routes.js +288 -0
  163. package/server/policy-evaluator.js +36 -0
  164. package/server/project-root.js +122 -0
  165. package/server/security.js +23 -0
  166. package/server/session-manager.js +403 -0
  167. package/server/session-routes.js +190 -0
  168. package/server/session-store.js +107 -0
  169. package/server/settings-routes.js +35 -0
  170. package/server/settings-store.js +234 -0
  171. package/server/stratum-api.js +102 -0
  172. package/server/stratum-client.js +192 -0
  173. package/server/stratum-sync.js +193 -0
  174. package/server/summarizer.js +139 -0
  175. package/server/supervisor.js +196 -0
  176. package/server/vision-routes.js +668 -0
  177. package/server/vision-server.js +393 -0
  178. package/server/vision-store.js +360 -0
  179. package/server/vision-utils.js +179 -0
  180. package/server/worktree-gc.js +137 -0
  181. package/templates/ROADMAP.md +46 -0
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Agent Server (Tier 1 — immortal, port 3003 during migration, then 3002)
3
+ *
4
+ * Replaces terminal-server.js. Uses @anthropic-ai/claude-agent-sdk instead of
5
+ * node-pty + tmux. Streams typed SDK messages via SSE to the browser.
6
+ * User input arrives via POST — SSE is the right transport for a unidirectional
7
+ * async iterator.
8
+ *
9
+ * Process isolation: this server only talks to api-server (3001) via SDK hooks.
10
+ * SessionManager and VisionServer require zero changes.
11
+ */
12
+
13
+ import http from 'node:http';
14
+ import { readFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import express from 'express';
17
+ import cors from 'cors';
18
+ import { query } from '@anthropic-ai/claude-agent-sdk';
19
+ import { requireSensitiveToken } from './security.js';
20
+ import { HOOK_OPTIONS } from './agent-hooks.js';
21
+ import { getTargetRoot, getDataDir } from './project-root.js';
22
+ import { BuildStreamBridge } from './build-stream-bridge.js';
23
+ import { CoalescingBuffer } from './coalescing-buffer.js';
24
+
25
+ const _agentBuffer = new CoalescingBuffer((flushed) => {
26
+ if (flushed.agentMessage) {
27
+ for (const msg of flushed.agentMessage) {
28
+ _trackMessage(msg);
29
+ broadcast(msg);
30
+ }
31
+ }
32
+ }, { intervalMs: 16 });
33
+ _agentBuffer.register('agentMessage', 'append');
34
+
35
+ const _recentMessages = [];
36
+ const HYDRATE_LIMIT = 50;
37
+
38
+ function _trackMessage(msg) {
39
+ _recentMessages.push(msg);
40
+ if (_recentMessages.length > HYDRATE_LIMIT) _recentMessages.shift();
41
+ }
42
+
43
+ export function getAgentSnapshot() {
44
+ return _recentMessages.length > 0 ? [..._recentMessages] : null;
45
+ }
46
+
47
+ const PORT = process.env.AGENT_PORT || 4002;
48
+ const SETTINGS_FILE = path.join(getDataDir(), 'settings.json');
49
+
50
+ function _readModelSetting() {
51
+ try {
52
+ const raw = readFileSync(SETTINGS_FILE, 'utf-8');
53
+ return JSON.parse(raw)?.models?.interactive || null;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ const app = express();
60
+ app.use(cors({ origin: /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/ }));
61
+ app.use(express.json());
62
+
63
+ app.get('/api/health', (_req, res) => res.json({ ok: true }));
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Session state — one active SDK session at a time
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Active session state:
71
+ * id: string|null — SDK session_id captured from init message
72
+ * queryIter: Query|null — SDK async iterator, has .interrupt() method
73
+ */
74
+ let _session = { id: null, queryIter: null };
75
+
76
+ /** SSE clients waiting for messages */
77
+ const _sseClients = new Set();
78
+
79
+ function broadcast(msg) {
80
+ const line = `data: ${JSON.stringify(msg)}\n\n`;
81
+ for (const client of _sseClients) {
82
+ try {
83
+ client.write(line);
84
+ } catch {
85
+ _sseClients.delete(client);
86
+ }
87
+ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // GET /api/agent/stream — SSE subscription
92
+ // ---------------------------------------------------------------------------
93
+
94
+ app.get('/api/agent/stream', (req, res) => {
95
+ res.setHeader('Content-Type', 'text/event-stream');
96
+ res.setHeader('Cache-Control', 'no-cache');
97
+ res.setHeader('Connection', 'keep-alive');
98
+ res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering if proxied
99
+ res.flushHeaders();
100
+
101
+ _sseClients.add(res);
102
+
103
+ const snapshot = getAgentSnapshot();
104
+ if (snapshot) {
105
+ res.write(`event: hydrate\ndata: ${JSON.stringify(snapshot)}\n\n`);
106
+ }
107
+
108
+ // Immediately tell the new client whether a session exists
109
+ if (_session.id) {
110
+ res.write(`data: ${JSON.stringify({
111
+ type: 'system', subtype: 'connected', sessionId: _session.id,
112
+ })}\n\n`);
113
+ }
114
+
115
+ req.on('close', () => _sseClients.delete(res));
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // POST /api/agent/session — create a fresh session
120
+ // ---------------------------------------------------------------------------
121
+
122
+ app.post('/api/agent/session', requireSensitiveToken, (req, res) => {
123
+ const { prompt = '' } = req.body || {};
124
+ if (!prompt.trim()) return res.status(400).json({ error: 'prompt is required' });
125
+
126
+ _killCurrentSession();
127
+
128
+ try {
129
+ const q = _startQuery(prompt, null);
130
+ _session = { id: null, queryIter: q };
131
+ _consumeStream(q);
132
+ res.json({ ok: true });
133
+ } catch (err) {
134
+ res.status(500).json({ error: err.message });
135
+ }
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // POST /api/agent/message — send a follow-up message (resumes session)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ app.post('/api/agent/message', requireSensitiveToken, (req, res) => {
143
+ const { prompt } = req.body || {};
144
+ if (!prompt?.trim()) return res.status(400).json({ error: 'prompt is required' });
145
+
146
+ const resumeId = _session.id;
147
+ _killCurrentSession();
148
+
149
+ try {
150
+ const q = _startQuery(prompt, resumeId);
151
+ _session = { id: resumeId, queryIter: q };
152
+ _consumeStream(q);
153
+ res.json({ ok: true, resumeSessionId: resumeId });
154
+ } catch (err) {
155
+ res.status(500).json({ error: err.message });
156
+ }
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // POST /api/agent/interrupt — interrupt current query
161
+ // ---------------------------------------------------------------------------
162
+
163
+ app.post('/api/agent/interrupt', requireSensitiveToken, (req, res) => {
164
+ if (!_session.queryIter) {
165
+ return res.status(404).json({ error: 'No active query' });
166
+ }
167
+ try {
168
+ _session.queryIter.interrupt();
169
+
170
+ // COMP-AGT-1: Escalation — if not resolved within 5s, force-kill the session
171
+ const capturedIter = _session.queryIter;
172
+ setTimeout(() => {
173
+ if (_session.queryIter === capturedIter) {
174
+ console.log('[agent-server] Interrupt escalation: killing session after 5s timeout');
175
+ _killCurrentSession();
176
+ }
177
+ }, 5000);
178
+
179
+ res.json({ ok: true });
180
+ } catch (err) {
181
+ res.status(500).json({ error: err.message });
182
+ }
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // GET /api/agent/session/status
187
+ // ---------------------------------------------------------------------------
188
+
189
+ app.get('/api/agent/session/status', (_req, res) => {
190
+ res.json({
191
+ active: !!_session.queryIter,
192
+ sessionId: _session.id || null,
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Internals
198
+ // ---------------------------------------------------------------------------
199
+
200
+ function _buildOptions(prompt, resumeId) {
201
+ return {
202
+ cwd: getTargetRoot(),
203
+ model: _readModelSetting() || 'claude-sonnet-4-6',
204
+ permissionMode: 'acceptEdits',
205
+ settingSources: ['project'],
206
+ tools: { type: 'preset', preset: 'claude_code' },
207
+ hooks: HOOK_OPTIONS,
208
+ ...(resumeId ? { resume: resumeId } : {}),
209
+ };
210
+ }
211
+
212
+ function _startQuery(prompt, resumeId) {
213
+ return query({ prompt, options: _buildOptions(prompt, resumeId) });
214
+ }
215
+
216
+ function _killCurrentSession() {
217
+ if (_session.queryIter) {
218
+ try { _session.queryIter.return(); } catch { /* ignore */ }
219
+ }
220
+ _session = { id: null, queryIter: null };
221
+ _recentMessages.length = 0;
222
+ }
223
+
224
+ async function _consumeStream(q) {
225
+ try {
226
+ for await (const msg of q) {
227
+ // Capture session_id from the init message so we can resume
228
+ if (msg.type === 'system' && msg.subtype === 'init') {
229
+ _session.id = msg.session_id;
230
+ }
231
+ _agentBuffer.put('agentMessage', msg);
232
+ }
233
+ } catch (err) {
234
+ if (err?.name !== 'AbortError') {
235
+ broadcast({ type: 'error', message: err.message || String(err) });
236
+ }
237
+ } finally {
238
+ // Signal completion to clients regardless of success/error
239
+ if (_session.queryIter === q) {
240
+ _session.queryIter = null;
241
+ }
242
+ // Keep _recentMessages populated after natural session end so clients
243
+ // reloading the tab post-turn can still hydrate recent history.
244
+ // Ring is cleared only on force-kill / new-session start in _killCurrentSession.
245
+ }
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Server startup
250
+ // ---------------------------------------------------------------------------
251
+
252
+ let serverListening = false;
253
+ const server = http.createServer(app);
254
+
255
+ // Build stream bridge — tails JSONL from CLI build, rebroadcasts via SSE
256
+ const _bridge = new BuildStreamBridge(
257
+ path.join(getTargetRoot(), '.compose'),
258
+ broadcast
259
+ );
260
+
261
+ server.listen(PORT, '127.0.0.1', () => {
262
+ serverListening = true;
263
+ _bridge.start();
264
+ console.log(`Agent server running on http://127.0.0.1:${PORT}`);
265
+ });
266
+
267
+ function shutdown(sig) {
268
+ console.log(`[agent-server] ${sig}, shutting down`);
269
+ _agentBuffer.stop();
270
+ _bridge.stop();
271
+ _killCurrentSession();
272
+ server.close();
273
+ setTimeout(() => process.exit(0), 1000);
274
+ }
275
+
276
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
277
+ process.on('SIGINT', () => shutdown('SIGINT'));
278
+
279
+ process.on('uncaughtException', (err) => {
280
+ if (!serverListening && err.code === 'EADDRINUSE') {
281
+ console.error(`[agent-server] Port ${PORT} in use, exiting for supervisor retry`);
282
+ process.exit(1);
283
+ }
284
+ console.error('[agent-server] Uncaught exception (kept alive):', err.message);
285
+ console.error(err.stack);
286
+ });
287
+
288
+ process.on('unhandledRejection', (reason) => {
289
+ console.error('[agent-server] Unhandled rejection (kept alive):', reason);
290
+ });
@@ -0,0 +1,251 @@
1
+ /**
2
+ * agent-spawn.js — Hidden Claude subagent spawn/poll routes.
3
+ *
4
+ * Routes: POST /api/agent/spawn, GET /api/agent/:id, GET /api/agents
5
+ */
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import path from 'node:path';
9
+
10
+ import { getTargetRoot } from './project-root.js';
11
+ import { gracefulKill } from './agent-health.js';
12
+
13
+ const PROJECT_ROOT = getTargetRoot();
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Route registration
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Attach agent spawn/poll routes to an Express app.
21
+ * Each call creates an isolated agent registry — safe for multiple instances.
22
+ *
23
+ * @param {object} app — Express app
24
+ * @param {{ projectRoot: string, broadcastMessage: function, requireSensitiveToken: function }} deps
25
+ */
26
+ function deriveAgentType(prompt) {
27
+ const lower = (prompt ?? '').toLowerCase();
28
+ if (lower.includes('explore') || lower.includes('find features') || lower.includes('map the architecture'))
29
+ return 'compose-explorer';
30
+ if (lower.includes('architect') || lower.includes('competing') || lower.includes('proposal'))
31
+ return 'compose-architect';
32
+ if (lower.includes('review') || lower.includes('codex'))
33
+ return 'codex';
34
+ return 'claude';
35
+ }
36
+
37
+ export function attachAgentSpawnRoutes(app, { projectRoot = PROJECT_ROOT, broadcastMessage, requireSensitiveToken, registry, sessionManager, healthMonitor, worktreeGC }) {
38
+ const _agents = new Map();
39
+ // POST /api/agent/spawn — spawn a hidden Claude subagent
40
+ app.post('/api/agent/spawn', requireSensitiveToken, (req, res) => {
41
+ const { prompt, id } = req.body || {};
42
+ if (!prompt || typeof prompt !== 'string') return res.status(400).json({ error: 'prompt is required and must be a string' });
43
+
44
+ const agentId = id || `agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
45
+
46
+ if (_agents.has(agentId)) {
47
+ return res.status(409).json({ error: `Agent ${agentId} already running` });
48
+ }
49
+
50
+ const cleanEnv = { ...process.env, NO_COLOR: '1' };
51
+ delete cleanEnv.CLAUDECODE;
52
+
53
+ const proc = spawn('claude', [
54
+ '-p', prompt,
55
+ '--dangerously-skip-permissions',
56
+ ], {
57
+ cwd: projectRoot,
58
+ env: cleanEnv,
59
+ stdio: ['ignore', 'pipe', 'pipe'],
60
+ });
61
+
62
+ const agent = {
63
+ process: proc,
64
+ output: '',
65
+ stderr: '',
66
+ status: 'running',
67
+ prompt,
68
+ startedAt: new Date().toISOString(),
69
+ };
70
+ _agents.set(agentId, agent);
71
+
72
+ // Register with persistent registry + broadcast spawn event
73
+ const agentType = deriveAgentType(prompt);
74
+ const parentSessionId = sessionManager?.currentSession?.id ?? null;
75
+ if (registry) {
76
+ registry.register(agentId, { parentSessionId, agentType, prompt, pid: proc.pid });
77
+ }
78
+ broadcastMessage({
79
+ type: 'agentSpawned',
80
+ agentId,
81
+ parentSessionId,
82
+ agentType,
83
+ prompt: prompt.slice(0, 200),
84
+ startedAt: agent.startedAt,
85
+ });
86
+ broadcastMessage({
87
+ type: 'agentRelay',
88
+ fromAgentId: parentSessionId || 'session',
89
+ toAgentId: agentId,
90
+ direction: 'dispatch',
91
+ messagePreview: (prompt || '').slice(0, 80),
92
+ timestamp: new Date().toISOString(),
93
+ });
94
+
95
+ proc.stdout.on('data', (chunk) => {
96
+ agent.output += chunk.toString();
97
+ });
98
+
99
+ proc.stderr.on('data', (chunk) => {
100
+ agent.stderr += chunk.toString();
101
+ });
102
+
103
+ // COMP-AGT-1: Wire health monitor after stdout/stderr listeners
104
+ if (healthMonitor) healthMonitor.track(agentId, proc);
105
+
106
+ proc.on('close', (code) => {
107
+ // COMP-AGT-1: Untrack from health monitor, preserve terminal reason
108
+ if (healthMonitor) {
109
+ const terminalReason = healthMonitor.getTerminalReason(agentId);
110
+ healthMonitor.untrack(agentId);
111
+ if (terminalReason) {
112
+ agent.status = 'killed';
113
+ agent.terminalReason = terminalReason;
114
+ agent.exitCode = code;
115
+ if (registry) {
116
+ registry.updateStatus(agentId, 'killed', terminalReason);
117
+ }
118
+ broadcastMessage({
119
+ type: 'agentComplete',
120
+ agentId,
121
+ agentType,
122
+ status: 'killed',
123
+ terminalReason,
124
+ output: agent.output,
125
+ });
126
+ broadcastMessage({
127
+ type: 'agentRelay',
128
+ fromAgentId: agentId,
129
+ toAgentId: parentSessionId || 'session',
130
+ direction: 'result',
131
+ messagePreview: `Killed: ${terminalReason}`,
132
+ timestamp: new Date().toISOString(),
133
+ });
134
+ return; // skip normal close handling
135
+ }
136
+ }
137
+ agent.status = code === 0 ? 'complete' : 'failed';
138
+ agent.exitCode = code;
139
+ if (registry) {
140
+ registry.complete(agentId, { status: agent.status, exitCode: code });
141
+ }
142
+ broadcastMessage({
143
+ type: 'agentComplete',
144
+ agentId,
145
+ agentType,
146
+ status: agent.status,
147
+ output: agent.output,
148
+ });
149
+ broadcastMessage({
150
+ type: 'agentRelay',
151
+ fromAgentId: agentId,
152
+ toAgentId: parentSessionId || 'session',
153
+ direction: 'result',
154
+ messagePreview: (agent.output || '').slice(0, 80),
155
+ timestamp: new Date().toISOString(),
156
+ });
157
+ });
158
+
159
+ proc.on('error', (err) => {
160
+ agent.status = 'failed';
161
+ agent.stderr += err.message;
162
+ console.error(`[vision] Agent ${agentId} spawn error:`, err.message);
163
+ });
164
+
165
+ console.log(`[vision] Agent ${agentId} spawned (PID ${proc.pid})`);
166
+ res.status(201).json({ agentId, pid: proc.pid, status: 'running' });
167
+ });
168
+
169
+ // GET /api/agent/:id — poll agent status + output
170
+ app.get('/api/agent/:id', (req, res) => {
171
+ const agent = _agents.get(req.params.id);
172
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
173
+ res.json({
174
+ agentId: req.params.id,
175
+ status: agent.status,
176
+ output: agent.output,
177
+ stderr: agent.stderr,
178
+ exitCode: agent.exitCode,
179
+ startedAt: agent.startedAt,
180
+ });
181
+ });
182
+
183
+ // GET /api/agents — list all agents
184
+ app.get('/api/agents', (_req, res) => {
185
+ const agents = [];
186
+ for (const [id, agent] of _agents) {
187
+ agents.push({
188
+ agentId: id,
189
+ status: agent.status,
190
+ startedAt: agent.startedAt,
191
+ outputLength: agent.output.length,
192
+ });
193
+ }
194
+ res.json({ agents });
195
+ });
196
+
197
+ // GET /api/agents/tree — agent hierarchy for current session
198
+ app.get('/api/agents/tree', (_req, res) => {
199
+ if (!registry) return res.json({ agents: [] });
200
+ const parentId = sessionManager?.currentSession?.id ?? null;
201
+ const agents = parentId ? registry.getChildren(parentId) : registry.getAll();
202
+ res.json({ sessionId: parentId, agents });
203
+ });
204
+
205
+ // POST /api/agent/:id/stop — COMP-AGT-1: graceful stop (SIGTERM → 5s → SIGKILL)
206
+ app.post('/api/agent/:id/stop', requireSensitiveToken, (req, res) => {
207
+ const agentId = req.params.id;
208
+ const agent = _agents.get(agentId);
209
+
210
+ // For SDK sessions, proxy to agent-server's interrupt endpoint
211
+ const currentSessionId = sessionManager?.currentSession?.id ?? null;
212
+ if (agentId === currentSessionId) {
213
+ const agentPort = process.env.AGENT_PORT || 4002;
214
+ const headers = { 'Content-Type': 'application/json' };
215
+ if (process.env.COMPOSE_API_TOKEN) headers['x-compose-token'] = process.env.COMPOSE_API_TOKEN;
216
+ fetch(`http://127.0.0.1:${agentPort}/api/agent/interrupt`, {
217
+ method: 'POST',
218
+ headers,
219
+ }).then(r => {
220
+ if (!r.ok) {
221
+ return res.status(r.status).json({ ok: false, error: `Interrupt proxy failed: ${r.status}` });
222
+ }
223
+ return r.json().then(data => res.json({ ok: true, proxied: true, ...data }));
224
+ })
225
+ .catch(err => res.status(502).json({ error: `Interrupt proxy failed: ${err.message}` }));
226
+ return;
227
+ }
228
+
229
+ if (!agent) return res.status(404).json({ error: 'Agent not found' });
230
+ if (agent.status !== 'running') return res.json({ ok: true, status: agent.status, note: 'already stopped' });
231
+
232
+ // Set terminal reason before killing
233
+ if (healthMonitor) healthMonitor.setTerminalReason(agentId, 'manual_stop');
234
+
235
+ gracefulKill(agent.process);
236
+
237
+ res.json({ ok: true, agentId, action: 'SIGTERM sent, SIGKILL in 5s if needed' });
238
+ });
239
+
240
+ // POST /api/agent/gc — COMP-AGT-1: trigger worktree garbage collection
241
+ app.post('/api/agent/gc', requireSensitiveToken, async (_req, res) => {
242
+ if (!worktreeGC) return res.status(503).json({ error: 'WorktreeGC not available' });
243
+ try {
244
+ const removed = await worktreeGC.runNow();
245
+ broadcastMessage({ type: 'agentGC', removed, timestamp: new Date().toISOString() });
246
+ res.json({ ok: true, removed });
247
+ } catch (err) {
248
+ res.status(500).json({ error: err.message });
249
+ }
250
+ });
251
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * agent-templates.js — Agent capability profiles for COMP-AGENT-CAPS.
3
+ *
4
+ * Defines which tools each agent role is allowed or denied.
5
+ * Violations are informational in v1 — logged but not blocking.
6
+ */
7
+
8
+ /**
9
+ * @type {Map<string, {allowedTools: string[]|null, disallowedTools: string[]|null, description: string}>}
10
+ */
11
+ export const AGENT_TEMPLATES = new Map([
12
+ ['read-only-reviewer', {
13
+ allowedTools: ['Read', 'Grep', 'Glob', 'Agent'],
14
+ disallowedTools: ['Edit', 'Write', 'Bash'],
15
+ description: 'Read-only review agent',
16
+ }],
17
+ ['read-only-researcher', {
18
+ allowedTools: ['Read', 'Grep', 'Glob', 'Agent', 'WebSearch', 'WebFetch'],
19
+ disallowedTools: ['Edit', 'Write', 'Bash'],
20
+ description: 'Read-only research agent with web access',
21
+ }],
22
+ ['implementer', {
23
+ allowedTools: null,
24
+ disallowedTools: null,
25
+ description: 'Full access implementation agent',
26
+ }],
27
+ ['orchestrator', {
28
+ allowedTools: ['Read', 'Grep', 'Glob', 'Agent', 'Bash'],
29
+ disallowedTools: ['Edit', 'Write'],
30
+ description: 'Meta-config orchestrator',
31
+ }],
32
+ ['security-auditor', {
33
+ allowedTools: ['Read', 'Grep', 'Glob', 'Bash'],
34
+ disallowedTools: ['Edit', 'Write'],
35
+ description: 'Security audit agent',
36
+ }],
37
+ ]);
38
+
39
+ /**
40
+ * Resolve a template by name.
41
+ * @param {string|null|undefined} templateName
42
+ * @returns {{ allowedTools: string[]|null, disallowedTools: string[]|null, description: string }|null}
43
+ * Returns null if templateName is unknown — callers fall back to no restrictions.
44
+ */
45
+ export function resolveTemplate(templateName) {
46
+ if (!templateName) return null;
47
+ return AGENT_TEMPLATES.get(templateName) ?? null;
48
+ }
49
+
50
+ /**
51
+ * Check whether a given tool is allowed under a template.
52
+ * @param {{ allowedTools: string[]|null, disallowedTools: string[]|null }|null} template
53
+ * @param {string} toolName
54
+ * @returns {{ allowed: boolean, reason: string }}
55
+ */
56
+ export function validateCapabilities(template, toolName) {
57
+ if (!template) {
58
+ return { allowed: true, reason: 'No template — all tools permitted' };
59
+ }
60
+
61
+ const { allowedTools, disallowedTools } = template;
62
+
63
+ // Explicit deny list takes priority
64
+ if (disallowedTools && disallowedTools.includes(toolName)) {
65
+ return { allowed: false, reason: `Tool "${toolName}" is in disallowedTools` };
66
+ }
67
+
68
+ // If allowedTools is set, tool must appear in it
69
+ if (allowedTools !== null) {
70
+ if (allowedTools.includes(toolName)) {
71
+ return { allowed: true, reason: `Tool "${toolName}" is in allowedTools` };
72
+ }
73
+ return { allowed: false, reason: `Tool "${toolName}" is not in allowedTools` };
74
+ }
75
+
76
+ return { allowed: true, reason: 'No restrictions defined' };
77
+ }