tycono-server 0.1.0-beta.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 (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,1075 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { COMPANY_ROOT } from '../services/file-reader.js';
5
+ // activity-tracker removed — executionManager is Single Source of Truth
6
+ import { buildOrgTree, canDispatchTo, getSubordinates } from '../engine/org-tree.js';
7
+ import { createRunner, type RunnerResult } from '../engine/runners/index.js';
8
+ import {
9
+ getSession,
10
+ createSession,
11
+ addMessage,
12
+ updateMessage,
13
+ listSessions,
14
+ type Message,
15
+ type ImageAttachment,
16
+ } from '../services/session-store.js';
17
+ import { executionManager, type Execution } from '../services/execution-manager.js';
18
+ import { type MessageStatus, type WaveRoleStatus, type TeamStatus, messageStatusToRoleStatus, eventTypeToMessageStatus } from '../../../shared/types.js';
19
+ import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
20
+ import { earnCoinsInternal } from './coins.js';
21
+ import { appendFollowUpToWave } from '../services/wave-tracker.js';
22
+ import { waveMultiplexer } from '../services/wave-multiplexer.js';
23
+ import { supervisorHeartbeat } from '../services/supervisor-heartbeat.js';
24
+
25
+ /* ─── Auto-attach child executions to wave multiplexer ── */
26
+ executionManager.onExecutionCreated((exec) => {
27
+ waveMultiplexer.onExecutionCreated(exec);
28
+ });
29
+
30
+ // OOM fix: wave recovery runs once, not on every 5s poll
31
+ let waveRecoveryDone = false;
32
+
33
+ /* ─── Runner — lazy, re-created when engine changes ── */
34
+
35
+ function getRunner() {
36
+ return createRunner();
37
+ }
38
+
39
+ /* ─── Execution status via executionManager (Single SoT) ──── */
40
+
41
+ /* ─── Raw HTTP handler (Express 5 SSE 호환 문제 우회) ─── */
42
+
43
+ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): void {
44
+ const url = req.url ?? '';
45
+ const method = req.method ?? '';
46
+
47
+ // ── /api/waves/:waveId/stream — SSE multiplexed wave stream ──
48
+ const waveStreamMatch = url.match(/^\/api\/waves\/([^/]+)\/stream/);
49
+ if (method === 'GET' && waveStreamMatch) {
50
+ handleWaveStream(waveStreamMatch[1], url, res, req);
51
+ return;
52
+ }
53
+
54
+ // ── /api/waves/active — restore active waves after refresh ──
55
+ if (method === 'GET' && url === '/api/waves/active') {
56
+ // Recovery: rebuild wave→session mapping from session-store (ONE TIME ONLY)
57
+ // Previous bug: recovery ran on EVERY poll (5s) because getActiveWaves()
58
+ // returns empty for done executions → recovery loop → OOM
59
+ if (!waveRecoveryDone) {
60
+ waveRecoveryDone = true;
61
+ const allSessions = listSessions();
62
+ let recovered = 0;
63
+ for (const ses of allSessions) {
64
+ if (!ses.waveId) continue;
65
+ if (ses.roleId !== 'ceo') continue;
66
+ const exec = executionManager.getActiveExecution(ses.id);
67
+ if (exec) {
68
+ waveMultiplexer.registerSession(ses.waveId, exec);
69
+ recovered++;
70
+ }
71
+ }
72
+ if (recovered > 0) {
73
+ console.log(`[WaveRecovery] Recovered ${recovered} sessions (one-time)`);
74
+ }
75
+ }
76
+ jsonResponse(res, 200, { waves: waveMultiplexer.getActiveWaves() });
77
+ return;
78
+ }
79
+
80
+ // ── /api/waves/save ──
81
+ if (method === 'POST' && url === '/api/waves/save') {
82
+ readBody(req).then((body) => handleSaveWave(body, res));
83
+ return;
84
+ }
85
+
86
+ // ── /api/jobs/* routes (internal) ──
87
+ if (url.startsWith('/api/jobs')) {
88
+ handleJobsRequest(url, method, req, res);
89
+ return;
90
+ }
91
+
92
+ // ── /api/waves/:waveId/stop — Interrupt supervisor (like Claude Code Esc) ──
93
+ const stopMatch = url.match(/^\/api\/waves\/([^/]+)\/stop$/);
94
+ if (method === 'POST' && stopMatch) {
95
+ const waveId = stopMatch[1];
96
+ // Interrupt CEO supervisor only — children keep running naturally
97
+ // Wave stays alive for new directives (interrupt + redirect)
98
+ const state = supervisorHeartbeat.getState(waveId);
99
+ if (state?.supervisorSessionId) {
100
+ executionManager.abortSession(state.supervisorSessionId);
101
+ }
102
+ supervisorHeartbeat.stop(waveId);
103
+ jsonResponse(res, 200, { ok: true, waveId, interrupted: true });
104
+ return;
105
+ }
106
+
107
+ // ── /api/waves/:waveId/directive — CEO adds directive mid-execution ──
108
+ const directiveMatch = url.match(/^\/api\/waves\/([^/]+)\/directive$/);
109
+ if (method === 'POST' && directiveMatch) {
110
+ readBody(req).then((body) => handleWaveDirective(directiveMatch[1], body, res));
111
+ return;
112
+ }
113
+
114
+ // ── /api/waves/:waveId/question — Supervisor asks CEO, CEO answers ──
115
+ const questionMatch = url.match(/^\/api\/waves\/([^/]+)\/question$/);
116
+ if (method === 'POST' && questionMatch) {
117
+ readBody(req).then((body) => handleWaveQuestion(questionMatch[1], body, res));
118
+ return;
119
+ }
120
+
121
+ // ── /api/waves/:waveId/questions — Get pending questions ──
122
+ const questionsMatch = url.match(/^\/api\/waves\/([^/]+)\/questions$/);
123
+ if (method === 'GET' && questionsMatch) {
124
+ const questions = supervisorHeartbeat.getUnansweredQuestions(questionsMatch[1]);
125
+ jsonResponse(res, 200, { questions });
126
+ return;
127
+ }
128
+
129
+ // ── Legacy /api/exec/* routes ──
130
+ const sessionMatch = url.match(/\/api\/exec\/session\/([^/]+)\/message$/);
131
+
132
+ if (sessionMatch && method === 'POST') {
133
+ readBody(req).then((body) => handleSessionMessage(sessionMatch[1], body, req, res));
134
+ } else if (method === 'POST' && url.endsWith('/assign')) {
135
+ readBody(req).then((body) => handleAssign(body, req, res));
136
+ } else if (method === 'POST' && url.endsWith('/wave')) {
137
+ readBody(req).then((body) => handleWave(body, req, res));
138
+ } else if (method === 'GET' && url.endsWith('/status')) {
139
+ handleStatus(res);
140
+ } else {
141
+ res.writeHead(404);
142
+ res.end(JSON.stringify({ error: 'Not found' }));
143
+ }
144
+ }
145
+
146
+ /* ═══════════════════════════════════════════════
147
+ /api/jobs/* — Internal endpoints
148
+ ═══════════════════════════════════════════════ */
149
+
150
+ function handleJobsRequest(url: string, method: string, req: IncomingMessage, res: ServerResponse): void {
151
+ const [reqPath] = url.split('?');
152
+
153
+ // POST /api/jobs — start a new execution (creates session + execution)
154
+ if (method === 'POST' && reqPath === '/api/jobs') {
155
+ readBody(req).then((body) => handleStartJob(body, res));
156
+ return;
157
+ }
158
+
159
+ // GET /api/jobs/:id — internal only
160
+ const jobMatch = reqPath.match(/^\/api\/jobs\/([^/]+)$/);
161
+ if (method === 'GET' && jobMatch) {
162
+ const id = jobMatch[1];
163
+ const exec = executionManager.getExecution(id) ?? executionManager.getActiveExecution(id);
164
+ if (!exec) {
165
+ // Try reading from stream file directly
166
+ if (ActivityStream.exists(id)) {
167
+ const events = ActivityStream.readAll(id);
168
+ res.writeHead(200, { 'Content-Type': 'application/json' });
169
+ res.end(JSON.stringify({ id, events }));
170
+ } else {
171
+ res.writeHead(404);
172
+ res.end(JSON.stringify({ error: 'Not found' }));
173
+ }
174
+ } else {
175
+ res.writeHead(200, { 'Content-Type': 'application/json' });
176
+ res.end(JSON.stringify({
177
+ id: exec.id,
178
+ roleId: exec.roleId,
179
+ task: exec.task,
180
+ status: exec.status,
181
+ sessionId: exec.sessionId,
182
+ createdAt: exec.createdAt,
183
+ }));
184
+ }
185
+ return;
186
+ }
187
+
188
+ // POST /api/jobs/:id/abort — abort by execution ID or session ID
189
+ const abortMatch = reqPath.match(/^\/api\/jobs\/([^/]+)\/abort$/);
190
+ if (method === 'POST' && abortMatch) {
191
+ const id = abortMatch[1];
192
+ const success = executionManager.abortExecution(id) || executionManager.abortSession(id);
193
+ if (!success) {
194
+ res.writeHead(404);
195
+ res.end(JSON.stringify({ error: 'Not found or not running' }));
196
+ } else {
197
+ res.writeHead(200, { 'Content-Type': 'application/json' });
198
+ res.end(JSON.stringify({ ok: true }));
199
+ }
200
+ return;
201
+ }
202
+
203
+ res.writeHead(410);
204
+ res.end(JSON.stringify({ error: 'Use /api/sessions/* for client-facing operations.' }));
205
+ }
206
+
207
+ /* ─── POST /api/jobs ─────────────────────── */
208
+
209
+ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): void {
210
+ const type = (body.type as string) ?? 'assign';
211
+ const roleId = body.roleId as string;
212
+ const task = body.task as string;
213
+ const directive = body.directive as string;
214
+ const sourceRole = (body.sourceRole as string) || 'ceo';
215
+ const readOnly = body.readOnly === true;
216
+ const parentSessionId = body.parentSessionId as string | undefined;
217
+ const waveId = body.waveId as string | undefined;
218
+ const attachments = body.attachments as ImageAttachment[] | undefined;
219
+
220
+ if (type === 'wave') {
221
+ // directive가 없으면 idle 상태로 시작 (empty wave)
222
+ const actualDirective = directive || '';
223
+
224
+ const targetRoles = body.targetRoles as string[] | undefined;
225
+ const continuous = body.continuous === true;
226
+ const preset = body.preset as string | undefined;
227
+
228
+ // Always use supervisor mode — CEO supervises C-Levels who supervise members
229
+ {
230
+ const state = supervisorHeartbeat.start(
231
+ `wave-${Date.now()}`,
232
+ actualDirective,
233
+ targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
234
+ continuous,
235
+ preset,
236
+ );
237
+
238
+ if (state.status === 'error') {
239
+ jsonResponse(res, 500, { error: 'Failed to start supervisor' });
240
+ return;
241
+ }
242
+
243
+ jsonResponse(res, 200, {
244
+ waveId: state.waveId,
245
+ supervisorSessionId: state.supervisorSessionId,
246
+ mode: 'supervisor',
247
+ directive: actualDirective,
248
+ });
249
+ return;
250
+ }
251
+ }
252
+
253
+ // Assign
254
+ if (!roleId || !task) {
255
+ jsonResponse(res, 400, { error: 'roleId and task are required' });
256
+ return;
257
+ }
258
+
259
+ const orgTree = buildOrgTree(COMPANY_ROOT);
260
+ if (!canDispatchTo(orgTree, sourceRole, roleId)) {
261
+ jsonResponse(res, 403, { error: `${sourceRole} cannot dispatch to ${roleId}.` });
262
+ return;
263
+ }
264
+
265
+ const sessionSource: 'wave' | 'dispatch' = waveId ? 'wave' : 'dispatch';
266
+ const session = createSession(roleId, {
267
+ mode: readOnly ? 'talk' : 'do',
268
+ source: parentSessionId ? 'dispatch' : sessionSource,
269
+ ...(parentSessionId && { parentSessionId }),
270
+ ...(waveId && { waveId }),
271
+ });
272
+ const sessionId = session.id;
273
+
274
+ const ceoMsg: Message = {
275
+ id: `msg-${Date.now()}-ceo`,
276
+ from: 'ceo',
277
+ content: task,
278
+ type: readOnly ? 'conversation' : 'directive',
279
+ status: 'done',
280
+ timestamp: new Date().toISOString(),
281
+ attachments,
282
+ };
283
+ addMessage(session.id, ceoMsg);
284
+
285
+ const exec = executionManager.startExecution({
286
+ type: readOnly ? 'consult' : 'assign',
287
+ roleId,
288
+ task,
289
+ sourceRole,
290
+ readOnly,
291
+ parentSessionId,
292
+ sessionId,
293
+ attachments,
294
+ });
295
+
296
+ const roleMsg: Message = {
297
+ id: `msg-${Date.now() + 1}-role`,
298
+ from: 'role',
299
+ content: '',
300
+ type: 'conversation',
301
+ status: 'streaming',
302
+ timestamp: new Date().toISOString(),
303
+ readOnly: readOnly || undefined,
304
+ };
305
+ addMessage(sessionId, roleMsg, true);
306
+
307
+ if (waveId) {
308
+ appendFollowUpToWave(waveId, sessionId, roleId, task);
309
+ }
310
+
311
+ jsonResponse(res, 200, { sessionId, ...(waveId && { waveId }) });
312
+ }
313
+
314
+ /* ─── POST /api/waves/save ──────────────── */
315
+
316
+ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): void {
317
+ const directive = body.directive as string;
318
+ let sessionIds = (body.sessionIds ?? body.jobIds) as string[] | undefined;
319
+ const waveId = body.waveId as string | undefined;
320
+
321
+ // BUG-W01 + BUG-009 fix: auto-collect sessionIds from session-store AND activity-streams
322
+ if (waveId && (!sessionIds || sessionIds.length === 0)) {
323
+ const sessionIdSet = new Set(
324
+ listSessions().filter(s => s.waveId === waveId).map(s => s.id)
325
+ );
326
+
327
+ // Scan activity-streams for sessions belonging to this wave
328
+ const streamsDir = path.join(COMPANY_ROOT, '.tycono', 'activity-streams');
329
+ if (fs.existsSync(streamsDir)) {
330
+ const waveTimestamp = waveId.replace('wave-', '');
331
+ for (const file of fs.readdirSync(streamsDir)) {
332
+ if (!file.endsWith('.jsonl')) continue;
333
+ const sid = file.replace('.jsonl', '');
334
+ if (sessionIdSet.has(sid)) continue;
335
+ if (sid.includes(waveTimestamp)) {
336
+ sessionIdSet.add(sid);
337
+ }
338
+ }
339
+
340
+ // Recursively find all child sessions via dispatch:start events
341
+ let foundNew = true;
342
+ while (foundNew) {
343
+ foundNew = false;
344
+ for (const sid of Array.from(sessionIdSet)) {
345
+ try {
346
+ const events = ActivityStream.readAll(sid);
347
+ for (const e of events) {
348
+ const childSessionId = e.data.childSessionId as string | undefined;
349
+ if (e.type === 'dispatch:start' && childSessionId && !sessionIdSet.has(childSessionId)) {
350
+ sessionIdSet.add(childSessionId);
351
+ foundNew = true;
352
+ }
353
+ }
354
+ } catch { /* skip */ }
355
+ }
356
+ }
357
+ }
358
+
359
+ sessionIds = Array.from(sessionIdSet);
360
+ console.log(`[WaveSave] Auto-collected ${sessionIds.length} sessionIds for wave ${waveId}`);
361
+ }
362
+
363
+ if (!directive || !sessionIds || sessionIds.length === 0) {
364
+ jsonResponse(res, 400, { error: 'directive and sessionIds are required' });
365
+ return;
366
+ }
367
+
368
+ const now = new Date();
369
+ const dateStr = now.toISOString().slice(0, 10);
370
+
371
+ interface WaveRoleData {
372
+ roleId: string;
373
+ roleName: string;
374
+ sessionId: string;
375
+ status: WaveRoleStatus;
376
+ events: ReturnType<typeof ActivityStream.readAll>;
377
+ childSessions: Array<{ roleId: string; roleName: string; sessionId: string; status: WaveRoleStatus; events: ReturnType<typeof ActivityStream.readAll> }>;
378
+ }
379
+ const rolesData: WaveRoleData[] = [];
380
+
381
+ for (const sid of sessionIds) {
382
+ const events = ActivityStream.readAll(sid);
383
+ const startEvent = events.find(e => e.type === 'msg:start');
384
+ const roleId = startEvent?.roleId ?? 'unknown';
385
+ const roleName = (startEvent?.data?.roleName as string) ?? roleId;
386
+ const doneEvent = events.find(e => e.type === 'msg:done' || e.type === 'msg:awaiting_input' || e.type === 'msg:error');
387
+ const status: WaveRoleStatus = doneEvent ? eventTypeToMessageStatus(doneEvent.type) as WaveRoleStatus : 'unknown';
388
+
389
+ const childSessions: WaveRoleData['childSessions'] = [];
390
+ for (const e of events) {
391
+ const childSessionId = e.data.childSessionId as string | undefined;
392
+ if (e.type === 'dispatch:start' && childSessionId) {
393
+ const targetRoleId = (e.data.targetRoleId as string) ?? 'unknown';
394
+ const childEvents = ActivityStream.readAll(childSessionId);
395
+ const childDone = childEvents.find(ce => ce.type === 'msg:done' || ce.type === 'msg:error' || ce.type === 'msg:awaiting_input');
396
+ const childStatus: WaveRoleStatus = childDone ? eventTypeToMessageStatus(childDone.type) as WaveRoleStatus : 'unknown';
397
+ childSessions.push({
398
+ roleId: targetRoleId,
399
+ roleName: (childEvents.find(ce => ce.type === 'msg:start')?.data?.roleName as string) ?? targetRoleId,
400
+ sessionId: childSessionId,
401
+ status: childStatus,
402
+ events: childEvents,
403
+ });
404
+ }
405
+ }
406
+
407
+ rolesData.push({ roleId, roleName, sessionId: sid, status, events, childSessions });
408
+ }
409
+
410
+ const wavesDir = path.join(COMPANY_ROOT, '.tycono', 'waves');
411
+ if (!fs.existsSync(wavesDir)) {
412
+ fs.mkdirSync(wavesDir, { recursive: true });
413
+ }
414
+
415
+ let baseName: string;
416
+ if (waveId) {
417
+ const existing = fs.readdirSync(wavesDir).find(f => {
418
+ if (!f.endsWith('.json')) return false;
419
+ try {
420
+ const data = JSON.parse(fs.readFileSync(path.join(wavesDir, f), 'utf-8'));
421
+ return data.waveId === waveId || data.id === waveId;
422
+ } catch { return false; }
423
+ });
424
+ baseName = existing ? existing.replace('.json', '') : waveId;
425
+ } else {
426
+ const hhmmss = now.toTimeString().slice(0, 8).replace(/:/g, '');
427
+ baseName = `${dateStr.replace(/-/g, '')}-${hhmmss}-wave`;
428
+ }
429
+ const jsonPath = path.join(wavesDir, `${baseName}.json`);
430
+
431
+ // Calculate actual duration from activity stream timestamps
432
+ let startedAt = now;
433
+ let endedAt = now;
434
+ for (const role of rolesData) {
435
+ if (role.events.length > 0) {
436
+ const firstTs = new Date(role.events[0].ts);
437
+ const lastTs = new Date(role.events[role.events.length - 1].ts);
438
+ if (firstTs < startedAt) startedAt = firstTs;
439
+ if (lastTs > endedAt) endedAt = lastTs;
440
+ }
441
+ for (const child of role.childSessions) {
442
+ if (child.events.length > 0) {
443
+ const firstTs = new Date(child.events[0].ts);
444
+ const lastTs = new Date(child.events[child.events.length - 1].ts);
445
+ if (firstTs < startedAt) startedAt = firstTs;
446
+ if (lastTs > endedAt) endedAt = lastTs;
447
+ }
448
+ }
449
+ }
450
+ const duration = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
451
+
452
+ // Collect ALL session IDs including child sessions
453
+ const allSessionIds = [...sessionIds];
454
+ for (const role of rolesData) {
455
+ for (const child of role.childSessions) {
456
+ if (!allSessionIds.includes(child.sessionId)) {
457
+ allSessionIds.push(child.sessionId);
458
+ }
459
+ }
460
+ }
461
+
462
+ const waveJson = {
463
+ id: baseName,
464
+ directive,
465
+ startedAt: startedAt.toISOString(),
466
+ duration,
467
+ roles: rolesData,
468
+ ...(waveId && { waveId }),
469
+ sessionIds: allSessionIds,
470
+ };
471
+ fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
472
+
473
+ const roleCount = rolesData.length;
474
+ if (roleCount > 0) {
475
+ try {
476
+ earnCoinsInternal(roleCount * 500, `Wave done: ${roleCount} roles`, `wave:${baseName}`);
477
+ } catch { /* non-critical */ }
478
+ }
479
+
480
+ jsonResponse(res, 200, { ok: true, path: `.tycono/waves/${baseName}.json` });
481
+ }
482
+
483
+ /* ─── GET /api/waves/:waveId/stream ── */
484
+
485
+ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req: IncomingMessage): void {
486
+ const fromMatch = url.match(/[?&]from=(\d+)/);
487
+ const fromWaveSeq = fromMatch ? parseInt(fromMatch[1], 10) : 0;
488
+
489
+ let sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
490
+
491
+ // Recovery: recover sessions for this wave (active + done = persistent channel)
492
+ if (sessionIds.length === 0) {
493
+ const allSessions = listSessions();
494
+ const waveSessions = allSessions.filter(s => s.waveId === waveId);
495
+ for (const ses of waveSessions) {
496
+ const exec = executionManager.getActiveExecution(ses.id);
497
+ if (exec) {
498
+ waveMultiplexer.registerSession(waveId, exec);
499
+ }
500
+ }
501
+ sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
502
+ }
503
+
504
+ // Don't 404 on empty waves — keep SSE alive, sessions will appear later
505
+ // (e.g. idle wave waiting for first directive, or supervisor restarting)
506
+ const client = waveMultiplexer.attach(waveId, res as any, fromWaveSeq);
507
+
508
+ req.on('close', () => {
509
+ waveMultiplexer.detach(waveId, client);
510
+ });
511
+ }
512
+
513
+ /* ═══════════════════════════════════════════════
514
+ Legacy /api/exec/* — kept for backward compat
515
+ ═══════════════════════════════════════════════ */
516
+
517
+ /* ─── Body parser ────────────────────────────── */
518
+
519
+ function readBody(req: IncomingMessage): Promise<Record<string, unknown>> {
520
+ return new Promise((resolve) => {
521
+ let data = '';
522
+ req.on('data', (chunk) => { data += chunk; });
523
+ req.on('end', () => {
524
+ try { resolve(JSON.parse(data)); }
525
+ catch { resolve({}); }
526
+ });
527
+ });
528
+ }
529
+
530
+ /* ─── SSE helpers ────────────────────────────── */
531
+
532
+ function sendSSE(res: ServerResponse, event: string, data: unknown): boolean {
533
+ if (res.destroyed || res.writableEnded) return false;
534
+ try {
535
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
536
+ return true;
537
+ } catch {
538
+ return false;
539
+ }
540
+ }
541
+
542
+ function jsonResponse(res: ServerResponse, status: number, body: unknown): void {
543
+ res.writeHead(status, { 'Content-Type': 'application/json' });
544
+ res.end(JSON.stringify(body));
545
+ }
546
+
547
+ const SSE_TIMEOUT_MS = 10 * 60 * 1000;
548
+ const SSE_HEARTBEAT_MS = 15 * 1000;
549
+
550
+ function startSSE(res: ServerResponse): void {
551
+ res.writeHead(200, {
552
+ 'Content-Type': 'text/event-stream',
553
+ 'Cache-Control': 'no-cache',
554
+ 'Connection': 'keep-alive',
555
+ 'X-Accel-Buffering': 'no',
556
+ });
557
+ res.flushHeaders();
558
+ }
559
+
560
+ function startSSELifecycle(res: ServerResponse, onTimeout: () => void): () => void {
561
+ const heartbeat = setInterval(() => {
562
+ if (res.destroyed || res.writableEnded) {
563
+ clearInterval(heartbeat);
564
+ return;
565
+ }
566
+ try {
567
+ res.write(': heartbeat\n\n');
568
+ } catch {
569
+ clearInterval(heartbeat);
570
+ }
571
+ }, SSE_HEARTBEAT_MS);
572
+
573
+ const timeout = setTimeout(() => {
574
+ console.warn('[SSE] Connection timeout — forcing close');
575
+ onTimeout();
576
+ }, SSE_TIMEOUT_MS);
577
+
578
+ return () => {
579
+ clearInterval(heartbeat);
580
+ clearTimeout(timeout);
581
+ };
582
+ }
583
+
584
+ /* ─── POST /api/exec/assign ──────────────────── */
585
+
586
+ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res: ServerResponse): void {
587
+ const roleId = body.roleId as string;
588
+ const task = body.task as string;
589
+ const sourceRole = (body.sourceRole as string) || 'ceo';
590
+ const readOnly = body.readOnly === true;
591
+
592
+ if (!roleId || !task) {
593
+ jsonResponse(res, 400, { error: 'roleId and task are required' });
594
+ return;
595
+ }
596
+
597
+ const orgTree = buildOrgTree(COMPANY_ROOT);
598
+
599
+ if (!canDispatchTo(orgTree, sourceRole, roleId)) {
600
+ jsonResponse(res, 403, {
601
+ error: `${sourceRole} cannot dispatch to ${roleId}. Check organization hierarchy.`,
602
+ });
603
+ return;
604
+ }
605
+
606
+ const session = createSession(roleId, { mode: readOnly ? 'talk' : 'do' });
607
+ const exec = executionManager.startExecution({
608
+ type: 'assign',
609
+ roleId,
610
+ task,
611
+ sourceRole,
612
+ readOnly,
613
+ sessionId: session.id,
614
+ });
615
+
616
+ startSSE(res);
617
+ sendSSE(res, 'start', { id: exec.id, roleId, task, sourceRole });
618
+
619
+ const cleanupLifecycle = startSSELifecycle(res, () => {
620
+ sendSSE(res, 'error', { message: 'SSE timeout — connection forcibly closed after 10 minutes' });
621
+ if (!res.writableEnded) res.end();
622
+ exec.stream.unsubscribe(subscriber);
623
+ });
624
+
625
+ const subscriber = (event: ActivityEvent) => {
626
+ switch (event.type) {
627
+ case 'text':
628
+ sendSSE(res, 'output', { text: event.data.text });
629
+ break;
630
+ case 'thinking':
631
+ sendSSE(res, 'thinking', { text: event.data.text });
632
+ break;
633
+ case 'tool:start':
634
+ sendSSE(res, 'tool', { name: event.data.name, input: event.data.input });
635
+ break;
636
+ case 'dispatch:start':
637
+ sendSSE(res, 'dispatch', { roleId: event.data.targetRoleId, task: event.data.task, childSessionId: event.data.childSessionId });
638
+ break;
639
+ case 'msg:turn-complete':
640
+ sendSSE(res, 'turn', { turn: event.data.turn });
641
+ break;
642
+ case 'stderr':
643
+ sendSSE(res, 'stderr', { message: event.data.message });
644
+ break;
645
+ case 'msg:awaiting_input':
646
+ sendSSE(res, 'awaiting_input', { question: event.data.question, targetRole: event.data.targetRole, reason: event.data.reason });
647
+ break;
648
+ case 'msg:done':
649
+ cleanupLifecycle();
650
+ sendSSE(res, 'done', event.data);
651
+ if (!res.writableEnded) res.end();
652
+ exec.stream.unsubscribe(subscriber);
653
+ break;
654
+ case 'msg:error':
655
+ cleanupLifecycle();
656
+ sendSSE(res, 'error', { message: event.data.message });
657
+ if (!res.writableEnded) res.end();
658
+ exec.stream.unsubscribe(subscriber);
659
+ break;
660
+ }
661
+ };
662
+
663
+ exec.stream.subscribe(subscriber);
664
+
665
+ req.on('close', () => {
666
+ cleanupLifecycle();
667
+ exec.stream.unsubscribe(subscriber);
668
+ });
669
+ }
670
+
671
+ /* ─── POST /api/exec/wave ────────────────────── */
672
+
673
+ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: ServerResponse): void {
674
+ const directive = body.directive as string;
675
+
676
+ if (!directive) {
677
+ jsonResponse(res, 400, { error: 'directive is required' });
678
+ return;
679
+ }
680
+
681
+ const targetRoles = body.targetRoles as string[] | undefined;
682
+ const continuous = body.continuous === true;
683
+
684
+ // Always supervisor mode — CEO supervises C-Levels
685
+ handleWaveSupervisor(directive, targetRoles, continuous, req, res);
686
+ }
687
+
688
+ /**
689
+ * Supervisor mode: Start a single CEO Supervisor session that dispatches C-Levels.
690
+ * The supervisor uses dispatch/watch/amend tools — same pattern as any supervisor node.
691
+ */
692
+ function handleWaveSupervisor(directive: string, targetRoles: string[] | undefined, continuous: boolean, req: IncomingMessage, res: ServerResponse): void {
693
+ const state = supervisorHeartbeat.start(
694
+ `wave-${Date.now()}`,
695
+ directive,
696
+ targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
697
+ continuous,
698
+ );
699
+
700
+ if (state.status === 'error') {
701
+ jsonResponse(res, 500, { error: 'Failed to start supervisor' });
702
+ return;
703
+ }
704
+
705
+ // Return immediately with wave info — supervisor runs in background
706
+ // Frontend subscribes to /api/waves/:waveId/stream for SSE
707
+ jsonResponse(res, 200, {
708
+ waveId: state.waveId,
709
+ supervisorSessionId: state.supervisorSessionId,
710
+ mode: 'supervisor',
711
+ directive,
712
+ });
713
+ }
714
+
715
+ /* ─── POST /api/waves/:waveId/directive ──────── */
716
+
717
+ function handleWaveDirective(waveId: string, body: Record<string, unknown>, res: ServerResponse): void {
718
+ const text = body.text as string ?? body.directive as string;
719
+ if (!text) {
720
+ jsonResponse(res, 400, { error: 'text is required' });
721
+ return;
722
+ }
723
+
724
+ let directive = supervisorHeartbeat.addDirective(waveId, text);
725
+ if (!directive) {
726
+ // Fallback: wave exists but addDirective couldn't restore.
727
+ // Use start() with the SAME waveId to keep it in the same wave context.
728
+ console.log(`[WaveDirective] No supervisor found for wave ${waveId}, creating supervisor in-place`);
729
+ const state = supervisorHeartbeat.start(waveId, text);
730
+ if (state.status !== 'error') {
731
+ directive = { id: `dir-fallback-${Date.now()}`, text, createdAt: new Date().toISOString(), delivered: false };
732
+ }
733
+ }
734
+
735
+ if (!directive) {
736
+ jsonResponse(res, 404, { error: `No active supervisor for wave ${waveId}` });
737
+ return;
738
+ }
739
+
740
+ jsonResponse(res, 200, { directive });
741
+ }
742
+
743
+ /* ─── POST /api/waves/:waveId/question ──────── */
744
+
745
+ function handleWaveQuestion(waveId: string, body: Record<string, unknown>, res: ServerResponse): void {
746
+ const questionId = body.questionId as string;
747
+ const answer = body.answer as string;
748
+
749
+ if (!questionId || !answer) {
750
+ jsonResponse(res, 400, { error: 'questionId and answer are required' });
751
+ return;
752
+ }
753
+
754
+ const success = supervisorHeartbeat.answerQuestion(waveId, questionId, answer);
755
+ if (!success) {
756
+ jsonResponse(res, 404, { error: 'Question not found' });
757
+ return;
758
+ }
759
+
760
+ // Deliver answer as a directive so supervisor picks it up at next tick
761
+ supervisorHeartbeat.addDirective(waveId, `[CEO Answer to Q:${questionId}] ${answer}`);
762
+ jsonResponse(res, 200, { success: true });
763
+ }
764
+
765
+ /* ─── GET /api/exec/status ───────────────────── */
766
+
767
+ function handleStatus(res: ServerResponse): void {
768
+ const statuses: Record<string, string> = {};
769
+
770
+ let activeExecs = executionManager.listExecutions({ active: true });
771
+
772
+ // Recovery: if in-memory map is empty (e.g. after server restart),
773
+ // rebuild active executions from persisted session-store + activity-streams
774
+ if (activeExecs.length === 0) {
775
+ const allSessions = listSessions();
776
+ const activeSessions = allSessions.filter(s =>
777
+ s.status === 'active' &&
778
+ (s.source === 'wave' || s.source === 'dispatch' || s.source === 'chat')
779
+ );
780
+
781
+ const recovered: typeof activeExecs = [];
782
+ // Limit recovery scan to prevent OOM on large session stores
783
+ const MAX_RECOVERY_SCAN = 20;
784
+ const recentActive = activeSessions.slice(-MAX_RECOVERY_SCAN);
785
+
786
+ for (const ses of recentActive) {
787
+ if (!ActivityStream.exists(ses.id)) continue;
788
+ // Only read last few events to check done/error (not entire stream)
789
+ const events = ActivityStream.readFrom(ses.id, 0);
790
+ if (events.length === 0) continue;
791
+
792
+ // Check last 5 events for done/error (optimization: don't scan entire file)
793
+ const tail = events.slice(-5);
794
+ const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
795
+ if (isDone) continue;
796
+
797
+ const startEvent = events.find(e => e.type === 'msg:start');
798
+ const task = (startEvent?.data?.task as string) ?? ses.title ?? '';
799
+ recovered.push({
800
+ id: `recovered-${ses.id}`,
801
+ type: (startEvent?.data?.type as string ?? 'assign') as 'assign' | 'wave' | 'consult',
802
+ roleId: ses.roleId,
803
+ task,
804
+ status: 'running',
805
+ childSessionIds: [],
806
+ createdAt: ses.createdAt,
807
+ });
808
+ }
809
+
810
+ if (recovered.length > 0) {
811
+ activeExecs = recovered;
812
+ console.log(`[ExecStatus] Recovered ${recovered.length} active executions from session-store`);
813
+ }
814
+ }
815
+
816
+ for (const exec of activeExecs) {
817
+ // ExecStatus 'running' → RoleStatus 'working' (not MessageStatus 'streaming')
818
+ statuses[exec.roleId] = exec.status === 'running' ? 'working'
819
+ : exec.status === 'awaiting_input' ? 'awaiting_input'
820
+ : 'done';
821
+ }
822
+
823
+ const activeExecutions = activeExecs.map((e) => ({
824
+ id: e.id,
825
+ roleId: e.roleId,
826
+ task: e.task,
827
+ startedAt: e.createdAt,
828
+ }));
829
+
830
+ jsonResponse(res, 200, { statuses, activeExecutions });
831
+ }
832
+
833
+
834
+
835
+ /* ─── POST /api/exec/session/{id}/message ──── */
836
+
837
+ function handleSessionMessage(
838
+ sessionId: string,
839
+ body: Record<string, unknown>,
840
+ req: IncomingMessage,
841
+ res: ServerResponse,
842
+ ): void {
843
+ const session = getSession(sessionId);
844
+ if (!session) {
845
+ jsonResponse(res, 404, { error: 'Session not found' });
846
+ return;
847
+ }
848
+
849
+ const content = body.content as string;
850
+ const mode = (body.mode as 'talk' | 'do') ?? session.mode;
851
+ const attachments = body.attachments as ImageAttachment[] | undefined;
852
+
853
+ if (!content && (!attachments || attachments.length === 0)) {
854
+ jsonResponse(res, 400, { error: 'content or attachments required' });
855
+ return;
856
+ }
857
+
858
+ const MAX_FILE_SIZE = 5 * 1024 * 1024;
859
+ const SUPPORTED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
860
+ if (attachments && attachments.length > 0) {
861
+ for (const att of attachments) {
862
+ if (!SUPPORTED_TYPES.includes(att.mediaType)) {
863
+ jsonResponse(res, 400, { error: `Unsupported image type: ${att.mediaType}` });
864
+ return;
865
+ }
866
+ const approximateSize = (att.data.length * 3) / 4;
867
+ if (approximateSize > MAX_FILE_SIZE) {
868
+ jsonResponse(res, 400, { error: `File too large: ${att.name}. Max 5MB.` });
869
+ return;
870
+ }
871
+ }
872
+ }
873
+
874
+ const roleId = session.roleId;
875
+ const readOnly = mode === 'talk';
876
+
877
+ const orgTree = buildOrgTree(COMPANY_ROOT);
878
+ if (mode === 'do' && !canDispatchTo(orgTree, 'ceo', roleId)) {
879
+ jsonResponse(res, 403, { error: `CEO cannot dispatch to ${roleId}. Use Talk mode or dispatch via their manager.` });
880
+ return;
881
+ }
882
+
883
+ const ceoMsg: Message = {
884
+ id: `msg-${Date.now()}-ceo`,
885
+ from: 'ceo',
886
+ content: content || '',
887
+ type: mode === 'do' ? 'directive' : 'conversation',
888
+ status: 'done',
889
+ timestamp: new Date().toISOString(),
890
+ attachments,
891
+ };
892
+ addMessage(sessionId, ceoMsg);
893
+
894
+ const contextWindow = buildConversationContext(session.messages, ceoMsg);
895
+ const fullTask = contextWindow
896
+ ? `${contextWindow}\n[Current Message]\nCEO: ${content || '(image attached)'}`
897
+ : content || '(image attached)';
898
+
899
+ const roleMsg: Message = {
900
+ id: `msg-${Date.now() + 1}-role`,
901
+ from: 'role',
902
+ content: '',
903
+ type: 'conversation',
904
+ status: 'streaming',
905
+ timestamp: new Date().toISOString(),
906
+ };
907
+ addMessage(sessionId, roleMsg, true);
908
+
909
+ startSSE(res);
910
+ sendSSE(res, 'session', { sessionId, ceoMessageId: ceoMsg.id, roleMessageId: roleMsg.id });
911
+
912
+ const cleanupSSELifecycle = startSSELifecycle(res, () => {
913
+ cleanupChildSubscriptions();
914
+ updateMessage(sessionId, roleMsg.id, { status: 'error' });
915
+ sendSSE(res, 'error', { message: 'SSE timeout — connection forcibly closed after 10 minutes' });
916
+ if (!res.writableEnded) res.end();
917
+ handle.abort();
918
+ });
919
+
920
+ const childSubscriptions: Array<{ exec: Execution; subscriber: ActivitySubscriber }> = [];
921
+ const pendingDispatches = new Set<string>();
922
+
923
+ const unwatchExecs = executionManager.onExecutionCreated((childExec) => {
924
+ if (childExec.type !== 'assign') return;
925
+ if (roleMsg.status !== 'streaming') return;
926
+ if (!pendingDispatches.has(childExec.roleId)) return;
927
+ pendingDispatches.delete(childExec.roleId);
928
+
929
+ const subscriber: ActivitySubscriber = (event) => {
930
+ switch (event.type) {
931
+ case 'text':
932
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'text', text: event.data.text });
933
+ break;
934
+ case 'thinking':
935
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'thinking', text: event.data.text });
936
+ break;
937
+ case 'tool:start':
938
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'tool', name: event.data.name, input: event.data.input });
939
+ break;
940
+ case 'msg:awaiting_input':
941
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'awaiting_input', question: event.data.question, targetRole: event.data.targetRole });
942
+ break;
943
+ case 'msg:done':
944
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'done' });
945
+ childExec.stream.unsubscribe(subscriber);
946
+ break;
947
+ case 'msg:error':
948
+ sendSSE(res, 'dispatch:progress', { roleId: event.roleId, type: 'error', message: event.data.message });
949
+ childExec.stream.unsubscribe(subscriber);
950
+ break;
951
+ }
952
+ };
953
+ childExec.stream.subscribe(subscriber);
954
+ childSubscriptions.push({ exec: childExec, subscriber });
955
+ });
956
+
957
+ const teamStatus: TeamStatus = {};
958
+ for (const e of executionManager.listExecutions({ active: true })) {
959
+ const mapped = messageStatusToRoleStatus(e.status as MessageStatus);
960
+ if (teamStatus[e.roleId]?.status === 'working' && mapped === 'awaiting_input') continue;
961
+ teamStatus[e.roleId] = { status: mapped, task: e.task };
962
+ }
963
+
964
+ const handle = getRunner().execute(
965
+ { companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model, attachments, teamStatus, sessionId },
966
+ {
967
+ onText: (text) => {
968
+ roleMsg.content += text;
969
+ updateMessage(sessionId, roleMsg.id, { content: roleMsg.content });
970
+ sendSSE(res, 'output', { text });
971
+ },
972
+ onThinking: (text) => {
973
+ sendSSE(res, 'thinking', { text });
974
+ },
975
+ onToolUse: (name, input) => {
976
+ sendSSE(res, 'tool', { name, input: input ? summarizeInput(input) : undefined });
977
+ },
978
+ onDispatch: (subRoleId, subTask) => {
979
+ pendingDispatches.add(subRoleId);
980
+ sendSSE(res, 'dispatch', { roleId: subRoleId, task: subTask });
981
+ },
982
+ onTurnComplete: (turn) => {
983
+ sendSSE(res, 'turn', { turn });
984
+ },
985
+ onError: (error) => {
986
+ sendSSE(res, 'stderr', { message: error });
987
+ },
988
+ },
989
+ );
990
+
991
+ const cleanupChildSubscriptions = () => {
992
+ unwatchExecs();
993
+ for (const { exec, subscriber } of childSubscriptions) {
994
+ exec.stream.unsubscribe(subscriber);
995
+ }
996
+ childSubscriptions.length = 0;
997
+ };
998
+
999
+ handle.promise
1000
+ .then((result: RunnerResult) => {
1001
+ cleanupSSELifecycle();
1002
+ cleanupChildSubscriptions();
1003
+ updateMessage(sessionId, roleMsg.id, {
1004
+ content: roleMsg.content,
1005
+ status: 'done',
1006
+ turns: result.turns,
1007
+ tokens: result.totalTokens,
1008
+ });
1009
+ sendSSE(res, 'done', {
1010
+ roleMessageId: roleMsg.id,
1011
+ output: roleMsg.content.slice(-500),
1012
+ turns: result.turns,
1013
+ tokens: result.totalTokens,
1014
+ });
1015
+ if (!res.writableEnded) res.end();
1016
+ })
1017
+ .catch((err: Error) => {
1018
+ cleanupSSELifecycle();
1019
+ cleanupChildSubscriptions();
1020
+ updateMessage(sessionId, roleMsg.id, { status: 'error' });
1021
+ sendSSE(res, 'error', { message: err.message });
1022
+ if (!res.writableEnded) res.end();
1023
+ });
1024
+
1025
+ req.on('close', () => {
1026
+ cleanupSSELifecycle();
1027
+ cleanupChildSubscriptions();
1028
+ if (roleMsg.status === 'streaming') {
1029
+ handle.abort();
1030
+ updateMessage(sessionId, roleMsg.id, { status: 'error' });
1031
+ }
1032
+ });
1033
+ }
1034
+
1035
+ /* ─── Conversation context builder ─────────── */
1036
+
1037
+ function buildConversationContext(messages: Message[], currentMsg?: Message): string {
1038
+ const history = currentMsg
1039
+ ? messages.filter((m) => m.id !== currentMsg.id)
1040
+ : messages;
1041
+
1042
+ if (history.length === 0) return '';
1043
+
1044
+ const selected: Message[] = [];
1045
+ let totalChars = 0;
1046
+ for (let i = history.length - 1; i >= 0; i--) {
1047
+ const msg = history[i];
1048
+ totalChars += msg.content.length;
1049
+ if (selected.length >= 10 || totalChars > 8000) break;
1050
+ selected.unshift(msg);
1051
+ }
1052
+
1053
+ if (selected.length === 0) return '';
1054
+
1055
+ const lines = selected.map((m) => {
1056
+ const speaker = m.from === 'ceo' ? 'CEO' : m.from.toUpperCase();
1057
+ return `${speaker}: ${m.content}`;
1058
+ });
1059
+
1060
+ return `[Conversation History]\n${lines.join('\n')}\n`;
1061
+ }
1062
+
1063
+ /* ─── Helpers ────────────────────────────────── */
1064
+
1065
+ function summarizeInput(input: Record<string, unknown>): Record<string, unknown> {
1066
+ const summary: Record<string, unknown> = {};
1067
+ for (const [key, value] of Object.entries(input)) {
1068
+ if (typeof value === 'string' && value.length > 200) {
1069
+ summary[key] = value.slice(0, 200) + '...';
1070
+ } else {
1071
+ summary[key] = value;
1072
+ }
1073
+ }
1074
+ return summary;
1075
+ }