@aion0/forge 0.4.16 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -2
- package/RELEASE_NOTES.md +21 -14
- package/app/api/agents/route.ts +17 -0
- package/app/api/delivery/[id]/route.ts +62 -0
- package/app/api/delivery/route.ts +40 -0
- package/app/api/mobile-chat/route.ts +13 -7
- package/app/api/monitor/route.ts +10 -6
- package/app/api/pipelines/[id]/route.ts +16 -3
- package/app/api/tasks/route.ts +2 -1
- package/app/api/workspace/[id]/agents/route.ts +35 -0
- package/app/api/workspace/[id]/memory/route.ts +23 -0
- package/app/api/workspace/[id]/smith/route.ts +22 -0
- package/app/api/workspace/[id]/stream/route.ts +28 -0
- package/app/api/workspace/route.ts +100 -0
- package/app/global-error.tsx +10 -4
- package/app/icon.ico +0 -0
- package/app/layout.tsx +2 -2
- package/app/login/LoginForm.tsx +96 -0
- package/app/login/page.tsx +7 -98
- package/app/page.tsx +2 -2
- package/bin/forge-server.mjs +13 -1
- package/check-forge-status.sh +9 -0
- package/components/ConversationEditor.tsx +411 -0
- package/components/ConversationGraphView.tsx +347 -0
- package/components/ConversationTerminalView.tsx +303 -0
- package/components/Dashboard.tsx +36 -39
- package/components/DashboardWrapper.tsx +9 -0
- package/components/DeliveryFlowEditor.tsx +491 -0
- package/components/DeliveryList.tsx +230 -0
- package/components/DeliveryWorkspace.tsx +589 -0
- package/components/DocTerminal.tsx +10 -2
- package/components/DocsViewer.tsx +10 -2
- package/components/HelpTerminal.tsx +11 -6
- package/components/InlinePipelineView.tsx +111 -0
- package/components/MobileView.tsx +20 -0
- package/components/MonitorPanel.tsx +9 -4
- package/components/NewTaskModal.tsx +32 -0
- package/components/PipelineEditor.tsx +49 -6
- package/components/PipelineView.tsx +482 -64
- package/components/ProjectDetail.tsx +314 -56
- package/components/ProjectManager.tsx +49 -4
- package/components/SessionView.tsx +27 -13
- package/components/SettingsModal.tsx +790 -124
- package/components/SkillsPanel.tsx +31 -8
- package/components/TaskBoard.tsx +3 -0
- package/components/WebTerminal.tsx +257 -43
- package/components/WorkspaceTree.tsx +221 -0
- package/components/WorkspaceView.tsx +2245 -0
- package/install.sh +2 -2
- package/lib/agents/claude-adapter.ts +104 -0
- package/lib/agents/generic-adapter.ts +64 -0
- package/lib/agents/index.ts +242 -0
- package/lib/agents/types.ts +70 -0
- package/lib/artifacts.ts +106 -0
- package/lib/delivery.ts +787 -0
- package/lib/forge-skills/forge-inbox.md +37 -0
- package/lib/forge-skills/forge-send.md +40 -0
- package/lib/forge-skills/forge-status.md +32 -0
- package/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/lib/help-docs/00-overview.md +7 -1
- package/lib/help-docs/01-settings.md +159 -2
- package/lib/help-docs/05-pipelines.md +89 -0
- package/lib/help-docs/07-projects.md +35 -1
- package/lib/help-docs/11-workspace.md +254 -0
- package/lib/help-docs/CLAUDE.md +7 -2
- package/lib/init.ts +60 -10
- package/lib/pipeline.ts +537 -1
- package/lib/settings.ts +115 -22
- package/lib/skills.ts +249 -372
- package/lib/task-manager.ts +113 -33
- package/lib/telegram-bot.ts +33 -1
- package/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/lib/workspace/agent-bus.ts +416 -0
- package/lib/workspace/agent-worker.ts +667 -0
- package/lib/workspace/backends/api-backend.ts +262 -0
- package/lib/workspace/backends/cli-backend.ts +479 -0
- package/lib/workspace/index.ts +82 -0
- package/lib/workspace/manager.ts +136 -0
- package/lib/workspace/orchestrator.ts +1914 -0
- package/lib/workspace/persistence.ts +310 -0
- package/lib/workspace/presets.ts +170 -0
- package/lib/workspace/skill-installer.ts +188 -0
- package/lib/workspace/smith-memory.ts +498 -0
- package/lib/workspace/types.ts +231 -0
- package/lib/workspace/watch-manager.ts +288 -0
- package/lib/workspace-standalone.ts +814 -0
- package/middleware.ts +1 -0
- package/next-env.d.ts +1 -1
- package/package.json +4 -1
- package/src/config/index.ts +12 -1
- package/src/core/db/database.ts +1 -0
- package/start.sh +7 -0
|
@@ -0,0 +1,814 @@
|
|
|
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
|
+
orch.emit('event', { type: 'bus_message_status', messageId: msg.id, status: 'pending' });
|
|
351
|
+
return json(res, { ok: true, messageId: msg.id, action: msg.payload.action });
|
|
352
|
+
}
|
|
353
|
+
case 'abort_message': {
|
|
354
|
+
const { messageId } = body;
|
|
355
|
+
if (!messageId) return jsonError(res, 'messageId required');
|
|
356
|
+
const abortMsg = orch.getBus().abortMessage(messageId);
|
|
357
|
+
if (abortMsg) {
|
|
358
|
+
orch.emit('event', { type: 'bus_message_status', messageId, status: 'failed' });
|
|
359
|
+
}
|
|
360
|
+
return json(res, { ok: true, messageId, aborted: !!abortMsg });
|
|
361
|
+
}
|
|
362
|
+
case 'approve_message': {
|
|
363
|
+
const { messageId } = body;
|
|
364
|
+
if (!messageId) return jsonError(res, 'messageId required');
|
|
365
|
+
const approveMsg = orch.getBus().getLog().find(m => m.id === messageId);
|
|
366
|
+
if (!approveMsg) return jsonError(res, 'Message not found');
|
|
367
|
+
if (approveMsg.status !== 'pending_approval') return jsonError(res, 'Message is not pending approval');
|
|
368
|
+
if (body.content) approveMsg.payload.content = body.content;
|
|
369
|
+
approveMsg.status = 'pending';
|
|
370
|
+
orch.emit('event', { type: 'bus_message_status', messageId, status: 'pending' });
|
|
371
|
+
return json(res, { ok: true });
|
|
372
|
+
}
|
|
373
|
+
case 'reject_message': {
|
|
374
|
+
const { messageId } = body;
|
|
375
|
+
if (!messageId) return jsonError(res, 'messageId required');
|
|
376
|
+
const rejectMsg = orch.getBus().getLog().find(m => m.id === messageId);
|
|
377
|
+
if (!rejectMsg) return jsonError(res, 'Message not found');
|
|
378
|
+
rejectMsg.status = 'failed';
|
|
379
|
+
orch.emit('event', { type: 'bus_message_status', messageId, status: 'failed' });
|
|
380
|
+
return json(res, { ok: true });
|
|
381
|
+
}
|
|
382
|
+
case 'delete_message': {
|
|
383
|
+
const { messageId } = body;
|
|
384
|
+
if (!messageId) return jsonError(res, 'messageId required');
|
|
385
|
+
orch.getBus().deleteMessage(messageId);
|
|
386
|
+
return json(res, { ok: true });
|
|
387
|
+
}
|
|
388
|
+
case 'start_daemon': {
|
|
389
|
+
orch.startDaemon().catch(err => {
|
|
390
|
+
console.error('[workspace] startDaemon error:', err.message);
|
|
391
|
+
});
|
|
392
|
+
return json(res, { ok: true, status: 'daemon_started' });
|
|
393
|
+
}
|
|
394
|
+
case 'stop_daemon': {
|
|
395
|
+
orch.stopDaemon();
|
|
396
|
+
return json(res, { ok: true, status: 'daemon_stopped' });
|
|
397
|
+
}
|
|
398
|
+
default:
|
|
399
|
+
return jsonError(res, `Unknown action: ${action}`);
|
|
400
|
+
}
|
|
401
|
+
} catch (err: any) {
|
|
402
|
+
return jsonError(res, err.message, 500);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function handleAgentsGet(id: string, res: ServerResponse): void {
|
|
407
|
+
let orch: WorkspaceOrchestrator;
|
|
408
|
+
try {
|
|
409
|
+
orch = loadOrchestrator(id);
|
|
410
|
+
} catch (err: any) {
|
|
411
|
+
return jsonError(res, err.message, err.message.includes('not found') ? 404 : 429);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
json(res, {
|
|
415
|
+
agents: orch.getSnapshot().agents,
|
|
416
|
+
states: orch.getAllAgentStates(),
|
|
417
|
+
busLog: orch.getBusLog(),
|
|
418
|
+
daemonActive: orch.isDaemonActive(),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─── Route: SSE Stream ───────────────────────────────────
|
|
423
|
+
|
|
424
|
+
function handleStream(id: string, req: IncomingMessage, res: ServerResponse): void {
|
|
425
|
+
let orch: WorkspaceOrchestrator;
|
|
426
|
+
try {
|
|
427
|
+
orch = loadOrchestrator(id);
|
|
428
|
+
} catch (err: any) {
|
|
429
|
+
res.writeHead(err.message.includes('not found') ? 404 : 429);
|
|
430
|
+
res.end(err.message);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
res.writeHead(200, {
|
|
435
|
+
'Content-Type': 'text/event-stream',
|
|
436
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
437
|
+
'Connection': 'keep-alive',
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// Send initial snapshot
|
|
441
|
+
const snapshot = orch.getSnapshot();
|
|
442
|
+
res.write(`data: ${JSON.stringify({ type: 'init', ...snapshot })}\n\n`);
|
|
443
|
+
|
|
444
|
+
addSSEClient(id, res);
|
|
445
|
+
|
|
446
|
+
// Keep-alive ping every 15s
|
|
447
|
+
const ping = setInterval(() => {
|
|
448
|
+
try { res.write(`: ping\n\n`); } catch {
|
|
449
|
+
clearInterval(ping);
|
|
450
|
+
removeSSEClient(id, res);
|
|
451
|
+
}
|
|
452
|
+
}, 15000);
|
|
453
|
+
|
|
454
|
+
// Cleanup on disconnect
|
|
455
|
+
req.on('close', () => {
|
|
456
|
+
clearInterval(ping);
|
|
457
|
+
removeSSEClient(id, res);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ─── Route: Smith API ────────────────────────────────────
|
|
462
|
+
|
|
463
|
+
async function handleSmith(id: string, body: any, res: ServerResponse): Promise<void> {
|
|
464
|
+
const orch = getOrchestrator(id);
|
|
465
|
+
if (!orch) return jsonError(res, 'Workspace not found', 404);
|
|
466
|
+
|
|
467
|
+
const { action, agentId } = body;
|
|
468
|
+
|
|
469
|
+
switch (action) {
|
|
470
|
+
case 'done': {
|
|
471
|
+
if (!agentId) return jsonError(res, 'agentId required');
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
let gitDiff = '';
|
|
475
|
+
try {
|
|
476
|
+
gitDiff = execSync('git diff --stat HEAD', {
|
|
477
|
+
cwd: orch.projectPath, encoding: 'utf-8', timeout: 5000,
|
|
478
|
+
}).trim();
|
|
479
|
+
} catch {}
|
|
480
|
+
|
|
481
|
+
let gitDiffDetail = '';
|
|
482
|
+
try {
|
|
483
|
+
gitDiffDetail = execSync('git diff HEAD --name-only', {
|
|
484
|
+
cwd: orch.projectPath, encoding: 'utf-8', timeout: 5000,
|
|
485
|
+
}).trim();
|
|
486
|
+
} catch {}
|
|
487
|
+
|
|
488
|
+
const changedFiles = gitDiffDetail.split('\n').filter(Boolean);
|
|
489
|
+
const entry = (orch as any).agents?.get(agentId);
|
|
490
|
+
const config = entry?.config;
|
|
491
|
+
|
|
492
|
+
if (config && changedFiles.length > 0) {
|
|
493
|
+
await addObservation(id, agentId, config.label, config.role, {
|
|
494
|
+
type: 'change',
|
|
495
|
+
title: `Manual work completed: ${changedFiles.length} files changed`,
|
|
496
|
+
filesModified: changedFiles.slice(0, 10),
|
|
497
|
+
detail: gitDiff.slice(0, 500),
|
|
498
|
+
stepLabel: 'manual',
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await addSessionSummary(id, agentId, {
|
|
502
|
+
request: 'Manual development session',
|
|
503
|
+
investigated: `Worked on ${changedFiles.length} files`,
|
|
504
|
+
learned: '', completed: gitDiff.slice(0, 300), nextSteps: '',
|
|
505
|
+
filesRead: [], filesModified: changedFiles,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Parse bus markers
|
|
510
|
+
const { output } = body;
|
|
511
|
+
let markersSent = 0;
|
|
512
|
+
if (output && typeof output === 'string') {
|
|
513
|
+
const markerRegex = /\[SEND:([^:]+):([^\]]+)\]\s*(.+)/g;
|
|
514
|
+
const snapshot = orch.getSnapshot();
|
|
515
|
+
const labelToId = new Map(snapshot.agents.map(a => [a.label.toLowerCase(), a.id]));
|
|
516
|
+
const seen = new Set<string>();
|
|
517
|
+
let match;
|
|
518
|
+
while ((match = markerRegex.exec(output)) !== null) {
|
|
519
|
+
const targetLabel = match[1].trim();
|
|
520
|
+
const msgAction = match[2].trim();
|
|
521
|
+
const content = match[3].trim();
|
|
522
|
+
const targetId = labelToId.get(targetLabel.toLowerCase());
|
|
523
|
+
if (targetId && targetId !== agentId) {
|
|
524
|
+
const key = `${targetId}:${msgAction}:${content}`;
|
|
525
|
+
if (!seen.has(key)) {
|
|
526
|
+
seen.add(key);
|
|
527
|
+
orch.getBus().send(agentId, targetId, 'notify', { action: msgAction, content });
|
|
528
|
+
markersSent++;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
orch.completeManualAgent(agentId, changedFiles);
|
|
535
|
+
|
|
536
|
+
return json(res, {
|
|
537
|
+
ok: true, filesChanged: changedFiles.length,
|
|
538
|
+
files: changedFiles.slice(0, 20),
|
|
539
|
+
gitDiff: gitDiff.slice(0, 500), markersSent,
|
|
540
|
+
});
|
|
541
|
+
} catch (err: any) {
|
|
542
|
+
return jsonError(res, err.message, 500);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
case 'send': {
|
|
547
|
+
const { to, msgAction, content } = body;
|
|
548
|
+
if (!to || !content) {
|
|
549
|
+
return jsonError(res, 'to and content required');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const snapshot = orch.getSnapshot();
|
|
553
|
+
const target = snapshot.agents.find(a => a.label.toLowerCase() === to.toLowerCase() || a.id === to);
|
|
554
|
+
if (!target) return jsonError(res, `Agent "${to}" not found. Available: ${snapshot.agents.map(a => a.label).join(', ')}`, 404);
|
|
555
|
+
|
|
556
|
+
// Resolve sender: use agentId if valid, otherwise 'user'
|
|
557
|
+
const senderId = (agentId && agentId !== 'unknown')
|
|
558
|
+
? agentId
|
|
559
|
+
: 'user';
|
|
560
|
+
|
|
561
|
+
// Block: if sender is currently processing a message FROM the target,
|
|
562
|
+
// don't send — the result is already delivered via markMessageDone
|
|
563
|
+
if (senderId !== 'user') {
|
|
564
|
+
const senderEntry = orch.getSnapshot().agentStates[senderId];
|
|
565
|
+
if (senderEntry?.currentMessageId) {
|
|
566
|
+
const currentMsg = orch.getBus().getLog().find(m => m.id === senderEntry.currentMessageId);
|
|
567
|
+
if (currentMsg && currentMsg.from === target.id && currentMsg.status === 'running') {
|
|
568
|
+
return json(res, {
|
|
569
|
+
ok: true, skipped: true,
|
|
570
|
+
reason: `You are processing a message from ${target.label}. Your result will be delivered automatically — no need to send a reply.`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const sentMsg = orch.getBus().send(senderId, target.id, 'notify', {
|
|
577
|
+
action: msgAction || 'agent_message',
|
|
578
|
+
content,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
return json(res, { ok: true, sentTo: target.label, messageId: sentMsg.id });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
case 'logs': {
|
|
585
|
+
if (!agentId) return jsonError(res, 'agentId required');
|
|
586
|
+
const { readAgentLog } = await import('./workspace/persistence.js');
|
|
587
|
+
const logs = readAgentLog(id, agentId);
|
|
588
|
+
return json(res, { logs });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
case 'clear_logs': {
|
|
592
|
+
if (!agentId) return jsonError(res, 'agentId required');
|
|
593
|
+
const { clearAgentLog } = await import('./workspace/persistence.js');
|
|
594
|
+
clearAgentLog(id, agentId);
|
|
595
|
+
// Also clear in-memory history
|
|
596
|
+
const agentState = orch.getAgentState(agentId);
|
|
597
|
+
if (agentState) (agentState as any).history = [];
|
|
598
|
+
return json(res, { ok: true });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case 'inbox': {
|
|
602
|
+
if (!agentId) return jsonError(res, 'agentId required');
|
|
603
|
+
|
|
604
|
+
const messages = orch.getBus().getMessagesFor(agentId)
|
|
605
|
+
.filter(m => m.type !== 'ack')
|
|
606
|
+
.slice(-20)
|
|
607
|
+
.map(m => ({
|
|
608
|
+
id: m.id,
|
|
609
|
+
from: (orch.getSnapshot().agents.find(a => a.id === m.from)?.label || m.from),
|
|
610
|
+
action: m.payload.action,
|
|
611
|
+
content: m.payload.content,
|
|
612
|
+
status: m.status || 'pending',
|
|
613
|
+
time: new Date(m.timestamp).toLocaleTimeString(),
|
|
614
|
+
}));
|
|
615
|
+
|
|
616
|
+
return json(res, { messages });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
case 'message_done': {
|
|
620
|
+
// Manual mode: user marks a specific inbox message as done
|
|
621
|
+
const { messageId } = body;
|
|
622
|
+
if (!agentId || !messageId) return jsonError(res, 'agentId and messageId required');
|
|
623
|
+
const busMsg = orch.getBus().getLog().find(m => m.id === messageId && m.to === agentId);
|
|
624
|
+
if (!busMsg) return jsonError(res, 'Message not found');
|
|
625
|
+
busMsg.status = 'done';
|
|
626
|
+
return json(res, { ok: true });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
case 'message_failed': {
|
|
630
|
+
const { messageId } = body;
|
|
631
|
+
if (!agentId || !messageId) return jsonError(res, 'agentId and messageId required');
|
|
632
|
+
const busMsg = orch.getBus().getLog().find(m => m.id === messageId && m.to === agentId);
|
|
633
|
+
if (!busMsg) return jsonError(res, 'Message not found');
|
|
634
|
+
busMsg.status = 'failed';
|
|
635
|
+
return json(res, { ok: true });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
case 'sessions': {
|
|
639
|
+
// List recent claude sessions for resume picker
|
|
640
|
+
// Uses the workspace's projectPath to find sessions in ~/.claude/projects/
|
|
641
|
+
try {
|
|
642
|
+
const encoded = orch.projectPath.replace(/\//g, '-');
|
|
643
|
+
const sessDir = join(homedir(), '.claude', 'projects', encoded);
|
|
644
|
+
const entries = readdirSync(sessDir);
|
|
645
|
+
const files = entries
|
|
646
|
+
.filter((f: string) => f.endsWith('.jsonl'))
|
|
647
|
+
.map((f: string) => {
|
|
648
|
+
const fp = join(sessDir, f);
|
|
649
|
+
const st = statSync(fp);
|
|
650
|
+
return { id: f.replace('.jsonl', ''), modified: st.mtime.toISOString(), size: st.size };
|
|
651
|
+
})
|
|
652
|
+
.sort((a: any, b: any) => new Date(b.modified).getTime() - new Date(a.modified).getTime())
|
|
653
|
+
.slice(0, 5);
|
|
654
|
+
return json(res, { sessions: files });
|
|
655
|
+
} catch {
|
|
656
|
+
return json(res, { sessions: [] });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
case 'status': {
|
|
661
|
+
const snapshot = orch.getSnapshot();
|
|
662
|
+
const states = orch.getAllAgentStates();
|
|
663
|
+
const agents = snapshot.agents.map(a => ({
|
|
664
|
+
id: a.id, label: a.label, icon: a.icon, type: a.type,
|
|
665
|
+
smithStatus: states[a.id]?.smithStatus || 'down',
|
|
666
|
+
mode: states[a.id]?.mode || 'auto',
|
|
667
|
+
taskStatus: states[a.id]?.taskStatus || 'idle',
|
|
668
|
+
currentStep: states[a.id]?.currentStep,
|
|
669
|
+
}));
|
|
670
|
+
return json(res, { agents });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
default:
|
|
674
|
+
return jsonError(res, `Unknown action: ${action}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ─── Route: Memory ───────────────────────────────────────
|
|
679
|
+
|
|
680
|
+
function handleMemory(workspaceId: string, query: URLSearchParams, res: ServerResponse): void {
|
|
681
|
+
const agentId = query.get('agentId');
|
|
682
|
+
if (!agentId) return jsonError(res, 'agentId required');
|
|
683
|
+
|
|
684
|
+
const memory = loadMemory(workspaceId, agentId);
|
|
685
|
+
const stats = getMemoryStats(memory);
|
|
686
|
+
const display = formatMemoryForDisplay(memory);
|
|
687
|
+
|
|
688
|
+
json(res, { memory, stats, display });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── HTTP Router ─────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
const server = createServer(async (req, res) => {
|
|
694
|
+
const { path, query } = parseUrl(req.url || '/');
|
|
695
|
+
const method = req.method || 'GET';
|
|
696
|
+
|
|
697
|
+
// CORS for local dev
|
|
698
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
699
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
700
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
701
|
+
|
|
702
|
+
if (method === 'OPTIONS') {
|
|
703
|
+
res.writeHead(204);
|
|
704
|
+
res.end();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
try {
|
|
709
|
+
// Health check
|
|
710
|
+
if (path === '/health' && method === 'GET') {
|
|
711
|
+
return json(res, {
|
|
712
|
+
ok: true,
|
|
713
|
+
active: orchestrators.size,
|
|
714
|
+
maxActive: MAX_ACTIVE,
|
|
715
|
+
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Active workspaces
|
|
720
|
+
if (path === '/workspaces/active' && method === 'GET') {
|
|
721
|
+
return json(res, {
|
|
722
|
+
workspaces: Array.from(orchestrators.keys()),
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Route: /workspace/:id/...
|
|
727
|
+
const wsMatch = path.match(/^\/workspace\/([^/]+)(\/.*)?$/);
|
|
728
|
+
if (!wsMatch) {
|
|
729
|
+
return jsonError(res, 'Not found', 404);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const id = wsMatch[1];
|
|
733
|
+
const subPath = wsMatch[2] || '';
|
|
734
|
+
|
|
735
|
+
// Load/Unload
|
|
736
|
+
if (subPath === '/load' && method === 'POST') {
|
|
737
|
+
try {
|
|
738
|
+
loadOrchestrator(id);
|
|
739
|
+
return json(res, { ok: true });
|
|
740
|
+
} catch (err: any) {
|
|
741
|
+
return jsonError(res, err.message, err.message.includes('not found') ? 404 : 429);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (subPath === '/unload' && method === 'POST') {
|
|
746
|
+
unloadOrchestrator(id);
|
|
747
|
+
return json(res, { ok: true });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// Agent operations
|
|
751
|
+
if (subPath === '/agents' && method === 'POST') {
|
|
752
|
+
const bodyStr = await readBody(req);
|
|
753
|
+
const body = JSON.parse(bodyStr);
|
|
754
|
+
return handleAgentsPost(id, body, res);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (subPath === '/agents' && method === 'GET') {
|
|
758
|
+
return handleAgentsGet(id, res);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// SSE stream
|
|
762
|
+
if (subPath === '/stream' && method === 'GET') {
|
|
763
|
+
return handleStream(id, req, res);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Smith API
|
|
767
|
+
if (subPath === '/smith' && method === 'POST') {
|
|
768
|
+
const bodyStr = await readBody(req);
|
|
769
|
+
const body = JSON.parse(bodyStr);
|
|
770
|
+
return handleSmith(id, body, res);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Memory
|
|
774
|
+
if (subPath === '/memory' && method === 'GET') {
|
|
775
|
+
return handleMemory(id, query, res);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return jsonError(res, 'Not found', 404);
|
|
779
|
+
|
|
780
|
+
} catch (err: any) {
|
|
781
|
+
console.error('[workspace] Request error:', err);
|
|
782
|
+
return jsonError(res, err.message || 'Internal error', 500);
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// ─── Graceful Shutdown ───────────────────────────────────
|
|
787
|
+
|
|
788
|
+
function shutdown() {
|
|
789
|
+
console.log('[workspace] Shutting down...');
|
|
790
|
+
for (const [id] of orchestrators) {
|
|
791
|
+
unloadOrchestrator(id);
|
|
792
|
+
}
|
|
793
|
+
server.close(() => {
|
|
794
|
+
console.log('[workspace] Server closed.');
|
|
795
|
+
process.exit(0);
|
|
796
|
+
});
|
|
797
|
+
// Force exit after 5s
|
|
798
|
+
setTimeout(() => process.exit(0), 5000);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
process.on('SIGTERM', shutdown);
|
|
802
|
+
process.on('SIGINT', shutdown);
|
|
803
|
+
process.on('uncaughtException', (err) => {
|
|
804
|
+
console.error('[workspace] Uncaught exception:', err);
|
|
805
|
+
});
|
|
806
|
+
process.on('unhandledRejection', (err) => {
|
|
807
|
+
console.error('[workspace] Unhandled rejection:', err);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// ─── Start ───────────────────────────────────────────────
|
|
811
|
+
|
|
812
|
+
server.listen(PORT, () => {
|
|
813
|
+
console.log(`[workspace] Daemon started on http://0.0.0.0:${PORT} (max ${MAX_ACTIVE} workspaces)`);
|
|
814
|
+
});
|