tycono 0.1.96-beta.18 → 0.1.96-beta.19
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/package.json +1 -1
- package/src/api/src/routes/active-sessions.ts +3 -0
- package/src/api/src/routes/execute.ts +4 -6
- package/src/api/src/services/supervisor-heartbeat.ts +19 -0
- package/src/tui/api.ts +27 -2
- package/src/tui/app.tsx +230 -49
- package/src/tui/components/StatusBar.tsx +22 -8
- package/src/tui/hooks/useApi.ts +27 -6
- package/src/tui/hooks/useCommand.ts +57 -39
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,
|
|
@@ -212,10 +212,8 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
212
212
|
const attachments = body.attachments as ImageAttachment[] | undefined;
|
|
213
213
|
|
|
214
214
|
if (type === 'wave') {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
215
|
+
// directive가 없으면 idle 상태로 시작 (empty wave)
|
|
216
|
+
const actualDirective = directive || '';
|
|
219
217
|
|
|
220
218
|
const targetRoles = body.targetRoles as string[] | undefined;
|
|
221
219
|
const continuous = body.continuous === true;
|
|
@@ -224,7 +222,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
224
222
|
{
|
|
225
223
|
const state = supervisorHeartbeat.start(
|
|
226
224
|
`wave-${Date.now()}`,
|
|
227
|
-
|
|
225
|
+
actualDirective,
|
|
228
226
|
targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
|
|
229
227
|
continuous,
|
|
230
228
|
);
|
|
@@ -238,7 +236,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
238
236
|
waveId: state.waveId,
|
|
239
237
|
supervisorSessionId: state.supervisorSessionId,
|
|
240
238
|
mode: 'supervisor',
|
|
241
|
-
directive,
|
|
239
|
+
directive: actualDirective,
|
|
242
240
|
});
|
|
243
241
|
return;
|
|
244
242
|
}
|
|
@@ -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,17 @@ 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
|
+
this.scheduleRestart(state, 0);
|
|
153
|
+
}
|
|
154
|
+
|
|
136
155
|
return directive;
|
|
137
156
|
}
|
|
138
157
|
|
package/src/tui/api.ts
CHANGED
|
@@ -129,7 +129,7 @@ export async function fetchExecStatus(): Promise<ExecStatus> {
|
|
|
129
129
|
return fetchJson<ExecStatus>('/api/exec/status');
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export async function dispatchWave(directive
|
|
132
|
+
export async function dispatchWave(directive?: string, options?: {
|
|
133
133
|
targetRoles?: string[];
|
|
134
134
|
continuous?: boolean;
|
|
135
135
|
}): Promise<WaveResponse> {
|
|
@@ -137,7 +137,7 @@ export async function dispatchWave(directive: string, options?: {
|
|
|
137
137
|
method: 'POST',
|
|
138
138
|
body: {
|
|
139
139
|
type: 'wave',
|
|
140
|
-
directive,
|
|
140
|
+
directive: directive ?? '',
|
|
141
141
|
targetRoles: options?.targetRoles,
|
|
142
142
|
continuous: options?.continuous ?? false,
|
|
143
143
|
},
|
|
@@ -155,6 +155,31 @@ export async function fetchActiveWaves(): Promise<{ waves: Array<{ waveId: strin
|
|
|
155
155
|
return fetchJson('/api/waves/active');
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/* ─── Active Sessions (port/worktree visibility) ─── */
|
|
159
|
+
|
|
160
|
+
export interface ActiveSessionInfo {
|
|
161
|
+
sessionId: string;
|
|
162
|
+
roleId: string;
|
|
163
|
+
task: string;
|
|
164
|
+
ports: { api: number; vite: number; hmr?: number };
|
|
165
|
+
worktreePath?: string;
|
|
166
|
+
pid?: number;
|
|
167
|
+
startedAt: string;
|
|
168
|
+
status: 'active' | 'idle' | 'dead';
|
|
169
|
+
waveId?: string | null;
|
|
170
|
+
messageStatus?: string | null;
|
|
171
|
+
alive?: boolean | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface ActiveSessionsResponse {
|
|
175
|
+
sessions: ActiveSessionInfo[];
|
|
176
|
+
summary: { active: number; totalPorts: number };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function fetchActiveSessions(): Promise<ActiveSessionsResponse> {
|
|
180
|
+
return fetchJson<ActiveSessionsResponse>('/api/active-sessions');
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
/* ─── Setup API calls ─── */
|
|
159
184
|
|
|
160
185
|
export interface TeamTemplate {
|
package/src/tui/app.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TUI App v2 — Hybrid Mode (Command + Panel)
|
|
2
|
+
* TUI App v2 — Multi-Wave Hybrid Mode (Command + Panel)
|
|
3
|
+
*
|
|
4
|
+
* Wave = Claude Code session. Persistent, resumable.
|
|
5
|
+
* Multiple waves can be open; user switches with /focus.
|
|
3
6
|
*
|
|
4
7
|
* Two modes:
|
|
5
8
|
* Command Mode (default) — stream summary + command input (> prompt)
|
|
@@ -8,7 +11,7 @@
|
|
|
8
11
|
* Tab toggles between modes, Esc returns to Command Mode.
|
|
9
12
|
*/
|
|
10
13
|
|
|
11
|
-
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
14
|
+
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
|
12
15
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
13
16
|
import { StatusBar } from './components/StatusBar';
|
|
14
17
|
import { CommandMode, type StreamLine } from './components/CommandMode';
|
|
@@ -16,7 +19,9 @@ import { PanelMode } from './components/PanelMode';
|
|
|
16
19
|
import { SetupWizard } from './components/SetupWizard';
|
|
17
20
|
import { useApi } from './hooks/useApi';
|
|
18
21
|
import { useSSE } from './hooks/useSSE';
|
|
19
|
-
import { useCommand } from './hooks/useCommand';
|
|
22
|
+
import { useCommand, type WaveInfo } from './hooks/useCommand';
|
|
23
|
+
import { dispatchWave } from './api';
|
|
24
|
+
import type { ActiveSessionInfo } from './api';
|
|
20
25
|
import { buildOrgTree, flattenOrgRoleIds } from './store';
|
|
21
26
|
|
|
22
27
|
type Mode = 'command' | 'panel';
|
|
@@ -24,6 +29,121 @@ type View = 'loading' | 'setup' | 'dashboard';
|
|
|
24
29
|
|
|
25
30
|
let sysLineId = 100000;
|
|
26
31
|
|
|
32
|
+
/** Format agent tree for /agents command */
|
|
33
|
+
function formatAgentsTree(
|
|
34
|
+
waves: WaveInfo[],
|
|
35
|
+
activeSessions: ActiveSessionInfo[],
|
|
36
|
+
focusedWaveId: string | null,
|
|
37
|
+
): StreamLine[] {
|
|
38
|
+
const lines: StreamLine[] = [];
|
|
39
|
+
|
|
40
|
+
if (waves.length === 0 && activeSessions.length === 0) {
|
|
41
|
+
lines.push({ id: ++sysLineId, text: 'No active agents.', color: 'gray' });
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Group sessions by waveId
|
|
46
|
+
const sessionsByWave = new Map<string, ActiveSessionInfo[]>();
|
|
47
|
+
const unlinked: ActiveSessionInfo[] = [];
|
|
48
|
+
for (const s of activeSessions) {
|
|
49
|
+
if (s.waveId) {
|
|
50
|
+
if (!sessionsByWave.has(s.waveId)) sessionsByWave.set(s.waveId, []);
|
|
51
|
+
sessionsByWave.get(s.waveId)!.push(s);
|
|
52
|
+
} else {
|
|
53
|
+
unlinked.push(s);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Display each wave
|
|
58
|
+
for (let i = 0; i < waves.length; i++) {
|
|
59
|
+
const w = waves[i];
|
|
60
|
+
const isFocused = w.waveId === focusedWaveId;
|
|
61
|
+
const marker = isFocused ? '*' : ' ';
|
|
62
|
+
const label = w.directive ? w.directive.slice(0, 50) : '(idle)';
|
|
63
|
+
lines.push({
|
|
64
|
+
id: ++sysLineId,
|
|
65
|
+
text: `${marker}Wave ${i + 1}: "${label}"`,
|
|
66
|
+
color: isFocused ? 'green' : 'cyan',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const waveSessions = sessionsByWave.get(w.waveId) ?? [];
|
|
70
|
+
if (waveSessions.length === 0) {
|
|
71
|
+
lines.push({ id: ++sysLineId, text: ' (no agents)', color: 'gray' });
|
|
72
|
+
}
|
|
73
|
+
for (const s of waveSessions) {
|
|
74
|
+
const statusIcon = s.status === 'active' ? '\u25CF' : s.status === 'dead' ? '\u25CF' : '\u25CB';
|
|
75
|
+
const statusColor = s.status === 'active' ? 'green' : s.status === 'dead' ? 'red' : 'gray';
|
|
76
|
+
const portInfo = s.ports.api ? `API:${s.ports.api} Vite:${s.ports.vite}` : '(no ports)';
|
|
77
|
+
const worktree = s.worktreePath ? `\u{1F33F} ${s.worktreePath.split('/').pop()}` : '';
|
|
78
|
+
lines.push({
|
|
79
|
+
id: ++sysLineId,
|
|
80
|
+
text: ` ${statusIcon} ${s.roleId.padEnd(14)} ${portInfo}${worktree ? ' ' + worktree : ''}`,
|
|
81
|
+
color: statusColor,
|
|
82
|
+
});
|
|
83
|
+
if (s.task) {
|
|
84
|
+
lines.push({
|
|
85
|
+
id: ++sysLineId,
|
|
86
|
+
text: ` ${s.task.slice(0, 60)}`,
|
|
87
|
+
color: 'gray',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Unlinked sessions (not associated with any wave)
|
|
94
|
+
if (unlinked.length > 0) {
|
|
95
|
+
lines.push({ id: ++sysLineId, text: '', color: 'white' });
|
|
96
|
+
lines.push({ id: ++sysLineId, text: 'Unlinked sessions:', color: 'yellow' });
|
|
97
|
+
for (const s of unlinked) {
|
|
98
|
+
const portInfo = s.ports.api ? `API:${s.ports.api} Vite:${s.ports.vite}` : '(no ports)';
|
|
99
|
+
lines.push({
|
|
100
|
+
id: ++sysLineId,
|
|
101
|
+
text: ` ${s.roleId.padEnd(14)} ${portInfo} ${s.task?.slice(0, 40) ?? ''}`,
|
|
102
|
+
color: 'gray',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Format port allocations for /ports command */
|
|
111
|
+
function formatPortsList(activeSessions: ActiveSessionInfo[], portSummary: { active: number; totalPorts: number }): StreamLine[] {
|
|
112
|
+
const lines: StreamLine[] = [];
|
|
113
|
+
|
|
114
|
+
if (activeSessions.length === 0) {
|
|
115
|
+
lines.push({ id: ++sysLineId, text: 'No port allocations.', color: 'gray' });
|
|
116
|
+
return lines;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push({
|
|
120
|
+
id: ++sysLineId,
|
|
121
|
+
text: `Port Allocations (${portSummary.active} active, ${portSummary.totalPorts} ports):`,
|
|
122
|
+
color: 'cyan',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
for (const s of activeSessions) {
|
|
126
|
+
const alive = s.alive === false ? ' DEAD' : s.pid ? ` PID:${s.pid}` : '';
|
|
127
|
+
const waveLabel = s.waveId ? ` (${s.waveId.replace('wave-', 'W')})` : '';
|
|
128
|
+
lines.push({
|
|
129
|
+
id: ++sysLineId,
|
|
130
|
+
text: ` :${s.ports.api}/:${s.ports.vite} \u2192 ${s.roleId}${waveLabel}${alive}`,
|
|
131
|
+
color: s.alive === false ? 'red' : 'white',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Available range hint
|
|
136
|
+
const usedApi = activeSessions.map(s => s.ports.api).filter(Boolean);
|
|
137
|
+
const maxApi = usedApi.length > 0 ? Math.max(...usedApi) + 1 : 3001;
|
|
138
|
+
lines.push({
|
|
139
|
+
id: ++sysLineId,
|
|
140
|
+
text: ` Available: :${maxApi}+ API, :${5173 + activeSessions.length}+ Vite`,
|
|
141
|
+
color: 'gray',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
|
|
27
147
|
export const App: React.FC = () => {
|
|
28
148
|
const { exit } = useApp();
|
|
29
149
|
const api = useApi();
|
|
@@ -46,9 +166,10 @@ export const App: React.FC = () => {
|
|
|
46
166
|
// Mode state
|
|
47
167
|
const [mode, setMode] = useState<Mode>('command');
|
|
48
168
|
|
|
49
|
-
// Wave state
|
|
50
|
-
const [
|
|
51
|
-
const [
|
|
169
|
+
// Multi-Wave state
|
|
170
|
+
const [waves, setWaves] = useState<WaveInfo[]>([]);
|
|
171
|
+
const [focusedWaveId, setFocusedWaveId] = useState<string | null>(null);
|
|
172
|
+
const autoWaveCreated = useRef(false);
|
|
52
173
|
|
|
53
174
|
// Panel mode state
|
|
54
175
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
@@ -77,18 +198,47 @@ export const App: React.FC = () => {
|
|
|
77
198
|
});
|
|
78
199
|
}, []);
|
|
79
200
|
|
|
80
|
-
|
|
201
|
+
const addSystemLines = useCallback((lines: StreamLine[]) => {
|
|
202
|
+
setSystemMessages(prev => {
|
|
203
|
+
const next = [...prev, ...lines];
|
|
204
|
+
return next.length > 80 ? next.slice(-80) : next;
|
|
205
|
+
});
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// Auto-wave: on dashboard entry, create an empty wave or attach to existing
|
|
81
209
|
useEffect(() => {
|
|
82
|
-
if (
|
|
83
|
-
setWaveId(api.activeWaveId);
|
|
84
|
-
}
|
|
85
|
-
}, [waveId, api.activeWaveId]);
|
|
210
|
+
if (view !== 'dashboard' || autoWaveCreated.current) return;
|
|
86
211
|
|
|
87
|
-
|
|
88
|
-
|
|
212
|
+
if (api.activeWaves.length > 0) {
|
|
213
|
+
// Attach to existing waves from API
|
|
214
|
+
const apiWaves: WaveInfo[] = api.activeWaves.map(w => ({
|
|
215
|
+
waveId: w.waveId,
|
|
216
|
+
directive: w.directive ?? '',
|
|
217
|
+
startedAt: w.startedAt ?? Date.now(),
|
|
218
|
+
}));
|
|
219
|
+
setWaves(apiWaves);
|
|
220
|
+
setFocusedWaveId(apiWaves[apiWaves.length - 1].waveId);
|
|
221
|
+
autoWaveCreated.current = true;
|
|
222
|
+
} else if (api.loaded) {
|
|
223
|
+
// Create a new empty wave
|
|
224
|
+
autoWaveCreated.current = true;
|
|
225
|
+
dispatchWave().then(result => {
|
|
226
|
+
const newWave: WaveInfo = {
|
|
227
|
+
waveId: result.waveId,
|
|
228
|
+
directive: '',
|
|
229
|
+
startedAt: Date.now(),
|
|
230
|
+
};
|
|
231
|
+
setWaves([newWave]);
|
|
232
|
+
setFocusedWaveId(result.waveId);
|
|
233
|
+
}).catch(() => {
|
|
234
|
+
// If empty wave creation fails, still proceed — user can /new
|
|
235
|
+
autoWaveCreated.current = true;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}, [view, api.activeWaves, api.loaded]);
|
|
89
239
|
|
|
90
|
-
// SSE subscription
|
|
91
|
-
const sse = useSSE(
|
|
240
|
+
// SSE subscription to focused wave
|
|
241
|
+
const sse = useSSE(focusedWaveId);
|
|
92
242
|
|
|
93
243
|
// Build org tree — flatRoleIds follows visual top-to-bottom order
|
|
94
244
|
const roles = api.company?.roles ?? [];
|
|
@@ -105,23 +255,34 @@ export const App: React.FC = () => {
|
|
|
105
255
|
const derivedWaveStatus = useMemo(() => {
|
|
106
256
|
if (sse.streamStatus === 'streaming') return 'running' as const;
|
|
107
257
|
if (sse.streamStatus === 'done') return 'done' as const;
|
|
108
|
-
if (
|
|
109
|
-
return
|
|
110
|
-
}, [sse.streamStatus,
|
|
258
|
+
if (activeCount > 0) return 'running' as const;
|
|
259
|
+
return 'idle' as const;
|
|
260
|
+
}, [sse.streamStatus, activeCount]);
|
|
261
|
+
|
|
262
|
+
// Focused wave index (1-based)
|
|
263
|
+
const focusedWaveIndex = useMemo(() => {
|
|
264
|
+
if (!focusedWaveId) return 0;
|
|
265
|
+
return waves.findIndex(w => w.waveId === focusedWaveId) + 1;
|
|
266
|
+
}, [focusedWaveId, waves]);
|
|
111
267
|
|
|
112
268
|
// Command handler
|
|
113
269
|
const { execute } = useCommand({
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
270
|
+
focusedWaveId,
|
|
271
|
+
waves,
|
|
272
|
+
onWaveCreated: (newWaveId, directive) => {
|
|
273
|
+
const newWave: WaveInfo = {
|
|
274
|
+
waveId: newWaveId,
|
|
275
|
+
directive,
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
};
|
|
278
|
+
setWaves(prev => [...prev, newWave]);
|
|
279
|
+
setFocusedWaveId(newWaveId);
|
|
118
280
|
sse.clearEvents();
|
|
119
281
|
api.refresh();
|
|
120
282
|
},
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
api.refresh();
|
|
283
|
+
onFocusWave: (waveId) => {
|
|
284
|
+
setFocusedWaveId(waveId);
|
|
285
|
+
sse.clearEvents();
|
|
125
286
|
},
|
|
126
287
|
onQuit: () => exit(),
|
|
127
288
|
onShowPanel: () => setMode('panel'),
|
|
@@ -135,51 +296,73 @@ export const App: React.FC = () => {
|
|
|
135
296
|
|
|
136
297
|
switch (result.type) {
|
|
137
298
|
case 'wave_started':
|
|
138
|
-
// Don't show wave ID noise — supervisor handles it
|
|
139
299
|
break;
|
|
140
300
|
case 'directive_sent':
|
|
141
|
-
// Silently sent — supervisor will respond
|
|
142
301
|
break;
|
|
143
|
-
case '
|
|
144
|
-
addSystemMessage(`\
|
|
302
|
+
case 'focus_changed':
|
|
303
|
+
addSystemMessage(`\u2192 ${result.message}`, 'cyan');
|
|
145
304
|
break;
|
|
305
|
+
case 'waves_list': {
|
|
306
|
+
if (waves.length === 0) {
|
|
307
|
+
addSystemMessage('No waves.', 'gray');
|
|
308
|
+
} else {
|
|
309
|
+
addSystemMessage('Waves:', 'cyan');
|
|
310
|
+
waves.forEach((w, i) => {
|
|
311
|
+
const isFocused = w.waveId === focusedWaveId;
|
|
312
|
+
const prefix = isFocused ? '*' : ' ';
|
|
313
|
+
const label = w.directive ? w.directive.slice(0, 60) : '(idle)';
|
|
314
|
+
addSystemMessage(`${prefix}${i + 1}. Wave ${i + 1} \u2014 ${label}`, isFocused ? 'green' : 'white');
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'agents': {
|
|
320
|
+
const lines = formatAgentsTree(waves, api.activeSessions, focusedWaveId);
|
|
321
|
+
addSystemLines(lines);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
case 'ports': {
|
|
325
|
+
const lines = formatPortsList(api.activeSessions, api.portSummary);
|
|
326
|
+
addSystemLines(lines);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
146
329
|
case 'error':
|
|
147
330
|
addSystemMessage(result.message, 'red');
|
|
148
331
|
break;
|
|
149
332
|
case 'help':
|
|
150
333
|
addSystemMessage('Type naturally to talk to your AI team.', 'cyan');
|
|
151
|
-
addSystemMessage('Commands
|
|
152
|
-
addSystemMessage(' /
|
|
334
|
+
addSystemMessage('Commands:', 'cyan');
|
|
335
|
+
addSystemMessage(' /new [text] Create new wave', 'white');
|
|
336
|
+
addSystemMessage(' /waves List all waves', 'white');
|
|
337
|
+
addSystemMessage(' /focus <n> Switch to wave n', 'white');
|
|
338
|
+
addSystemMessage(' /agents Agent tree + resources', 'white');
|
|
339
|
+
addSystemMessage(' /ports Port allocations', 'white');
|
|
153
340
|
addSystemMessage(' /status Show current status', 'white');
|
|
154
341
|
addSystemMessage(' /assign <role> <task> Assign task to role', 'white');
|
|
155
342
|
addSystemMessage(' /roles Org tree (Panel Mode)', 'white');
|
|
156
343
|
addSystemMessage(' /help Show this help', 'white');
|
|
157
344
|
addSystemMessage(' /quit Exit TUI', 'white');
|
|
158
|
-
addSystemMessage('Keys: [Tab] panel [Esc] back [Ctrl+C]
|
|
345
|
+
addSystemMessage('Keys: [Tab] panel [Esc] back [Ctrl+C] quit', 'gray');
|
|
159
346
|
break;
|
|
160
347
|
case 'info':
|
|
161
348
|
if (result.message === '__status__') {
|
|
162
|
-
const wLabel =
|
|
163
|
-
? `Wave ${
|
|
349
|
+
const wLabel = focusedWaveId
|
|
350
|
+
? `Wave ${focusedWaveIndex}: ${derivedWaveStatus}`
|
|
164
351
|
: 'No active wave';
|
|
165
352
|
addSystemMessage(wLabel, derivedWaveStatus === 'running' ? 'green' : 'gray');
|
|
166
|
-
addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount}`, 'white');
|
|
167
|
-
} else if (result.message === '__summary__') {
|
|
168
|
-
addSystemMessage('Summary: not yet implemented', 'gray');
|
|
353
|
+
addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount} Waves: ${waves.length} Ports: ${api.portSummary.totalPorts}`, 'white');
|
|
169
354
|
}
|
|
170
355
|
break;
|
|
171
356
|
case 'panel':
|
|
172
|
-
// mode already switched
|
|
173
357
|
break;
|
|
174
358
|
case 'quit':
|
|
175
|
-
// exit already called
|
|
176
359
|
break;
|
|
177
360
|
default:
|
|
178
361
|
if (result.message) {
|
|
179
362
|
addSystemMessage(result.message, 'green');
|
|
180
363
|
}
|
|
181
364
|
}
|
|
182
|
-
}, [execute, addSystemMessage,
|
|
365
|
+
}, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary]);
|
|
183
366
|
|
|
184
367
|
// Global key handler: Tab to toggle mode, Ctrl+C handling
|
|
185
368
|
useInput((input, key) => {
|
|
@@ -187,13 +370,9 @@ export const App: React.FC = () => {
|
|
|
187
370
|
setMode('panel');
|
|
188
371
|
return;
|
|
189
372
|
}
|
|
190
|
-
// Ctrl+C in command mode:
|
|
373
|
+
// Ctrl+C in command mode: exit
|
|
191
374
|
if (key.ctrl && input === 'c') {
|
|
192
|
-
|
|
193
|
-
execute('stop');
|
|
194
|
-
} else {
|
|
195
|
-
exit();
|
|
196
|
-
}
|
|
375
|
+
exit();
|
|
197
376
|
}
|
|
198
377
|
}, { isActive: mode === 'command' });
|
|
199
378
|
|
|
@@ -247,7 +426,7 @@ export const App: React.FC = () => {
|
|
|
247
426
|
selectedRoleIndex={selectedRoleIndex}
|
|
248
427
|
selectedRoleId={selectedRoleId}
|
|
249
428
|
streamStatus={sse.streamStatus}
|
|
250
|
-
waveId={
|
|
429
|
+
waveId={focusedWaveId}
|
|
251
430
|
onMove={(dir) => {
|
|
252
431
|
if (dir === 'up') {
|
|
253
432
|
setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
|
|
@@ -267,9 +446,11 @@ export const App: React.FC = () => {
|
|
|
267
446
|
{/* Status Bar — bottom (Claude Code style) */}
|
|
268
447
|
<StatusBar
|
|
269
448
|
companyName={api.company?.name ?? 'Loading...'}
|
|
270
|
-
|
|
449
|
+
waveIndex={focusedWaveIndex}
|
|
450
|
+
waveCount={waves.length}
|
|
271
451
|
waveStatus={derivedWaveStatus}
|
|
272
452
|
activeCount={activeCount}
|
|
453
|
+
portCount={api.portSummary.totalPorts}
|
|
273
454
|
totalCost={0}
|
|
274
455
|
/>
|
|
275
456
|
</Box>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* StatusBar — bottom bar (Claude Code style)
|
|
3
|
-
* Shows: company name, wave
|
|
3
|
+
* Shows: company name, wave index [focused/total], active roles, ports, cost
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React from 'react';
|
|
@@ -8,26 +8,34 @@ import { Box, Text } from 'ink';
|
|
|
8
8
|
|
|
9
9
|
interface StatusBarProps {
|
|
10
10
|
companyName: string;
|
|
11
|
-
|
|
11
|
+
waveIndex: number; // 1-based focused wave index (0 = none)
|
|
12
|
+
waveCount: number; // total waves
|
|
12
13
|
waveStatus: 'idle' | 'running' | 'done';
|
|
13
14
|
activeCount: number;
|
|
15
|
+
portCount: number; // total allocated ports
|
|
14
16
|
totalCost: number;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export const StatusBar: React.FC<StatusBarProps> = ({
|
|
18
20
|
companyName,
|
|
19
|
-
|
|
21
|
+
waveIndex,
|
|
22
|
+
waveCount,
|
|
20
23
|
waveStatus,
|
|
21
24
|
activeCount,
|
|
25
|
+
portCount,
|
|
22
26
|
totalCost,
|
|
23
27
|
}) => {
|
|
24
|
-
const
|
|
25
|
-
|
|
28
|
+
const statusDot = waveStatus === 'running' ? ' \u25CF'
|
|
29
|
+
: waveStatus === 'done' ? ' \u2713'
|
|
26
30
|
: '';
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
|
|
32
|
+
const waveLabel = waveIndex > 0
|
|
33
|
+
? `Wave ${waveIndex}${statusDot}`
|
|
29
34
|
: '';
|
|
30
35
|
|
|
36
|
+
// Show [1/3] only when 2+ waves
|
|
37
|
+
const countLabel = waveCount >= 2 ? ` [${waveIndex}/${waveCount}]` : '';
|
|
38
|
+
|
|
31
39
|
return (
|
|
32
40
|
<Box width="100%" paddingX={1}>
|
|
33
41
|
<Text color="cyan" bold>Tycono</Text>
|
|
@@ -37,7 +45,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({
|
|
|
37
45
|
<>
|
|
38
46
|
<Text color="gray"> | </Text>
|
|
39
47
|
<Text color={waveStatus === 'running' ? 'green' : 'gray'}>
|
|
40
|
-
{waveLabel}{
|
|
48
|
+
{waveLabel}{countLabel}
|
|
41
49
|
</Text>
|
|
42
50
|
</>
|
|
43
51
|
)}
|
|
@@ -47,6 +55,12 @@ export const StatusBar: React.FC<StatusBarProps> = ({
|
|
|
47
55
|
<Text color="yellow">{activeCount} active</Text>
|
|
48
56
|
</>
|
|
49
57
|
)}
|
|
58
|
+
{portCount > 0 && (
|
|
59
|
+
<>
|
|
60
|
+
<Text color="gray"> | </Text>
|
|
61
|
+
<Text color="blue">{portCount} ports</Text>
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
50
64
|
<Text color="gray"> | </Text>
|
|
51
65
|
<Text color="green">${totalCost.toFixed(2)}</Text>
|
|
52
66
|
</Box>
|
package/src/tui/hooks/useApi.ts
CHANGED
|
@@ -8,18 +8,29 @@ import {
|
|
|
8
8
|
fetchSessions,
|
|
9
9
|
fetchExecStatus,
|
|
10
10
|
fetchActiveWaves,
|
|
11
|
+
fetchActiveSessions,
|
|
11
12
|
type CompanyInfo,
|
|
12
13
|
type SessionInfo,
|
|
13
14
|
type ExecStatus,
|
|
15
|
+
type ActiveSessionInfo,
|
|
14
16
|
} from '../api';
|
|
15
17
|
|
|
16
18
|
const POLL_INTERVAL = 3000; // 3 seconds
|
|
17
19
|
|
|
20
|
+
export interface ActiveWaveInfo {
|
|
21
|
+
waveId: string;
|
|
22
|
+
sessionIds: string[];
|
|
23
|
+
directive?: string;
|
|
24
|
+
startedAt?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
export interface ApiState {
|
|
19
28
|
company: CompanyInfo | null;
|
|
20
29
|
sessions: SessionInfo[];
|
|
21
30
|
execStatus: ExecStatus | null;
|
|
22
|
-
|
|
31
|
+
activeWaves: ActiveWaveInfo[];
|
|
32
|
+
activeSessions: ActiveSessionInfo[];
|
|
33
|
+
portSummary: { active: number; totalPorts: number };
|
|
23
34
|
error: string | null;
|
|
24
35
|
loaded: boolean;
|
|
25
36
|
refresh(): void;
|
|
@@ -29,18 +40,21 @@ export function useApi(): ApiState {
|
|
|
29
40
|
const [company, setCompany] = useState<CompanyInfo | null>(null);
|
|
30
41
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
31
42
|
const [execStatus, setExecStatus] = useState<ExecStatus | null>(null);
|
|
32
|
-
const [
|
|
43
|
+
const [activeWaves, setActiveWaves] = useState<ActiveWaveInfo[]>([]);
|
|
44
|
+
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
|
|
45
|
+
const [portSummary, setPortSummary] = useState<{ active: number; totalPorts: number }>({ active: 0, totalPorts: 0 });
|
|
33
46
|
const [error, setError] = useState<string | null>(null);
|
|
34
47
|
const [loaded, setLoaded] = useState(false);
|
|
35
48
|
const mountedRef = useRef(true);
|
|
36
49
|
|
|
37
50
|
const refresh = useCallback(async () => {
|
|
38
51
|
try {
|
|
39
|
-
const [comp, sess, exec, waves] = await Promise.all([
|
|
52
|
+
const [comp, sess, exec, waves, activeSess] = await Promise.all([
|
|
40
53
|
fetchCompany().catch(() => null),
|
|
41
54
|
fetchSessions().catch(() => []),
|
|
42
55
|
fetchExecStatus().catch(() => null),
|
|
43
56
|
fetchActiveWaves().catch(() => ({ waves: [] })),
|
|
57
|
+
fetchActiveSessions().catch(() => ({ sessions: [], summary: { active: 0, totalPorts: 0 } })),
|
|
44
58
|
]);
|
|
45
59
|
|
|
46
60
|
if (!mountedRef.current) return;
|
|
@@ -49,11 +63,18 @@ export function useApi(): ApiState {
|
|
|
49
63
|
setSessions(Array.isArray(sess) ? sess : []);
|
|
50
64
|
if (exec) setExecStatus(exec);
|
|
51
65
|
|
|
52
|
-
//
|
|
66
|
+
// Store full active waves array
|
|
53
67
|
if (waves.waves && waves.waves.length > 0) {
|
|
54
|
-
|
|
68
|
+
setActiveWaves(waves.waves.map((w: { waveId: string; sessionIds: string[] }) => ({
|
|
69
|
+
waveId: w.waveId,
|
|
70
|
+
sessionIds: w.sessionIds ?? [],
|
|
71
|
+
})));
|
|
55
72
|
}
|
|
56
73
|
|
|
74
|
+
// Active sessions (port/resource visibility)
|
|
75
|
+
setActiveSessions(activeSess.sessions ?? []);
|
|
76
|
+
setPortSummary(activeSess.summary ?? { active: 0, totalPorts: 0 });
|
|
77
|
+
|
|
57
78
|
setError(null);
|
|
58
79
|
setLoaded(true);
|
|
59
80
|
} catch (err) {
|
|
@@ -73,5 +94,5 @@ export function useApi(): ApiState {
|
|
|
73
94
|
};
|
|
74
95
|
}, [refresh]);
|
|
75
96
|
|
|
76
|
-
return { company, sessions, execStatus,
|
|
97
|
+
return { company, sessions, execStatus, activeWaves, activeSessions, portSummary, error, loaded, refresh };
|
|
77
98
|
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useCommand — input handler for TUI v2
|
|
2
|
+
* useCommand — input handler for TUI v2 (Multi-Wave)
|
|
3
3
|
*
|
|
4
|
-
* Default: natural language →
|
|
4
|
+
* Default: natural language → sendDirective to focused wave
|
|
5
5
|
* Commands (/ prefix):
|
|
6
|
-
* /
|
|
6
|
+
* /waves — list all waves
|
|
7
|
+
* /focus <n> — switch to nth wave
|
|
8
|
+
* /new [text] — create new wave (optionally with directive)
|
|
9
|
+
* /agents — show wave→agent tree with resources
|
|
10
|
+
* /ports — show port allocations
|
|
7
11
|
* /status — show current wave + session status
|
|
8
12
|
* /assign <role> <task> — assign task to specific role
|
|
9
13
|
* /roles — show org tree (Panel Mode)
|
|
@@ -14,28 +18,18 @@
|
|
|
14
18
|
import { useCallback } from 'react';
|
|
15
19
|
import { dispatchWave, sendDirective, fetchJson } from '../api';
|
|
16
20
|
|
|
21
|
+
export interface WaveInfo {
|
|
22
|
+
waveId: string;
|
|
23
|
+
directive: string;
|
|
24
|
+
startedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export interface CommandResult {
|
|
18
|
-
type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel';
|
|
28
|
+
type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports';
|
|
19
29
|
message: string;
|
|
20
30
|
waveId?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
async function postAbortAll(): Promise<void> {
|
|
24
|
-
try {
|
|
25
|
-
const status = await fetchJson<{
|
|
26
|
-
statuses: Record<string, string>;
|
|
27
|
-
activeExecutions: Array<{ id: string }>;
|
|
28
|
-
}>('/api/exec/status');
|
|
29
|
-
|
|
30
|
-
const abortPromises = (status.activeExecutions || []).map((exec) =>
|
|
31
|
-
fetchJson(`/api/jobs/${exec.id}/abort`, { method: 'POST' }).catch(() => {})
|
|
32
|
-
);
|
|
33
|
-
await Promise.all(abortPromises);
|
|
34
|
-
} catch {
|
|
35
|
-
// If we can't get status, silently fail
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
33
|
async function postAssign(roleId: string, task: string): Promise<{ waveId?: string }> {
|
|
40
34
|
return fetchJson<{ waveId?: string }>('/api/jobs', {
|
|
41
35
|
method: 'POST',
|
|
@@ -48,37 +42,63 @@ async function postAssign(roleId: string, task: string): Promise<{ waveId?: stri
|
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
export interface UseCommandOptions {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
focusedWaveId: string | null;
|
|
46
|
+
waves: WaveInfo[];
|
|
47
|
+
onWaveCreated: (waveId: string, directive: string) => void;
|
|
48
|
+
onFocusWave: (waveId: string) => void;
|
|
54
49
|
onQuit: () => void;
|
|
55
50
|
onShowPanel: () => void;
|
|
56
51
|
}
|
|
57
52
|
|
|
58
53
|
export function useCommand(options: UseCommandOptions) {
|
|
59
|
-
const {
|
|
54
|
+
const { focusedWaveId, waves, onWaveCreated, onFocusWave, onQuit, onShowPanel } = options;
|
|
60
55
|
|
|
61
56
|
const execute = useCallback(async (input: string): Promise<CommandResult> => {
|
|
62
57
|
const trimmed = input.trim();
|
|
63
58
|
if (!trimmed) return { type: 'info', message: '' };
|
|
64
59
|
|
|
65
|
-
// Slash commands
|
|
60
|
+
// Slash commands
|
|
66
61
|
if (trimmed.startsWith('/')) {
|
|
67
62
|
const parts = trimmed.slice(1).split(/\s+/);
|
|
68
63
|
const cmd = parts[0].toLowerCase();
|
|
69
64
|
const args = parts.slice(1).join(' ');
|
|
70
65
|
|
|
71
66
|
switch (cmd) {
|
|
72
|
-
case '
|
|
67
|
+
case 'waves': {
|
|
68
|
+
return { type: 'waves_list', message: '__waves__' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'focus': {
|
|
72
|
+
const idx = parseInt(args, 10);
|
|
73
|
+
if (isNaN(idx) || idx < 1 || idx > waves.length) {
|
|
74
|
+
return { type: 'error', message: `Usage: /focus <1-${waves.length}>` };
|
|
75
|
+
}
|
|
76
|
+
const target = waves[idx - 1];
|
|
77
|
+
onFocusWave(target.waveId);
|
|
78
|
+
return { type: 'focus_changed', message: `Focused on Wave ${idx}`, waveId: target.waveId };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
case 'new': {
|
|
82
|
+
const directive = args || undefined;
|
|
73
83
|
try {
|
|
74
|
-
await
|
|
75
|
-
|
|
76
|
-
return {
|
|
84
|
+
const result = await dispatchWave(directive);
|
|
85
|
+
onWaveCreated(result.waveId, directive ?? '');
|
|
86
|
+
return {
|
|
87
|
+
type: 'wave_started',
|
|
88
|
+
message: `Wave created`,
|
|
89
|
+
waveId: result.waveId,
|
|
90
|
+
};
|
|
77
91
|
} catch (err) {
|
|
78
|
-
return { type: 'error', message: `
|
|
92
|
+
return { type: 'error', message: `New wave failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
95
|
|
|
96
|
+
case 'agents':
|
|
97
|
+
return { type: 'agents', message: '__agents__' };
|
|
98
|
+
|
|
99
|
+
case 'ports':
|
|
100
|
+
return { type: 'ports', message: '__ports__' };
|
|
101
|
+
|
|
82
102
|
case 'status':
|
|
83
103
|
return { type: 'info', message: '__status__' };
|
|
84
104
|
|
|
@@ -91,9 +111,6 @@ export function useCommand(options: UseCommandOptions) {
|
|
|
91
111
|
const task = args.slice(spaceIdx + 1);
|
|
92
112
|
try {
|
|
93
113
|
const result = await postAssign(roleId, task);
|
|
94
|
-
if (result.waveId) {
|
|
95
|
-
onWaveStarted(result.waveId);
|
|
96
|
-
}
|
|
97
114
|
return { type: 'success', message: `Task assigned to ${roleId}`, waveId: result.waveId };
|
|
98
115
|
} catch (err) {
|
|
99
116
|
return { type: 'error', message: `Assign failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
@@ -117,28 +134,29 @@ export function useCommand(options: UseCommandOptions) {
|
|
|
117
134
|
}
|
|
118
135
|
}
|
|
119
136
|
|
|
120
|
-
// Default: natural language → directive
|
|
121
|
-
if (
|
|
137
|
+
// Default: natural language → directive to focused wave
|
|
138
|
+
if (focusedWaveId) {
|
|
122
139
|
try {
|
|
123
|
-
await sendDirective(
|
|
140
|
+
await sendDirective(focusedWaveId, trimmed);
|
|
124
141
|
return { type: 'directive_sent', message: `Directive sent` };
|
|
125
142
|
} catch (err) {
|
|
126
143
|
return { type: 'error', message: `Failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
127
144
|
}
|
|
128
145
|
} else {
|
|
146
|
+
// No focused wave — create one with the directive
|
|
129
147
|
try {
|
|
130
148
|
const result = await dispatchWave(trimmed);
|
|
131
|
-
|
|
149
|
+
onWaveCreated(result.waveId, trimmed);
|
|
132
150
|
return {
|
|
133
151
|
type: 'wave_started',
|
|
134
|
-
message: `Wave
|
|
152
|
+
message: `Wave created`,
|
|
135
153
|
waveId: result.waveId,
|
|
136
154
|
};
|
|
137
155
|
} catch (err) {
|
|
138
156
|
return { type: 'error', message: `Wave failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
|
-
}, [
|
|
159
|
+
}, [focusedWaveId, waves, onWaveCreated, onFocusWave, onQuit, onShowPanel]);
|
|
142
160
|
|
|
143
161
|
return { execute };
|
|
144
162
|
}
|