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 +1 -1
- package/src/api/src/engine/agent-loop.ts +2 -1
- package/src/api/src/engine/context-assembler.ts +30 -1
- package/src/api/src/engine/org-tree.ts +65 -50
- package/src/api/src/engine/runners/claude-cli.ts +2 -2
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/routes/execute.ts +2 -0
- package/src/api/src/services/execution-manager.ts +29 -2
- package/src/api/src/services/scaffold.ts +1 -1
- package/src/api/src/services/supervisor-heartbeat.ts +10 -6
- package/src/api/src/services/wave-tracker.ts +11 -1
- package/src/tui/app.tsx +99 -13
- package/src/tui/components/CommandMode.tsx +1 -0
- package/src/tui/components/PanelMode.tsx +20 -1
- package/src/tui/components/StreamView.tsx +4 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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);
|