tycono 0.1.96-beta.17 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.96-beta.17",
3
+ "version": "0.1.96-beta.19",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- if (!directive) {
216
- jsonResponse(res, 400, { error: 'directive is required for wave jobs' });
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
- directive,
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: string, options?: {
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 [waveId, setWaveId] = useState<string | null>(null);
51
- const [waveStatus, setWaveStatus] = useState<'idle' | 'running' | 'done'>('idle');
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
- // Auto-sync: when waveId is null (e.g. --attach), pick up api.activeWaveId
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 (waveId === null && api.activeWaveId) {
83
- setWaveId(api.activeWaveId);
84
- }
85
- }, [waveId, api.activeWaveId]);
210
+ if (view !== 'dashboard' || autoWaveCreated.current) return;
86
211
 
87
- // Derive effective wave ID (from manual set or API polling)
88
- const effectiveWaveId = waveId ?? api.activeWaveId;
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(effectiveWaveId);
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,31 +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 (waveStatus === 'running' && activeCount > 0) return 'running' as const;
109
- return waveStatus;
110
- }, [sse.streamStatus, waveStatus, activeCount]);
258
+ if (activeCount > 0) return 'running' as const;
259
+ return 'idle' as const;
260
+ }, [sse.streamStatus, activeCount]);
111
261
 
112
- // Auto-clear waveId when wave completes → next message starts fresh wave
113
- useEffect(() => {
114
- if (derivedWaveStatus === 'done' && waveId) {
115
- setWaveId(null);
116
- setWaveStatus('idle');
117
- }
118
- }, [derivedWaveStatus, waveId]);
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]);
119
267
 
120
268
  // Command handler
121
269
  const { execute } = useCommand({
122
- activeWaveId: effectiveWaveId,
123
- onWaveStarted: (newWaveId) => {
124
- setWaveId(newWaveId);
125
- setWaveStatus('running');
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);
126
280
  sse.clearEvents();
127
281
  api.refresh();
128
282
  },
129
- onStopped: () => {
130
- setWaveId(null);
131
- setWaveStatus('idle');
132
- api.refresh();
283
+ onFocusWave: (waveId) => {
284
+ setFocusedWaveId(waveId);
285
+ sse.clearEvents();
133
286
  },
134
287
  onQuit: () => exit(),
135
288
  onShowPanel: () => setMode('panel'),
@@ -143,51 +296,73 @@ export const App: React.FC = () => {
143
296
 
144
297
  switch (result.type) {
145
298
  case 'wave_started':
146
- // Don't show wave ID noise — supervisor handles it
147
299
  break;
148
300
  case 'directive_sent':
149
- // Silently sent — supervisor will respond
150
301
  break;
151
- case 'stopped':
152
- addSystemMessage(`\u26A1 ${result.message}`, 'red');
302
+ case 'focus_changed':
303
+ addSystemMessage(`\u2192 ${result.message}`, 'cyan');
153
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
+ }
154
329
  case 'error':
155
330
  addSystemMessage(result.message, 'red');
156
331
  break;
157
332
  case 'help':
158
333
  addSystemMessage('Type naturally to talk to your AI team.', 'cyan');
159
- addSystemMessage('Commands (/ prefix):', 'cyan');
160
- addSystemMessage(' /stop Stop all executions', 'white');
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');
161
340
  addSystemMessage(' /status Show current status', 'white');
162
341
  addSystemMessage(' /assign <role> <task> Assign task to role', 'white');
163
342
  addSystemMessage(' /roles Org tree (Panel Mode)', 'white');
164
343
  addSystemMessage(' /help Show this help', 'white');
165
344
  addSystemMessage(' /quit Exit TUI', 'white');
166
- addSystemMessage('Keys: [Tab] panel [Esc] back [Ctrl+C] stop/quit', 'gray');
345
+ addSystemMessage('Keys: [Tab] panel [Esc] back [Ctrl+C] quit', 'gray');
167
346
  break;
168
347
  case 'info':
169
348
  if (result.message === '__status__') {
170
- const wLabel = effectiveWaveId
171
- ? `Wave ${effectiveWaveId.replace('wave-', '#')}: ${derivedWaveStatus}`
349
+ const wLabel = focusedWaveId
350
+ ? `Wave ${focusedWaveIndex}: ${derivedWaveStatus}`
172
351
  : 'No active wave';
173
352
  addSystemMessage(wLabel, derivedWaveStatus === 'running' ? 'green' : 'gray');
174
- addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount}`, 'white');
175
- } else if (result.message === '__summary__') {
176
- addSystemMessage('Summary: not yet implemented', 'gray');
353
+ addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount} Waves: ${waves.length} Ports: ${api.portSummary.totalPorts}`, 'white');
177
354
  }
178
355
  break;
179
356
  case 'panel':
180
- // mode already switched
181
357
  break;
182
358
  case 'quit':
183
- // exit already called
184
359
  break;
185
360
  default:
186
361
  if (result.message) {
187
362
  addSystemMessage(result.message, 'green');
188
363
  }
189
364
  }
190
- }, [execute, addSystemMessage, effectiveWaveId, derivedWaveStatus, api.sessions.length, activeCount]);
365
+ }, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary]);
191
366
 
192
367
  // Global key handler: Tab to toggle mode, Ctrl+C handling
193
368
  useInput((input, key) => {
@@ -195,13 +370,9 @@ export const App: React.FC = () => {
195
370
  setMode('panel');
196
371
  return;
197
372
  }
198
- // Ctrl+C in command mode: stop wave or exit
373
+ // Ctrl+C in command mode: exit
199
374
  if (key.ctrl && input === 'c') {
200
- if (derivedWaveStatus === 'running') {
201
- execute('stop');
202
- } else {
203
- exit();
204
- }
375
+ exit();
205
376
  }
206
377
  }, { isActive: mode === 'command' });
207
378
 
@@ -255,7 +426,7 @@ export const App: React.FC = () => {
255
426
  selectedRoleIndex={selectedRoleIndex}
256
427
  selectedRoleId={selectedRoleId}
257
428
  streamStatus={sse.streamStatus}
258
- waveId={effectiveWaveId}
429
+ waveId={focusedWaveId}
259
430
  onMove={(dir) => {
260
431
  if (dir === 'up') {
261
432
  setSelectedRoleIndex(Math.max(0, selectedRoleIndex - 1));
@@ -275,9 +446,11 @@ export const App: React.FC = () => {
275
446
  {/* Status Bar — bottom (Claude Code style) */}
276
447
  <StatusBar
277
448
  companyName={api.company?.name ?? 'Loading...'}
278
- waveId={effectiveWaveId}
449
+ waveIndex={focusedWaveIndex}
450
+ waveCount={waves.length}
279
451
  waveStatus={derivedWaveStatus}
280
452
  activeCount={activeCount}
453
+ portCount={api.portSummary.totalPorts}
281
454
  totalCost={0}
282
455
  />
283
456
  </Box>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * StatusBar — bottom bar (Claude Code style)
3
- * Shows: company name, wave status, active roles, cost
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
- waveId: string | null;
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
- waveId,
21
+ waveIndex,
22
+ waveCount,
20
23
  waveStatus,
21
24
  activeCount,
25
+ portCount,
22
26
  totalCost,
23
27
  }) => {
24
- const waveLabel = waveId
25
- ? `Wave ${waveId.replace('wave-', '#')}`
28
+ const statusDot = waveStatus === 'running' ? ' \u25CF'
29
+ : waveStatus === 'done' ? ' \u2713'
26
30
  : '';
27
- const statusDot = waveStatus === 'running' ? ' ●'
28
- : waveStatus === 'done' ? ' ✓'
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}{statusDot}
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>
@@ -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
- activeWaveId: string | null;
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 [activeWaveId, setActiveWaveId] = useState<string | null>(null);
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
- // Find active wave
66
+ // Store full active waves array
53
67
  if (waves.waves && waves.waves.length > 0) {
54
- setActiveWaveId(waves.waves[0].waveId);
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, activeWaveId, error, loaded, refresh };
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 → wave dispatch or directive (if wave active)
4
+ * Default: natural language → sendDirective to focused wave
5
5
  * Commands (/ prefix):
6
- * /stop abort all active executions
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
- activeWaveId: string | null;
52
- onWaveStarted: (waveId: string) => void;
53
- onStopped: () => void;
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 { activeWaveId, onWaveStarted, onStopped, onQuit, onShowPanel } = options;
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: /stop, /status, /help, /quit, /roles, /assign
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 'stop': {
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 postAbortAll();
75
- onStopped();
76
- return { type: 'stopped', message: 'All active executions stopped.' };
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: `Stop failed: ${err instanceof Error ? err.message : 'unknown'}` };
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 (if wave active) or new wave
121
- if (activeWaveId) {
137
+ // Default: natural language → directive to focused wave
138
+ if (focusedWaveId) {
122
139
  try {
123
- await sendDirective(activeWaveId, trimmed);
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
- onWaveStarted(result.waveId);
149
+ onWaveCreated(result.waveId, trimmed);
132
150
  return {
133
151
  type: 'wave_started',
134
- message: `Wave ${result.waveId.replace('wave-', '#')} started`,
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
- }, [activeWaveId, onWaveStarted, onStopped, onQuit, onShowPanel]);
159
+ }, [focusedWaveId, waves, onWaveCreated, onFocusWave, onQuit, onShowPanel]);
142
160
 
143
161
  return { execute };
144
162
  }