tycono 0.3.20 → 0.3.22-beta.0

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.20",
3
+ "version": "0.3.22-beta.0",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -237,10 +237,33 @@ class WaveMultiplexer {
237
237
  this.registerSession(waveId, execution);
238
238
  }
239
239
 
240
- /** Remove completed wave sessions from memory */
240
+ /** Remove completed wave sessions from memory.
241
+ * Keep SSE clients registered — they persist until connection closes.
242
+ * When the wave restarts (new directive), registerSession will resubscribe them. */
241
243
  cleanupWave(waveId: string): void {
242
244
  this.waveSessions.delete(waveId);
243
- this.clients.delete(waveId);
245
+
246
+ // Don't delete clients — TUI SSE connections stay open across directives.
247
+ // Just unsubscribe dead session listeners to prevent stale callbacks.
248
+ const clients = this.clients.get(waveId);
249
+ if (clients) {
250
+ // Remove disconnected clients
251
+ for (const client of clients) {
252
+ if (client.closed || client.res.destroyed || client.res.writableEnded) {
253
+ clearInterval(client.heartbeat);
254
+ clients.delete(client);
255
+ continue;
256
+ }
257
+ // Unsubscribe old session listeners (execution is done)
258
+ for (const [, attached] of client.attachedSessions) {
259
+ attached.unsubscribe();
260
+ }
261
+ client.attachedSessions.clear();
262
+ }
263
+ if (clients.size === 0) {
264
+ this.clients.delete(waveId);
265
+ }
266
+ }
244
267
  }
245
268
 
246
269
  detach(waveId: string, client: WaveStreamClient): void {
@@ -5,7 +5,7 @@
5
5
  * Shows full output: text, tools, thinking, dispatch — no aggressive truncation.
6
6
  */
7
7
 
8
- import React, { useState, useCallback, useRef } from 'react';
8
+ import React, { useState, useCallback, useRef, useMemo } from 'react';
9
9
  import { Box, Text, Static, useInput } from 'ink';
10
10
  import TextInput from 'ink-text-input';
11
11
  import type { SSEEvent, ActiveSessionInfo } from '../api';
@@ -306,28 +306,58 @@ export const CommandMode: React.FC<CommandModeProps> = ({
306
306
  const [quickBarIndex, setQuickBarIndex] = useState(0);
307
307
  const [acIndex, setAcIndex] = useState(0);
308
308
 
309
- // Convert events to stream lines (collapse duplicates from session reuse)
310
- const eventLines: StreamLine[] = [];
311
- const seenDone = new Set<string>(); // dedup msg:done per role
312
- for (let i = 0; i < events.length; i++) {
313
- const event = events[i];
314
- // Skip consecutive thinking from same role
315
- if (event.type === 'thinking' && i + 1 < events.length) {
316
- const next = events[i + 1];
317
- if (next.type === 'thinking' && next.roleId === event.roleId) continue;
309
+ // Convert events to stream lines memoized to prevent <Static> duplication.
310
+ // Without useMemo, every re-render calls summarizeEvent → new lineCounter IDs
311
+ // <Static> treats them as new items duplicate output in terminal scrollback.
312
+ const prevEventsLenRef = useRef(0);
313
+ const eventLinesRef = useRef<StreamLine[]>([]);
314
+
315
+ useMemo(() => {
316
+ // Reset if events array was cleared (wave switch)
317
+ if (events.length < prevEventsLenRef.current) {
318
+ eventLinesRef.current = [];
319
+ prevEventsLenRef.current = 0;
318
320
  }
319
- // Dedup msg:done — only show last one per role (session reuse causes duplicates)
320
- if (event.type === 'msg:done') {
321
- const key = `${event.roleId}:done`;
322
- if (seenDone.has(key)) continue;
323
- // Check if there's a later msg:done from same role — skip this one
324
- const hasLater = events.slice(i + 1).some(e => e.type === 'msg:done' && e.roleId === event.roleId);
325
- if (hasLater) continue;
326
- seenDone.add(key);
321
+
322
+ // Only process newly added events (events array grows monotonically)
323
+ const startIdx = prevEventsLenRef.current;
324
+ if (startIdx >= events.length) return;
325
+
326
+ const seenDone = new Set<string>();
327
+ // Re-scan for msg:done dedup across ALL events (need full context)
328
+ const doneIndices = new Map<string, number>();
329
+ for (let i = 0; i < events.length; i++) {
330
+ if (events[i].type === 'msg:done') {
331
+ doneIndices.set(events[i].roleId, i);
332
+ }
327
333
  }
328
- const line = summarizeEvent(event, allRoleIds);
329
- if (line) eventLines.push(line);
330
- }
334
+
335
+ for (let i = startIdx; i < events.length; i++) {
336
+ const event = events[i];
337
+ // Skip consecutive thinking from same role
338
+ if (event.type === 'thinking' && i + 1 < events.length) {
339
+ const next = events[i + 1];
340
+ if (next.type === 'thinking' && next.roleId === event.roleId) continue;
341
+ }
342
+ // Dedup msg:done — only show last one per role
343
+ if (event.type === 'msg:done') {
344
+ const key = `${event.roleId}:done`;
345
+ if (seenDone.has(key)) continue;
346
+ if (doneIndices.get(event.roleId) !== i) continue; // Not the last one
347
+ seenDone.add(key);
348
+ }
349
+ const line = summarizeEvent(event, allRoleIds);
350
+ if (line) eventLinesRef.current.push(line);
351
+ }
352
+ prevEventsLenRef.current = events.length;
353
+
354
+ // Cap to prevent unbounded growth
355
+ if (eventLinesRef.current.length > 60) {
356
+ eventLinesRef.current = eventLinesRef.current.slice(-60);
357
+ }
358
+ }, [events, allRoleIds]);
359
+
360
+ const eventLines = eventLinesRef.current;
331
361
 
332
362
  // Merge all lines sorted by ID (chronological) — prevents user input from being sliced off
333
363
  const allLines = [...userInputs, ...systemMessages, ...eventLines]