tycono 0.3.9 → 0.3.10
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/execute.ts +26 -11
- package/src/api/src/services/supervisor-heartbeat.ts +43 -13
- package/src/api/src/services/wave-multiplexer.ts +11 -1
- package/src/tui/api.ts +6 -0
- package/src/tui/app.tsx +5 -73
- package/src/tui/components/CommandMode.tsx +8 -2
- package/src/tui/hooks/useCommand.ts +15 -1
- package/src/tui/hooks/useSSE.ts +1 -6
package/package.json
CHANGED
|
@@ -86,6 +86,21 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
|
|
|
86
86
|
return;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// ── /api/waves/:waveId/stop — Stop wave execution ──
|
|
90
|
+
const stopMatch = url.match(/^\/api\/waves\/([^/]+)\/stop$/);
|
|
91
|
+
if (method === 'POST' && stopMatch) {
|
|
92
|
+
const waveId = stopMatch[1];
|
|
93
|
+
supervisorHeartbeat.stop(waveId);
|
|
94
|
+
// Also abort all running executions for this wave
|
|
95
|
+
const waveSessions = listSessions().filter(s => s.waveId === waveId && s.status === 'active');
|
|
96
|
+
let aborted = 0;
|
|
97
|
+
for (const ses of waveSessions) {
|
|
98
|
+
if (executionManager.abortSession(ses.id)) aborted++;
|
|
99
|
+
}
|
|
100
|
+
jsonResponse(res, 200, { ok: true, waveId, abortedSessions: aborted });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
// ── /api/waves/:waveId/directive — CEO adds directive mid-execution ──
|
|
90
105
|
const directiveMatch = url.match(/^\/api\/waves\/([^/]+)\/directive$/);
|
|
91
106
|
if (method === 'POST' && directiveMatch) {
|
|
@@ -167,16 +182,6 @@ function handleJobsRequest(url: string, method: string, req: IncomingMessage, re
|
|
|
167
182
|
return;
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
// GET /api/jobs/:id/history — internal only
|
|
171
|
-
const historyMatch = reqPath.match(/^\/api\/jobs\/([^/]+)\/history$/);
|
|
172
|
-
if (method === 'GET' && historyMatch) {
|
|
173
|
-
const id = historyMatch[1];
|
|
174
|
-
const events = ActivityStream.readAll(id);
|
|
175
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
176
|
-
res.end(JSON.stringify({ events }));
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
185
|
// POST /api/jobs/:id/abort — abort by execution ID or session ID
|
|
181
186
|
const abortMatch = reqPath.match(/^\/api\/jobs\/([^/]+)\/abort$/);
|
|
182
187
|
if (method === 'POST' && abortMatch) {
|
|
@@ -711,7 +716,17 @@ function handleWaveDirective(waveId: string, body: Record<string, unknown>, res:
|
|
|
711
716
|
return;
|
|
712
717
|
}
|
|
713
718
|
|
|
714
|
-
|
|
719
|
+
let directive = supervisorHeartbeat.addDirective(waveId, text);
|
|
720
|
+
if (!directive) {
|
|
721
|
+
// Fallback: wave exists but addDirective couldn't restore.
|
|
722
|
+
// Use start() with the SAME waveId to keep it in the same wave context.
|
|
723
|
+
console.log(`[WaveDirective] No supervisor found for wave ${waveId}, creating supervisor in-place`);
|
|
724
|
+
const state = supervisorHeartbeat.start(waveId, text);
|
|
725
|
+
if (state.status !== 'error') {
|
|
726
|
+
directive = { id: `dir-fallback-${Date.now()}`, text, createdAt: new Date().toISOString(), delivered: false };
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
715
730
|
if (!directive) {
|
|
716
731
|
jsonResponse(res, 404, { error: `No active supervisor for wave ${waveId}` });
|
|
717
732
|
return;
|
|
@@ -98,10 +98,38 @@ class SupervisorHeartbeat {
|
|
|
98
98
|
return state;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Save wave file immediately so directive persists across restarts
|
|
102
|
+
this.saveWaveFile(waveId, directive);
|
|
103
|
+
|
|
101
104
|
this.spawnSupervisor(state);
|
|
102
105
|
return state;
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Save wave file immediately so directive persists across restarts.
|
|
110
|
+
* saveCompletedWave() adds session/role details on completion.
|
|
111
|
+
*/
|
|
112
|
+
private saveWaveFile(waveId: string, directive: string): void {
|
|
113
|
+
try {
|
|
114
|
+
const wavesDir = path.join(COMPANY_ROOT, 'operations', 'waves');
|
|
115
|
+
if (!fs.existsSync(wavesDir)) fs.mkdirSync(wavesDir, { recursive: true });
|
|
116
|
+
const wavePath = path.join(wavesDir, `${waveId}.json`);
|
|
117
|
+
if (!fs.existsSync(wavePath)) {
|
|
118
|
+
fs.writeFileSync(wavePath, JSON.stringify({
|
|
119
|
+
id: waveId,
|
|
120
|
+
waveId,
|
|
121
|
+
directive,
|
|
122
|
+
startedAt: new Date().toISOString(),
|
|
123
|
+
sessionIds: [],
|
|
124
|
+
roles: [],
|
|
125
|
+
}, null, 2));
|
|
126
|
+
console.log(`[Supervisor] Wave file created: ${wavePath}`);
|
|
127
|
+
}
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.warn(`[Supervisor] Failed to save wave file for ${waveId}:`, err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/**
|
|
106
134
|
* Stop the supervisor for a wave (graceful).
|
|
107
135
|
*/
|
|
@@ -137,18 +165,20 @@ class SupervisorHeartbeat {
|
|
|
137
165
|
if (!state) {
|
|
138
166
|
// Check if this wave existed before (has sessions in session-store)
|
|
139
167
|
const waveSessions = listSessions().filter(s => s.waveId === waveId);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
168
|
+
const ceoSession = waveSessions.find(s => s.roleId === 'ceo') ?? null;
|
|
169
|
+
|
|
170
|
+
// Read original directive from wave artifact file
|
|
171
|
+
let originalDirective = '';
|
|
172
|
+
try {
|
|
173
|
+
const waveFile = path.join(COMPANY_ROOT, 'operations', 'waves', `${waveId}.json`);
|
|
174
|
+
if (fs.existsSync(waveFile)) {
|
|
175
|
+
const waveData = JSON.parse(fs.readFileSync(waveFile, 'utf-8'));
|
|
176
|
+
originalDirective = waveData.directive ?? '';
|
|
177
|
+
}
|
|
178
|
+
} catch { /* ignore */ }
|
|
179
|
+
|
|
180
|
+
if (waveSessions.length > 0 || originalDirective) {
|
|
181
|
+
// Restore supervisor state — from sessions or wave file
|
|
152
182
|
state = {
|
|
153
183
|
waveId,
|
|
154
184
|
directive: originalDirective || text,
|
|
@@ -164,7 +194,7 @@ class SupervisorHeartbeat {
|
|
|
164
194
|
createdAt: ceoSession?.createdAt ?? new Date().toISOString(),
|
|
165
195
|
};
|
|
166
196
|
this.supervisors.set(waveId, state);
|
|
167
|
-
console.log(`[Supervisor] Restored wave ${waveId} from disk (${waveSessions.length} sessions)`);
|
|
197
|
+
console.log(`[Supervisor] Restored wave ${waveId} from disk (${waveSessions.length} sessions, directive=${originalDirective ? 'yes' : 'no'})`);
|
|
168
198
|
} else {
|
|
169
199
|
return null;
|
|
170
200
|
}
|
|
@@ -16,6 +16,7 @@ export interface WaveStreamEnvelope {
|
|
|
16
16
|
interface AttachedSession {
|
|
17
17
|
sessionId: string;
|
|
18
18
|
roleId: string;
|
|
19
|
+
executionId: string;
|
|
19
20
|
unsubscribe: () => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -137,7 +138,15 @@ class WaveMultiplexer {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
private subscribeSessionToClient(waveId: string, client: WaveStreamClient, execution: Execution, sendNotification: boolean): void {
|
|
140
|
-
|
|
141
|
+
// If already attached to this session with a DIFFERENT execution, re-subscribe
|
|
142
|
+
// This handles the resume case where a new execution reuses the same sessionId
|
|
143
|
+
const existing = client.attachedSessions.get(execution.sessionId);
|
|
144
|
+
if (existing) {
|
|
145
|
+
if (existing.executionId === execution.id) return; // Same execution, skip
|
|
146
|
+
existing.unsubscribe();
|
|
147
|
+
client.attachedSessions.delete(execution.sessionId);
|
|
148
|
+
console.log(`[WaveMux] re-subscribing session=${execution.sessionId} (new exec=${execution.id})`);
|
|
149
|
+
}
|
|
141
150
|
|
|
142
151
|
const sessionId = execution.sessionId;
|
|
143
152
|
const roleId = execution.roleId;
|
|
@@ -197,6 +206,7 @@ class WaveMultiplexer {
|
|
|
197
206
|
client.attachedSessions.set(sessionId, {
|
|
198
207
|
sessionId,
|
|
199
208
|
roleId,
|
|
209
|
+
executionId: execution.id,
|
|
200
210
|
unsubscribe: () => execution.stream.unsubscribe(subscriber),
|
|
201
211
|
});
|
|
202
212
|
|
package/src/tui/api.ts
CHANGED
|
@@ -155,6 +155,12 @@ export async function sendDirective(waveId: string, text: string): Promise<{ ok:
|
|
|
155
155
|
});
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
export async function stopWave(waveId: string): Promise<{ ok: boolean; abortedSessions: number }> {
|
|
159
|
+
return fetchJson<{ ok: boolean; abortedSessions: number }>(`/api/waves/${waveId}/stop`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
158
164
|
export async function fetchActiveWaves(): Promise<{ waves: Array<{ waveId: string; sessionIds: string[] }> }> {
|
|
159
165
|
return fetchJson('/api/waves/active');
|
|
160
166
|
}
|
package/src/tui/app.tsx
CHANGED
|
@@ -258,41 +258,6 @@ export const App: React.FC = () => {
|
|
|
258
258
|
});
|
|
259
259
|
}, []);
|
|
260
260
|
|
|
261
|
-
// Load previous conversation from wave's activity stream into system messages
|
|
262
|
-
const loadPreviousConversation = useCallback(async (waveId: string) => {
|
|
263
|
-
try {
|
|
264
|
-
const sessions = api.sessions.filter(s => s.waveId === waveId && s.roleId === 'ceo');
|
|
265
|
-
if (sessions.length === 0) return;
|
|
266
|
-
|
|
267
|
-
for (const ses of sessions.slice(-2)) { // Last 2 sessions
|
|
268
|
-
try {
|
|
269
|
-
const resp = await import('./api').then(m => m.fetchJson<{ events: any[] }>(`/api/jobs/${ses.id}/history`));
|
|
270
|
-
const events = resp?.events ?? (Array.isArray(resp) ? resp : []);
|
|
271
|
-
if (!events.length) continue;
|
|
272
|
-
|
|
273
|
-
for (const e of events) {
|
|
274
|
-
if (e.type === 'msg:start' && e.data?.task) {
|
|
275
|
-
const task = String(e.data.task);
|
|
276
|
-
const match = task.match(/\[CEO (?:Supervisor|Question)\]\s*(.*?)(?:\n|$)/);
|
|
277
|
-
if (match) {
|
|
278
|
-
addSystemMessage(`> ${match[1].slice(0, 80)}`, 'green');
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (e.type === 'text' && e.roleId === 'ceo') {
|
|
282
|
-
const text = String(e.data?.text ?? '').trim();
|
|
283
|
-
if (text && text.length > 5 && !text.startsWith('#') && !text.startsWith('\u26D4') && !text.startsWith('[')) {
|
|
284
|
-
addSystemMessage(text.slice(0, 200), 'white');
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
} catch { /* skip individual session errors */ }
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
addSystemMessage('\u2500'.repeat(40), 'gray');
|
|
292
|
-
addSystemMessage('(previous conversation loaded)', 'gray');
|
|
293
|
-
} catch { /* ignore */ }
|
|
294
|
-
}, [api.sessions, addSystemMessage]);
|
|
295
|
-
|
|
296
261
|
// Auto-wave: on dashboard entry, create an empty wave or attach to existing
|
|
297
262
|
useEffect(() => {
|
|
298
263
|
if (view !== 'dashboard' || autoWaveCreated.current) return;
|
|
@@ -342,44 +307,6 @@ export const App: React.FC = () => {
|
|
|
342
307
|
// SSE subscription to focused wave
|
|
343
308
|
const sse = useSSE(focusedWaveId);
|
|
344
309
|
|
|
345
|
-
// Load wave history into SSE events (for Panel Mode Stream tab)
|
|
346
|
-
const historyLoadingRef = useRef<string | null>(null);
|
|
347
|
-
const loadWaveHistoryEvents = useCallback(async (waveId: string) => {
|
|
348
|
-
// Guard: skip if already loading this wave
|
|
349
|
-
if (historyLoadingRef.current === waveId) return;
|
|
350
|
-
historyLoadingRef.current = waveId;
|
|
351
|
-
// Wait for SSE reconnection to settle (it calls setEvents([]) on connect)
|
|
352
|
-
await new Promise(r => setTimeout(r, 500));
|
|
353
|
-
|
|
354
|
-
try {
|
|
355
|
-
const sessions = api.sessions.filter(s => s.waveId === waveId && s.roleId === 'ceo');
|
|
356
|
-
const allEvents: import('./api').SSEEvent[] = [];
|
|
357
|
-
|
|
358
|
-
for (const ses of sessions.slice(-2)) {
|
|
359
|
-
// Abort if wave changed during loading
|
|
360
|
-
if (historyLoadingRef.current !== waveId) return;
|
|
361
|
-
try {
|
|
362
|
-
const resp = await import('./api').then(m => m.fetchJson<{ events: any[] }>(`/api/jobs/${ses.id}/history`));
|
|
363
|
-
const events = resp?.events ?? [];
|
|
364
|
-
for (const e of events) {
|
|
365
|
-
if (['text', 'tool:start', 'msg:start', 'msg:done', 'msg:error', 'dispatch:start', 'thinking'].includes(e.type)) {
|
|
366
|
-
allEvents.push(e as import('./api').SSEEvent);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
} catch { /* skip */ }
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Only apply if still on this wave
|
|
373
|
-
if (historyLoadingRef.current === waveId && allEvents.length > 0) {
|
|
374
|
-
sse.loadHistory(allEvents);
|
|
375
|
-
}
|
|
376
|
-
} catch { /* ignore */ } finally {
|
|
377
|
-
if (historyLoadingRef.current === waveId) {
|
|
378
|
-
historyLoadingRef.current = null;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}, [api.sessions, sse]);
|
|
382
|
-
|
|
383
310
|
// Build org tree — flatRoleIds follows visual top-to-bottom order
|
|
384
311
|
const roles = api.company?.roles ?? [];
|
|
385
312
|
const statuses = api.execStatus?.statuses ?? {};
|
|
@@ -554,6 +481,10 @@ export const App: React.FC = () => {
|
|
|
554
481
|
}
|
|
555
482
|
break;
|
|
556
483
|
}
|
|
484
|
+
case 'success':
|
|
485
|
+
addSystemMessage(result.message, 'green');
|
|
486
|
+
api.refresh();
|
|
487
|
+
break;
|
|
557
488
|
case 'error':
|
|
558
489
|
addSystemMessage(result.message, 'red');
|
|
559
490
|
break;
|
|
@@ -563,6 +494,7 @@ export const App: React.FC = () => {
|
|
|
563
494
|
addSystemMessage(' /new [text] Create new wave', 'white');
|
|
564
495
|
addSystemMessage(' /waves List all waves', 'white');
|
|
565
496
|
addSystemMessage(' /focus <n> Switch to wave n', 'white');
|
|
497
|
+
addSystemMessage(' /stop Stop current wave execution', 'white');
|
|
566
498
|
addSystemMessage(' /docs Files created in this wave', 'white');
|
|
567
499
|
addSystemMessage(' /read <path> Preview file content', 'white');
|
|
568
500
|
addSystemMessage(' /open <path> Open in $EDITOR', 'white');
|
|
@@ -164,8 +164,14 @@ export function summarizeEvent(event: SSEEvent, allRoleIds: string[]): StreamLin
|
|
|
164
164
|
case 'msg:start': {
|
|
165
165
|
const task = ((event.data.task as string) ?? '');
|
|
166
166
|
const cleanTask = task.replace(/\u26D4[^\u26D4]*\u26D4[^"]*/g, '').trim().slice(0, 60);
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
if (isSupervisor) {
|
|
168
|
+
// Show minimal feedback so user knows input was received
|
|
169
|
+
return {
|
|
170
|
+
id: ++lineCounter,
|
|
171
|
+
text: '\u2026 processing',
|
|
172
|
+
color: 'gray',
|
|
173
|
+
};
|
|
174
|
+
}
|
|
169
175
|
return {
|
|
170
176
|
id: ++lineCounter,
|
|
171
177
|
prefix: event.roleId,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { useCallback } from 'react';
|
|
19
|
-
import { dispatchWave, sendDirective, fetchJson, killSession, cleanupSessions, fetchActiveSessions } from '../api';
|
|
19
|
+
import { dispatchWave, sendDirective, stopWave, fetchJson, killSession, cleanupSessions, fetchActiveSessions } from '../api';
|
|
20
20
|
|
|
21
21
|
export interface WaveInfo {
|
|
22
22
|
waveId: string;
|
|
@@ -102,6 +102,20 @@ export function useCommand(options: UseCommandOptions) {
|
|
|
102
102
|
case 'sessions':
|
|
103
103
|
return { type: 'sessions', message: '__sessions__' };
|
|
104
104
|
|
|
105
|
+
case 'stop': {
|
|
106
|
+
// Stop current wave execution
|
|
107
|
+
const targetWaveId = args?.trim() || focusedWaveId;
|
|
108
|
+
if (!targetWaveId) {
|
|
109
|
+
return { type: 'error', message: 'No wave to stop. Use /stop or focus a wave first.' };
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const result = await stopWave(targetWaveId);
|
|
113
|
+
return { type: 'success', message: `Wave stopped. ${result.abortedSessions} sessions aborted.` };
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { type: 'error', message: `Stop failed: ${err instanceof Error ? err.message : 'unknown'}` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
case 'kill': {
|
|
106
120
|
if (!args) {
|
|
107
121
|
return { type: 'error', message: 'Usage: /kill <sessionId>' };
|
package/src/tui/hooks/useSSE.ts
CHANGED
|
@@ -45,7 +45,6 @@ export interface SSEState {
|
|
|
45
45
|
events: SSEEvent[];
|
|
46
46
|
streamStatus: 'idle' | 'streaming' | 'done' | 'error';
|
|
47
47
|
clearEvents(): void;
|
|
48
|
-
loadHistory(events: SSEEvent[]): void;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
export function useSSE(waveId: string | null): SSEState {
|
|
@@ -79,10 +78,6 @@ export function useSSE(waveId: string | null): SSEState {
|
|
|
79
78
|
maxSeqRef.current = 0;
|
|
80
79
|
}, []);
|
|
81
80
|
|
|
82
|
-
const loadHistory = useCallback((historyEvents: SSEEvent[]) => {
|
|
83
|
-
setEvents(historyEvents.slice(-MAX_EVENTS));
|
|
84
|
-
}, []);
|
|
85
|
-
|
|
86
81
|
useEffect(() => {
|
|
87
82
|
if (waveIdRef.current !== waveId) {
|
|
88
83
|
connRef.current?.close();
|
|
@@ -176,5 +171,5 @@ export function useSSE(waveId: string | null): SSEState {
|
|
|
176
171
|
};
|
|
177
172
|
}, [waveId, flushBatch]);
|
|
178
173
|
|
|
179
|
-
return { events, streamStatus, clearEvents
|
|
174
|
+
return { events, streamStatus, clearEvents };
|
|
180
175
|
}
|