tycono-server 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,1036 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { COMPANY_ROOT } from './file-reader.js';
4
+ import { ActivityStream, type ActivityEvent } from './activity-stream.js';
5
+ import { buildOrgTree } from '../engine/org-tree.js';
6
+ import { validateDispatch, validateConsult } from '../engine/authority-validator.js';
7
+ import { createRunner } from '../engine/runners/index.js';
8
+ import type { ExecutionRunner } from '../engine/runners/types.js';
9
+ // activity-tracker removed — executionManager is Single Source of Truth for role status
10
+ import type { RunnerResult } from '../engine/runners/types.js';
11
+ import { estimateCost } from './pricing.js';
12
+ import { readConfig, getConversationLimits, resolveCodeRoot } from './company-config.js';
13
+ import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
14
+ import { earnCoinsInternal } from '../routes/coins.js';
15
+ import { getSession, createSession, addMessage, updateMessage as updateSessionMessage, updateSession, appendMessageEvent, type Message, type ImageAttachment } from './session-store.js';
16
+ import { portRegistry, type PortAllocation } from './port-registry.js';
17
+ import { type MessageStatus, isMessageActive, canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
18
+
19
+ /* ─── Types ─── */
20
+
21
+ export type ExecStatus = 'idle' | 'running' | 'done' | 'error' | 'awaiting_input' | 'interrupted';
22
+ export type ExecType = 'assign' | 'wave' | 'consult';
23
+
24
+ export { canTransition, messageStatusToRoleStatus } from '../../../shared/types.js';
25
+
26
+ export interface Execution {
27
+ id: string;
28
+ sessionId: string;
29
+ type: ExecType;
30
+ roleId: string;
31
+ task: string;
32
+ status: ExecStatus;
33
+ stream: ActivityStream;
34
+ abort: () => void;
35
+ parentSessionId?: string;
36
+ childSessionIds: string[];
37
+ createdAt: string;
38
+ result?: RunnerResult;
39
+ error?: string;
40
+ targetRole?: string;
41
+ targetRoles?: string[];
42
+ knowledgeDebt?: KnowledgeDebtItem[];
43
+ ports?: PortAllocation;
44
+ traceId?: string;
45
+ /** CLI session ID for --resume (captured from Claude CLI result event) */
46
+ cliSessionId?: string;
47
+ }
48
+
49
+ export interface StartExecutionParams {
50
+ type: ExecType;
51
+ roleId: string;
52
+ task: string;
53
+ sourceRole?: string;
54
+ readOnly?: boolean;
55
+ parentSessionId?: string;
56
+ model?: string;
57
+ isContinuation?: boolean;
58
+ targetRoles?: string[];
59
+ sessionId: string;
60
+ attachments?: ImageAttachment[];
61
+ /** CLI session ID for --resume (context continuity across turn limits) */
62
+ cliSessionId?: string;
63
+ }
64
+
65
+ /* ─── Helpers ────────────────────────────── */
66
+
67
+ function summarizeInput(input: Record<string, unknown>): Record<string, unknown> {
68
+ const summary: Record<string, unknown> = {};
69
+ for (const [key, value] of Object.entries(input)) {
70
+ if (typeof value === 'string' && value.length > 200) {
71
+ summary[key] = value.slice(0, 200) + '...';
72
+ } else {
73
+ summary[key] = value;
74
+ }
75
+ }
76
+ return summary;
77
+ }
78
+
79
+ function hasQuestion(output: string): boolean {
80
+ const lastBlock = output.trim().split('\n').slice(-5).join('\n');
81
+ return /\?\s*$/.test(lastBlock) || /할까요|해볼까요|어떨까요|확인.*필요/.test(lastBlock);
82
+ }
83
+
84
+ function isExecActive(status: ExecStatus): boolean {
85
+ return status === 'running' || status === 'awaiting_input';
86
+ }
87
+
88
+ function resolveTargetRole(sourceRole: string | undefined, parentSessionId: string | undefined, executions: Map<string, Execution>): string {
89
+ if (sourceRole && sourceRole !== 'ceo') return sourceRole;
90
+
91
+ if (parentSessionId) {
92
+ for (const exec of executions.values()) {
93
+ if (exec.sessionId === parentSessionId && exec.roleId !== 'ceo') {
94
+ return exec.roleId;
95
+ }
96
+ }
97
+ }
98
+
99
+ return 'ceo';
100
+ }
101
+
102
+ /* ─── ExecutionManager Singleton ───────────────── */
103
+
104
+ class ExecutionManager {
105
+ private executions = new Map<string, Execution>();
106
+ private runner = createRunner();
107
+ private nextId = 1;
108
+ private executionCreatedListeners = new Set<(exec: Execution) => void>();
109
+
110
+ setRunner(newRunner: ExecutionRunner): void {
111
+ this.runner = newRunner;
112
+ }
113
+
114
+ refreshRunner(): void {
115
+ this.runner = createRunner();
116
+ }
117
+
118
+ onExecutionCreated(listener: (exec: Execution) => void): () => void {
119
+ this.executionCreatedListeners.add(listener);
120
+ return () => { this.executionCreatedListeners.delete(listener); };
121
+ }
122
+
123
+ startExecution(params: StartExecutionParams): Execution {
124
+ const execId = `exec-${Date.now()}-${this.nextId++}`;
125
+
126
+ // Resolve preset from wave file for org tree building
127
+ let presetId: string | undefined;
128
+ const session = getSession(params.sessionId);
129
+ if (session?.waveId) {
130
+ try {
131
+ const wavePath = path.join(COMPANY_ROOT, '.tycono', 'waves', `${session.waveId}.json`);
132
+ if (fs.existsSync(wavePath)) {
133
+ const waveData = JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
134
+ presetId = waveData.preset;
135
+ }
136
+ } catch { /* ignore */ }
137
+ }
138
+ const orgTree = buildOrgTree(COMPANY_ROOT, presetId);
139
+
140
+ // Authority gate
141
+ if (params.sourceRole && params.sourceRole !== 'ceo') {
142
+ if (params.type === 'consult') {
143
+ const auth = validateConsult(orgTree, params.sourceRole, params.roleId);
144
+ if (!auth.allowed) {
145
+ throw new Error(`Authority denied: ${auth.reason}`);
146
+ }
147
+ } else if (params.type === 'assign' && params.parentSessionId) {
148
+ const auth = validateDispatch(orgTree, params.sourceRole, params.roleId);
149
+ if (!auth.allowed) {
150
+ throw new Error(`Authority denied: ${auth.reason}`);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Resolve traceId: root sessions use their own sessionId, children inherit from parent
156
+ let traceId = params.sessionId;
157
+ if (params.parentSessionId) {
158
+ // Find the root trace by walking up the parent chain
159
+ for (const exec of this.executions.values()) {
160
+ if (exec.sessionId === params.parentSessionId) {
161
+ traceId = exec.traceId ?? params.parentSessionId;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+
167
+ const stream = ActivityStream.getOrCreate(params.sessionId, params.roleId, params.parentSessionId, traceId);
168
+
169
+ const execution: Execution = {
170
+ id: execId,
171
+ sessionId: params.sessionId,
172
+ type: params.type,
173
+ roleId: params.roleId,
174
+ task: params.task,
175
+ status: 'running',
176
+ stream,
177
+ abort: () => {},
178
+ parentSessionId: params.parentSessionId,
179
+ childSessionIds: [],
180
+ createdAt: new Date().toISOString(),
181
+ targetRoles: params.targetRoles,
182
+ traceId,
183
+ };
184
+
185
+ this.executions.set(execId, execution);
186
+
187
+ this.initializeAndRunExecution(execution, params, orgTree, presetId);
188
+
189
+ return execution;
190
+ }
191
+
192
+ private async initializeAndRunExecution(
193
+ execution: Execution,
194
+ params: StartExecutionParams,
195
+ orgTree: ReturnType<typeof buildOrgTree>,
196
+ presetId?: string,
197
+ ): Promise<void> {
198
+ try {
199
+ const ports = await portRegistry.allocate(execution.sessionId || execution.id, params.roleId, params.task);
200
+ execution.ports = ports;
201
+ console.log(`[ExecMgr] Allocated ports for ${execution.id} (${params.roleId}): API :${ports.api}, Vite :${ports.vite}`);
202
+ } catch (err) {
203
+ console.warn(`[ExecMgr] Port allocation failed for ${execution.id}:`, err);
204
+ }
205
+
206
+ // Emit msg:start
207
+ execution.stream.emit('msg:start', params.roleId, {
208
+ traceId: execution.traceId,
209
+ type: params.type,
210
+ task: params.task,
211
+ sourceRole: params.sourceRole ?? 'ceo',
212
+ sessionId: params.sessionId,
213
+ ...(params.parentSessionId && { parentSessionId: params.parentSessionId }),
214
+ });
215
+
216
+ // If this execution has a parent session, emit dispatch:start on the parent's stream
217
+ if (params.parentSessionId) {
218
+ const parentExec = this.getActiveExecution(params.parentSessionId);
219
+ if (parentExec) {
220
+ parentExec.childSessionIds.push(params.sessionId);
221
+ parentExec.stream.emit('dispatch:start', parentExec.roleId, {
222
+ targetRoleId: params.roleId,
223
+ task: params.task,
224
+ childSessionId: params.sessionId,
225
+ parentSessionId: parentExec.sessionId,
226
+ });
227
+ }
228
+ }
229
+
230
+ const model = params.model ?? orgTree.nodes.get(params.roleId)?.model;
231
+
232
+ const config = readConfig(COMPANY_ROOT);
233
+ const limits = getConversationLimits(config);
234
+ let harnessTurnCount = 0;
235
+ let softLimitWarned = false;
236
+ let hardLimitReached = false;
237
+ const outputChunks: string[] = [];
238
+
239
+ const teamStatus: import('../../../shared/types').TeamStatus = {};
240
+ for (const [, e] of this.executions) {
241
+ if (e.status === 'running' && e.id !== execution.id) {
242
+ teamStatus[e.roleId] = { status: 'working', task: e.task };
243
+ }
244
+ }
245
+
246
+ const portEnv = execution.ports ? {
247
+ API_PORT: String(execution.ports.api),
248
+ PORT: String(execution.ports.api),
249
+ VITE_PORT: String(execution.ports.vite),
250
+ ...(execution.ports.hmr && { VITE_HMR_PORT: String(execution.ports.hmr) }),
251
+ } : {};
252
+
253
+ const handle = this.runner.execute(
254
+ {
255
+ companyRoot: COMPANY_ROOT,
256
+ roleId: params.roleId,
257
+ task: params.task,
258
+ sourceRole: params.sourceRole ?? 'ceo',
259
+ orgTree,
260
+ readOnly: params.readOnly,
261
+ maxTurns: limits.hardLimit,
262
+ model,
263
+ sessionId: params.sessionId,
264
+ teamStatus,
265
+ targetRoles: params.targetRoles,
266
+ presetId,
267
+ codeRoot: resolveCodeRoot(COMPANY_ROOT),
268
+ attachments: params.attachments,
269
+ cliSessionId: params.cliSessionId,
270
+ env: {
271
+ ...process.env,
272
+ ...portEnv,
273
+ },
274
+ // SV-6, SV-7: Supervision callbacks (direct-api runner only)
275
+ onAbortSession: (sessionId: string) => this.abortSession(sessionId),
276
+ onAmendSession: (sessionId: string, instruction: string) => {
277
+ const result = this.continueSession(sessionId, `[SUPERVISION AMENDMENT] ${instruction}`, params.roleId);
278
+ return result !== null;
279
+ },
280
+ },
281
+ {
282
+ onText: (text) => {
283
+ outputChunks.push(text);
284
+ execution.stream.emit('text', params.roleId, { text });
285
+ if (execution.sessionId) {
286
+ this.updateSessionRoleMessage(execution, text);
287
+ }
288
+ },
289
+ onThinking: (text) => {
290
+ execution.stream.emit('thinking', params.roleId, { text });
291
+ if (execution.sessionId) {
292
+ this.embedSessionEvent(execution, 'thinking', { text: text.slice(0, 200) });
293
+ }
294
+ },
295
+ onToolUse: (name, input) => {
296
+ execution.stream.emit('tool:start', params.roleId, {
297
+ name,
298
+ input: input ? summarizeInput(input) : undefined,
299
+ });
300
+ if (execution.sessionId) {
301
+ this.embedSessionEvent(execution, 'tool:start', {
302
+ name,
303
+ input: input ? summarizeInput(input) : undefined,
304
+ });
305
+ }
306
+ },
307
+ onDispatch: (subRoleId, subTask) => {
308
+ if (params.targetRoles && params.targetRoles.length > 0) {
309
+ if (!params.targetRoles.includes(subRoleId)) {
310
+ console.warn(`[ExecMgr] Dispatch blocked: ${params.roleId} → ${subRoleId} (not in targetRoles)`);
311
+ execution.stream.emit('stderr', params.roleId, {
312
+ message: `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`,
313
+ });
314
+ return;
315
+ }
316
+ }
317
+
318
+ // BUG-W02 fix: propagate waveId from parent session to child
319
+ const parentSession = getSession(execution.sessionId);
320
+ const parentWaveId = parentSession?.waveId;
321
+
322
+ const childSession = createSession(subRoleId, {
323
+ mode: 'do',
324
+ source: 'dispatch',
325
+ parentSessionId: execution.sessionId,
326
+ ...(parentWaveId && { waveId: parentWaveId }),
327
+ });
328
+ const dispatchMsg: Message = {
329
+ id: `msg-${Date.now()}-dispatch-${subRoleId}`,
330
+ from: 'ceo',
331
+ content: subTask,
332
+ type: 'directive',
333
+ status: 'done',
334
+ timestamp: new Date().toISOString(),
335
+ };
336
+ addMessage(childSession.id, dispatchMsg);
337
+
338
+ const childExec = this.startExecution({
339
+ type: 'assign',
340
+ roleId: subRoleId,
341
+ task: subTask,
342
+ sourceRole: params.roleId,
343
+ parentSessionId: execution.sessionId,
344
+ targetRoles: params.targetRoles,
345
+ sessionId: childSession.id,
346
+ });
347
+
348
+ const childRoleMsg: Message = {
349
+ id: `msg-${Date.now() + 1}-role-${subRoleId}`,
350
+ from: 'role',
351
+ content: '',
352
+ type: 'conversation',
353
+ status: 'streaming',
354
+ timestamp: new Date().toISOString(),
355
+ };
356
+ addMessage(childSession.id, childRoleMsg, true);
357
+
358
+ if (execution.sessionId) {
359
+ this.embedSessionEvent(execution, 'dispatch:start', {
360
+ roleId: subRoleId,
361
+ task: subTask,
362
+ childSessionId: childSession.id,
363
+ targetRoleId: subRoleId,
364
+ });
365
+ }
366
+ },
367
+ onConsult: (subRoleId, question) => {
368
+ this.startExecution({
369
+ type: 'consult',
370
+ roleId: subRoleId,
371
+ task: `[Consultation from ${params.roleId}] ${question}\n\nAnswer this question based on your role's expertise and knowledge. Be concise and specific.`,
372
+ sourceRole: params.roleId,
373
+ readOnly: true,
374
+ parentSessionId: execution.sessionId,
375
+ sessionId: `ses-consult-${Date.now()}-${subRoleId}`,
376
+ });
377
+ },
378
+ onTurnComplete: (turn) => {
379
+ harnessTurnCount++;
380
+ execution.stream.emit('msg:turn-complete', params.roleId, {
381
+ turn: harnessTurnCount,
382
+ runnerTurn: turn,
383
+ });
384
+
385
+ if (!softLimitWarned && harnessTurnCount >= limits.softLimit) {
386
+ softLimitWarned = true;
387
+ console.warn(
388
+ `[Harness] Exec ${execution.id} (${params.roleId}): turn ${harnessTurnCount} reached softLimit (${limits.softLimit})`,
389
+ );
390
+ execution.stream.emit('turn:warning', params.roleId, {
391
+ turn: harnessTurnCount,
392
+ softLimit: limits.softLimit,
393
+ hardLimit: limits.hardLimit,
394
+ });
395
+ }
396
+
397
+ if (harnessTurnCount >= limits.hardLimit) {
398
+ hardLimitReached = true;
399
+ console.warn(
400
+ `[Harness] Exec ${execution.id} (${params.roleId}): turn ${harnessTurnCount} reached hardLimit (${limits.hardLimit}). Pausing for approval.`,
401
+ );
402
+ execution.stream.emit('turn:limit', params.roleId, {
403
+ turn: harnessTurnCount,
404
+ hardLimit: limits.hardLimit,
405
+ });
406
+ handle.abort();
407
+ }
408
+ },
409
+ onPromptAssembled: (systemPrompt, userTask) => {
410
+ execution.stream.emit('prompt:assembled', params.roleId, {
411
+ systemPrompt,
412
+ userTask,
413
+ systemPromptLength: systemPrompt.length,
414
+ });
415
+ },
416
+ onError: (error) => {
417
+ execution.stream.emit('msg:error', params.roleId, { message: error });
418
+ },
419
+ },
420
+ );
421
+
422
+ execution.abort = handle.abort;
423
+
424
+ // Notify listeners
425
+ for (const listener of this.executionCreatedListeners) {
426
+ try { listener(execution); } catch { /* ignore */ }
427
+ }
428
+
429
+ handle.promise
430
+ .then((result: RunnerResult) => {
431
+ execution.result = result;
432
+ if (result.cliSessionId) execution.cliSessionId = result.cliSessionId;
433
+
434
+ const costUsd = estimateCost(
435
+ result.totalTokens.input,
436
+ result.totalTokens.output,
437
+ model ?? '',
438
+ );
439
+
440
+ execution.stream.emit('trace:response', params.roleId, {
441
+ fullOutput: result.output,
442
+ outputLength: result.output.length,
443
+ turns: result.turns,
444
+ tokens: result.totalTokens,
445
+ });
446
+
447
+ const doneData = {
448
+ output: result.output.slice(-1000),
449
+ turns: result.turns,
450
+ tokens: result.totalTokens,
451
+ costUsd,
452
+ toolCalls: result.toolCalls.length,
453
+ dispatches: result.dispatches.map((d) => ({ roleId: d.roleId, task: d.task })),
454
+ };
455
+
456
+ const targetRole = resolveTargetRole(params.sourceRole, params.parentSessionId, this.executions);
457
+
458
+ if (hardLimitReached) {
459
+ execution.status = 'awaiting_input';
460
+ execution.targetRole = targetRole;
461
+ const question = `[Turn limit] ${harnessTurnCount}턴 도달 (hardLimit: ${limits.hardLimit}). 계속 진행할까요?`;
462
+ execution.stream.emit('msg:awaiting_input', params.roleId, {
463
+ ...doneData,
464
+ question,
465
+ awaitingInput: true,
466
+ targetRole,
467
+ reason: 'turn_limit',
468
+ });
469
+
470
+ // Auto-continue on turn limit: resume with --resume for context continuity
471
+ // Delay slightly to allow stream event to propagate
472
+ setTimeout(() => {
473
+ console.log(`[Harness] Auto-continuing ${params.roleId} (${execution.sessionId}) after turn limit`);
474
+ this.continueSession(execution.sessionId, '턴 한도에 도달했습니다. 이전 작업을 이어서 계속 진행하세요.');
475
+ }, 3_000);
476
+ } else if (!params.isContinuation && hasQuestion(result.output)) {
477
+ execution.status = 'awaiting_input';
478
+ execution.targetRole = targetRole;
479
+ execution.stream.emit('msg:awaiting_input', params.roleId, {
480
+ ...doneData,
481
+ question: result.output.trim().split('\n').slice(-5).join('\n'),
482
+ awaitingInput: true,
483
+ targetRole,
484
+ });
485
+ } else {
486
+ const changedMdFiles = result.toolCalls
487
+ .filter(tc => (tc.name === 'write_file' || tc.name === 'edit_file') && tc.input && typeof tc.input.path === 'string')
488
+ .map(tc => String(tc.input!.path))
489
+ .filter(p => p.endsWith('.md'));
490
+
491
+ if (changedMdFiles.length > 0) {
492
+ try {
493
+ const pkResult = postKnowledgingCheck(COMPANY_ROOT, changedMdFiles);
494
+ if (!pkResult.pass) {
495
+ execution.knowledgeDebt = pkResult.debt;
496
+ console.log(
497
+ `[Post-K] Exec ${execution.id} (${params.roleId}): ${pkResult.debt.length} knowledge debt item(s)`,
498
+ );
499
+ for (const d of pkResult.debt) {
500
+ console.log(` [Post-K] ${d.type}: ${d.message}`);
501
+ }
502
+ (doneData as Record<string, unknown>).knowledgeDebt = pkResult.debt.map(d => ({
503
+ type: d.type,
504
+ file: d.file,
505
+ message: d.message,
506
+ }));
507
+ }
508
+ } catch (err) {
509
+ console.warn('[Post-K] Check failed:', err);
510
+ }
511
+ }
512
+
513
+ execution.status = 'done';
514
+ execution.stream.emit('msg:done', params.roleId, doneData);
515
+ if (execution.sessionId) {
516
+ this.finalizeSessionMessage(execution, 'done', result);
517
+ }
518
+
519
+ if (!params.parentSessionId && result) {
520
+ const totalTokens = (result.totalTokens?.input ?? 0) + (result.totalTokens?.output ?? 0);
521
+ const bonus = Math.min(2000, Math.max(500, Math.round(totalTokens / 500)));
522
+ try {
523
+ earnCoinsInternal(bonus, `Execution done: ${params.roleId}`, `exec:${execution.id}`);
524
+ } catch { /* non-critical */ }
525
+ }
526
+
527
+ this.cleanupOrphanedChildren(execution.sessionId);
528
+ this.attemptSupervisionRecovery(execution);
529
+ }
530
+ })
531
+ .catch((err: Error) => {
532
+ if (hardLimitReached) {
533
+ execution.result = {
534
+ output: outputChunks.join(''),
535
+ turns: harnessTurnCount,
536
+ totalTokens: { input: 0, output: 0 },
537
+ toolCalls: [],
538
+ dispatches: [],
539
+ };
540
+
541
+ const targetRole = resolveTargetRole(params.sourceRole, params.parentSessionId, this.executions);
542
+ execution.status = 'awaiting_input';
543
+ execution.targetRole = targetRole;
544
+ const question = `[Turn limit] ${harnessTurnCount}턴 도달 (hardLimit: ${limits.hardLimit}). 계속 진행할까요?`;
545
+ execution.stream.emit('msg:awaiting_input', params.roleId, {
546
+ question,
547
+ awaitingInput: true,
548
+ targetRole,
549
+ reason: 'turn_limit',
550
+ });
551
+ return;
552
+ }
553
+
554
+ execution.status = 'error';
555
+ execution.error = err.message;
556
+
557
+ execution.stream.emit('msg:error', params.roleId, { message: err.message });
558
+ if (execution.sessionId) {
559
+ this.finalizeSessionMessage(execution, 'error');
560
+ }
561
+
562
+ // SV: If C-Level crashed with running children, restart supervision
563
+ this.attemptSupervisionRecovery(execution);
564
+ })
565
+ .finally(() => {
566
+ if (execution.ports) {
567
+ const released = portRegistry.release(execution.sessionId || execution.id);
568
+ if (released) {
569
+ console.log(`[ExecMgr] Released ports for ${execution.id}: API :${execution.ports.api}, Vite :${execution.ports.vite}`);
570
+ }
571
+ }
572
+
573
+ // Clean up sessionMsgContent immediately (no longer needed after finalize)
574
+ if (execution.sessionId) {
575
+ for (const key of this.sessionMsgContent.keys()) {
576
+ if (key.startsWith(execution.sessionId + ':')) {
577
+ this.sessionMsgContent.delete(key);
578
+ }
579
+ }
580
+ }
581
+
582
+ // OOM prevention: remove completed execution from memory after delay
583
+ // (delay allows getActiveExecution to find it briefly for multiplexer/recovery)
584
+ setTimeout(() => {
585
+ this.executions.delete(execution.id);
586
+ // Also close the ActivityStream to free subscribers + file handles
587
+ execution.stream.close();
588
+ }, 30_000).unref();
589
+ });
590
+ }
591
+
592
+ /** Debug: return memory stats for monitoring */
593
+ getMemoryStats(): { executions: number; msgContentKeys: number; msgContentSize: number } {
594
+ let msgContentSize = 0;
595
+ for (const v of this.sessionMsgContent.values()) {
596
+ msgContentSize += v.length;
597
+ }
598
+ return {
599
+ executions: this.executions.size,
600
+ msgContentKeys: this.sessionMsgContent.size,
601
+ msgContentSize,
602
+ };
603
+ }
604
+
605
+ /* ─── Session ↔ Execution bridge ───────── */
606
+
607
+ private sessionMsgContent = new Map<string, string>();
608
+
609
+ private updateSessionRoleMessage(execution: Execution, text: string): void {
610
+ if (!execution.sessionId) return;
611
+ const session = getSession(execution.sessionId);
612
+ if (!session) return;
613
+
614
+ // Find the latest streaming role message
615
+ const roleMsg = [...session.messages].reverse().find(m => m.from === 'role' && m.status === 'streaming');
616
+ if (!roleMsg) return;
617
+
618
+ const key = `${execution.sessionId}:${roleMsg.id}`;
619
+ const current = (this.sessionMsgContent.get(key) ?? '') + text;
620
+ this.sessionMsgContent.set(key, current);
621
+
622
+ updateSessionMessage(execution.sessionId, roleMsg.id, { content: current });
623
+ }
624
+
625
+ private embedSessionEvent(execution: Execution, type: string, data: Record<string, unknown>): void {
626
+ if (!execution.sessionId) return;
627
+ const session = getSession(execution.sessionId);
628
+ if (!session) return;
629
+
630
+ const roleMsg = [...session.messages].reverse().find(m => m.from === 'role' && m.status === 'streaming');
631
+ if (!roleMsg) return;
632
+
633
+ const event: ActivityEvent = {
634
+ seq: (roleMsg.events?.length ?? 0) + 1,
635
+ ts: new Date().toISOString(),
636
+ type: type as ActivityEvent['type'],
637
+ roleId: execution.roleId,
638
+ data,
639
+ };
640
+ appendMessageEvent(execution.sessionId, roleMsg.id, event);
641
+ }
642
+
643
+ private finalizeSessionMessage(execution: Execution, status: 'done' | 'error', result?: RunnerResult): void {
644
+ if (!execution.sessionId) return;
645
+ const session = getSession(execution.sessionId);
646
+ if (!session) return;
647
+
648
+ const roleMsg = [...session.messages].reverse().find(m => m.from === 'role');
649
+ if (!roleMsg) return;
650
+
651
+ const key = `${execution.sessionId}:${roleMsg.id}`;
652
+ const finalContent = this.sessionMsgContent.get(key) ?? roleMsg.content;
653
+ this.sessionMsgContent.delete(key);
654
+
655
+ updateSessionMessage(execution.sessionId, roleMsg.id, {
656
+ content: finalContent,
657
+ status,
658
+ ...(result && {
659
+ turns: result.turns,
660
+ tokens: result.totalTokens,
661
+ }),
662
+ ...(execution.knowledgeDebt && execution.knowledgeDebt.length > 0 && {
663
+ knowledgeDebt: execution.knowledgeDebt.map(d => ({ type: d.type, file: d.file, message: d.message })),
664
+ }),
665
+ });
666
+
667
+ // Mark session as done in session-store (persisted to file)
668
+ // Skip CEO supervisor sessions — they stay active for wave lifecycle
669
+ if (session.roleId !== 'ceo' || session.source !== 'wave') {
670
+ updateSession(execution.sessionId, { status: 'done' });
671
+ }
672
+ }
673
+
674
+ private cleanupOrphanedChildren(parentSessionId: string): void {
675
+ for (const exec of this.executions.values()) {
676
+ if (exec.parentSessionId === parentSessionId && exec.status === 'awaiting_input') {
677
+ exec.status = 'done';
678
+ exec.stream.emit('msg:done', exec.roleId, {
679
+ output: '[Auto-closed] Parent session completed',
680
+ turns: 0,
681
+ });
682
+ }
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Get children of a parent session that are still running.
688
+ * Public for supervisor heartbeat done-guard (Principle 5).
689
+ */
690
+ getRunningChildren(parentSessionId: string): Execution[] {
691
+ const running: Execution[] = [];
692
+ for (const exec of this.executions.values()) {
693
+ if (exec.parentSessionId === parentSessionId && exec.status === 'running') {
694
+ running.push(exec);
695
+ }
696
+ }
697
+ return running;
698
+ }
699
+
700
+ /**
701
+ * SV: Crash Recovery — C-Level이 죽었는데 부하가 아직 실행 중이면 자동 재시작.
702
+ * "죽으면 오히려 이거하라고 다시 깨우는거야" (CEO 결정, 2026-03-14)
703
+ */
704
+ private attemptSupervisionRecovery(deadExecution: Execution): void {
705
+ const runningChildren = this.getRunningChildren(deadExecution.sessionId);
706
+ if (runningChildren.length === 0) return;
707
+
708
+ // Only restart C-Level roles (CTO, CBO etc.)
709
+ // Resolve preset from wave file for correct org tree
710
+ let recoveryPresetId: string | undefined;
711
+ const deadSession = getSession(deadExecution.sessionId);
712
+ if (deadSession?.waveId) {
713
+ try {
714
+ const wp = path.join(COMPANY_ROOT, '.tycono', 'waves', `${deadSession.waveId}.json`);
715
+ if (fs.existsSync(wp)) {
716
+ recoveryPresetId = JSON.parse(fs.readFileSync(wp, 'utf-8')).preset;
717
+ }
718
+ } catch { /* ignore */ }
719
+ }
720
+ const orgTree = buildOrgTree(COMPANY_ROOT, recoveryPresetId);
721
+ const node = orgTree.nodes.get(deadExecution.roleId);
722
+ if (!node || node.level !== 'c-level') return;
723
+
724
+ const childSummary = runningChildren.map(c =>
725
+ `- [${c.roleId}] Session: ${c.sessionId} | Task: ${c.task.slice(0, 150)}`
726
+ ).join('\n');
727
+
728
+ const recoveryTask = `[SUPERVISION RECOVERY] Your previous session ended, but subordinates are still running.
729
+
730
+ Resume supervision immediately. These sessions are still active:
731
+ ${childSummary}
732
+
733
+ Use supervision watch to monitor them:
734
+ python3 "$SUPERVISION_CMD" watch ${runningChildren.map(c => c.sessionId).join(',')} --duration 120
735
+
736
+ Your job: monitor progress, course-correct if needed, wait for completion, then compile results and report.`;
737
+
738
+ console.log(`[ExecMgr] Supervision recovery: ${deadExecution.roleId} died with ${runningChildren.length} running children. Restarting.`);
739
+
740
+ // Propagate waveId from the dead session
741
+ const deadSes = getSession(deadExecution.sessionId);
742
+ const waveId = deadSes?.waveId;
743
+
744
+ // Create new session for recovery
745
+ const newSession = createSession(deadExecution.roleId, {
746
+ mode: 'do',
747
+ source: 'wave',
748
+ ...(waveId && { waveId }),
749
+ });
750
+
751
+ // Re-parent running children to the new session
752
+ for (const child of runningChildren) {
753
+ child.parentSessionId = newSession.id;
754
+ }
755
+
756
+ // Start new execution
757
+ try {
758
+ this.startExecution({
759
+ type: 'assign',
760
+ roleId: deadExecution.roleId,
761
+ task: recoveryTask,
762
+ sourceRole: 'ceo',
763
+ sessionId: newSession.id,
764
+ targetRoles: deadExecution.targetRoles,
765
+ });
766
+ } catch (err) {
767
+ console.error(`[ExecMgr] Supervision recovery failed for ${deadExecution.roleId}:`, err);
768
+ }
769
+ }
770
+
771
+ getExecution(id: string): Execution | undefined {
772
+ return this.executions.get(id);
773
+ }
774
+
775
+ getActiveExecution(sessionId: string): Execution | undefined {
776
+ let active: Execution | undefined;
777
+ let latest: Execution | undefined;
778
+ for (const exec of this.executions.values()) {
779
+ if (exec.sessionId === sessionId) {
780
+ if (isExecActive(exec.status)) {
781
+ if (!active || exec.createdAt > active.createdAt) {
782
+ active = exec;
783
+ }
784
+ }
785
+ if (!latest || exec.createdAt > latest.createdAt) {
786
+ latest = exec;
787
+ }
788
+ }
789
+ }
790
+ if (active ?? latest) return active ?? latest;
791
+
792
+ return this.recoverExecutionFromStream(sessionId);
793
+ }
794
+
795
+ listExecutions(filter?: { status?: ExecStatus; roleId?: string; active?: boolean }): Array<{
796
+ id: string;
797
+ type: ExecType;
798
+ roleId: string;
799
+ task: string;
800
+ status: ExecStatus;
801
+ parentSessionId?: string;
802
+ childSessionIds: string[];
803
+ createdAt: string;
804
+ targetRole?: string;
805
+ }> {
806
+ const result: Array<{
807
+ id: string;
808
+ type: ExecType;
809
+ roleId: string;
810
+ task: string;
811
+ status: ExecStatus;
812
+ parentSessionId?: string;
813
+ childSessionIds: string[];
814
+ createdAt: string;
815
+ targetRole?: string;
816
+ }> = [];
817
+
818
+ for (const exec of this.executions.values()) {
819
+ if (filter?.active && !isExecActive(exec.status)) continue;
820
+ if (filter?.status && exec.status !== filter.status) continue;
821
+ if (filter?.roleId && exec.roleId !== filter.roleId) continue;
822
+ result.push({
823
+ id: exec.id,
824
+ type: exec.type,
825
+ roleId: exec.roleId,
826
+ task: exec.task,
827
+ status: exec.status,
828
+ parentSessionId: exec.parentSessionId,
829
+ childSessionIds: exec.childSessionIds,
830
+ createdAt: exec.createdAt,
831
+ targetRole: exec.targetRole,
832
+ });
833
+ }
834
+
835
+ return result;
836
+ }
837
+
838
+ abortSession(sessionId: string): boolean {
839
+ const exec = this.getActiveExecution(sessionId);
840
+ if (!exec || !isExecActive(exec.status)) return false;
841
+
842
+ if (exec.status === 'running') exec.abort();
843
+ exec.status = 'error';
844
+ exec.error = 'Aborted by user';
845
+ exec.stream.emit('msg:error', exec.roleId, { message: 'Aborted by user' });
846
+ return true;
847
+ }
848
+
849
+ /** Also support aborting by execution ID for internal use */
850
+ abortExecution(execId: string): boolean {
851
+ const exec = this.executions.get(execId);
852
+ if (!exec || !isExecActive(exec.status)) return false;
853
+
854
+ if (exec.status === 'running') exec.abort();
855
+ exec.status = 'error';
856
+ exec.error = 'Aborted by user';
857
+ exec.stream.emit('msg:error', exec.roleId, { message: 'Aborted by user' });
858
+ return true;
859
+ }
860
+
861
+ continueSession(sessionId: string, response: string, responderRole?: string): Execution | null {
862
+ const exec = this.getActiveExecution(sessionId);
863
+ if (!exec || (exec.status !== 'awaiting_input' && exec.status !== 'done')) return null;
864
+
865
+ const isFollowUp = exec.status === 'done';
866
+ const effectiveResponder = responderRole ?? exec.targetRole ?? 'ceo';
867
+
868
+ exec.status = 'done';
869
+ exec.stream.emit('msg:reply', exec.roleId, { response, responderRole: effectiveResponder, isFollowUp });
870
+
871
+ const prevOutput = exec.result?.output ?? '';
872
+ const hasCliSession = !!exec.cliSessionId;
873
+
874
+ const responderLabel = effectiveResponder === 'ceo' ? 'CEO' : effectiveResponder.toUpperCase();
875
+ let continuationTask: string;
876
+ if (hasCliSession) {
877
+ // --resume preserves full conversation context — no need to repeat output
878
+ continuationTask = isFollowUp
879
+ ? `[CEO Follow-up Directive]\n${response}`
880
+ : `[${responderLabel} Response — continue where you left off]\n${response}`;
881
+ } else {
882
+ const contextSummary = prevOutput.length > 2000
883
+ ? prevOutput.slice(-2000)
884
+ : prevOutput;
885
+ continuationTask = isFollowUp
886
+ ? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
887
+ : `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
888
+ }
889
+
890
+ const newExec = this.startExecution({
891
+ type: exec.type,
892
+ roleId: exec.roleId,
893
+ task: continuationTask,
894
+ sourceRole: effectiveResponder,
895
+ parentSessionId: exec.parentSessionId,
896
+ isContinuation: !isFollowUp,
897
+ sessionId: exec.sessionId, // Same session → same stream
898
+ // Pass CLI session ID for --resume (preserves Claude conversation context)
899
+ cliSessionId: exec.cliSessionId,
900
+ });
901
+
902
+ return newExec;
903
+ }
904
+
905
+ getActiveExecutionForRole(roleId: string): Execution | undefined {
906
+ for (const exec of this.executions.values()) {
907
+ if (exec.roleId === roleId && isExecActive(exec.status)) {
908
+ return exec;
909
+ }
910
+ }
911
+ return undefined;
912
+ }
913
+
914
+ private recoverExecutionFromStream(sessionId: string): Execution | undefined {
915
+ try {
916
+ // Try reading directly from session-keyed stream file
917
+ if (ActivityStream.exists(sessionId)) {
918
+ const events = ActivityStream.readAll(sessionId);
919
+ const startEvent = events.find(e => e.type === 'msg:start');
920
+ if (startEvent) {
921
+ return this.reconstructExecution(sessionId, sessionId, events, startEvent);
922
+ }
923
+ }
924
+
925
+ // Fallback: scan all stream files
926
+ const streamIds = ActivityStream.listAll();
927
+ let bestExec: { streamId: string; roleId: string; task: string; type: ExecType; status: ExecStatus; createdAt: string; output?: string } | undefined;
928
+
929
+ for (const streamId of streamIds) {
930
+ if (this.executions.has(streamId)) continue;
931
+
932
+ const events = ActivityStream.readAll(streamId);
933
+ const startEvent = events.find(e => e.type === 'msg:start');
934
+ if (!startEvent || (startEvent.data.sessionId as string) !== sessionId) continue;
935
+
936
+ const doneEvent = events.find(e => e.type === 'msg:done');
937
+ const errorEvent = events.find(e => e.type === 'msg:error');
938
+ const awaitingEvent = events.find(e => e.type === 'msg:awaiting_input');
939
+ const status: ExecStatus = awaitingEvent && !doneEvent ? 'awaiting_input'
940
+ : doneEvent ? 'done'
941
+ : errorEvent ? 'error'
942
+ : 'running'; // No done/error event = still running
943
+
944
+ const candidate = {
945
+ streamId,
946
+ roleId: startEvent.roleId,
947
+ task: startEvent.data.task as string ?? '',
948
+ type: (startEvent.data.type as string ?? 'assign') as ExecType,
949
+ status,
950
+ createdAt: startEvent.ts,
951
+ output: doneEvent?.data?.output as string | undefined,
952
+ };
953
+
954
+ if (!bestExec || candidate.createdAt > bestExec.createdAt) {
955
+ bestExec = candidate;
956
+ }
957
+ }
958
+
959
+ if (!bestExec) return undefined;
960
+
961
+ const stream = ActivityStream.getOrCreate(sessionId, bestExec.roleId);
962
+ const execution: Execution = {
963
+ id: `recovered-${bestExec.streamId}`,
964
+ sessionId,
965
+ type: bestExec.type,
966
+ roleId: bestExec.roleId,
967
+ task: bestExec.task,
968
+ status: bestExec.status,
969
+ stream,
970
+ abort: () => {},
971
+ childSessionIds: [],
972
+ createdAt: bestExec.createdAt,
973
+ result: bestExec.output ? { output: bestExec.output, turns: 0, totalTokens: { input: 0, output: 0 }, toolCalls: [], dispatches: [] } : undefined,
974
+ };
975
+
976
+ this.executions.set(execution.id, execution);
977
+ console.log(`[ExecMgr] Recovered execution for session ${sessionId} (status: ${execution.status})`);
978
+
979
+ // OOM prevention: auto-cleanup recovered executions (they're only needed briefly for replay)
980
+ if (execution.status === 'done' || execution.status === 'error') {
981
+ setTimeout(() => {
982
+ this.executions.delete(execution.id);
983
+ execution.stream.close();
984
+ }, 30_000).unref();
985
+ }
986
+
987
+ return execution;
988
+ } catch (err) {
989
+ console.warn(`[ExecMgr] Failed to recover execution from streams:`, err);
990
+ return undefined;
991
+ }
992
+ }
993
+
994
+ private reconstructExecution(
995
+ sessionId: string,
996
+ _streamId: string,
997
+ events: ActivityEvent[],
998
+ startEvent: ActivityEvent,
999
+ ): Execution {
1000
+ const doneEvent = events.find(e => e.type === 'msg:done');
1001
+ const errorEvent = events.find(e => e.type === 'msg:error');
1002
+ const awaitingEvent = events.find(e => e.type === 'msg:awaiting_input');
1003
+ const status: ExecStatus = awaitingEvent && !doneEvent ? 'awaiting_input'
1004
+ : doneEvent ? 'done'
1005
+ : errorEvent ? 'error'
1006
+ : 'running'; // No done/error event = still running
1007
+
1008
+ const stream = ActivityStream.getOrCreate(sessionId, startEvent.roleId);
1009
+ const execution: Execution = {
1010
+ id: `recovered-${sessionId}`,
1011
+ sessionId,
1012
+ type: (startEvent.data.type as string ?? 'assign') as ExecType,
1013
+ roleId: startEvent.roleId,
1014
+ task: startEvent.data.task as string ?? '',
1015
+ status,
1016
+ stream,
1017
+ abort: () => {},
1018
+ childSessionIds: [],
1019
+ createdAt: startEvent.ts,
1020
+ result: doneEvent?.data?.output
1021
+ ? { output: doneEvent.data.output as string, turns: 0, totalTokens: { input: 0, output: 0 }, toolCalls: [], dispatches: [] }
1022
+ : undefined,
1023
+ };
1024
+
1025
+ this.executions.set(execution.id, execution);
1026
+ console.log(`[ExecMgr] Recovered execution for session ${sessionId} (status: ${execution.status})`);
1027
+ return execution;
1028
+ }
1029
+ }
1030
+
1031
+ /* ─── Export singleton ───────────────────── */
1032
+
1033
+ export const executionManager = new ExecutionManager();
1034
+
1035
+ /** Backward-compat alias for gradual migration */
1036
+ export const jobManager = executionManager;