tycono 0.3.13-beta.6 → 0.3.13

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/bin/cli.js CHANGED
@@ -10,9 +10,8 @@ if (!process.env.__TYCONO_HEAP_SET && !process.execArgv.some(a => a.includes('ma
10
10
  process.env.__TYCONO_HEAP_SET = '1';
11
11
  try {
12
12
  execFileSync(process.execPath, [
13
- '--max-old-space-size=4096',
13
+ '--max-old-space-size=8192',
14
14
  '--expose-gc',
15
- '--heapsnapshot-near-heap-limit=1',
16
15
  ...process.execArgv,
17
16
  fileURLToPath(import.meta.url),
18
17
  ...process.argv.slice(2),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.3.13-beta.6",
3
+ "version": "0.3.13",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,7 @@ import { engineRouter } from './routes/engine.js';
17
17
  import { sessionsRouter } from './routes/sessions.js';
18
18
  import { setupRouter } from './routes/setup.js';
19
19
  import { skillsRouter } from './routes/skills.js';
20
+ import { presetsRouter } from './routes/presets.js';
20
21
 
21
22
  export function createApp() {
22
23
  const app = express();
@@ -53,6 +54,7 @@ export function createApp() {
53
54
  app.use('/api/engine', engineRouter);
54
55
  app.use('/api/sessions', sessionsRouter);
55
56
  app.use('/api/skills', skillsRouter);
57
+ app.use('/api/presets', presetsRouter);
56
58
 
57
59
  app.get('/api/health', (_req, res) => {
58
60
  res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
@@ -27,6 +27,9 @@ executionManager.onExecutionCreated((exec) => {
27
27
  waveMultiplexer.onExecutionCreated(exec);
28
28
  });
29
29
 
30
+ // OOM fix: wave recovery runs once, not on every 5s poll
31
+ let waveRecoveryDone = false;
32
+
30
33
  /* ─── Runner — lazy, re-created when engine changes ── */
31
34
 
32
35
  function getRunner() {
@@ -50,15 +53,15 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
50
53
 
51
54
  // ── /api/waves/active — restore active waves after refresh ──
52
55
  if (method === 'GET' && url === '/api/waves/active') {
53
- // Recovery: rebuild wave→session mapping from session-store
54
- // Include done sessions (persistent channel model) but limit to CEO sessions
55
- const waves = waveMultiplexer.getActiveWaves();
56
- if (waves.length === 0) {
56
+ // Recovery: rebuild wave→session mapping from session-store (ONE TIME ONLY)
57
+ // Previous bug: recovery ran on EVERY poll (5s) because getActiveWaves()
58
+ // returns empty for done executions → recovery loop → OOM
59
+ if (!waveRecoveryDone) {
60
+ waveRecoveryDone = true;
57
61
  const allSessions = listSessions();
58
62
  let recovered = 0;
59
63
  for (const ses of allSessions) {
60
64
  if (!ses.waveId) continue;
61
- // Only recover CEO sessions for wave display (team sessions loaded on demand)
62
65
  if (ses.roleId !== 'ceo') continue;
63
66
  const exec = executionManager.getActiveExecution(ses.id);
64
67
  if (exec) {
@@ -67,7 +70,7 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
67
70
  }
68
71
  }
69
72
  if (recovered > 0) {
70
- console.log(`[WaveRecovery] Recovered ${recovered} active sessions`);
73
+ console.log(`[WaveRecovery] Recovered ${recovered} sessions (one-time)`);
71
74
  }
72
75
  }
73
76
  jsonResponse(res, 200, { waves: waveMultiplexer.getActiveWaves() });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * presets.ts — Preset API routes
3
+ *
4
+ * GET /api/presets — list all preset summaries
5
+ * GET /api/presets/:id — get full preset detail
6
+ */
7
+ import { Router } from 'express';
8
+ import { COMPANY_ROOT } from '../services/file-reader.js';
9
+ import { getPresetSummaries, getPresetById } from '../services/preset-loader.js';
10
+
11
+ export const presetsRouter = Router();
12
+
13
+ /** GET /api/presets — list preset summaries for TUI */
14
+ presetsRouter.get('/', (_req, res) => {
15
+ try {
16
+ const summaries = getPresetSummaries(COMPANY_ROOT);
17
+ res.json(summaries);
18
+ } catch (err) {
19
+ res.status(500).json({ error: 'Failed to load presets' });
20
+ }
21
+ });
22
+
23
+ /** GET /api/presets/:id — get full preset detail */
24
+ presetsRouter.get('/:id', (req, res) => {
25
+ try {
26
+ const preset = getPresetById(COMPANY_ROOT, req.params.id);
27
+ if (!preset) {
28
+ res.status(404).json({ error: `Preset not found: ${req.params.id}` });
29
+ return;
30
+ }
31
+ res.json(preset.definition);
32
+ } catch (err) {
33
+ res.status(500).json({ error: 'Failed to load preset' });
34
+ }
35
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * preset-loader.ts — Load presets from company/presets/
3
+ *
4
+ * Scans company/presets/ for:
5
+ * - _default.yaml (auto-generated from existing roles/)
6
+ * - {name}/preset.yaml (installed presets with roles/skills/knowledge)
7
+ *
8
+ * Returns PresetSummary[] for TUI display and full LoadedPreset for wave creation.
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import YAML from 'yaml';
13
+ import type { PresetDefinition, LoadedPreset, PresetSummary } from '../../../shared/types.js';
14
+
15
+ const PRESETS_DIR = 'company/presets';
16
+ const DEFAULT_PRESET_FILE = '_default.yaml';
17
+
18
+ /**
19
+ * Build a default preset definition from existing roles/ directory.
20
+ * This is generated on-the-fly — no need to persist _default.yaml.
21
+ */
22
+ function buildDefaultPreset(companyRoot: string): LoadedPreset {
23
+ const rolesDir = path.join(companyRoot, 'roles');
24
+ const roles: string[] = [];
25
+
26
+ if (fs.existsSync(rolesDir)) {
27
+ const entries = fs.readdirSync(rolesDir, { withFileTypes: true });
28
+ for (const entry of entries) {
29
+ if (!entry.isDirectory()) continue;
30
+ const yamlPath = path.join(rolesDir, entry.name, 'role.yaml');
31
+ if (fs.existsSync(yamlPath)) {
32
+ roles.push(entry.name);
33
+ }
34
+ }
35
+ }
36
+
37
+ return {
38
+ definition: {
39
+ spec: 'preset/v1',
40
+ id: 'default',
41
+ name: 'Default Team',
42
+ tagline: 'Your current team',
43
+ version: '1.0.0',
44
+ roles,
45
+ },
46
+ path: null,
47
+ isDefault: true,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Load a single preset from a directory containing preset.yaml.
53
+ */
54
+ function loadPresetFromDir(presetDir: string): LoadedPreset | null {
55
+ const yamlPath = path.join(presetDir, 'preset.yaml');
56
+ if (!fs.existsSync(yamlPath)) return null;
57
+
58
+ try {
59
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as PresetDefinition;
60
+ if (!raw.id || !raw.name || !Array.isArray(raw.roles)) return null;
61
+
62
+ return {
63
+ definition: {
64
+ spec: raw.spec || 'preset/v1',
65
+ id: raw.id,
66
+ name: raw.name,
67
+ tagline: raw.tagline,
68
+ version: raw.version || '1.0.0',
69
+ description: raw.description,
70
+ author: raw.author,
71
+ category: raw.category,
72
+ industry: raw.industry,
73
+ stage: raw.stage,
74
+ use_case: raw.use_case,
75
+ roles: raw.roles,
76
+ knowledge_docs: raw.knowledge_docs,
77
+ skills_count: raw.skills_count,
78
+ pricing: raw.pricing,
79
+ tags: raw.tags,
80
+ languages: raw.languages,
81
+ stats: raw.stats,
82
+ wave_scoped: raw.wave_scoped,
83
+ },
84
+ path: presetDir,
85
+ isDefault: false,
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Load all presets from company/presets/ + auto-generated default.
94
+ * Returns [default, ...installed] — default is always first.
95
+ */
96
+ export function loadPresets(companyRoot: string): LoadedPreset[] {
97
+ const presets: LoadedPreset[] = [];
98
+
99
+ // 1. Default preset (always present)
100
+ const defaultPreset = buildDefaultPreset(companyRoot);
101
+
102
+ // Check if _default.yaml exists with overrides
103
+ const defaultYamlPath = path.join(companyRoot, PRESETS_DIR, DEFAULT_PRESET_FILE);
104
+ if (fs.existsSync(defaultYamlPath)) {
105
+ try {
106
+ const raw = YAML.parse(fs.readFileSync(defaultYamlPath, 'utf-8')) as Partial<PresetDefinition>;
107
+ if (raw.name) defaultPreset.definition.name = raw.name;
108
+ if (raw.tagline) defaultPreset.definition.tagline = raw.tagline;
109
+ if (raw.description) defaultPreset.definition.description = raw.description;
110
+ } catch { /* ignore malformed _default.yaml */ }
111
+ }
112
+
113
+ presets.push(defaultPreset);
114
+
115
+ // 2. Installed presets from company/presets/{name}/preset.yaml
116
+ const presetsDir = path.join(companyRoot, PRESETS_DIR);
117
+ if (fs.existsSync(presetsDir)) {
118
+ const entries = fs.readdirSync(presetsDir, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ if (!entry.isDirectory()) continue;
121
+ const preset = loadPresetFromDir(path.join(presetsDir, entry.name));
122
+ if (preset) presets.push(preset);
123
+ }
124
+ }
125
+
126
+ return presets;
127
+ }
128
+
129
+ /**
130
+ * Get preset summaries for TUI display.
131
+ */
132
+ export function getPresetSummaries(companyRoot: string): PresetSummary[] {
133
+ return loadPresets(companyRoot).map(p => ({
134
+ id: p.definition.id,
135
+ name: p.definition.name,
136
+ description: p.definition.description ?? p.definition.tagline,
137
+ rolesCount: p.definition.roles.length,
138
+ roles: p.definition.roles,
139
+ isDefault: p.isDefault,
140
+ }));
141
+ }
142
+
143
+ /**
144
+ * Find a specific preset by ID.
145
+ */
146
+ export function getPresetById(companyRoot: string, presetId: string): LoadedPreset | null {
147
+ const presets = loadPresets(companyRoot);
148
+ return presets.find(p => p.definition.id === presetId) ?? null;
149
+ }
@@ -144,3 +144,81 @@ export function eventTypeToMessageStatus(eventType: ActivityEventType): MessageS
144
144
 
145
145
  /** TeamStatus — Role별 현재 상태 + 작업 내용 (context-assembler, runner에서 공유) */
146
146
  export type TeamStatus = Record<string, { status: RoleStatus; task?: string }>;
147
+
148
+ /* ═══════════════════════════════════════════════
149
+ * Preset — Wave-scoped team configuration
150
+ * ═══════════════════════════════════════════════ */
151
+
152
+ /** preset.yaml 스키마 */
153
+ export interface PresetDefinition {
154
+ /** Spec version (e.g. "preset/v1") */
155
+ spec: string;
156
+ /** Unique identifier (directory name) */
157
+ id: string;
158
+ /** Display name */
159
+ name: string;
160
+ /** Short tagline */
161
+ tagline?: string;
162
+ /** Version string */
163
+ version: string;
164
+ /** Full description */
165
+ description?: string;
166
+ /** Author info */
167
+ author?: {
168
+ id: string;
169
+ name: string;
170
+ verified?: boolean;
171
+ };
172
+ /** Category / classification */
173
+ category?: string;
174
+ industry?: string;
175
+ stage?: string;
176
+ use_case?: string[];
177
+ /** Role IDs included in this preset */
178
+ roles: string[];
179
+ /** Counts */
180
+ knowledge_docs?: number;
181
+ skills_count?: number;
182
+ /** Pricing */
183
+ pricing?: {
184
+ type: 'one-time' | 'subscription';
185
+ price: number;
186
+ wave_scoped_tier?: string;
187
+ };
188
+ /** Tags for search */
189
+ tags?: string[];
190
+ languages?: string[];
191
+ /** Stats (marketplace) */
192
+ stats?: {
193
+ installs: number;
194
+ rating: number;
195
+ reviews: number;
196
+ waves_used: number;
197
+ };
198
+ /** Wave-scoped recommendations */
199
+ wave_scoped?: {
200
+ recommended_tasks?: string[];
201
+ task_keywords?: string[];
202
+ avg_wave_duration?: string;
203
+ complexity?: string;
204
+ };
205
+ }
206
+
207
+ /** Loaded preset with resolved path info */
208
+ export interface LoadedPreset {
209
+ definition: PresetDefinition;
210
+ /** Absolute path to preset directory (or null for _default) */
211
+ path: string | null;
212
+ /** Whether this is the _default preset */
213
+ isDefault: boolean;
214
+ }
215
+
216
+ /** Preset summary for TUI display */
217
+ export interface PresetSummary {
218
+ id: string;
219
+ name: string;
220
+ description?: string;
221
+ rolesCount: number;
222
+ roles: string[];
223
+ isDefault: boolean;
224
+ }
package/src/tui/api.ts CHANGED
@@ -136,6 +136,7 @@ export async function fetchExecStatus(): Promise<ExecStatus> {
136
136
  export async function dispatchWave(directive?: string, options?: {
137
137
  targetRoles?: string[];
138
138
  continuous?: boolean;
139
+ preset?: string;
139
140
  }): Promise<WaveResponse> {
140
141
  return fetchJson<WaveResponse>('/api/jobs', {
141
142
  method: 'POST',
@@ -144,6 +145,7 @@ export async function dispatchWave(directive?: string, options?: {
144
145
  directive: directive ?? '',
145
146
  targetRoles: options?.targetRoles,
146
147
  continuous: options?.continuous ?? false,
148
+ preset: options?.preset,
147
149
  },
148
150
  });
149
151
  }
@@ -211,6 +213,21 @@ export async function cleanupSessions(): Promise<{ cleaned: number; remaining: n
211
213
  return fetchJson<{ cleaned: number; remaining: number }>('/api/active-sessions/cleanup', { method: 'POST' });
212
214
  }
213
215
 
216
+ /* ─── Presets ─── */
217
+
218
+ export interface PresetSummary {
219
+ id: string;
220
+ name: string;
221
+ description?: string;
222
+ rolesCount: number;
223
+ roles: string[];
224
+ isDefault: boolean;
225
+ }
226
+
227
+ export async function fetchPresets(): Promise<PresetSummary[]> {
228
+ return fetchJson<PresetSummary[]>('/api/presets');
229
+ }
230
+
214
231
  /* ─── Knowledge docs ─── */
215
232
 
216
233
  export interface KnowledgeDoc {
@@ -139,26 +139,11 @@ function readFilePreview(filePath: string, maxLines: number): string[] {
139
139
  }
140
140
  }
141
141
 
142
- // OOM debug: track render count
143
- let panelRenderCount = 0;
144
-
145
142
  export const PanelMode: React.FC<PanelModeProps> = ({
146
143
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
147
144
  streamStatus, waveId, activeSessions, allSessions, companyRoot, waves,
148
145
  focusedWaveId, onMove, onSelect, onEscape, onFocusWave,
149
146
  }) => {
150
- panelRenderCount++;
151
- if (panelRenderCount % 100 === 0) {
152
- const mem = process.memoryUsage();
153
- console.error(`[PanelMode] render #${panelRenderCount} heap=${Math.round(mem.heapUsed/1024/1024)}MB events=${events.length}`);
154
- }
155
- if (panelRenderCount > 1000) {
156
- console.error(`[PanelMode] ⛔ RENDER LOOP DETECTED: ${panelRenderCount} renders. Bailing out.`);
157
- onEscape(); // Force back to command mode
158
- panelRenderCount = 0;
159
- return null;
160
- }
161
-
162
147
  const [termHeight, setTermHeight] = useState(process.stdout.rows || 30);
163
148
  const [rightTab, setRightTab] = useState<RightTab>('stream');
164
149
  const [docsFilter, setDocsFilter] = useState<DocsFilter>('all');
@@ -16,7 +16,8 @@
16
16
  */
17
17
 
18
18
  import { useCallback } from 'react';
19
- import { dispatchWave, sendDirective, stopWave, fetchJson, killSession, cleanupSessions, fetchActiveSessions } from '../api';
19
+ import { dispatchWave, sendDirective, stopWave, fetchJson, killSession, cleanupSessions, fetchActiveSessions, fetchPresets } from '../api';
20
+ import type { PresetSummary } from '../api';
20
21
 
21
22
  export interface WaveInfo {
22
23
  waveId: string;
@@ -25,9 +26,10 @@ export interface WaveInfo {
25
26
  }
26
27
 
27
28
  export interface CommandResult {
28
- type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup' | 'docs' | 'read_file' | 'open_file';
29
+ type: 'success' | 'error' | 'info' | 'wave_started' | 'directive_sent' | 'stopped' | 'quit' | 'help' | 'panel' | 'waves_list' | 'focus_changed' | 'agents' | 'ports' | 'sessions' | 'cleanup' | 'docs' | 'read_file' | 'open_file' | 'preset_list' | 'preset_select';
29
30
  message: string;
30
31
  waveId?: string;
32
+ presets?: PresetSummary[];
31
33
  }
32
34
 
33
35
  async function postAssign(roleId: string, task: string): Promise<{ waveId?: string }> {
@@ -80,6 +82,15 @@ export function useCommand(options: UseCommandOptions) {
80
82
 
81
83
  case 'new': {
82
84
  const directive = args || undefined;
85
+ if (!directive) {
86
+ // No args → show preset selection UI
87
+ try {
88
+ const presets = await fetchPresets();
89
+ return { type: 'preset_select', message: '', presets };
90
+ } catch (err) {
91
+ return { type: 'error', message: `Failed to load presets: ${err instanceof Error ? err.message : 'unknown'}` };
92
+ }
93
+ }
83
94
  try {
84
95
  const result = await dispatchWave(directive);
85
96
  onWaveCreated(result.waveId, directive ?? '');
@@ -168,6 +179,19 @@ export function useCommand(options: UseCommandOptions) {
168
179
  }
169
180
  }
170
181
 
182
+ case 'preset': {
183
+ const subCmd = args.split(/\s+/)[0]?.toLowerCase() || 'list';
184
+ if (subCmd === 'list' || !subCmd) {
185
+ try {
186
+ const presets = await fetchPresets();
187
+ return { type: 'preset_list', message: '', presets };
188
+ } catch (err) {
189
+ return { type: 'error', message: `Failed to load presets: ${err instanceof Error ? err.message : 'unknown'}` };
190
+ }
191
+ }
192
+ return { type: 'error', message: `Unknown preset command: ${subCmd}. Try: /preset list` };
193
+ }
194
+
171
195
  case 'roles':
172
196
  onShowPanel();
173
197
  return { type: 'panel', message: '' };