tycono 0.1.96-beta.5 → 0.1.96-beta.50
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 +16 -14
- package/bin/tycono.ts +19 -8
- package/package.json +1 -1
- package/src/api/src/routes/active-sessions.ts +3 -0
- package/src/api/src/routes/execute.ts +30 -41
- package/src/api/src/server.ts +44 -0
- package/src/api/src/services/execution-manager.ts +7 -1
- package/src/api/src/services/supervisor-heartbeat.ts +186 -11
- package/src/api/src/services/wave-multiplexer.ts +21 -7
- package/src/tui/api.ts +61 -5
- package/src/tui/app.tsx +518 -164
- package/src/tui/components/CommandMode.tsx +348 -0
- package/src/tui/components/OrgTree.tsx +17 -18
- package/src/tui/components/PanelMode.tsx +521 -0
- package/src/tui/components/SetupWizard.tsx +1 -1
- package/src/tui/components/StatusBar.tsx +44 -25
- package/src/tui/components/StreamView.tsx +171 -0
- package/src/tui/hooks/useApi.ts +42 -7
- package/src/tui/hooks/useCommand.ts +199 -0
- package/src/tui/hooks/useSSE.ts +130 -27
- package/src/tui/store.ts +12 -0
- package/src/tui/utils/markdown.tsx +102 -0
- package/src/tui/components/CommandInput.tsx +0 -32
- package/src/tui/components/HelpOverlay.tsx +0 -51
- package/src/tui/components/SessionList.tsx +0 -74
- package/src/tui/components/StreamPanel.tsx +0 -182
- package/src/tui/components/WaveDialog.tsx +0 -56
- package/src/tui/hooks/useKeyboard.ts +0 -62
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<h1 align="center">tycono</h1>
|
|
6
6
|
|
|
7
7
|
<p align="center">
|
|
8
|
-
<strong>
|
|
9
|
-
<sub>
|
|
8
|
+
<strong>Cursor gives you one AI developer. Tycono gives you an AI team.</strong><br>
|
|
9
|
+
<sub>Give one order. Watch your AI team plan, build, and learn together.</sub>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Cursor, Lovable, Bolt — they all give you **one AI agent**. It helps, but you still drive everything.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
**tycono** gives you an **AI team**. A CTO reviews architecture. Engineers write code. A PM breaks down tasks. QA catches bugs. You just give the order and watch them work.
|
|
31
|
+
|
|
32
|
+
One command. Your AI team is running.
|
|
31
33
|
|
|
32
34
|
```bash
|
|
33
35
|
npx tycono
|
|
@@ -83,16 +85,16 @@ Session 50 is dramatically smarter than session 1. Your company learns.
|
|
|
83
85
|
|
|
84
86
|
## Why Tycono?
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
Same goal as Cursor, Lovable, Bolt — **get AI to do your work**. Different method.
|
|
87
89
|
|
|
88
|
-
| |
|
|
90
|
+
| | Cursor / Lovable / Bolt | Tycono |
|
|
89
91
|
|---|---|---|
|
|
90
|
-
| **
|
|
91
|
-
| **
|
|
92
|
-
| **
|
|
93
|
-
| **
|
|
94
|
-
| **Scale** | 1
|
|
95
|
-
| **Visibility** |
|
|
92
|
+
| **Agents** | 1 AI helps you | **AI team works for you** |
|
|
93
|
+
| **Your role** | Keep directing | **Give one order, watch** |
|
|
94
|
+
| **Knowledge** | Resets every session | **Compounds forever** |
|
|
95
|
+
| **Quality** | You review everything | **QA agent catches bugs** |
|
|
96
|
+
| **Scale** | 1 task at a time | **Parallel across roles** |
|
|
97
|
+
| **Visibility** | Editor / chat | **Real-time org tree** |
|
|
96
98
|
|
|
97
99
|
## Company-as-Code
|
|
98
100
|
|
|
@@ -258,9 +260,9 @@ npx tycono --version # Show version
|
|
|
258
260
|
- [x] CEO Wave dispatch with org-tree targeting
|
|
259
261
|
- [x] AKB — Pre-K / Post-K knowledge loop
|
|
260
262
|
- [x] Port Registry for multi-agent isolation
|
|
261
|
-
- [ ] **TUI mode** — terminal-native multi-panel interface
|
|
263
|
+
- [ ] **TUI mode** — terminal-native multi-panel interface *(in progress)*
|
|
262
264
|
- [ ] Git worktree isolation per agent session
|
|
263
|
-
- [ ] Desktop app (.dmg / .exe) — background execution,
|
|
265
|
+
- [ ] **Desktop app** (.dmg / .exe) — background execution, notifications, no API key setup needed
|
|
264
266
|
- [ ] Multi-LLM support (OpenAI, local models)
|
|
265
267
|
|
|
266
268
|
## Built with Tycono
|
package/bin/tycono.ts
CHANGED
|
@@ -213,16 +213,27 @@ async function startServerForTui(): Promise<void> {
|
|
|
213
213
|
const port = process.env.PORT ? Number(process.env.PORT) : await findFreePort();
|
|
214
214
|
process.env.PORT = String(port);
|
|
215
215
|
|
|
216
|
-
// Suppress ALL server
|
|
216
|
+
// Suppress ALL server output BEFORE creating server — hijack process streams
|
|
217
217
|
const logFile = path.resolve(process.env.COMPANY_ROOT || process.cwd(), '.tycono', 'server.log');
|
|
218
218
|
try { fs.mkdirSync(path.dirname(logFile), { recursive: true }); } catch {}
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
219
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
220
|
+
const logStream = fs.createWriteStream(logFile, { fd: logFd });
|
|
221
|
+
const origStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
222
|
+
// Redirect ALL non-Ink output to log file.
|
|
223
|
+
// Ink uses stdout.write with ANSI sequences. Server uses console.log (which calls stdout.write).
|
|
224
|
+
// We must intercept stdout.write but ALWAYS pass through to real stdout,
|
|
225
|
+
// just also copy non-Ink output to log file.
|
|
226
|
+
// The key insight: DON'T BLOCK anything — just copy server output to log file.
|
|
227
|
+
// Ink can handle interleaved output by re-rendering.
|
|
228
|
+
console.log = (...args: unknown[]) => { logStream.write(args.join(' ') + '\n'); };
|
|
229
|
+
console.error = (...args: unknown[]) => { logStream.write(args.join(' ') + '\n'); };
|
|
230
|
+
console.warn = (...args: unknown[]) => { logStream.write(args.join(' ') + '\n'); };
|
|
231
|
+
// Also intercept direct stderr.write (used by our debug logging)
|
|
232
|
+
process.stderr.write = ((chunk: any, ...args: any[]) => {
|
|
233
|
+
logStream.write(typeof chunk === 'string' ? chunk : chunk.toString());
|
|
234
|
+
return true;
|
|
235
|
+
}) as any;
|
|
236
|
+
const origLog = (...args: unknown[]) => origStdoutWrite(args.join(' ') + '\n');
|
|
226
237
|
|
|
227
238
|
const { createHttpServer } = await import('../src/api/src/create-server.js');
|
|
228
239
|
const server = createHttpServer();
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { Router } from 'express';
|
|
7
7
|
import { portRegistry } from '../services/port-registry.js';
|
|
8
8
|
import { executionManager } from '../services/execution-manager.js';
|
|
9
|
+
import { getSession } from '../services/session-store.js';
|
|
9
10
|
|
|
10
11
|
export const activeSessionsRouter = Router();
|
|
11
12
|
|
|
@@ -17,8 +18,10 @@ activeSessionsRouter.get('/', (_req, res) => {
|
|
|
17
18
|
|
|
18
19
|
const enriched = sessions.map(s => {
|
|
19
20
|
const exec = executionManager.getActiveExecution(s.sessionId);
|
|
21
|
+
const session = getSession(s.sessionId);
|
|
20
22
|
return {
|
|
21
23
|
...s,
|
|
24
|
+
waveId: session?.waveId ?? null,
|
|
22
25
|
messageStatus: exec?.status ?? null,
|
|
23
26
|
roleName: exec?.roleId ?? s.roleId,
|
|
24
27
|
alive: s.pid ? isAlive(s.pid) : null,
|
|
@@ -51,25 +51,21 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
|
|
|
51
51
|
// ── /api/waves/active — restore active waves after refresh ──
|
|
52
52
|
if (method === 'GET' && url === '/api/waves/active') {
|
|
53
53
|
// Recovery: rebuild wave→session mapping from session-store if lost
|
|
54
|
+
// Only recover ACTIVE sessions to prevent OOM on large datasets (140+ sessions)
|
|
54
55
|
const waves = waveMultiplexer.getActiveWaves();
|
|
55
56
|
if (waves.length === 0) {
|
|
56
57
|
const allSessions = listSessions();
|
|
57
|
-
|
|
58
|
+
let recovered = 0;
|
|
58
59
|
for (const ses of allSessions) {
|
|
59
|
-
if (ses.waveId)
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
if (!ses.waveId || ses.status !== 'active') continue;
|
|
61
|
+
const exec = executionManager.getActiveExecution(ses.id);
|
|
62
|
+
if (exec) {
|
|
63
|
+
waveMultiplexer.registerSession(ses.waveId, exec);
|
|
64
|
+
recovered++;
|
|
62
65
|
}
|
|
63
66
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for (const sid of sids) {
|
|
67
|
-
// getActiveExecution falls back to stream recovery for completed sessions
|
|
68
|
-
const exec = executionManager.getActiveExecution(sid);
|
|
69
|
-
if (exec) {
|
|
70
|
-
waveMultiplexer.registerSession(wid, exec);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
67
|
+
if (recovered > 0) {
|
|
68
|
+
console.log(`[WaveRecovery] Recovered ${recovered} active sessions`);
|
|
73
69
|
}
|
|
74
70
|
}
|
|
75
71
|
jsonResponse(res, 200, { waves: waveMultiplexer.getActiveWaves() });
|
|
@@ -212,10 +208,8 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
212
208
|
const attachments = body.attachments as ImageAttachment[] | undefined;
|
|
213
209
|
|
|
214
210
|
if (type === 'wave') {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
211
|
+
// directive가 없으면 idle 상태로 시작 (empty wave)
|
|
212
|
+
const actualDirective = directive || '';
|
|
219
213
|
|
|
220
214
|
const targetRoles = body.targetRoles as string[] | undefined;
|
|
221
215
|
const continuous = body.continuous === true;
|
|
@@ -224,7 +218,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
224
218
|
{
|
|
225
219
|
const state = supervisorHeartbeat.start(
|
|
226
220
|
`wave-${Date.now()}`,
|
|
227
|
-
|
|
221
|
+
actualDirective,
|
|
228
222
|
targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
|
|
229
223
|
continuous,
|
|
230
224
|
);
|
|
@@ -238,7 +232,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
238
232
|
waveId: state.waveId,
|
|
239
233
|
supervisorSessionId: state.supervisorSessionId,
|
|
240
234
|
mode: 'supervisor',
|
|
241
|
-
directive,
|
|
235
|
+
directive: actualDirective,
|
|
242
236
|
});
|
|
243
237
|
return;
|
|
244
238
|
}
|
|
@@ -482,13 +476,11 @@ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req:
|
|
|
482
476
|
|
|
483
477
|
let sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
|
|
484
478
|
|
|
485
|
-
// Recovery:
|
|
486
|
-
// rebuild from session-store (sessions have waveId) + executionManager
|
|
479
|
+
// Recovery: only recover ACTIVE sessions for this wave (not all 140)
|
|
487
480
|
if (sessionIds.length === 0) {
|
|
488
481
|
const allSessions = listSessions();
|
|
489
|
-
const waveSessions = allSessions.filter(s => s.waveId === waveId);
|
|
482
|
+
const waveSessions = allSessions.filter(s => s.waveId === waveId && s.status === 'active');
|
|
490
483
|
for (const ses of waveSessions) {
|
|
491
|
-
// getActiveExecution recovers from stream files for completed sessions too
|
|
492
484
|
const exec = executionManager.getActiveExecution(ses.id);
|
|
493
485
|
if (exec) {
|
|
494
486
|
waveMultiplexer.registerSession(waveId, exec);
|
|
@@ -497,12 +489,8 @@ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req:
|
|
|
497
489
|
sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
|
|
498
490
|
}
|
|
499
491
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
res.end(JSON.stringify({ error: `No sessions found for wave: ${waveId}` }));
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
|
|
492
|
+
// Don't 404 on empty waves — keep SSE alive, sessions will appear later
|
|
493
|
+
// (e.g. idle wave waiting for first directive, or supervisor restarting)
|
|
506
494
|
const client = waveMultiplexer.attach(waveId, res as any, fromWaveSeq);
|
|
507
495
|
|
|
508
496
|
req.on('close', () => {
|
|
@@ -769,25 +757,26 @@ function handleStatus(res: ServerResponse): void {
|
|
|
769
757
|
);
|
|
770
758
|
|
|
771
759
|
const recovered: typeof activeExecs = [];
|
|
772
|
-
|
|
773
|
-
|
|
760
|
+
// Limit recovery scan to prevent OOM on large session stores
|
|
761
|
+
const MAX_RECOVERY_SCAN = 20;
|
|
762
|
+
const recentActive = activeSessions.slice(-MAX_RECOVERY_SCAN);
|
|
763
|
+
|
|
764
|
+
for (const ses of recentActive) {
|
|
774
765
|
if (!ActivityStream.exists(ses.id)) continue;
|
|
766
|
+
// Only read last few events to check done/error (not entire stream)
|
|
775
767
|
const events = ActivityStream.readFrom(ses.id, 0);
|
|
776
768
|
if (events.length === 0) continue;
|
|
777
769
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const errorEvent = events.find(e => e.type === 'msg:error');
|
|
783
|
-
|
|
784
|
-
// Only include sessions that haven't finished yet
|
|
785
|
-
if (doneEvent || errorEvent) continue;
|
|
770
|
+
// Check last 5 events for done/error (optimization: don't scan entire file)
|
|
771
|
+
const tail = events.slice(-5);
|
|
772
|
+
const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
|
|
773
|
+
if (isDone) continue;
|
|
786
774
|
|
|
787
|
-
const
|
|
775
|
+
const startEvent = events.find(e => e.type === 'msg:start');
|
|
776
|
+
const task = (startEvent?.data?.task as string) ?? ses.title ?? '';
|
|
788
777
|
recovered.push({
|
|
789
778
|
id: `recovered-${ses.id}`,
|
|
790
|
-
type: (startEvent
|
|
779
|
+
type: (startEvent?.data?.type as string ?? 'assign') as 'assign' | 'wave' | 'consult',
|
|
791
780
|
roleId: ses.roleId,
|
|
792
781
|
task,
|
|
793
782
|
status: 'running',
|
package/src/api/src/server.ts
CHANGED
|
@@ -1,11 +1,40 @@
|
|
|
1
1
|
import { COMPANY_ROOT } from './services/file-reader.js';
|
|
2
2
|
import { applyConfig } from './services/company-config.js';
|
|
3
3
|
import { createHttpServer } from './create-server.js';
|
|
4
|
+
import { listSessions, updateSession } from './services/session-store.js';
|
|
5
|
+
import { ActivityStream } from './services/activity-stream.js';
|
|
4
6
|
|
|
5
7
|
// Load .tycono/config.json and apply to process.env
|
|
6
8
|
const config = applyConfig(COMPANY_ROOT);
|
|
7
9
|
console.log(`[STARTUP] Engine: ${config.engine}, API key: ${config.apiKey ? 'set' : 'none'}`);
|
|
8
10
|
|
|
11
|
+
// Startup: mark orphaned 'active' sessions as 'interrupted'
|
|
12
|
+
// These are sessions from a previous server that crashed or was killed
|
|
13
|
+
{
|
|
14
|
+
const allSessions = listSessions();
|
|
15
|
+
let orphaned = 0;
|
|
16
|
+
for (const ses of allSessions) {
|
|
17
|
+
if (ses.status !== 'active') continue;
|
|
18
|
+
// Check activity stream — if it has msg:done/msg:error, mark done
|
|
19
|
+
// If not, mark interrupted (previous server died mid-execution)
|
|
20
|
+
if (ActivityStream.exists(ses.id)) {
|
|
21
|
+
const events = ActivityStream.readFrom(ses.id, 0);
|
|
22
|
+
const tail = events.slice(-5);
|
|
23
|
+
const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
|
|
24
|
+
if (isDone) {
|
|
25
|
+
updateSession(ses.id, { status: 'done' });
|
|
26
|
+
orphaned++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
updateSession(ses.id, { status: 'interrupted' as any });
|
|
31
|
+
orphaned++;
|
|
32
|
+
}
|
|
33
|
+
if (orphaned > 0) {
|
|
34
|
+
console.log(`[STARTUP] Cleaned ${orphaned} orphaned sessions (active → done/interrupted)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
const PORT = Number(process.env.PORT) || 3001;
|
|
10
39
|
const server = createHttpServer();
|
|
11
40
|
|
|
@@ -13,3 +42,18 @@ server.listen(PORT, () => {
|
|
|
13
42
|
console.log(`[API] Server running on http://localhost:${PORT}`);
|
|
14
43
|
console.log(`[API] COMPANY_ROOT: ${COMPANY_ROOT}`);
|
|
15
44
|
});
|
|
45
|
+
|
|
46
|
+
// Graceful shutdown: mark running sessions as interrupted
|
|
47
|
+
function gracefulShutdown(signal: string) {
|
|
48
|
+
console.log(`[SHUTDOWN] ${signal} received, marking active sessions as interrupted...`);
|
|
49
|
+
const sessions = listSessions();
|
|
50
|
+
for (const ses of sessions) {
|
|
51
|
+
if (ses.status === 'active') {
|
|
52
|
+
updateSession(ses.id, { status: 'interrupted' as any });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
59
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
@@ -10,7 +10,7 @@ import { estimateCost } from './pricing.js';
|
|
|
10
10
|
import { readConfig, getConversationLimits, resolveCodeRoot } from './company-config.js';
|
|
11
11
|
import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
|
|
12
12
|
import { earnCoinsInternal } from '../routes/coins.js';
|
|
13
|
-
import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
|
|
13
|
+
import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, updateSession, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
|
|
14
14
|
import { portRegistry, type PortAllocation } from './port-registry.js';
|
|
15
15
|
import { type MessageStatus, isMessageActive, canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
|
|
16
16
|
|
|
@@ -603,6 +603,12 @@ class ExecutionManager {
|
|
|
603
603
|
knowledgeDebt: execution.knowledgeDebt.map(d => ({ type: d.type, file: d.file, message: d.message })),
|
|
604
604
|
}),
|
|
605
605
|
});
|
|
606
|
+
|
|
607
|
+
// Mark session as done in session-store (persisted to file)
|
|
608
|
+
// Skip CEO supervisor sessions — they stay active for wave lifecycle
|
|
609
|
+
if (session.roleId !== 'ceo' || session.source !== 'wave') {
|
|
610
|
+
updateSession(execution.sessionId, { status: 'done' });
|
|
611
|
+
}
|
|
606
612
|
}
|
|
607
613
|
|
|
608
614
|
private cleanupOrphanedChildren(parentSessionId: string): void {
|
|
@@ -88,6 +88,14 @@ class SupervisorHeartbeat {
|
|
|
88
88
|
};
|
|
89
89
|
|
|
90
90
|
this.supervisors.set(waveId, state);
|
|
91
|
+
|
|
92
|
+
// Empty directive → idle wave (don't spawn supervisor yet)
|
|
93
|
+
if (!directive) {
|
|
94
|
+
state.status = 'stopped';
|
|
95
|
+
console.log(`[Supervisor] Idle wave created: ${waveId} (no directive)`);
|
|
96
|
+
return state;
|
|
97
|
+
}
|
|
98
|
+
|
|
91
99
|
this.spawnSupervisor(state);
|
|
92
100
|
return state;
|
|
93
101
|
}
|
|
@@ -133,6 +141,25 @@ class SupervisorHeartbeat {
|
|
|
133
141
|
|
|
134
142
|
state.pendingDirectives.push(directive);
|
|
135
143
|
console.log(`[Supervisor] Directive queued for wave ${waveId}: ${text.slice(0, 80)}`);
|
|
144
|
+
|
|
145
|
+
// If supervisor is stopped (agent finished or idle wave), wake it up
|
|
146
|
+
if (state.status === 'stopped') {
|
|
147
|
+
// Update the wave's directive if it was empty (idle wave first message)
|
|
148
|
+
if (!state.directive) {
|
|
149
|
+
state.directive = text;
|
|
150
|
+
}
|
|
151
|
+
state.crashCount = 0;
|
|
152
|
+
|
|
153
|
+
// Dual Mode: Conversation vs Dispatch (code-level enforcement)
|
|
154
|
+
// If directive looks like a question/status check → spawn conversation mode
|
|
155
|
+
// If directive looks like a task → spawn full supervisor with dispatch tools
|
|
156
|
+
if (this.isConversationDirective(text)) {
|
|
157
|
+
this.spawnConversation(state, text);
|
|
158
|
+
} else {
|
|
159
|
+
this.scheduleRestart(state, 0);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
136
163
|
return directive;
|
|
137
164
|
}
|
|
138
165
|
|
|
@@ -215,6 +242,120 @@ class SupervisorHeartbeat {
|
|
|
215
242
|
.filter(s => s.status === 'running' || s.status === 'starting' || s.status === 'restarting');
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
/* ─── Internal: Dual Mode ─────────────────── */
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Heuristic: is this directive a question/status check (conversation)
|
|
249
|
+
* or a work task (needs dispatch)?
|
|
250
|
+
*
|
|
251
|
+
* Conversation signals: question marks, status keywords, short length
|
|
252
|
+
* Dispatch signals: imperative verbs (만들어, 수정해, 구현해), long directives
|
|
253
|
+
*/
|
|
254
|
+
private isConversationDirective(text: string): boolean {
|
|
255
|
+
const t = text.trim();
|
|
256
|
+
|
|
257
|
+
// Short messages with question marks → conversation
|
|
258
|
+
if (t.includes('?') && t.length < 100) return true;
|
|
259
|
+
|
|
260
|
+
// Korean question patterns
|
|
261
|
+
const questionPatterns = [
|
|
262
|
+
/확인해/, /알려줘/, /보여줘/, /어때/, /뭐야/, /뭐지/, /뭘까/,
|
|
263
|
+
/상태/, /상황/, /진행/, /현재/, /어디/, /얼마/,
|
|
264
|
+
/what/i, /how.*going/i, /status/i, /check/i, /show/i, /tell/i,
|
|
265
|
+
];
|
|
266
|
+
if (questionPatterns.some(p => p.test(t))) return true;
|
|
267
|
+
|
|
268
|
+
// Long directives with action verbs → dispatch
|
|
269
|
+
const taskPatterns = [
|
|
270
|
+
/만들어/, /구현해/, /개발해/, /수정해/, /변경해/, /리팩토링/,
|
|
271
|
+
/설계해/, /작성해/, /배포해/, /테스트해/, /고쳐/,
|
|
272
|
+
/build/i, /create/i, /implement/i, /develop/i, /fix/i, /deploy/i, /refactor/i,
|
|
273
|
+
];
|
|
274
|
+
if (taskPatterns.some(p => p.test(t))) return false;
|
|
275
|
+
|
|
276
|
+
// Default: short → conversation, long → dispatch
|
|
277
|
+
return t.length < 60;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Spawn a lightweight conversation session (no dispatch tools).
|
|
282
|
+
* CEO reads files and answers directly.
|
|
283
|
+
*/
|
|
284
|
+
private spawnConversation(state: SupervisorState, directive: string): void {
|
|
285
|
+
// Build conversation context: previous directives + last execution summary
|
|
286
|
+
const deliveredDirectives = state.pendingDirectives.filter(d => d.delivered);
|
|
287
|
+
const directiveHistory = deliveredDirectives.length > 0
|
|
288
|
+
? deliveredDirectives.map(d => `- CEO: "${d.text}"`).join('\n')
|
|
289
|
+
: '';
|
|
290
|
+
|
|
291
|
+
// Extract last execution's output from activity stream (what "just happened")
|
|
292
|
+
let lastExecutionSummary = '';
|
|
293
|
+
if (state.supervisorSessionId) {
|
|
294
|
+
try {
|
|
295
|
+
const events = ActivityStream.readAll(state.supervisorSessionId);
|
|
296
|
+
// Get last text outputs (the supervisor's final response)
|
|
297
|
+
const textEvents = events.filter(e => e.type === 'text' && e.roleId === 'ceo');
|
|
298
|
+
const toolEvents = events.filter(e => e.type === 'tool:start' && e.roleId === 'ceo');
|
|
299
|
+
|
|
300
|
+
// Summarize: what tools were used + final text
|
|
301
|
+
const toolSummary = toolEvents.slice(-10).map(e => {
|
|
302
|
+
const name = (e.data.name as string) ?? '';
|
|
303
|
+
const inp = e.data.input as Record<string, unknown> | undefined;
|
|
304
|
+
const detail = inp?.file_path ?? inp?.command ?? '';
|
|
305
|
+
return ` → ${name} ${String(detail).slice(0, 60)}`;
|
|
306
|
+
}).join('\n');
|
|
307
|
+
|
|
308
|
+
const lastText = textEvents.slice(-5).map(e => String(e.data.text ?? '')).join('').slice(-500);
|
|
309
|
+
|
|
310
|
+
if (toolSummary || lastText) {
|
|
311
|
+
lastExecutionSummary = `\n[Previous execution in this wave]\nTools used:\n${toolSummary}\n\nLast response:\n${lastText.slice(0, 500)}\n`;
|
|
312
|
+
}
|
|
313
|
+
} catch { /* ignore */ }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const context = [directiveHistory, lastExecutionSummary].filter(Boolean).join('\n');
|
|
317
|
+
|
|
318
|
+
const task = `${context ? context + '\n' : ''}[CEO Question] ${directive}
|
|
319
|
+
|
|
320
|
+
You are the CEO's AI assistant. The above shows what happened previously in this wave.
|
|
321
|
+
Answer the CEO's question based on context. Be specific — reference files, results, and actions from the previous execution.
|
|
322
|
+
Do NOT dispatch anyone. Do NOT create new files. Just answer concisely.`;
|
|
323
|
+
|
|
324
|
+
// Reuse session
|
|
325
|
+
let sessionId = state.supervisorSessionId;
|
|
326
|
+
if (!sessionId || !getSession(sessionId)) {
|
|
327
|
+
const session = createSession('ceo', {
|
|
328
|
+
mode: 'do',
|
|
329
|
+
source: 'wave',
|
|
330
|
+
waveId: state.waveId,
|
|
331
|
+
});
|
|
332
|
+
sessionId = session.id;
|
|
333
|
+
state.supervisorSessionId = sessionId;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
state.status = 'running';
|
|
337
|
+
|
|
338
|
+
try {
|
|
339
|
+
const exec = executionManager.startExecution({
|
|
340
|
+
type: 'assign', // assign = no supervisor tools (dispatch/watch/amend)
|
|
341
|
+
roleId: 'ceo',
|
|
342
|
+
task,
|
|
343
|
+
sourceRole: 'ceo',
|
|
344
|
+
readOnly: true, // readOnly = no code changes, conversation only
|
|
345
|
+
sessionId,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
state.executionId = exec.id;
|
|
349
|
+
this.watchExecution(state, exec);
|
|
350
|
+
|
|
351
|
+
console.log(`[Supervisor] Conversation mode for wave ${state.waveId} | directive: ${directive.slice(0, 60)}`);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
console.error(`[Supervisor] Conversation spawn failed:`, err);
|
|
354
|
+
// Fallback to full supervisor
|
|
355
|
+
this.scheduleRestart(state, 0);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
218
359
|
/* ─── Internal: Spawn / Restart ────────────── */
|
|
219
360
|
|
|
220
361
|
private spawnSupervisor(state: SupervisorState): void {
|
|
@@ -245,12 +386,45 @@ class SupervisorHeartbeat {
|
|
|
245
386
|
? `\n\n⚠️ [RECOVERY] This is a restart after crash #${state.crashCount}. Check all session states via supervision watch.`
|
|
246
387
|
: '';
|
|
247
388
|
|
|
248
|
-
|
|
389
|
+
// Build conversation context from previous directives
|
|
390
|
+
const deliveredDirectives = state.pendingDirectives.filter(d => d.delivered);
|
|
391
|
+
const conversationHistory = deliveredDirectives.length > 0
|
|
392
|
+
? `\n## Previous Conversation in This Wave
|
|
393
|
+
${deliveredDirectives.map((d, i) => `${i + 1}. CEO said: "${d.text}"`).join('\n')}
|
|
394
|
+
|
|
395
|
+
You are continuing this conversation. The CEO's latest message builds on the above context.
|
|
396
|
+
Do NOT re-analyze from scratch — reference your previous findings.\n`
|
|
397
|
+
: '';
|
|
249
398
|
|
|
399
|
+
const supervisorTask = `[CEO Supervisor] ${state.directive}
|
|
400
|
+
${conversationHistory}
|
|
250
401
|
## Your Role
|
|
251
|
-
You are the CEO Supervisor — the
|
|
252
|
-
|
|
253
|
-
|
|
402
|
+
You are the CEO Supervisor — the CEO's AI proxy.
|
|
403
|
+
You can answer questions directly OR dispatch C-Level roles for complex work.
|
|
404
|
+
|
|
405
|
+
## Response Mode Decision (BEFORE dispatching)
|
|
406
|
+
|
|
407
|
+
⛔ Dispatch is expensive (spawns entire teams). Judge first:
|
|
408
|
+
|
|
409
|
+
**1. Direct Answer** — Can YOU handle this without dispatching?
|
|
410
|
+
- Status check, progress report → Read files/docs yourself, answer directly
|
|
411
|
+
- Simple question → Answer directly
|
|
412
|
+
- Opinion request → Answer directly
|
|
413
|
+
- Clarification on previous work → Answer from context
|
|
414
|
+
→ **Do NOT dispatch. Just answer.**
|
|
415
|
+
|
|
416
|
+
**2. Selective Dispatch** — Only specific C-Level(s) needed?
|
|
417
|
+
- "코드 수정해" → CTO only
|
|
418
|
+
- "디자인 개선해" → CBO only
|
|
419
|
+
- "테스트해봐" → CTO only (who dispatches QA)
|
|
420
|
+
→ **Dispatch only the relevant C-Level(s).**
|
|
421
|
+
|
|
422
|
+
**3. Full Dispatch** — Multi-team collaboration required?
|
|
423
|
+
- "새 기능 만들어" → CTO + CBO
|
|
424
|
+
- "출시 준비해" → All C-Levels
|
|
425
|
+
→ **Dispatch multiple C-Levels with clear tasks.**
|
|
426
|
+
|
|
427
|
+
**Default: Direct Answer first. Dispatch only when code changes or creative work is needed.**
|
|
254
428
|
|
|
255
429
|
## Available C-Level Roles
|
|
256
430
|
${cLevelList}
|
|
@@ -333,13 +507,14 @@ ${state.continuous ? `## Continuous Improvement Mode (ON)
|
|
|
333
507
|
5. 사용자가 Stop을 누를 때까지 계속한다. 스스로 done 선언하지 마라.
|
|
334
508
|
|
|
335
509
|
` : ''}## Instructions
|
|
336
|
-
1.
|
|
337
|
-
2.
|
|
338
|
-
3.
|
|
339
|
-
4.
|
|
340
|
-
5.
|
|
341
|
-
6.
|
|
342
|
-
7.
|
|
510
|
+
1. **First: Apply Response Mode Decision** — Can you answer directly? If yes, answer and report done.
|
|
511
|
+
2. If dispatch is needed: decide which C-Level roles (not necessarily all)
|
|
512
|
+
3. Dispatch with clear, specific tasks
|
|
513
|
+
4. Enter supervision watch loop
|
|
514
|
+
5. Monitor, **actively relay results between teams**, course-correct
|
|
515
|
+
6. When subordinates report done → **verify deliverables against requirements (G-09)**
|
|
516
|
+
7. If gaps exist → re-dispatch with specific feedback. Repeat 4-6.
|
|
517
|
+
8. Only when ALL requirements are met → compile results and report`;
|
|
343
518
|
|
|
344
519
|
// BUG-008 fix: Wave:Supervisor:Session = 1:1:1 invariant.
|
|
345
520
|
// Reuse existing session on restart instead of creating a new one.
|
|
@@ -83,19 +83,30 @@ class WaveMultiplexer {
|
|
|
83
83
|
|
|
84
84
|
const sessions = this.waveSessions.get(waveId);
|
|
85
85
|
if (sessions) {
|
|
86
|
-
// Phase 1: Replay
|
|
87
|
-
|
|
86
|
+
// Phase 1: Replay recent historical events (capped to prevent OOM)
|
|
87
|
+
// Only replay from recent sessions (last 5) to avoid 120-session waves killing memory
|
|
88
|
+
const MAX_REPLAY_SESSIONS = 5;
|
|
89
|
+
const MAX_REPLAY_EVENTS = 200;
|
|
90
|
+
|
|
91
|
+
const sessionList = Array.from(sessions.values());
|
|
92
|
+
const recentSessions = sessionList.slice(-MAX_REPLAY_SESSIONS);
|
|
88
93
|
|
|
89
|
-
|
|
94
|
+
const allEvents: { event: ActivityEvent; sessionId: string }[] = [];
|
|
95
|
+
for (const exec of recentSessions) {
|
|
90
96
|
const events = ActivityStream.readFrom(exec.sessionId, 0);
|
|
91
|
-
|
|
97
|
+
// Take last N events per session
|
|
98
|
+
const recent = events.slice(-50);
|
|
99
|
+
for (const event of recent) {
|
|
92
100
|
allEvents.push({ event, sessionId: exec.sessionId });
|
|
93
101
|
}
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
allEvents.sort((a, b) => a.event.ts.localeCompare(b.event.ts));
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
// Cap total replay events
|
|
107
|
+
const replayEvents = allEvents.slice(-MAX_REPLAY_EVENTS);
|
|
108
|
+
|
|
109
|
+
for (const item of replayEvents) {
|
|
99
110
|
const waveSeq = client.waveSeq++;
|
|
100
111
|
if (waveSeq < fromWaveSeq) continue;
|
|
101
112
|
|
|
@@ -111,8 +122,10 @@ class WaveMultiplexer {
|
|
|
111
122
|
} as WaveStreamEnvelope);
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
console.log(`[WaveMux] Replayed ${replayEvents.length} events (${sessionList.length} total sessions, ${recentSessions.length} replayed)`);
|
|
126
|
+
|
|
114
127
|
// Phase 2: Subscribe to live events for active sessions
|
|
115
|
-
for (const
|
|
128
|
+
for (const exec of sessionList) {
|
|
116
129
|
if (exec.status === 'running' || exec.status === 'awaiting_input') {
|
|
117
130
|
this.subscribeSessionToClient(waveId, client, exec, true);
|
|
118
131
|
}
|
|
@@ -137,7 +150,8 @@ class WaveMultiplexer {
|
|
|
137
150
|
});
|
|
138
151
|
|
|
139
152
|
const events = ActivityStream.readFrom(execution.sessionId, 0);
|
|
140
|
-
|
|
153
|
+
const recentEvents = events.slice(-50); // Cap replay per session
|
|
154
|
+
for (const event of recentEvents) {
|
|
141
155
|
const key = `${event.roleId}:${event.seq}`;
|
|
142
156
|
if (client.sentEvents.has(key)) continue;
|
|
143
157
|
client.sentEvents.add(key);
|