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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const directive = supervisorHeartbeat.addDirective(waveId, text);
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
- if (waveSessions.length > 0) {
141
- // Restore supervisor state for this wave
142
- const ceoSession = waveSessions.find(s => s.roleId === 'ceo');
143
- // Read original directive from wave artifact file (not the new text)
144
- let originalDirective = '';
145
- try {
146
- const waveFile = path.join(COMPANY_ROOT, 'operations', 'waves', `${waveId}.json`);
147
- if (fs.existsSync(waveFile)) {
148
- const waveData = JSON.parse(fs.readFileSync(waveFile, 'utf-8'));
149
- originalDirective = waveData.directive ?? '';
150
- }
151
- } catch { /* ignore */ }
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
- if (client.attachedSessions.has(execution.sessionId)) return;
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
- // Hide supervisor started message (internal noise)
168
- if (isSupervisor) return null;
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>' };
@@ -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, loadHistory };
174
+ return { events, streamStatus, clearEvents };
180
175
  }