tycono 0.1.96-beta.6 → 0.1.96-beta.60

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/README.md CHANGED
@@ -5,8 +5,8 @@
5
5
  <h1 align="center">tycono</h1>
6
6
 
7
7
  <p align="center">
8
- <strong>Build an AI company. Watch them work.</strong><br>
9
- <sub>Infrastructure-as-Code defined servers. Company-as-Code defines organizations.</sub>
8
+ <strong>Cursor gives you one AI developer. Tycono gives you an AI team.</strong><br>
9
+ <sub>Give one order. Watch your AI team plan, build, and learn together.</sub>
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -25,9 +25,11 @@
25
25
 
26
26
  ---
27
27
 
28
- **tycono** is an open-source platform that lets you define and run an AI-powered organization. Roles, authority, knowledge, and workflows all defined in files, executed by AI agents, visualized in real time.
28
+ Cursor, Lovable, Bolt they all give you **one AI agent**. It helps, but you still drive everything.
29
29
 
30
- One command. Your AI company is running.
30
+ **tycono** gives you an **AI team**. A CTO reviews architecture. Engineers write code. A PM breaks down tasks. QA catches bugs. You just give the order and watch them work.
31
+
32
+ One command. Your AI team is running.
31
33
 
32
34
  ```bash
33
35
  npx tycono
@@ -83,16 +85,16 @@ Session 50 is dramatically smarter than session 1. Your company learns.
83
85
 
84
86
  ## Why Tycono?
85
87
 
86
- Coding agents simulate **one developer**. Tycono simulates **the entire company**.
88
+ Same goal as Cursor, Lovable, Bolt **get AI to do your work**. Different method.
87
89
 
88
- | | Single AI Agent | Tycono |
90
+ | | Cursor / Lovable / Bolt | Tycono |
89
91
  |---|---|---|
90
- | **What it runs** | One agent, one context | Multiple roles with org hierarchy |
91
- | **Knowledge** | Resets every session | Compounds forever (AKB Pre-K/Post-K) |
92
- | **Authority** | Can do anything (or nothing) | Scoped each role has clear boundaries |
93
- | **Delegation** | Manual prompt chaining | CEO dispatches, org chart routes automatically |
94
- | **Scale** | 1 agent | 7 700 agents |
95
- | **Visibility** | Terminal output | Real-time org tree + activity stream |
92
+ | **Agents** | 1 AI helps you | **AI team works for you** |
93
+ | **Your role** | Keep directing | **Give one order, watch** |
94
+ | **Knowledge** | Resets every session | **Compounds forever** |
95
+ | **Quality** | You review everything | **QA agent catches bugs** |
96
+ | **Scale** | 1 task at a time | **Parallel across roles** |
97
+ | **Visibility** | Editor / chat | **Real-time org tree** |
96
98
 
97
99
  ## Company-as-Code
98
100
 
@@ -258,9 +260,9 @@ npx tycono --version # Show version
258
260
  - [x] CEO Wave dispatch with org-tree targeting
259
261
  - [x] AKB — Pre-K / Post-K knowledge loop
260
262
  - [x] Port Registry for multi-agent isolation
261
- - [ ] **TUI mode** — terminal-native multi-panel interface
263
+ - [ ] **TUI mode** — terminal-native multi-panel interface *(in progress)*
262
264
  - [ ] Git worktree isolation per agent session
263
- - [ ] Desktop app (.dmg / .exe) — background execution, system notifications
265
+ - [ ] **Desktop app** (.dmg / .exe) — background execution, notifications, no API key setup needed
264
266
  - [ ] Multi-LLM support (OpenAI, local models)
265
267
 
266
268
  ## Built with Tycono
package/bin/tycono.ts CHANGED
@@ -19,18 +19,17 @@ function printHelp(): void {
19
19
  Build an AI company. Watch them work.
20
20
 
21
21
  Usage:
22
- tycono [path] Start the server (optionally point to a company directory)
23
- tycono tui Start API server + TUI mode
24
- tycono tui --attach Connect TUI to existing API server
22
+ tycono [path] Start TUI (default, optionally point to a company directory)
23
+ tycono --classic Start pixel office web UI
24
+ tycono --attach Connect TUI to existing API server
25
25
  tycono --help Show this help message
26
26
  tycono --version Show version
27
27
 
28
28
  Examples:
29
- tycono Start in current directory
30
- tycono ./my-company Start with existing company folder
31
- tycono /path/to/akb Start with absolute path
32
- tycono tui Start with terminal UI
33
- PORT=3000 tycono tui --attach Attach TUI to running server
29
+ tycono Start TUI in current directory
30
+ tycono ./my-company Start TUI with existing company folder
31
+ tycono --classic Start pixel office web UI
32
+ PORT=3000 tycono --attach Attach TUI to running server
34
33
 
35
34
  AI Engine (auto-detected):
36
35
  1. Claude Code CLI Install from https://claude.ai/download (recommended)
@@ -219,8 +218,16 @@ async function startServerForTui(): Promise<void> {
219
218
  const logFd = fs.openSync(logFile, 'a');
220
219
  const logStream = fs.createWriteStream(logFile, { fd: logFd });
221
220
  const origStdoutWrite = process.stdout.write.bind(process.stdout);
222
- const origStderrWrite = process.stderr.write.bind(process.stderr);
223
- // Intercept all stdout/stderr — only allow Ink's output (ANSI escape sequences)
221
+ const origLog = (...args: unknown[]) => origStdoutWrite(args.join(' ') + '\n');
222
+
223
+ // Redirect console + stdout.write to suppress server logs
224
+ // ⛔ Do NOT redirect process.stderr.write — breaks Node.js http client
225
+ console.log = (...a: unknown[]) => { logStream.write(a.join(' ') + '\n'); };
226
+ console.error = (...a: unknown[]) => { logStream.write(a.join(' ') + '\n'); };
227
+ console.warn = (...a: unknown[]) => { logStream.write(a.join(' ') + '\n'); };
228
+ console.info = (...a: unknown[]) => { logStream.write(a.join(' ') + '\n'); };
229
+
230
+ // Intercept stdout.write — allow Ink (ANSI), redirect server text to log
224
231
  const isInkOutput = (s: string) => s.includes('\x1b[') || s.includes('\x1b(');
225
232
  process.stdout.write = ((chunk: any, ...args: any[]) => {
226
233
  const str = typeof chunk === 'string' ? chunk : chunk.toString();
@@ -228,19 +235,12 @@ async function startServerForTui(): Promise<void> {
228
235
  logStream.write(str);
229
236
  return true;
230
237
  }) as any;
231
- process.stderr.write = ((chunk: any, ...args: any[]) => {
232
- logStream.write(typeof chunk === 'string' ? chunk : chunk.toString());
233
- return true;
234
- }) as any;
235
- const origLog = (...args: unknown[]) => origStdoutWrite(args.join(' ') + '\n');
236
238
 
237
239
  const { createHttpServer } = await import('../src/api/src/create-server.js');
238
240
  const server = createHttpServer();
239
241
 
240
- const host = process.env.HOST || '0.0.0.0';
241
-
242
242
  await new Promise<void>((resolve) => {
243
- server.listen(port, host, () => resolve());
243
+ server.listen(port, '0.0.0.0', () => resolve());
244
244
  });
245
245
 
246
246
  origLog(` API server started on port ${port}`);
@@ -254,7 +254,7 @@ async function startServerForTui(): Promise<void> {
254
254
  process.on('SIGINT', shutdown);
255
255
  process.on('SIGTERM', shutdown);
256
256
 
257
- // Start TUI
257
+ // Start TUI — stdout.write is NOT intercepted, Ink has full control
258
258
  const { startTui } = await import('../src/tui/index.tsx');
259
259
  await startTui({ port });
260
260
  }
@@ -272,24 +272,37 @@ export async function main(args: string[]): Promise<void> {
272
272
  return;
273
273
  }
274
274
 
275
- // tui subcommand: start API server + TUI mode
275
+ // --classic: legacy pixel office web UI
276
+ if (command === '--classic' || args.includes('--classic')) {
277
+ if (command === '--classic' && args[1] && !args[1].startsWith('-')) {
278
+ process.env.COMPANY_ROOT = path.resolve(args[1]);
279
+ }
280
+ await startServer();
281
+ return;
282
+ }
283
+
284
+ // --attach: connect TUI to existing API server
285
+ if (command === '--attach' || args.includes('--attach')) {
286
+ const port = process.env.PORT ? Number(process.env.PORT) : 3000;
287
+ const { startTui } = await import('../src/tui/index.tsx');
288
+ await startTui({ port });
289
+ return;
290
+ }
291
+
292
+ // Legacy: `tui` subcommand still works
276
293
  if (command === 'tui') {
277
- const attachMode = args.includes('--attach');
278
- // If --attach, skip server start — just connect to existing API
279
- if (attachMode) {
294
+ if (args.includes('--attach')) {
280
295
  const port = process.env.PORT ? Number(process.env.PORT) : 3000;
281
296
  const { startTui } = await import('../src/tui/index.tsx');
282
297
  await startTui({ port });
283
298
  return;
284
299
  }
285
-
286
- // Start API server, then TUI
287
300
  await startServerForTui();
288
301
  return;
289
302
  }
290
303
 
304
+ // Path argument: treat as company directory
291
305
  if (command && !command.startsWith('-')) {
292
- // Treat as path to company directory
293
306
  const resolved = path.resolve(command);
294
307
  if (!fs.existsSync(resolved)) {
295
308
  console.error(` Path not found: ${resolved}`);
@@ -298,5 +311,27 @@ export async function main(args: string[]): Promise<void> {
298
311
  process.env.COMPANY_ROOT = resolved;
299
312
  }
300
313
 
301
- await startServer();
314
+ // Show first-run notice (once only)
315
+ const prefsPath = path.resolve(process.env.COMPANY_ROOT || process.cwd(), '.tycono', 'preferences.json');
316
+ let prefs: Record<string, unknown> = {};
317
+ try { prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')); } catch {}
318
+ if (!prefs.tuiNoticeShown) {
319
+ console.log('');
320
+ console.log(' Tycono v' + VERSION + ' — AI Company OS');
321
+ console.log('');
322
+ console.log(' New: Terminal mode is now the default.');
323
+ console.log(' Faster, scriptable, built for work.');
324
+ console.log('');
325
+ console.log(' Looking for the pixel office?');
326
+ console.log(' → npx tycono --classic');
327
+ console.log('');
328
+ prefs.tuiNoticeShown = true;
329
+ try {
330
+ fs.mkdirSync(path.dirname(prefsPath), { recursive: true });
331
+ fs.writeFileSync(prefsPath, JSON.stringify(prefs, null, 2));
332
+ } catch {}
333
+ }
334
+
335
+ // Default: TUI mode
336
+ await startServerForTui();
302
337
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.96-beta.6",
3
+ "version": "0.1.96-beta.60",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -227,8 +227,12 @@ export function createExpressApp(): express.Application {
227
227
  }
228
228
 
229
229
  app.use((err: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
230
- console.error(`[ERROR] ${req.method} ${req.url} — ${err.message}`);
231
230
  const status = err.name === 'FileNotFoundError' ? 404 : 500;
231
+ // Log server errors via console.error (redirected to log file in TUI mode)
232
+ // 404 errors are expected (e.g. fresh install, no company.md) — skip
233
+ if (status >= 500) {
234
+ console.error(`[ERROR] ${req.method} ${req.url} — ${err.message}`);
235
+ }
232
236
  res.status(status).json({ error: err.message });
233
237
  });
234
238
 
@@ -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,
@@ -51,25 +51,21 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
51
51
  // ── /api/waves/active — restore active waves after refresh ──
52
52
  if (method === 'GET' && url === '/api/waves/active') {
53
53
  // Recovery: rebuild wave→session mapping from session-store if lost
54
+ // Only recover ACTIVE sessions to prevent OOM on large datasets (140+ sessions)
54
55
  const waves = waveMultiplexer.getActiveWaves();
55
56
  if (waves.length === 0) {
56
57
  const allSessions = listSessions();
57
- const waveGroups = new Map<string, string[]>();
58
+ let recovered = 0;
58
59
  for (const ses of allSessions) {
59
- if (ses.waveId) {
60
- if (!waveGroups.has(ses.waveId)) waveGroups.set(ses.waveId, []);
61
- waveGroups.get(ses.waveId)!.push(ses.id);
60
+ if (!ses.waveId || ses.status !== 'active') continue;
61
+ const exec = executionManager.getActiveExecution(ses.id);
62
+ if (exec) {
63
+ waveMultiplexer.registerSession(ses.waveId, exec);
64
+ recovered++;
62
65
  }
63
66
  }
64
- // BUG-W03 fix: register ALL wave sessions (including completed) for tree display
65
- for (const [wid, sids] of waveGroups) {
66
- for (const sid of sids) {
67
- // getActiveExecution falls back to stream recovery for completed sessions
68
- const exec = executionManager.getActiveExecution(sid);
69
- if (exec) {
70
- waveMultiplexer.registerSession(wid, exec);
71
- }
72
- }
67
+ if (recovered > 0) {
68
+ console.log(`[WaveRecovery] Recovered ${recovered} active sessions`);
73
69
  }
74
70
  }
75
71
  jsonResponse(res, 200, { waves: waveMultiplexer.getActiveWaves() });
@@ -212,10 +208,8 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
212
208
  const attachments = body.attachments as ImageAttachment[] | undefined;
213
209
 
214
210
  if (type === 'wave') {
215
- if (!directive) {
216
- jsonResponse(res, 400, { error: 'directive is required for wave jobs' });
217
- return;
218
- }
211
+ // directive가 없으면 idle 상태로 시작 (empty wave)
212
+ const actualDirective = directive || '';
219
213
 
220
214
  const targetRoles = body.targetRoles as string[] | undefined;
221
215
  const continuous = body.continuous === true;
@@ -224,7 +218,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
224
218
  {
225
219
  const state = supervisorHeartbeat.start(
226
220
  `wave-${Date.now()}`,
227
- directive,
221
+ actualDirective,
228
222
  targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
229
223
  continuous,
230
224
  );
@@ -238,7 +232,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
238
232
  waveId: state.waveId,
239
233
  supervisorSessionId: state.supervisorSessionId,
240
234
  mode: 'supervisor',
241
- directive,
235
+ directive: actualDirective,
242
236
  });
243
237
  return;
244
238
  }
@@ -482,13 +476,11 @@ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req:
482
476
 
483
477
  let sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
484
478
 
485
- // Recovery: if wave→session mapping was lost (e.g. server restart),
486
- // rebuild from session-store (sessions have waveId) + executionManager
479
+ // Recovery: only recover ACTIVE sessions for this wave (not all 140)
487
480
  if (sessionIds.length === 0) {
488
481
  const allSessions = listSessions();
489
- const waveSessions = allSessions.filter(s => s.waveId === waveId);
482
+ const waveSessions = allSessions.filter(s => s.waveId === waveId && s.status === 'active');
490
483
  for (const ses of waveSessions) {
491
- // getActiveExecution recovers from stream files for completed sessions too
492
484
  const exec = executionManager.getActiveExecution(ses.id);
493
485
  if (exec) {
494
486
  waveMultiplexer.registerSession(waveId, exec);
@@ -497,12 +489,8 @@ function handleWaveStream(waveId: string, url: string, res: ServerResponse, req:
497
489
  sessionIds = waveMultiplexer.getWaveSessionIds(waveId);
498
490
  }
499
491
 
500
- if (sessionIds.length === 0) {
501
- res.writeHead(404, { 'Content-Type': 'application/json' });
502
- res.end(JSON.stringify({ error: `No sessions found for wave: ${waveId}` }));
503
- return;
504
- }
505
-
492
+ // Don't 404 on empty waves — keep SSE alive, sessions will appear later
493
+ // (e.g. idle wave waiting for first directive, or supervisor restarting)
506
494
  const client = waveMultiplexer.attach(waveId, res as any, fromWaveSeq);
507
495
 
508
496
  req.on('close', () => {
@@ -769,25 +757,26 @@ function handleStatus(res: ServerResponse): void {
769
757
  );
770
758
 
771
759
  const recovered: typeof activeExecs = [];
772
- for (const ses of activeSessions) {
773
- // Check activity-stream for actual running state
760
+ // Limit recovery scan to prevent OOM on large session stores
761
+ const MAX_RECOVERY_SCAN = 20;
762
+ const recentActive = activeSessions.slice(-MAX_RECOVERY_SCAN);
763
+
764
+ for (const ses of recentActive) {
774
765
  if (!ActivityStream.exists(ses.id)) continue;
766
+ // Only read last few events to check done/error (not entire stream)
775
767
  const events = ActivityStream.readFrom(ses.id, 0);
776
768
  if (events.length === 0) continue;
777
769
 
778
- const startEvent = events.find(e => e.type === 'msg:start');
779
- if (!startEvent) continue;
780
-
781
- const doneEvent = events.find(e => e.type === 'msg:done');
782
- const errorEvent = events.find(e => e.type === 'msg:error');
783
-
784
- // Only include sessions that haven't finished yet
785
- if (doneEvent || errorEvent) continue;
770
+ // Check last 5 events for done/error (optimization: don't scan entire file)
771
+ const tail = events.slice(-5);
772
+ const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
773
+ if (isDone) continue;
786
774
 
787
- const task = (startEvent.data?.task as string) ?? ses.title ?? '';
775
+ const startEvent = events.find(e => e.type === 'msg:start');
776
+ const task = (startEvent?.data?.task as string) ?? ses.title ?? '';
788
777
  recovered.push({
789
778
  id: `recovered-${ses.id}`,
790
- type: (startEvent.data?.type as string ?? 'assign') as 'assign' | 'wave' | 'consult',
779
+ type: (startEvent?.data?.type as string ?? 'assign') as 'assign' | 'wave' | 'consult',
791
780
  roleId: ses.roleId,
792
781
  task,
793
782
  status: 'running',
@@ -1,11 +1,40 @@
1
1
  import { COMPANY_ROOT } from './services/file-reader.js';
2
2
  import { applyConfig } from './services/company-config.js';
3
3
  import { createHttpServer } from './create-server.js';
4
+ import { listSessions, updateSession } from './services/session-store.js';
5
+ import { ActivityStream } from './services/activity-stream.js';
4
6
 
5
7
  // Load .tycono/config.json and apply to process.env
6
8
  const config = applyConfig(COMPANY_ROOT);
7
9
  console.log(`[STARTUP] Engine: ${config.engine}, API key: ${config.apiKey ? 'set' : 'none'}`);
8
10
 
11
+ // Startup: mark orphaned 'active' sessions as 'interrupted'
12
+ // These are sessions from a previous server that crashed or was killed
13
+ {
14
+ const allSessions = listSessions();
15
+ let orphaned = 0;
16
+ for (const ses of allSessions) {
17
+ if (ses.status !== 'active') continue;
18
+ // Check activity stream — if it has msg:done/msg:error, mark done
19
+ // If not, mark interrupted (previous server died mid-execution)
20
+ if (ActivityStream.exists(ses.id)) {
21
+ const events = ActivityStream.readFrom(ses.id, 0);
22
+ const tail = events.slice(-5);
23
+ const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
24
+ if (isDone) {
25
+ updateSession(ses.id, { status: 'done' });
26
+ orphaned++;
27
+ continue;
28
+ }
29
+ }
30
+ updateSession(ses.id, { status: 'interrupted' as any });
31
+ orphaned++;
32
+ }
33
+ if (orphaned > 0) {
34
+ console.log(`[STARTUP] Cleaned ${orphaned} orphaned sessions (active → done/interrupted)`);
35
+ }
36
+ }
37
+
9
38
  const PORT = Number(process.env.PORT) || 3001;
10
39
  const server = createHttpServer();
11
40
 
@@ -13,3 +42,18 @@ server.listen(PORT, () => {
13
42
  console.log(`[API] Server running on http://localhost:${PORT}`);
14
43
  console.log(`[API] COMPANY_ROOT: ${COMPANY_ROOT}`);
15
44
  });
45
+
46
+ // Graceful shutdown: mark running sessions as interrupted
47
+ function gracefulShutdown(signal: string) {
48
+ console.log(`[SHUTDOWN] ${signal} received, marking active sessions as interrupted...`);
49
+ const sessions = listSessions();
50
+ for (const ses of sessions) {
51
+ if (ses.status === 'active') {
52
+ updateSession(ses.id, { status: 'interrupted' as any });
53
+ }
54
+ }
55
+ process.exit(0);
56
+ }
57
+
58
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
59
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
@@ -10,7 +10,7 @@ import { estimateCost } from './pricing.js';
10
10
  import { readConfig, getConversationLimits, resolveCodeRoot } from './company-config.js';
11
11
  import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
12
12
  import { earnCoinsInternal } from '../routes/coins.js';
13
- import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
13
+ import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, updateSession, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
14
14
  import { portRegistry, type PortAllocation } from './port-registry.js';
15
15
  import { type MessageStatus, isMessageActive, canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
16
16
 
@@ -603,6 +603,12 @@ class ExecutionManager {
603
603
  knowledgeDebt: execution.knowledgeDebt.map(d => ({ type: d.type, file: d.file, message: d.message })),
604
604
  }),
605
605
  });
606
+
607
+ // Mark session as done in session-store (persisted to file)
608
+ // Skip CEO supervisor sessions — they stay active for wave lifecycle
609
+ if (session.roleId !== 'ceo' || session.source !== 'wave') {
610
+ updateSession(execution.sessionId, { status: 'done' });
611
+ }
606
612
  }
607
613
 
608
614
  private cleanupOrphanedChildren(parentSessionId: string): void {