@aion0/forge 0.4.16 → 0.5.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 (92) hide show
  1. package/README.md +1 -1
  2. package/RELEASE_NOTES.md +170 -14
  3. package/app/api/agents/route.ts +17 -0
  4. package/app/api/delivery/[id]/route.ts +62 -0
  5. package/app/api/delivery/route.ts +40 -0
  6. package/app/api/mobile-chat/route.ts +13 -7
  7. package/app/api/monitor/route.ts +10 -6
  8. package/app/api/pipelines/[id]/route.ts +16 -3
  9. package/app/api/tasks/route.ts +2 -1
  10. package/app/api/workspace/[id]/agents/route.ts +35 -0
  11. package/app/api/workspace/[id]/memory/route.ts +23 -0
  12. package/app/api/workspace/[id]/smith/route.ts +22 -0
  13. package/app/api/workspace/[id]/stream/route.ts +28 -0
  14. package/app/api/workspace/route.ts +100 -0
  15. package/app/global-error.tsx +10 -4
  16. package/app/icon.ico +0 -0
  17. package/app/layout.tsx +2 -2
  18. package/app/login/LoginForm.tsx +96 -0
  19. package/app/login/page.tsx +7 -98
  20. package/app/page.tsx +2 -2
  21. package/bin/forge-server.mjs +13 -1
  22. package/check-forge-status.sh +9 -0
  23. package/components/ConversationEditor.tsx +411 -0
  24. package/components/ConversationGraphView.tsx +347 -0
  25. package/components/ConversationTerminalView.tsx +303 -0
  26. package/components/Dashboard.tsx +36 -39
  27. package/components/DashboardWrapper.tsx +9 -0
  28. package/components/DeliveryFlowEditor.tsx +491 -0
  29. package/components/DeliveryList.tsx +230 -0
  30. package/components/DeliveryWorkspace.tsx +589 -0
  31. package/components/DocTerminal.tsx +10 -2
  32. package/components/DocsViewer.tsx +10 -2
  33. package/components/HelpTerminal.tsx +11 -6
  34. package/components/InlinePipelineView.tsx +111 -0
  35. package/components/MobileView.tsx +20 -0
  36. package/components/MonitorPanel.tsx +9 -4
  37. package/components/NewTaskModal.tsx +32 -0
  38. package/components/PipelineEditor.tsx +49 -6
  39. package/components/PipelineView.tsx +482 -64
  40. package/components/ProjectDetail.tsx +314 -56
  41. package/components/ProjectManager.tsx +49 -4
  42. package/components/SessionView.tsx +27 -13
  43. package/components/SettingsModal.tsx +790 -124
  44. package/components/SkillsPanel.tsx +31 -8
  45. package/components/TaskBoard.tsx +3 -0
  46. package/components/WebTerminal.tsx +257 -43
  47. package/components/WorkspaceTree.tsx +221 -0
  48. package/components/WorkspaceView.tsx +2224 -0
  49. package/install.sh +2 -2
  50. package/lib/agents/claude-adapter.ts +104 -0
  51. package/lib/agents/generic-adapter.ts +64 -0
  52. package/lib/agents/index.ts +242 -0
  53. package/lib/agents/types.ts +70 -0
  54. package/lib/artifacts.ts +106 -0
  55. package/lib/delivery.ts +787 -0
  56. package/lib/forge-skills/forge-inbox.md +37 -0
  57. package/lib/forge-skills/forge-send.md +40 -0
  58. package/lib/forge-skills/forge-status.md +32 -0
  59. package/lib/forge-skills/forge-workspace-sync.md +37 -0
  60. package/lib/help-docs/00-overview.md +7 -1
  61. package/lib/help-docs/01-settings.md +159 -2
  62. package/lib/help-docs/05-pipelines.md +89 -0
  63. package/lib/help-docs/07-projects.md +35 -1
  64. package/lib/help-docs/11-workspace.md +204 -0
  65. package/lib/help-docs/CLAUDE.md +5 -2
  66. package/lib/init.ts +60 -10
  67. package/lib/pipeline.ts +537 -1
  68. package/lib/settings.ts +115 -22
  69. package/lib/skills.ts +249 -372
  70. package/lib/task-manager.ts +113 -33
  71. package/lib/telegram-bot.ts +33 -1
  72. package/lib/workspace/__tests__/state-machine.test.ts +388 -0
  73. package/lib/workspace/__tests__/workspace.test.ts +311 -0
  74. package/lib/workspace/agent-bus.ts +416 -0
  75. package/lib/workspace/agent-worker.ts +667 -0
  76. package/lib/workspace/backends/api-backend.ts +262 -0
  77. package/lib/workspace/backends/cli-backend.ts +479 -0
  78. package/lib/workspace/index.ts +82 -0
  79. package/lib/workspace/manager.ts +136 -0
  80. package/lib/workspace/orchestrator.ts +1804 -0
  81. package/lib/workspace/persistence.ts +310 -0
  82. package/lib/workspace/presets.ts +170 -0
  83. package/lib/workspace/skill-installer.ts +188 -0
  84. package/lib/workspace/smith-memory.ts +498 -0
  85. package/lib/workspace/types.ts +231 -0
  86. package/lib/workspace/watch-manager.ts +288 -0
  87. package/lib/workspace-standalone.ts +790 -0
  88. package/middleware.ts +1 -0
  89. package/package.json +4 -1
  90. package/src/config/index.ts +12 -1
  91. package/src/core/db/database.ts +1 -0
  92. package/start.sh +7 -0
@@ -0,0 +1,790 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * Workspace Daemon — standalone process for managing workspace agent orchestrators.
4
+ *
5
+ * Runs as an independent HTTP server (like terminal-standalone.ts).
6
+ * Next.js API routes proxy requests here.
7
+ *
8
+ * Usage: npx tsx lib/workspace-standalone.ts [--forge-port=8403]
9
+ *
10
+ * Env:
11
+ * WORKSPACE_PORT — HTTP port (default: webPort + 2 = 8405)
12
+ * FORGE_DATA_DIR — data directory
13
+ */
14
+
15
+ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
16
+ import { readdirSync, statSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import { WorkspaceOrchestrator, type OrchestratorEvent } from './workspace/orchestrator';
20
+ import { loadWorkspace, saveWorkspace } from './workspace/persistence';
21
+ import { installForgeSkills, applyProfileToProject } from './workspace/skill-installer';
22
+ import {
23
+ loadMemory, formatMemoryForDisplay, getMemoryStats,
24
+ addObservation, addSessionSummary,
25
+ } from './workspace/smith-memory';
26
+ import type { WorkspaceAgentConfig, WorkspaceState, BusMessage } from './workspace/types';
27
+ import { execSync } from 'node:child_process';
28
+
29
+ // ─── Config ──────────────────────────────────────────────
30
+
31
+ const PORT = Number(process.env.WORKSPACE_PORT) || 8405;
32
+ const FORGE_PORT = Number(process.env.PORT) || 8403;
33
+ const MAX_ACTIVE = 2;
34
+
35
+ // ─── State ───────────────────────────────────────────────
36
+
37
+ const orchestrators = new Map<string, WorkspaceOrchestrator>();
38
+ const sseClients = new Map<string, Set<ServerResponse>>();
39
+ const startTime = Date.now();
40
+
41
+ // ─── Orchestrator Lifecycle ──────────────────────────────
42
+
43
+ function getOrchestrator(id: string): WorkspaceOrchestrator | null {
44
+ return orchestrators.get(id) || null;
45
+ }
46
+
47
+ function loadOrchestrator(id: string): WorkspaceOrchestrator {
48
+ const existing = orchestrators.get(id);
49
+ if (existing) return existing;
50
+
51
+ // Enforce max active limit
52
+ if (orchestrators.size >= MAX_ACTIVE) {
53
+ const evicted = evictIdleWorkspace();
54
+ if (!evicted) {
55
+ throw new Error(`Maximum ${MAX_ACTIVE} active workspaces. Stop agents in another workspace first.`);
56
+ }
57
+ }
58
+
59
+ const state = loadWorkspace(id);
60
+ if (!state) throw new Error('Workspace not found');
61
+
62
+ const orch = new WorkspaceOrchestrator(state.id, state.projectPath, state.projectName);
63
+ if (state.agents.length > 0) {
64
+ orch.loadSnapshot({
65
+ agents: state.agents,
66
+ agentStates: state.agentStates,
67
+ busLog: state.busLog,
68
+ busOutbox: state.busOutbox,
69
+ });
70
+ }
71
+
72
+ // Wire up SSE broadcasting
73
+ orch.on('event', (event: OrchestratorEvent) => {
74
+ broadcastSSE(id, event);
75
+ });
76
+
77
+ orchestrators.set(id, orch);
78
+ console.log(`[workspace] Loaded orchestrator: ${state.projectName} (${id})`);
79
+ return orch;
80
+ }
81
+
82
+ function unloadOrchestrator(id: string): void {
83
+ const orch = orchestrators.get(id);
84
+ if (!orch) return;
85
+ orch.shutdown();
86
+ orchestrators.delete(id);
87
+ // Close SSE connections for this workspace
88
+ const clients = sseClients.get(id);
89
+ if (clients) {
90
+ for (const res of clients) {
91
+ try { res.end(); } catch {}
92
+ }
93
+ sseClients.delete(id);
94
+ }
95
+ console.log(`[workspace] Unloaded orchestrator: ${id}`);
96
+ }
97
+
98
+ function evictIdleWorkspace(): boolean {
99
+ for (const [id, orch] of orchestrators) {
100
+ const states = orch.getAllAgentStates();
101
+ const hasRunning = Object.values(states).some(s =>
102
+ s.taskStatus === 'running' || s.smithStatus === 'active'
103
+ );
104
+ if (!hasRunning) {
105
+ unloadOrchestrator(id);
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+
112
+ // ─── SSE Management ──────────────────────────────────────
113
+
114
+ function addSSEClient(workspaceId: string, res: ServerResponse): void {
115
+ if (!sseClients.has(workspaceId)) sseClients.set(workspaceId, new Set());
116
+ sseClients.get(workspaceId)!.add(res);
117
+ }
118
+
119
+ function removeSSEClient(workspaceId: string, res: ServerResponse): void {
120
+ sseClients.get(workspaceId)?.delete(res);
121
+ if (sseClients.get(workspaceId)?.size === 0) sseClients.delete(workspaceId);
122
+ }
123
+
124
+ function broadcastSSE(workspaceId: string, event: OrchestratorEvent): void {
125
+ const clients = sseClients.get(workspaceId);
126
+ if (!clients) return;
127
+ const data = `data: ${JSON.stringify(event)}\n\n`;
128
+ for (const res of clients) {
129
+ try { res.write(data); } catch { removeSSEClient(workspaceId, res); }
130
+ }
131
+ }
132
+
133
+ // ─── HTTP Helpers ────────────────────────────────────────
134
+
135
+ function readBody(req: IncomingMessage): Promise<string> {
136
+ return new Promise((resolve, reject) => {
137
+ const chunks: Buffer[] = [];
138
+ req.on('data', (c: Buffer) => chunks.push(c));
139
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
140
+ req.on('error', reject);
141
+ });
142
+ }
143
+
144
+ function json(res: ServerResponse, data: unknown, status = 200): void {
145
+ res.writeHead(status, { 'Content-Type': 'application/json' });
146
+ res.end(JSON.stringify(data));
147
+ }
148
+
149
+ function jsonError(res: ServerResponse, msg: string, status = 400): void {
150
+ json(res, { error: msg }, status);
151
+ }
152
+
153
+ function parseUrl(url: string): { path: string; query: URLSearchParams } {
154
+ const u = new URL(url, 'http://localhost');
155
+ return { path: u.pathname, query: u.searchParams };
156
+ }
157
+
158
+ // ─── Route: Agent Operations ─────────────────────────────
159
+
160
+ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Promise<void> {
161
+ let orch: WorkspaceOrchestrator;
162
+ try {
163
+ orch = loadOrchestrator(id);
164
+ } catch (err: any) {
165
+ return jsonError(res, err.message, err.message.includes('not found') ? 404 : 429);
166
+ }
167
+
168
+ const { action, agentId, config, content, input } = body;
169
+
170
+ try {
171
+ switch (action) {
172
+ case 'add': {
173
+ if (!config) return jsonError(res, 'config required');
174
+ try {
175
+ orch.addAgent(config as WorkspaceAgentConfig);
176
+ return json(res, { ok: true });
177
+ } catch (err: any) {
178
+ return jsonError(res, err.message);
179
+ }
180
+ }
181
+ case 'create_pipeline': {
182
+ const { createDevPipeline } = require('./workspace/presets');
183
+ const pipeline = createDevPipeline();
184
+ for (const cfg of pipeline) orch.addAgent(cfg);
185
+ return json(res, { ok: true, agents: pipeline.length });
186
+ }
187
+ case 'remove': {
188
+ if (!agentId) return jsonError(res, 'agentId required');
189
+ orch.removeAgent(agentId);
190
+ return json(res, { ok: true });
191
+ }
192
+ case 'update': {
193
+ if (!agentId || !config) return jsonError(res, 'agentId and config required');
194
+ try {
195
+ orch.updateAgentConfig(agentId, config as WorkspaceAgentConfig);
196
+ return json(res, { ok: true });
197
+ } catch (err: any) {
198
+ return jsonError(res, err.message);
199
+ }
200
+ }
201
+ case 'run': {
202
+ if (!agentId) return jsonError(res, 'agentId required');
203
+ if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before running agents');
204
+ try {
205
+ await orch.runAgent(agentId, input, true); // force=true: manual trigger skips dep check
206
+ return json(res, { ok: true, status: 'started' });
207
+ } catch (err: any) {
208
+ return jsonError(res, err.message);
209
+ }
210
+ }
211
+ case 'run_all': {
212
+ orch.runAll().catch(err => {
213
+ console.error('[workspace] runAll error:', err.message);
214
+ });
215
+ return json(res, { ok: true, status: 'started' });
216
+ }
217
+ case 'complete_input': {
218
+ if (!agentId || !content) return jsonError(res, 'agentId and content required');
219
+ orch.completeInput(agentId, content);
220
+ return json(res, { ok: true });
221
+ }
222
+ case 'pause': {
223
+ if (!agentId) return jsonError(res, 'agentId required');
224
+ orch.pauseAgent(agentId);
225
+ return json(res, { ok: true });
226
+ }
227
+ case 'resume': {
228
+ if (!agentId) return jsonError(res, 'agentId required');
229
+ orch.resumeAgent(agentId);
230
+ return json(res, { ok: true });
231
+ }
232
+ case 'stop': {
233
+ if (!agentId) return jsonError(res, 'agentId required');
234
+ orch.stopAgent(agentId);
235
+ return json(res, { ok: true });
236
+ }
237
+ case 'retry': {
238
+ if (!agentId) return jsonError(res, 'agentId required');
239
+ if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before retrying agents');
240
+ const retryState = orch.getAgentState(agentId);
241
+ if (!retryState) return jsonError(res, 'Agent not found');
242
+ if (retryState.taskStatus === 'running') return jsonError(res, 'Agent is already running');
243
+ if (retryState.taskStatus !== 'failed') return jsonError(res, `Agent is ${retryState.taskStatus}, not failed`);
244
+ try {
245
+ console.log(`[workspace] Retry ${agentId}: smith=${retryState.smithStatus}, task=${retryState.taskStatus}`);
246
+ await orch.runAgent(agentId, undefined, true);
247
+ return json(res, { ok: true, status: 'retrying' });
248
+ } catch (err: any) {
249
+ console.error(`[workspace] Retry failed for ${agentId}:`, err.message);
250
+ return jsonError(res, err.message);
251
+ }
252
+ }
253
+ case 'set_tmux_session': {
254
+ if (!agentId) return jsonError(res, 'agentId required');
255
+ const { sessionName } = body;
256
+ orch.setTmuxSession(agentId, sessionName);
257
+ return json(res, { ok: true });
258
+ }
259
+ case 'reset': {
260
+ if (!agentId) return jsonError(res, 'agentId required');
261
+ orch.resetAgent(agentId);
262
+ // If daemon is active, re-enter daemon mode for this agent
263
+ if (orch.isDaemonActive()) {
264
+ orch.restartAgentDaemon(agentId);
265
+ }
266
+ return json(res, { ok: true });
267
+ }
268
+ case 'open_terminal': {
269
+ if (!agentId) return jsonError(res, 'agentId required');
270
+ if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before opening terminal');
271
+ const agentState = orch.getAgentState(agentId);
272
+ const agentConfig = orch.getSnapshot().agents.find(a => a.id === agentId);
273
+ if (!agentState || !agentConfig) return jsonError(res, 'Agent not found', 404);
274
+
275
+ // Resolve launch info using shared logic (same as VibeCoding terminal)
276
+ let launchInfo: any = { cliCmd: 'claude', cliType: 'claude-code', supportsSession: true };
277
+ try {
278
+ const { resolveTerminalLaunch, clearAgentCache } = await import('./agents/index.js');
279
+ clearAgentCache(); // ensure fresh settings are read
280
+ launchInfo = resolveTerminalLaunch(agentConfig.agentId);
281
+ } catch {}
282
+
283
+ // resolveOnly: just return launch info without side effects
284
+ if (body.resolveOnly) {
285
+ return json(res, { ok: true, ...launchInfo });
286
+ }
287
+
288
+ if (agentState.taskStatus === 'running') return jsonError(res, 'Cannot open terminal while agent is running. Wait for it to finish.');
289
+ const hasPending = orch.getBus().getPendingMessagesFor(agentId).length > 0;
290
+ if (hasPending) return jsonError(res, 'Agent has pending messages being processed. Wait for execution to complete.');
291
+
292
+ if (agentState.mode === 'manual') {
293
+ return json(res, { ok: true, mode: 'manual', alreadyManual: true, ...launchInfo });
294
+ }
295
+
296
+ orch.setManualMode(agentId);
297
+ // Skills call Next.js API (/api/workspace/.../smith), so use FORGE_PORT not daemon PORT
298
+ const result = installForgeSkills(orch.projectPath, id, agentId, FORGE_PORT);
299
+
300
+ return json(res, {
301
+ ok: true,
302
+ mode: 'manual',
303
+ skillsInstalled: result.installed,
304
+ agentId,
305
+ label: agentConfig.label,
306
+ ...launchInfo,
307
+ });
308
+ }
309
+ case 'close_terminal': {
310
+ if (!agentId) return jsonError(res, 'agentId required');
311
+ orch.restartAgentDaemon(agentId);
312
+ return json(res, { ok: true });
313
+ }
314
+ case 'create_ticket': {
315
+ if (!agentId || !content) return jsonError(res, 'agentId (from) and content required');
316
+ const targetId = body.targetId;
317
+ if (!targetId) return jsonError(res, 'targetId required');
318
+ const causedByMsg = body.causedByMessageId ? orch.getBus().getLog().find(m => m.id === body.causedByMessageId) : undefined;
319
+ const causedBy = causedByMsg ? { messageId: causedByMsg.id, from: causedByMsg.from, to: causedByMsg.to } : undefined;
320
+ const ticket = orch.getBus().createTicket(agentId, targetId, body.ticketAction || 'bug_report', content, body.files, causedBy);
321
+ return json(res, { ok: true, ticketId: ticket.id });
322
+ }
323
+ case 'update_ticket': {
324
+ const { messageId, ticketStatus } = body;
325
+ if (!messageId || !ticketStatus) return jsonError(res, 'messageId and ticketStatus required');
326
+ orch.getBus().updateTicketStatus(messageId, ticketStatus);
327
+ return json(res, { ok: true });
328
+ }
329
+ case 'message': {
330
+ if (!agentId || !content) return jsonError(res, 'agentId and content required');
331
+ orch.sendMessageToAgent(agentId, content);
332
+ return json(res, { ok: true });
333
+ }
334
+ case 'approve': {
335
+ if (!agentId) return jsonError(res, 'agentId required');
336
+ orch.approveAgent(agentId);
337
+ return json(res, { ok: true });
338
+ }
339
+ case 'reject': {
340
+ if (!agentId) return jsonError(res, 'agentId required');
341
+ orch.rejectApproval(agentId);
342
+ return json(res, { ok: true });
343
+ }
344
+ case 'retry_message': {
345
+ const { messageId } = body;
346
+ if (!messageId) return jsonError(res, 'messageId required');
347
+ if (!orch.isDaemonActive()) return jsonError(res, 'Start daemon first before retrying messages');
348
+ const msg = orch.getBus().retryMessage(messageId);
349
+ if (!msg) return jsonError(res, 'Message not found or already pending');
350
+ return json(res, { ok: true, messageId: msg.id, action: msg.payload.action });
351
+ }
352
+ case 'abort_message': {
353
+ const { messageId } = body;
354
+ if (!messageId) return jsonError(res, 'messageId required');
355
+ const abortMsg = orch.getBus().abortMessage(messageId);
356
+ return json(res, { ok: true, messageId, aborted: !!abortMsg });
357
+ }
358
+ case 'delete_message': {
359
+ const { messageId } = body;
360
+ if (!messageId) return jsonError(res, 'messageId required');
361
+ orch.getBus().deleteMessage(messageId);
362
+ return json(res, { ok: true });
363
+ }
364
+ case 'start_daemon': {
365
+ orch.startDaemon().catch(err => {
366
+ console.error('[workspace] startDaemon error:', err.message);
367
+ });
368
+ return json(res, { ok: true, status: 'daemon_started' });
369
+ }
370
+ case 'stop_daemon': {
371
+ orch.stopDaemon();
372
+ return json(res, { ok: true, status: 'daemon_stopped' });
373
+ }
374
+ default:
375
+ return jsonError(res, `Unknown action: ${action}`);
376
+ }
377
+ } catch (err: any) {
378
+ return jsonError(res, err.message, 500);
379
+ }
380
+ }
381
+
382
+ function handleAgentsGet(id: string, res: ServerResponse): void {
383
+ let orch: WorkspaceOrchestrator;
384
+ try {
385
+ orch = loadOrchestrator(id);
386
+ } catch (err: any) {
387
+ return jsonError(res, err.message, err.message.includes('not found') ? 404 : 429);
388
+ }
389
+
390
+ json(res, {
391
+ agents: orch.getSnapshot().agents,
392
+ states: orch.getAllAgentStates(),
393
+ busLog: orch.getBusLog(),
394
+ daemonActive: orch.isDaemonActive(),
395
+ });
396
+ }
397
+
398
+ // ─── Route: SSE Stream ───────────────────────────────────
399
+
400
+ function handleStream(id: string, req: IncomingMessage, res: ServerResponse): void {
401
+ let orch: WorkspaceOrchestrator;
402
+ try {
403
+ orch = loadOrchestrator(id);
404
+ } catch (err: any) {
405
+ res.writeHead(err.message.includes('not found') ? 404 : 429);
406
+ res.end(err.message);
407
+ return;
408
+ }
409
+
410
+ res.writeHead(200, {
411
+ 'Content-Type': 'text/event-stream',
412
+ 'Cache-Control': 'no-cache, no-transform',
413
+ 'Connection': 'keep-alive',
414
+ });
415
+
416
+ // Send initial snapshot
417
+ const snapshot = orch.getSnapshot();
418
+ res.write(`data: ${JSON.stringify({ type: 'init', ...snapshot })}\n\n`);
419
+
420
+ addSSEClient(id, res);
421
+
422
+ // Keep-alive ping every 15s
423
+ const ping = setInterval(() => {
424
+ try { res.write(`: ping\n\n`); } catch {
425
+ clearInterval(ping);
426
+ removeSSEClient(id, res);
427
+ }
428
+ }, 15000);
429
+
430
+ // Cleanup on disconnect
431
+ req.on('close', () => {
432
+ clearInterval(ping);
433
+ removeSSEClient(id, res);
434
+ });
435
+ }
436
+
437
+ // ─── Route: Smith API ────────────────────────────────────
438
+
439
+ async function handleSmith(id: string, body: any, res: ServerResponse): Promise<void> {
440
+ const orch = getOrchestrator(id);
441
+ if (!orch) return jsonError(res, 'Workspace not found', 404);
442
+
443
+ const { action, agentId } = body;
444
+
445
+ switch (action) {
446
+ case 'done': {
447
+ if (!agentId) return jsonError(res, 'agentId required');
448
+
449
+ try {
450
+ let gitDiff = '';
451
+ try {
452
+ gitDiff = execSync('git diff --stat HEAD', {
453
+ cwd: orch.projectPath, encoding: 'utf-8', timeout: 5000,
454
+ }).trim();
455
+ } catch {}
456
+
457
+ let gitDiffDetail = '';
458
+ try {
459
+ gitDiffDetail = execSync('git diff HEAD --name-only', {
460
+ cwd: orch.projectPath, encoding: 'utf-8', timeout: 5000,
461
+ }).trim();
462
+ } catch {}
463
+
464
+ const changedFiles = gitDiffDetail.split('\n').filter(Boolean);
465
+ const entry = (orch as any).agents?.get(agentId);
466
+ const config = entry?.config;
467
+
468
+ if (config && changedFiles.length > 0) {
469
+ await addObservation(id, agentId, config.label, config.role, {
470
+ type: 'change',
471
+ title: `Manual work completed: ${changedFiles.length} files changed`,
472
+ filesModified: changedFiles.slice(0, 10),
473
+ detail: gitDiff.slice(0, 500),
474
+ stepLabel: 'manual',
475
+ });
476
+
477
+ await addSessionSummary(id, agentId, {
478
+ request: 'Manual development session',
479
+ investigated: `Worked on ${changedFiles.length} files`,
480
+ learned: '', completed: gitDiff.slice(0, 300), nextSteps: '',
481
+ filesRead: [], filesModified: changedFiles,
482
+ });
483
+ }
484
+
485
+ // Parse bus markers
486
+ const { output } = body;
487
+ let markersSent = 0;
488
+ if (output && typeof output === 'string') {
489
+ const markerRegex = /\[SEND:([^:]+):([^\]]+)\]\s*(.+)/g;
490
+ const snapshot = orch.getSnapshot();
491
+ const labelToId = new Map(snapshot.agents.map(a => [a.label.toLowerCase(), a.id]));
492
+ const seen = new Set<string>();
493
+ let match;
494
+ while ((match = markerRegex.exec(output)) !== null) {
495
+ const targetLabel = match[1].trim();
496
+ const msgAction = match[2].trim();
497
+ const content = match[3].trim();
498
+ const targetId = labelToId.get(targetLabel.toLowerCase());
499
+ if (targetId && targetId !== agentId) {
500
+ const key = `${targetId}:${msgAction}:${content}`;
501
+ if (!seen.has(key)) {
502
+ seen.add(key);
503
+ orch.getBus().send(agentId, targetId, 'notify', { action: msgAction, content });
504
+ markersSent++;
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ orch.completeManualAgent(agentId, changedFiles);
511
+
512
+ return json(res, {
513
+ ok: true, filesChanged: changedFiles.length,
514
+ files: changedFiles.slice(0, 20),
515
+ gitDiff: gitDiff.slice(0, 500), markersSent,
516
+ });
517
+ } catch (err: any) {
518
+ return jsonError(res, err.message, 500);
519
+ }
520
+ }
521
+
522
+ case 'send': {
523
+ const { to, msgAction, content } = body;
524
+ if (!to || !content) {
525
+ return jsonError(res, 'to and content required');
526
+ }
527
+
528
+ const snapshot = orch.getSnapshot();
529
+ const target = snapshot.agents.find(a => a.label.toLowerCase() === to.toLowerCase() || a.id === to);
530
+ if (!target) return jsonError(res, `Agent "${to}" not found. Available: ${snapshot.agents.map(a => a.label).join(', ')}`, 404);
531
+
532
+ // Resolve sender: use agentId if valid, otherwise 'user'
533
+ const senderId = (agentId && agentId !== 'unknown')
534
+ ? agentId
535
+ : 'user';
536
+
537
+ // Block: if sender is currently processing a message FROM the target,
538
+ // don't send — the result is already delivered via markMessageDone
539
+ if (senderId !== 'user') {
540
+ const senderEntry = orch.getSnapshot().agentStates[senderId];
541
+ if (senderEntry?.currentMessageId) {
542
+ const currentMsg = orch.getBus().getLog().find(m => m.id === senderEntry.currentMessageId);
543
+ if (currentMsg && currentMsg.from === target.id && currentMsg.status === 'running') {
544
+ return json(res, {
545
+ ok: true, skipped: true,
546
+ reason: `You are processing a message from ${target.label}. Your result will be delivered automatically — no need to send a reply.`,
547
+ });
548
+ }
549
+ }
550
+ }
551
+
552
+ const sentMsg = orch.getBus().send(senderId, target.id, 'notify', {
553
+ action: msgAction || 'agent_message',
554
+ content,
555
+ });
556
+
557
+ return json(res, { ok: true, sentTo: target.label, messageId: sentMsg.id });
558
+ }
559
+
560
+ case 'logs': {
561
+ if (!agentId) return jsonError(res, 'agentId required');
562
+ const { readAgentLog } = await import('./workspace/persistence.js');
563
+ const logs = readAgentLog(id, agentId);
564
+ return json(res, { logs });
565
+ }
566
+
567
+ case 'clear_logs': {
568
+ if (!agentId) return jsonError(res, 'agentId required');
569
+ const { clearAgentLog } = await import('./workspace/persistence.js');
570
+ clearAgentLog(id, agentId);
571
+ // Also clear in-memory history
572
+ const agentState = orch.getAgentState(agentId);
573
+ if (agentState) (agentState as any).history = [];
574
+ return json(res, { ok: true });
575
+ }
576
+
577
+ case 'inbox': {
578
+ if (!agentId) return jsonError(res, 'agentId required');
579
+
580
+ const messages = orch.getBus().getMessagesFor(agentId)
581
+ .filter(m => m.type !== 'ack')
582
+ .slice(-20)
583
+ .map(m => ({
584
+ id: m.id,
585
+ from: (orch.getSnapshot().agents.find(a => a.id === m.from)?.label || m.from),
586
+ action: m.payload.action,
587
+ content: m.payload.content,
588
+ status: m.status || 'pending',
589
+ time: new Date(m.timestamp).toLocaleTimeString(),
590
+ }));
591
+
592
+ return json(res, { messages });
593
+ }
594
+
595
+ case 'message_done': {
596
+ // Manual mode: user marks a specific inbox message as done
597
+ const { messageId } = body;
598
+ if (!agentId || !messageId) return jsonError(res, 'agentId and messageId required');
599
+ const busMsg = orch.getBus().getLog().find(m => m.id === messageId && m.to === agentId);
600
+ if (!busMsg) return jsonError(res, 'Message not found');
601
+ busMsg.status = 'done';
602
+ return json(res, { ok: true });
603
+ }
604
+
605
+ case 'message_failed': {
606
+ const { messageId } = body;
607
+ if (!agentId || !messageId) return jsonError(res, 'agentId and messageId required');
608
+ const busMsg = orch.getBus().getLog().find(m => m.id === messageId && m.to === agentId);
609
+ if (!busMsg) return jsonError(res, 'Message not found');
610
+ busMsg.status = 'failed';
611
+ return json(res, { ok: true });
612
+ }
613
+
614
+ case 'sessions': {
615
+ // List recent claude sessions for resume picker
616
+ // Uses the workspace's projectPath to find sessions in ~/.claude/projects/
617
+ try {
618
+ const encoded = orch.projectPath.replace(/\//g, '-');
619
+ const sessDir = join(homedir(), '.claude', 'projects', encoded);
620
+ const entries = readdirSync(sessDir);
621
+ const files = entries
622
+ .filter((f: string) => f.endsWith('.jsonl'))
623
+ .map((f: string) => {
624
+ const fp = join(sessDir, f);
625
+ const st = statSync(fp);
626
+ return { id: f.replace('.jsonl', ''), modified: st.mtime.toISOString(), size: st.size };
627
+ })
628
+ .sort((a: any, b: any) => new Date(b.modified).getTime() - new Date(a.modified).getTime())
629
+ .slice(0, 5);
630
+ return json(res, { sessions: files });
631
+ } catch {
632
+ return json(res, { sessions: [] });
633
+ }
634
+ }
635
+
636
+ case 'status': {
637
+ const snapshot = orch.getSnapshot();
638
+ const states = orch.getAllAgentStates();
639
+ const agents = snapshot.agents.map(a => ({
640
+ id: a.id, label: a.label, icon: a.icon, type: a.type,
641
+ smithStatus: states[a.id]?.smithStatus || 'down',
642
+ mode: states[a.id]?.mode || 'auto',
643
+ taskStatus: states[a.id]?.taskStatus || 'idle',
644
+ currentStep: states[a.id]?.currentStep,
645
+ }));
646
+ return json(res, { agents });
647
+ }
648
+
649
+ default:
650
+ return jsonError(res, `Unknown action: ${action}`);
651
+ }
652
+ }
653
+
654
+ // ─── Route: Memory ───────────────────────────────────────
655
+
656
+ function handleMemory(workspaceId: string, query: URLSearchParams, res: ServerResponse): void {
657
+ const agentId = query.get('agentId');
658
+ if (!agentId) return jsonError(res, 'agentId required');
659
+
660
+ const memory = loadMemory(workspaceId, agentId);
661
+ const stats = getMemoryStats(memory);
662
+ const display = formatMemoryForDisplay(memory);
663
+
664
+ json(res, { memory, stats, display });
665
+ }
666
+
667
+ // ─── HTTP Router ─────────────────────────────────────────
668
+
669
+ const server = createServer(async (req, res) => {
670
+ const { path, query } = parseUrl(req.url || '/');
671
+ const method = req.method || 'GET';
672
+
673
+ // CORS for local dev
674
+ res.setHeader('Access-Control-Allow-Origin', '*');
675
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
676
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
677
+
678
+ if (method === 'OPTIONS') {
679
+ res.writeHead(204);
680
+ res.end();
681
+ return;
682
+ }
683
+
684
+ try {
685
+ // Health check
686
+ if (path === '/health' && method === 'GET') {
687
+ return json(res, {
688
+ ok: true,
689
+ active: orchestrators.size,
690
+ maxActive: MAX_ACTIVE,
691
+ uptime: Math.floor((Date.now() - startTime) / 1000),
692
+ });
693
+ }
694
+
695
+ // Active workspaces
696
+ if (path === '/workspaces/active' && method === 'GET') {
697
+ return json(res, {
698
+ workspaces: Array.from(orchestrators.keys()),
699
+ });
700
+ }
701
+
702
+ // Route: /workspace/:id/...
703
+ const wsMatch = path.match(/^\/workspace\/([^/]+)(\/.*)?$/);
704
+ if (!wsMatch) {
705
+ return jsonError(res, 'Not found', 404);
706
+ }
707
+
708
+ const id = wsMatch[1];
709
+ const subPath = wsMatch[2] || '';
710
+
711
+ // Load/Unload
712
+ if (subPath === '/load' && method === 'POST') {
713
+ try {
714
+ loadOrchestrator(id);
715
+ return json(res, { ok: true });
716
+ } catch (err: any) {
717
+ return jsonError(res, err.message, err.message.includes('not found') ? 404 : 429);
718
+ }
719
+ }
720
+
721
+ if (subPath === '/unload' && method === 'POST') {
722
+ unloadOrchestrator(id);
723
+ return json(res, { ok: true });
724
+ }
725
+
726
+ // Agent operations
727
+ if (subPath === '/agents' && method === 'POST') {
728
+ const bodyStr = await readBody(req);
729
+ const body = JSON.parse(bodyStr);
730
+ return handleAgentsPost(id, body, res);
731
+ }
732
+
733
+ if (subPath === '/agents' && method === 'GET') {
734
+ return handleAgentsGet(id, res);
735
+ }
736
+
737
+ // SSE stream
738
+ if (subPath === '/stream' && method === 'GET') {
739
+ return handleStream(id, req, res);
740
+ }
741
+
742
+ // Smith API
743
+ if (subPath === '/smith' && method === 'POST') {
744
+ const bodyStr = await readBody(req);
745
+ const body = JSON.parse(bodyStr);
746
+ return handleSmith(id, body, res);
747
+ }
748
+
749
+ // Memory
750
+ if (subPath === '/memory' && method === 'GET') {
751
+ return handleMemory(id, query, res);
752
+ }
753
+
754
+ return jsonError(res, 'Not found', 404);
755
+
756
+ } catch (err: any) {
757
+ console.error('[workspace] Request error:', err);
758
+ return jsonError(res, err.message || 'Internal error', 500);
759
+ }
760
+ });
761
+
762
+ // ─── Graceful Shutdown ───────────────────────────────────
763
+
764
+ function shutdown() {
765
+ console.log('[workspace] Shutting down...');
766
+ for (const [id] of orchestrators) {
767
+ unloadOrchestrator(id);
768
+ }
769
+ server.close(() => {
770
+ console.log('[workspace] Server closed.');
771
+ process.exit(0);
772
+ });
773
+ // Force exit after 5s
774
+ setTimeout(() => process.exit(0), 5000);
775
+ }
776
+
777
+ process.on('SIGTERM', shutdown);
778
+ process.on('SIGINT', shutdown);
779
+ process.on('uncaughtException', (err) => {
780
+ console.error('[workspace] Uncaught exception:', err);
781
+ });
782
+ process.on('unhandledRejection', (err) => {
783
+ console.error('[workspace] Unhandled rejection:', err);
784
+ });
785
+
786
+ // ─── Start ───────────────────────────────────────────────
787
+
788
+ server.listen(PORT, () => {
789
+ console.log(`[workspace] Daemon started on http://0.0.0.0:${PORT} (max ${MAX_ACTIVE} workspaces)`);
790
+ });