tycono 0.3.13 → 0.3.14-beta.1

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.13",
3
+ "version": "0.3.14-beta.1",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@ export interface AgentConfig {
29
29
  tokenLedger?: TokenLedger; // Token usage ledger (optional)
30
30
  attachments?: ImageAttachment[]; // Image attachments for vision
31
31
  targetRoles?: string[]; // Selective dispatch scope
32
+ presetId?: string; // Wave-scoped preset for knowledge injection
32
33
  // Callbacks
33
34
  onText?: (text: string) => void;
34
35
  onToolExec?: (name: string, input: Record<string, unknown>) => void;
@@ -162,7 +163,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
162
163
  const llm = config.llm ?? new AnthropicProvider();
163
164
 
164
165
  // 1. Assemble context
165
- const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles });
166
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles, presetId: config.presetId });
166
167
 
167
168
  // Trace: capture assembled prompt for debugging
168
169
  config.onPromptAssembled?.(context.systemPrompt, task);
@@ -51,7 +51,7 @@ export function assembleContext(
51
51
  task: string,
52
52
  sourceRole: string,
53
53
  orgTree: OrgTree,
54
- options?: { teamStatus?: TeamStatus; targetRoles?: string[] },
54
+ options?: { teamStatus?: TeamStatus; targetRoles?: string[]; presetId?: string },
55
55
  ): AssembledContext {
56
56
  const node = orgTree.nodes.get(roleId);
57
57
  if (!node) {
@@ -123,6 +123,14 @@ Use the code repository path for all source code work (reading, writing, buildin
123
123
  sections.push(preKSection);
124
124
  }
125
125
 
126
+ // 11. Preset Knowledge (wave-scoped preset docs)
127
+ if (options?.presetId && options.presetId !== 'default') {
128
+ const presetKnowledge = loadPresetKnowledge(companyRoot, options.presetId);
129
+ if (presetKnowledge) {
130
+ sections.push('# Preset Knowledge\n\n' + presetKnowledge);
131
+ }
132
+ }
133
+
126
134
  // Task는 별도 필드로 분리
127
135
  let subordinates = getSubordinates(orgTree, roleId);
128
136
 
@@ -262,6 +270,27 @@ ${docList}
262
270
  > **Knowledging Rule**: Check these documents first. If your work produces new knowledge, update existing docs or create new ones with cross-links.`;
263
271
  }
264
272
 
273
+ /**
274
+ * Load knowledge docs from a preset's knowledge/ directory.
275
+ * Returns concatenated content (capped at 2000 chars per doc).
276
+ */
277
+ function loadPresetKnowledge(companyRoot: string, presetId: string): string | null {
278
+ const knowledgeDir = path.join(companyRoot, 'company', 'presets', presetId, 'knowledge');
279
+ if (!fs.existsSync(knowledgeDir)) return null;
280
+
281
+ const parts: string[] = [];
282
+ try {
283
+ const entries = fs.readdirSync(knowledgeDir).filter(f => f.endsWith('.md'));
284
+ for (const file of entries.slice(0, 10)) { // Cap at 10 docs
285
+ const content = fs.readFileSync(path.join(knowledgeDir, file), 'utf-8');
286
+ const preview = content.slice(0, 2000);
287
+ parts.push(`## ${file}\n\n${preview}${content.length > 2000 ? '\n\n... (truncated)' : ''}`);
288
+ }
289
+ } catch { /* ignore */ }
290
+
291
+ return parts.length > 0 ? parts.join('\n\n---\n\n') : null;
292
+ }
293
+
265
294
  function loadCompanyRules(companyRoot: string): string | null {
266
295
  const parts: string[] = [];
267
296
 
@@ -85,7 +85,7 @@ interface RawRoleYaml {
85
85
 
86
86
  /* ─── Build ──────────────────────────────────── */
87
87
 
88
- export function buildOrgTree(companyRoot: string): OrgTree {
88
+ export function buildOrgTree(companyRoot: string, presetId?: string): OrgTree {
89
89
  const rolesDir = path.join(companyRoot, 'roles');
90
90
  const tree: OrgTree = { root: 'ceo', nodes: new Map() };
91
91
 
@@ -102,53 +102,68 @@ export function buildOrgTree(companyRoot: string): OrgTree {
102
102
  reports: { daily: '', weekly: '' },
103
103
  });
104
104
 
105
- if (!fs.existsSync(rolesDir)) return tree;
106
-
107
- // Read all role.yaml files
108
- const entries = fs.readdirSync(rolesDir, { withFileTypes: true });
109
- for (const entry of entries) {
110
- if (!entry.isDirectory()) continue;
111
- const yamlPath = path.join(rolesDir, entry.name, 'role.yaml');
112
- if (!fs.existsSync(yamlPath)) continue;
113
-
114
- try {
115
- const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as RawRoleYaml;
116
- const node: OrgNode = {
117
- id: raw.id || entry.name,
118
- name: raw.name || entry.name,
119
- level: (raw.level as OrgNode['level']) || 'member',
120
- reportsTo: (raw.reports_to || 'ceo').toLowerCase(),
121
- children: [],
122
- persona: raw.persona || '',
123
- authority: {
124
- autonomous: raw.authority?.autonomous ?? [],
125
- needsApproval: raw.authority?.needs_approval ?? [],
126
- },
127
- knowledge: {
128
- reads: raw.knowledge?.reads ?? [],
129
- writes: raw.knowledge?.writes ?? [],
130
- },
131
- reports: {
132
- daily: raw.reports?.daily ?? '',
133
- weekly: raw.reports?.weekly ?? '',
134
- },
135
- skills: raw.skills,
136
- model: raw.model,
137
- source: raw.source ? {
138
- id: raw.source.id || '',
139
- sync: (raw.source.sync as RoleSource['sync']) || 'manual',
140
- forked_at: raw.source.forked_at,
141
- upstream_version: raw.source.upstream_version,
142
- } : undefined,
143
- heartbeat: raw.heartbeat ? {
144
- enabled: raw.heartbeat.enabled ?? false,
145
- intervalSec: raw.heartbeat.intervalSec ?? 120,
146
- maxTicks: raw.heartbeat.maxTicks ?? 60,
147
- } : undefined,
148
- };
149
- tree.nodes.set(node.id, node);
150
- } catch {
151
- // Skip malformed YAML
105
+ // Collect role directories to scan: base roles/ + preset roles/
106
+ const roleDirs: string[] = [];
107
+ if (fs.existsSync(rolesDir)) roleDirs.push(rolesDir);
108
+
109
+ // If preset specified, also scan preset's roles directory
110
+ if (presetId && presetId !== 'default') {
111
+ const presetRolesDir = path.join(companyRoot, 'company', 'presets', presetId, 'roles');
112
+ if (fs.existsSync(presetRolesDir)) roleDirs.push(presetRolesDir);
113
+ }
114
+
115
+ // Read all role.yaml files from all role directories
116
+ for (const dir of roleDirs) {
117
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
118
+ for (const entry of entries) {
119
+ if (!entry.isDirectory()) continue;
120
+ const yamlPath = path.join(dir, entry.name, 'role.yaml');
121
+ if (!fs.existsSync(yamlPath)) continue;
122
+
123
+ try {
124
+ const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as RawRoleYaml;
125
+ const nodeId = raw.id || entry.name;
126
+
127
+ // Skip if already loaded (base roles take precedence over preset roles)
128
+ if (tree.nodes.has(nodeId)) continue;
129
+
130
+ const node: OrgNode = {
131
+ id: nodeId,
132
+ name: raw.name || entry.name,
133
+ level: (raw.level as OrgNode['level']) || 'member',
134
+ reportsTo: (raw.reports_to || 'ceo').toLowerCase(),
135
+ children: [],
136
+ persona: raw.persona || '',
137
+ authority: {
138
+ autonomous: raw.authority?.autonomous ?? [],
139
+ needsApproval: raw.authority?.needs_approval ?? [],
140
+ },
141
+ knowledge: {
142
+ reads: raw.knowledge?.reads ?? [],
143
+ writes: raw.knowledge?.writes ?? [],
144
+ },
145
+ reports: {
146
+ daily: raw.reports?.daily ?? '',
147
+ weekly: raw.reports?.weekly ?? '',
148
+ },
149
+ skills: raw.skills,
150
+ model: raw.model,
151
+ source: raw.source ? {
152
+ id: raw.source.id || '',
153
+ sync: (raw.source.sync as RoleSource['sync']) || 'manual',
154
+ forked_at: raw.source.forked_at,
155
+ upstream_version: raw.source.upstream_version,
156
+ } : undefined,
157
+ heartbeat: raw.heartbeat ? {
158
+ enabled: raw.heartbeat.enabled ?? false,
159
+ intervalSec: raw.heartbeat.intervalSec ?? 120,
160
+ maxTicks: raw.heartbeat.maxTicks ?? 60,
161
+ } : undefined,
162
+ };
163
+ tree.nodes.set(node.id, node);
164
+ } catch {
165
+ // Skip malformed YAML
166
+ }
152
167
  }
153
168
  }
154
169
 
@@ -231,8 +246,8 @@ export function canConsult(tree: OrgTree, source: string, target: string): boole
231
246
  }
232
247
 
233
248
  /** Refresh tree (re-read all role.yaml files) */
234
- export function refreshOrgTree(companyRoot: string): OrgTree {
235
- return buildOrgTree(companyRoot);
249
+ export function refreshOrgTree(companyRoot: string, presetId?: string): OrgTree {
250
+ return buildOrgTree(companyRoot, presetId);
236
251
  }
237
252
 
238
253
  /** Get a human-readable org chart string for context injection */
@@ -407,7 +407,7 @@ else:
407
407
  */
408
408
  export class ClaudeCliRunner implements ExecutionRunner {
409
409
  execute(config: RunnerConfig, callbacks: RunnerCallbacks): RunnerHandle {
410
- const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments, targetRoles } = config;
410
+ const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments, targetRoles, presetId } = config;
411
411
 
412
412
  // Note: Claude CLI doesn't support inline image attachments.
413
413
  // Images will be ignored with a warning if passed.
@@ -416,7 +416,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
416
416
  }
417
417
 
418
418
  // 1. Context Assembly
419
- const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles });
419
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles, presetId });
420
420
 
421
421
  // Trace: capture assembled prompt for debugging
422
422
  callbacks.onPromptAssembled?.(context.systemPrompt, task);
@@ -45,6 +45,8 @@ export interface RunnerConfig {
45
45
  codeRoot?: string;
46
46
  /** PSM-004: Environment variables to inject (e.g., port assignments) */
47
47
  env?: Record<string, string>;
48
+ /** Wave-scoped preset ID for knowledge injection */
49
+ presetId?: string;
48
50
  /** SV-7: Supervision — abort a running session */
49
51
  onAbortSession?: (sessionId: string) => boolean;
50
52
  /** SV-6: Supervision — amend a running session */
@@ -223,6 +223,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
223
223
 
224
224
  const targetRoles = body.targetRoles as string[] | undefined;
225
225
  const continuous = body.continuous === true;
226
+ const preset = body.preset as string | undefined;
226
227
 
227
228
  // Always use supervisor mode — CEO supervises C-Levels who supervise members
228
229
  {
@@ -231,6 +232,7 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
231
232
  actualDirective,
232
233
  targetRoles && targetRoles.length > 0 ? targetRoles : undefined,
233
234
  continuous,
235
+ preset,
234
236
  );
235
237
 
236
238
  if (state.status === 'error') {
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
1
3
  import { COMPANY_ROOT } from './file-reader.js';
2
4
  import { ActivityStream, type ActivityEvent } from './activity-stream.js';
3
5
  import { buildOrgTree } from '../engine/org-tree.js';
@@ -116,7 +118,20 @@ class ExecutionManager {
116
118
 
117
119
  startExecution(params: StartExecutionParams): Execution {
118
120
  const execId = `exec-${Date.now()}-${this.nextId++}`;
119
- const orgTree = buildOrgTree(COMPANY_ROOT);
121
+
122
+ // Resolve preset from wave file for org tree building
123
+ let presetId: string | undefined;
124
+ const session = getSession(params.sessionId);
125
+ if (session?.waveId) {
126
+ try {
127
+ const wavePath = path.join(COMPANY_ROOT, 'operations', 'waves', `${session.waveId}.json`);
128
+ if (fs.existsSync(wavePath)) {
129
+ const waveData = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
130
+ presetId = waveData.preset;
131
+ }
132
+ } catch { /* ignore */ }
133
+ }
134
+ const orgTree = buildOrgTree(COMPANY_ROOT, presetId);
120
135
 
121
136
  // Authority gate
122
137
  if (params.sourceRole && params.sourceRole !== 'ceo') {
@@ -243,6 +258,7 @@ class ExecutionManager {
243
258
  sessionId: params.sessionId,
244
259
  teamStatus,
245
260
  targetRoles: params.targetRoles,
261
+ presetId,
246
262
  codeRoot: resolveCodeRoot(COMPANY_ROOT),
247
263
  attachments: params.attachments,
248
264
  env: {
@@ -676,7 +692,18 @@ class ExecutionManager {
676
692
  if (runningChildren.length === 0) return;
677
693
 
678
694
  // Only restart C-Level roles (CTO, CBO etc.)
679
- const orgTree = buildOrgTree(COMPANY_ROOT);
695
+ // Resolve preset from wave file for correct org tree
696
+ let recoveryPresetId: string | undefined;
697
+ const deadSession = getSession(deadExecution.sessionId);
698
+ if (deadSession?.waveId) {
699
+ try {
700
+ const wp = path.join(COMPANY_ROOT, 'operations', 'waves', `${deadSession.waveId}.json`);
701
+ if (fs.existsSync(wp)) {
702
+ recoveryPresetId = JSON.parse(fs.readFileSync(wp, 'utf-8')).preset;
703
+ }
704
+ } catch { /* ignore */ }
705
+ }
706
+ const orgTree = buildOrgTree(COMPANY_ROOT, recoveryPresetId);
680
707
  const node = orgTree.nodes.get(deadExecution.roleId);
681
708
  if (!node || node.level !== 'c-level') return;
682
709
 
@@ -319,7 +319,7 @@ export function scaffold(config: ScaffoldConfig): string[] {
319
319
  'operations/decisions', 'operations/activity-streams',
320
320
  'operations/sessions', 'operations/cost',
321
321
  'knowledge', 'methodologies', '.claude/skills',
322
- '.claude/skills/_shared', '.tycono',
322
+ '.claude/skills/_shared', '.tycono', 'company/presets',
323
323
  ];
324
324
  for (const dir of dirs) {
325
325
  fs.mkdirSync(path.join(root, dir), { recursive: true });
@@ -28,6 +28,7 @@ interface SupervisorState {
28
28
  directive: string;
29
29
  targetRoles?: string[];
30
30
  continuous: boolean;
31
+ preset?: string;
31
32
  supervisorSessionId: string | null;
32
33
  executionId: string | null;
33
34
  status: 'starting' | 'running' | 'restarting' | 'stopped' | 'error';
@@ -66,7 +67,7 @@ class SupervisorHeartbeat {
66
67
  * This creates a supervisor session and starts an execution.
67
68
  * If the execution dies, it auto-restarts (heartbeat).
68
69
  */
69
- start(waveId: string, directive: string, targetRoles?: string[], continuous = false): SupervisorState {
70
+ start(waveId: string, directive: string, targetRoles?: string[], continuous = false, preset?: string): SupervisorState {
70
71
  // Check if supervisor already running for this wave
71
72
  const existing = this.supervisors.get(waveId);
72
73
  if (existing && (existing.status === 'running' || existing.status === 'starting')) {
@@ -79,6 +80,7 @@ class SupervisorHeartbeat {
79
80
  directive,
80
81
  targetRoles,
81
82
  continuous,
83
+ preset,
82
84
  supervisorSessionId: null,
83
85
  executionId: null,
84
86
  status: 'starting',
@@ -100,7 +102,7 @@ class SupervisorHeartbeat {
100
102
  }
101
103
 
102
104
  // Save wave file immediately so directive persists across restarts
103
- this.saveWaveFile(waveId, directive);
105
+ this.saveWaveFile(waveId, directive, preset);
104
106
 
105
107
  this.spawnSupervisor(state);
106
108
  return state;
@@ -110,20 +112,22 @@ class SupervisorHeartbeat {
110
112
  * Save wave file immediately so directive persists across restarts.
111
113
  * saveCompletedWave() adds session/role details on completion.
112
114
  */
113
- private saveWaveFile(waveId: string, directive: string): void {
115
+ private saveWaveFile(waveId: string, directive: string, preset?: string): void {
114
116
  try {
115
117
  const wavesDir = path.join(COMPANY_ROOT, 'operations', 'waves');
116
118
  if (!fs.existsSync(wavesDir)) fs.mkdirSync(wavesDir, { recursive: true });
117
119
  const wavePath = path.join(wavesDir, `${waveId}.json`);
118
120
  if (!fs.existsSync(wavePath)) {
119
- fs.writeFileSync(wavePath, JSON.stringify({
121
+ const waveData: Record<string, unknown> = {
120
122
  id: waveId,
121
123
  waveId,
122
124
  directive,
123
125
  startedAt: new Date().toISOString(),
124
126
  sessionIds: [],
125
127
  roles: [],
126
- }, null, 2));
128
+ };
129
+ if (preset) waveData.preset = preset;
130
+ fs.writeFileSync(wavePath, JSON.stringify(waveData, null, 2));
127
131
  console.log(`[Supervisor] Wave file created: ${wavePath}`);
128
132
  }
129
133
  } catch (err) {
@@ -510,7 +514,7 @@ Do NOT dispatch anyone. Do NOT create new files. Just answer concisely.`;
510
514
  /* ─── Internal: Spawn / Restart ────────────── */
511
515
 
512
516
  private spawnSupervisor(state: SupervisorState): void {
513
- const orgTree = buildOrgTree(COMPANY_ROOT);
517
+ const orgTree = buildOrgTree(COMPANY_ROOT, state.preset);
514
518
  let cLevelRoles = getSubordinates(orgTree, 'ceo');
515
519
 
516
520
  if (state.targetRoles && state.targetRoles.length > 0) {
@@ -304,7 +304,16 @@ export function saveCompletedWave(waveId: string, directive: string): { ok: bool
304
304
  }
305
305
  }
306
306
 
307
- const waveJson = {
307
+ // Preserve preset field from existing wave file
308
+ let existingPreset: string | undefined;
309
+ if (existing) {
310
+ try {
311
+ const existingData = JSON.parse(fs.readFileSync(existing, 'utf-8'));
312
+ existingPreset = existingData.preset;
313
+ } catch { /* ignore */ }
314
+ }
315
+
316
+ const waveJson: Record<string, unknown> = {
308
317
  id: baseName,
309
318
  directive,
310
319
  startedAt: startedAt.toISOString(),
@@ -313,6 +322,7 @@ export function saveCompletedWave(waveId: string, directive: string): { ok: bool
313
322
  waveId,
314
323
  sessionIds: allSessionIds,
315
324
  };
325
+ if (existingPreset) waveJson.preset = existingPreset;
316
326
  fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
317
327
 
318
328
  const relativePath = `operations/waves/${baseName}.json`;
package/src/tui/app.tsx CHANGED
@@ -21,7 +21,7 @@ import { useApi } from './hooks/useApi';
21
21
  import { useSSE } from './hooks/useSSE';
22
22
  import { useCommand, type WaveInfo } from './hooks/useCommand';
23
23
  import { dispatchWave } from './api';
24
- import type { ActiveSessionInfo } from './api';
24
+ import type { ActiveSessionInfo, PresetSummary } from './api';
25
25
  import { buildOrgTree, flattenOrgRoleIds } from './store';
26
26
 
27
27
  type Mode = 'command' | 'panel';
@@ -231,6 +231,10 @@ export const App: React.FC = () => {
231
231
  // System messages (command feedback displayed in stream area)
232
232
  const [systemMessages, setSystemMessages] = useState<StreamLine[]>([]);
233
233
 
234
+ // Preset selection state (for /new without args)
235
+ const [pendingPresetSelect, setPendingPresetSelect] = useState<PresetSummary[] | null>(null);
236
+ const selectedPresetRef = useRef<string | null>(null);
237
+
234
238
  // Terminal full height with resize tracking (minus 1 for wide-char overflow safety)
235
239
  const [termHeight, setTermHeight] = useState((process.stdout.rows || 30) - 1);
236
240
 
@@ -337,21 +341,24 @@ export const App: React.FC = () => {
337
341
  return waves.find(w => w.waveId === focusedWaveId)?.startedAt ?? 0;
338
342
  }, [focusedWaveId, waves]);
339
343
 
344
+ // Wave creation callback — shared by useCommand and preset selection flow
345
+ const onWaveCreated = useCallback((newWaveId: string, directive: string) => {
346
+ const newWave: WaveInfo = {
347
+ waveId: newWaveId,
348
+ directive,
349
+ startedAt: Date.now(),
350
+ };
351
+ setWaves(prev => [...prev, newWave]);
352
+ setFocusedWaveId(newWaveId);
353
+ sse.clearEvents();
354
+ api.refresh();
355
+ }, [sse, api]);
356
+
340
357
  // Command handler
341
358
  const { execute } = useCommand({
342
359
  focusedWaveId,
343
360
  waves,
344
- onWaveCreated: (newWaveId, directive) => {
345
- const newWave: WaveInfo = {
346
- waveId: newWaveId,
347
- directive,
348
- startedAt: Date.now(),
349
- };
350
- setWaves(prev => [...prev, newWave]);
351
- setFocusedWaveId(newWaveId);
352
- sse.clearEvents();
353
- api.refresh();
354
- },
361
+ onWaveCreated,
355
362
  onFocusWave: (waveId) => {
356
363
  setFocusedWaveId(waveId);
357
364
  sse.clearEvents();
@@ -366,6 +373,44 @@ export const App: React.FC = () => {
366
373
  const handleCommandSubmit = useCallback(async (input: string) => {
367
374
  // User input is already shown by CommandMode (immediate commit to Static)
368
375
 
376
+ // Preset selection mode: user types a number to pick preset
377
+ if (pendingPresetSelect) {
378
+ const trimmed = input.trim();
379
+ const idx = parseInt(trimmed, 10);
380
+ if (!isNaN(idx) && idx >= 1 && idx <= pendingPresetSelect.length) {
381
+ const selected = pendingPresetSelect[idx - 1];
382
+ setPendingPresetSelect(null);
383
+ addSystemMessage(`Selected: ${selected.name}. Type your directive:`, 'cyan');
384
+ // Store selected preset for next input
385
+ selectedPresetRef.current = selected.id;
386
+ return;
387
+ }
388
+ // If user typed text instead of number, treat as directive with selected/default preset
389
+ const presetId = selectedPresetRef.current || 'default';
390
+ setPendingPresetSelect(null);
391
+ selectedPresetRef.current = null;
392
+ try {
393
+ const waveResult = await dispatchWave(trimmed || undefined, { preset: presetId });
394
+ onWaveCreated(waveResult.waveId, trimmed);
395
+ } catch (err) {
396
+ addSystemMessage(`Wave failed: ${err instanceof Error ? err.message : 'unknown'}`, 'red');
397
+ }
398
+ return;
399
+ }
400
+
401
+ // If a preset was selected previously, this input is the directive
402
+ if (selectedPresetRef.current) {
403
+ const presetId = selectedPresetRef.current;
404
+ selectedPresetRef.current = null;
405
+ try {
406
+ const waveResult = await dispatchWave(input.trim() || undefined, { preset: presetId });
407
+ onWaveCreated(waveResult.waveId, input.trim());
408
+ } catch (err) {
409
+ addSystemMessage(`Wave failed: ${err instanceof Error ? err.message : 'unknown'}`, 'red');
410
+ }
411
+ return;
412
+ }
413
+
369
414
  const result = await execute(input);
370
415
 
371
416
  switch (result.type) {
@@ -513,6 +558,7 @@ export const App: React.FC = () => {
513
558
  addSystemMessage(' /sessions Sessions + ports (kill/cleanup)', 'white');
514
559
  addSystemMessage(' /kill <id> Kill a session', 'white');
515
560
  addSystemMessage(' /cleanup Remove dead sessions', 'white');
561
+ addSystemMessage(' /preset list Installed presets', 'white');
516
562
  addSystemMessage(' /help This help', 'white');
517
563
  addSystemMessage(' /quit Exit', 'white');
518
564
  addSystemMessage('Keys: [Tab] team panel [1-9] wave [Esc] back [Ctrl+C] quit', 'gray');
@@ -526,6 +572,46 @@ export const App: React.FC = () => {
526
572
  addSystemMessage(`Sessions: ${api.sessions.length} Active: ${activeCount} Waves: ${waves.length} Ports: ${api.portSummary.totalPorts}`, 'white');
527
573
  }
528
574
  break;
575
+ case 'preset_list': {
576
+ const presets = result.presets ?? [];
577
+ if (presets.length === 0) {
578
+ addSystemMessage('No presets installed.', 'gray');
579
+ } else {
580
+ addSystemMessage('Installed presets:', 'cyan');
581
+ for (const p of presets) {
582
+ const star = p.isDefault ? ' \u2605' : '';
583
+ const desc = p.description ? ` \u2014 ${p.description}` : '';
584
+ addSystemMessage(` ${p.id} (${p.rolesCount} roles)${desc}${star}`, p.isDefault ? 'green' : 'white');
585
+ }
586
+ }
587
+ break;
588
+ }
589
+ case 'preset_select': {
590
+ const presets = result.presets ?? [];
591
+ if (presets.length === 0) {
592
+ addSystemMessage('No presets. Creating wave with default team.', 'gray');
593
+ try {
594
+ const waveResult = await dispatchWave();
595
+ onWaveCreated(waveResult.waveId, '');
596
+ } catch { /* ignore */ }
597
+ } else if (presets.length === 1) {
598
+ // Only default → show prompt to enter directive
599
+ addSystemMessage('Only default preset available. Type your directive:', 'gray');
600
+ } else {
601
+ // Multiple presets → show selection
602
+ addSystemMessage('Select a team preset for this wave:', 'cyan');
603
+ for (let i = 0; i < presets.length; i++) {
604
+ const p = presets[i];
605
+ const star = p.isDefault ? ' \u2605' : '';
606
+ const desc = p.description ? ` \u2014 ${p.description}` : '';
607
+ addSystemMessage(` ${i + 1}. ${p.name} (${p.rolesCount} roles)${desc}${star}`, p.isDefault ? 'green' : 'white');
608
+ }
609
+ addSystemMessage('Type a number to select, then enter your directive.', 'gray');
610
+ // Store presets for number selection — handled via pendingPresetSelect
611
+ setPendingPresetSelect(presets);
612
+ }
613
+ break;
614
+ }
529
615
  case 'panel':
530
616
  break;
531
617
  case 'quit':
@@ -535,7 +621,7 @@ export const App: React.FC = () => {
535
621
  addSystemMessage(result.message, 'green');
536
622
  }
537
623
  }
538
- }, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary]);
624
+ }, [execute, addSystemMessage, addSystemLines, focusedWaveId, focusedWaveIndex, derivedWaveStatus, api.sessions.length, activeCount, waves, api.activeSessions, api.portSummary, pendingPresetSelect, onWaveCreated]);
539
625
 
540
626
  // Global key handler: Tab to toggle mode, Ctrl+C always exits
541
627
  useInput((input, key) => {
@@ -299,6 +299,7 @@ const COMMANDS: Array<{ cmd: string; desc: string }> = [
299
299
  { cmd: '/sessions', desc: 'Active sessions' },
300
300
  { cmd: '/kill <id>', desc: 'Kill session' },
301
301
  { cmd: '/cleanup', desc: 'Remove dead' },
302
+ { cmd: '/preset list', desc: 'Installed presets' },
302
303
  { cmd: '/help', desc: 'Help' },
303
304
  { cmd: '/quit', desc: 'Exit' },
304
305
  ];
@@ -139,7 +139,7 @@ function readFilePreview(filePath: string, maxLines: number): string[] {
139
139
  }
140
140
  }
141
141
 
142
- export const PanelMode: React.FC<PanelModeProps> = ({
142
+ const PanelModeInner: React.FC<PanelModeProps> = ({
143
143
  tree, flatRoles, events, selectedRoleIndex, selectedRoleId,
144
144
  streamStatus, waveId, activeSessions, allSessions, companyRoot, waves,
145
145
  focusedWaveId, onMove, onSelect, onEscape, onFocusWave,
@@ -338,6 +338,19 @@ export const PanelMode: React.FC<PanelModeProps> = ({
338
338
  ? allSessions.filter(s => s.waveId === focusedWaveId).length
339
339
  : 0;
340
340
 
341
+ // Read preset from wave file on disk
342
+ const wavePreset = useMemo(() => {
343
+ if (!focusedWaveId || !companyRoot) return null;
344
+ try {
345
+ const wavePath = path.join(companyRoot, 'operations', 'waves', `${focusedWaveId}.json`);
346
+ if (fs.existsSync(wavePath)) {
347
+ const data = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
348
+ return data.preset as string | undefined;
349
+ }
350
+ } catch { /* ignore */ }
351
+ return null;
352
+ }, [focusedWaveId, companyRoot]);
353
+
341
354
  const leftWidth = 28;
342
355
 
343
356
  return (
@@ -347,6 +360,9 @@ export const PanelMode: React.FC<PanelModeProps> = ({
347
360
  <Box flexDirection="column" width={leftWidth}>
348
361
  <Box paddingX={1}>
349
362
  <Text color="green" bold>W{focusedWaveIndex}</Text>
363
+ {wavePreset && wavePreset !== 'default' && (
364
+ <Text color="magenta"> ({wavePreset})</Text>
365
+ )}
350
366
  <Text color="gray"> </Text>
351
367
  <Text color="white" wrap="truncate">
352
368
  {focusedWave?.directive ? focusedWave.directive.slice(0, leftWidth - 6) : '(idle)'}
@@ -505,6 +521,7 @@ export const PanelMode: React.FC<PanelModeProps> = ({
505
521
  <Text bold color="cyan">Wave Info</Text>
506
522
  <Text color="gray">{'\u2500'.repeat(40)}</Text>
507
523
  <Text color="white">Wave: {focusedWave?.waveId ?? 'none'}</Text>
524
+ {wavePreset && <Text color="magenta">Preset: {wavePreset}</Text>}
508
525
  <Text color="white">Directive: {focusedWave?.directive || '(idle)'}</Text>
509
526
  <Text color="white">Sessions: {waveSessionCount}</Text>
510
527
  <Text color="white">Files modified: {waveFileSet.size}</Text>
@@ -546,3 +563,5 @@ export const PanelMode: React.FC<PanelModeProps> = ({
546
563
  </Box>
547
564
  );
548
565
  };
566
+
567
+ export const PanelMode = React.memo(PanelModeInner);
@@ -116,7 +116,7 @@ function renderEvent(event: SSEEvent): { content: string; contentColor: string }
116
116
  }
117
117
  }
118
118
 
119
- export const StreamView: React.FC<StreamViewProps> = ({
119
+ const StreamViewInner: React.FC<StreamViewProps> = ({
120
120
  events,
121
121
  allRoleIds,
122
122
  streamStatus,
@@ -162,10 +162,12 @@ export const StreamView: React.FC<StreamViewProps> = ({
162
162
  <Box key={`${event.seq}-${i}`}>
163
163
  <Text color="gray" dimColor>{formatTime(event.ts)} </Text>
164
164
  <Text color={roleColor} bold>{event.roleId.padEnd(12)}</Text>
165
- <Text color={rendered.contentColor} wrap="wrap">{rendered.content}</Text>
165
+ <Text color={rendered.contentColor} wrap="truncate">{rendered.content}</Text>
166
166
  </Box>
167
167
  );
168
168
  })}
169
169
  </Box>
170
170
  );
171
171
  };
172
+
173
+ export const StreamView = React.memo(StreamViewInner);