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,611 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import { glob } from 'glob';
5
+ import type { ToolCall, ToolResult } from '../llm-adapter.js';
6
+ import { validateWrite, validateRead } from '../authority-validator.js';
7
+ import type { OrgTree } from '../org-tree.js';
8
+ import { buildKnowledgeGateWarning } from '../knowledge-gate.js';
9
+ import { ActivityStream } from '../../services/activity-stream.js';
10
+ import { digest, quietDigest, type DigestResult } from '../../services/digest-engine.js';
11
+ import { supervisorHeartbeat } from '../../services/supervisor-heartbeat.js';
12
+ import { getSession } from '../../services/session-store.js';
13
+ import type { ActivityEvent } from '../../../../shared/types.js';
14
+
15
+ /* ─── Types ──────────────────────────────────── */
16
+
17
+ export interface ToolExecutorOptions {
18
+ companyRoot: string;
19
+ roleId: string;
20
+ orgTree: OrgTree;
21
+ codeRoot?: string;
22
+ sessionId?: string;
23
+ onDispatch?: (roleId: string, task: string) => Promise<string>;
24
+ onConsult?: (roleId: string, question: string) => Promise<string>;
25
+ onToolExec?: (name: string, input: Record<string, unknown>) => void;
26
+ /** For supervision: abort a running session */
27
+ onAbortSession?: (sessionId: string) => boolean;
28
+ /** For supervision: amend a running session with new instructions */
29
+ onAmendSession?: (sessionId: string, instruction: string) => boolean;
30
+ }
31
+
32
+ /* ─── Tool Executor ──────────────────────────── */
33
+
34
+ export async function executeTool(
35
+ toolCall: ToolCall,
36
+ options: ToolExecutorOptions,
37
+ ): Promise<ToolResult> {
38
+ const { companyRoot, roleId, orgTree, codeRoot, onDispatch, onConsult, onToolExec } = options;
39
+ const { id, name, input } = toolCall;
40
+
41
+ onToolExec?.(name, input);
42
+
43
+ try {
44
+ switch (name) {
45
+ case 'read_file':
46
+ return readFile(id, input, companyRoot, roleId, orgTree);
47
+ case 'list_files':
48
+ return listFiles(id, input, companyRoot, roleId, orgTree);
49
+ case 'search_files':
50
+ return searchFiles(id, input, companyRoot, roleId, orgTree);
51
+ case 'write_file':
52
+ return writeFile(id, input, companyRoot, roleId, orgTree);
53
+ case 'edit_file':
54
+ return editFile(id, input, companyRoot, roleId, orgTree);
55
+ case 'bash_execute':
56
+ return bashExecute(id, input, codeRoot ?? companyRoot);
57
+ case 'dispatch':
58
+ return await dispatchTask(id, input, onDispatch);
59
+ case 'consult':
60
+ return await consultTask(id, input, onConsult);
61
+ case 'heartbeat_watch':
62
+ return await heartbeatWatch(id, input, companyRoot);
63
+ case 'amend_session':
64
+ return amendSession(id, input, options.onAmendSession);
65
+ case 'abort_session':
66
+ return abortSession(id, input, options.onAbortSession);
67
+ default:
68
+ return { tool_use_id: id, content: `Unknown tool: ${name}`, is_error: true };
69
+ }
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return { tool_use_id: id, content: `Error: ${message}`, is_error: true };
73
+ }
74
+ }
75
+
76
+ /* ─── Tool Implementations ───────────────────── */
77
+
78
+ function readFile(
79
+ id: string,
80
+ input: Record<string, unknown>,
81
+ companyRoot: string,
82
+ roleId: string,
83
+ orgTree: OrgTree,
84
+ ): ToolResult {
85
+ const filePath = String(input.path ?? '');
86
+ if (!filePath) {
87
+ return { tool_use_id: id, content: 'Error: path is required', is_error: true };
88
+ }
89
+
90
+ // Authority check
91
+ const authResult = validateRead(orgTree, roleId, filePath);
92
+ if (!authResult.allowed) {
93
+ return { tool_use_id: id, content: `Access denied: ${authResult.reason}`, is_error: true };
94
+ }
95
+
96
+ const absolute = path.resolve(companyRoot, filePath);
97
+
98
+ // Security: prevent path traversal
99
+ if (!absolute.startsWith(companyRoot)) {
100
+ return { tool_use_id: id, content: 'Error: path traversal not allowed', is_error: true };
101
+ }
102
+
103
+ if (!fs.existsSync(absolute)) {
104
+ return { tool_use_id: id, content: `File not found: ${filePath}`, is_error: true };
105
+ }
106
+
107
+ const content = fs.readFileSync(absolute, 'utf-8');
108
+
109
+ // Truncate very large files
110
+ if (content.length > 50000) {
111
+ return {
112
+ tool_use_id: id,
113
+ content: content.slice(0, 50000) + '\n\n[... truncated, file is ' + content.length + ' chars]',
114
+ };
115
+ }
116
+
117
+ return { tool_use_id: id, content };
118
+ }
119
+
120
+ function listFiles(
121
+ id: string,
122
+ input: Record<string, unknown>,
123
+ companyRoot: string,
124
+ roleId: string,
125
+ orgTree: OrgTree,
126
+ ): ToolResult {
127
+ const directory = String(input.directory ?? '.');
128
+ const pattern = String(input.pattern ?? '*.md');
129
+
130
+ // Authority check
131
+ const authResult = validateRead(orgTree, roleId, directory);
132
+ if (!authResult.allowed) {
133
+ return { tool_use_id: id, content: `Access denied: ${authResult.reason}`, is_error: true };
134
+ }
135
+
136
+ const absolute = path.resolve(companyRoot, directory);
137
+ if (!absolute.startsWith(companyRoot)) {
138
+ return { tool_use_id: id, content: 'Error: path traversal not allowed', is_error: true };
139
+ }
140
+
141
+ if (!fs.existsSync(absolute)) {
142
+ return { tool_use_id: id, content: `Directory not found: ${directory}`, is_error: true };
143
+ }
144
+
145
+ const files = glob.sync(pattern, { cwd: absolute }).sort();
146
+ return { tool_use_id: id, content: files.length > 0 ? files.join('\n') : '(no matching files)' };
147
+ }
148
+
149
+ function searchFiles(
150
+ id: string,
151
+ input: Record<string, unknown>,
152
+ companyRoot: string,
153
+ roleId: string,
154
+ orgTree: OrgTree,
155
+ ): ToolResult {
156
+ const pattern = String(input.pattern ?? '');
157
+ const directory = String(input.directory ?? '.');
158
+ const filePattern = String(input.file_pattern ?? '*');
159
+
160
+ if (!pattern) {
161
+ return { tool_use_id: id, content: 'Error: pattern is required', is_error: true };
162
+ }
163
+
164
+ // Authority check
165
+ const authResult = validateRead(orgTree, roleId, directory);
166
+ if (!authResult.allowed) {
167
+ return { tool_use_id: id, content: `Access denied: ${authResult.reason}`, is_error: true };
168
+ }
169
+
170
+ const absolute = path.resolve(companyRoot, directory);
171
+ if (!absolute.startsWith(companyRoot)) {
172
+ return { tool_use_id: id, content: 'Error: path traversal not allowed', is_error: true };
173
+ }
174
+
175
+ const files = glob.sync(filePattern === '*' ? '**/*.{md,yaml,yml,ts,tsx,json}' : `**/${filePattern}`, {
176
+ cwd: absolute,
177
+ ignore: ['node_modules/**', 'dist/**', '.git/**'],
178
+ });
179
+
180
+ const regex = new RegExp(pattern, 'gi');
181
+ const results: string[] = [];
182
+
183
+ for (const file of files) {
184
+ const filePath = path.join(absolute, file);
185
+ try {
186
+ const content = fs.readFileSync(filePath, 'utf-8');
187
+ const lines = content.split('\n');
188
+ for (let i = 0; i < lines.length; i++) {
189
+ if (regex.test(lines[i])) {
190
+ results.push(`${file}:${i + 1}: ${lines[i].trim()}`);
191
+ if (results.length >= 50) break;
192
+ }
193
+ regex.lastIndex = 0; // Reset regex state
194
+ }
195
+ } catch {
196
+ // Skip files that can't be read
197
+ }
198
+ if (results.length >= 50) break;
199
+ }
200
+
201
+ return {
202
+ tool_use_id: id,
203
+ content: results.length > 0 ? results.join('\n') : `No matches found for "${pattern}"`,
204
+ };
205
+ }
206
+
207
+ function writeFile(
208
+ id: string,
209
+ input: Record<string, unknown>,
210
+ companyRoot: string,
211
+ roleId: string,
212
+ orgTree: OrgTree,
213
+ ): ToolResult {
214
+ const filePath = String(input.path ?? '');
215
+ const content = String(input.content ?? '');
216
+
217
+ if (!filePath) {
218
+ return { tool_use_id: id, content: 'Error: path is required', is_error: true };
219
+ }
220
+
221
+ // Authority check
222
+ const authResult = validateWrite(orgTree, roleId, filePath);
223
+ if (!authResult.allowed) {
224
+ return { tool_use_id: id, content: `Access denied: ${authResult.reason}`, is_error: true };
225
+ }
226
+
227
+ const absolute = path.resolve(companyRoot, filePath);
228
+ if (!absolute.startsWith(companyRoot)) {
229
+ return { tool_use_id: id, content: 'Error: path traversal not allowed', is_error: true };
230
+ }
231
+
232
+ // Create parent directories
233
+ const dir = path.dirname(absolute);
234
+ if (!fs.existsSync(dir)) {
235
+ fs.mkdirSync(dir, { recursive: true });
236
+ }
237
+
238
+ const isNewFile = !fs.existsSync(absolute);
239
+ fs.writeFileSync(absolute, content);
240
+
241
+ let result = `File written: ${filePath} (${content.length} chars)`;
242
+
243
+ // Knowledge Gate: 새 .md 파일 생성 시 자동 검색 + 경고 (journal 제외)
244
+ if (isNewFile && filePath.endsWith('.md') && !filePath.includes('journal/')) {
245
+ result += buildKnowledgeGateWarning(companyRoot, filePath, content);
246
+ }
247
+
248
+ return { tool_use_id: id, content: result };
249
+ }
250
+
251
+ function editFile(
252
+ id: string,
253
+ input: Record<string, unknown>,
254
+ companyRoot: string,
255
+ roleId: string,
256
+ orgTree: OrgTree,
257
+ ): ToolResult {
258
+ const filePath = String(input.path ?? '');
259
+ const oldString = String(input.old_string ?? '');
260
+ const newString = String(input.new_string ?? '');
261
+
262
+ if (!filePath || !oldString) {
263
+ return { tool_use_id: id, content: 'Error: path and old_string are required', is_error: true };
264
+ }
265
+
266
+ // Authority check
267
+ const authResult = validateWrite(orgTree, roleId, filePath);
268
+ if (!authResult.allowed) {
269
+ return { tool_use_id: id, content: `Access denied: ${authResult.reason}`, is_error: true };
270
+ }
271
+
272
+ const absolute = path.resolve(companyRoot, filePath);
273
+ if (!absolute.startsWith(companyRoot)) {
274
+ return { tool_use_id: id, content: 'Error: path traversal not allowed', is_error: true };
275
+ }
276
+
277
+ if (!fs.existsSync(absolute)) {
278
+ return { tool_use_id: id, content: `File not found: ${filePath}`, is_error: true };
279
+ }
280
+
281
+ const content = fs.readFileSync(absolute, 'utf-8');
282
+ if (!content.includes(oldString)) {
283
+ return { tool_use_id: id, content: `String not found in ${filePath}: "${oldString.slice(0, 100)}"`, is_error: true };
284
+ }
285
+
286
+ const updated = content.replace(oldString, newString);
287
+ fs.writeFileSync(absolute, updated);
288
+ return { tool_use_id: id, content: `File edited: ${filePath}` };
289
+ }
290
+
291
+ /* ─── Bash Safety Layer (EG-002) ─────────────── */
292
+
293
+ /** Dangerous patterns that are always blocked */
294
+ const BLOCKED_PATTERNS = [
295
+ /\brm\s+(-[a-z]*f|-[a-z]*r|--force|--recursive)\b/i,
296
+ /\brm\s+-rf\b/i,
297
+ /\bsudo\b/,
298
+ /\bmkfs\b/,
299
+ /\bdd\s+if=/,
300
+ /\b(shutdown|reboot|halt|poweroff)\b/,
301
+ /\bchmod\s+777\b/,
302
+ /\bchown\b/,
303
+ />\s*\/dev\//,
304
+ /\bcurl\b.*\|\s*(bash|sh|zsh)\b/,
305
+ /\bwget\b.*\|\s*(bash|sh|zsh)\b/,
306
+ /\bgit\s+push\s+.*--force\b/,
307
+ /\bgit\s+reset\s+--hard\b/,
308
+ /\bnpm\s+publish\b/,
309
+ /\beval\s*\(/,
310
+ /:\(\)\s*\{/, // fork bomb
311
+ /\bsleep\s+\d/, // Block sleep — use heartbeat_watch or supervision watch instead
312
+ ];
313
+
314
+ function validateBashCommand(command: string): string | null {
315
+ const trimmed = command.trim();
316
+ if (!trimmed) return 'Empty command';
317
+
318
+ for (const pattern of BLOCKED_PATTERNS) {
319
+ if (pattern.test(trimmed)) {
320
+ return `Blocked: command matches dangerous pattern "${pattern.source}"`;
321
+ }
322
+ }
323
+
324
+ // Block commands that try to leave codeRoot via cd
325
+ if (/\bcd\s+\//.test(trimmed) && !/\bcd\s+\/[^;|&]*&&/.test(trimmed)) {
326
+ // Allow cd to absolute if chained with other commands (common pattern)
327
+ // But block standalone cd to absolute paths outside codeRoot
328
+ }
329
+
330
+ return null; // OK
331
+ }
332
+
333
+ const MAX_BASH_TIMEOUT = 120_000;
334
+ const DEFAULT_BASH_TIMEOUT = 30_000;
335
+ const MAX_OUTPUT_LENGTH = 50_000;
336
+
337
+ function bashExecute(
338
+ id: string,
339
+ input: Record<string, unknown>,
340
+ codeRoot: string,
341
+ ): ToolResult {
342
+ const command = String(input.command ?? '');
343
+ const timeout = Math.min(Number(input.timeout) || DEFAULT_BASH_TIMEOUT, MAX_BASH_TIMEOUT);
344
+ const cwdRelative = String(input.cwd ?? '.');
345
+
346
+ // Validate command safety
347
+ const blockReason = validateBashCommand(command);
348
+ if (blockReason) {
349
+ return { tool_use_id: id, content: `Error: ${blockReason}`, is_error: true };
350
+ }
351
+
352
+ // Resolve and validate cwd
353
+ const cwd = path.resolve(codeRoot, cwdRelative);
354
+ if (!cwd.startsWith(codeRoot)) {
355
+ return { tool_use_id: id, content: 'Error: cwd path traversal not allowed', is_error: true };
356
+ }
357
+ if (!fs.existsSync(cwd)) {
358
+ return { tool_use_id: id, content: `Error: directory not found: ${cwdRelative}`, is_error: true };
359
+ }
360
+
361
+ try {
362
+ const stdout = execSync(command, {
363
+ cwd,
364
+ timeout,
365
+ encoding: 'utf-8',
366
+ maxBuffer: 1024 * 1024, // 1MB
367
+ stdio: ['pipe', 'pipe', 'pipe'],
368
+ env: { ...process.env, FORCE_COLOR: '0' },
369
+ });
370
+
371
+ const output = stdout.length > MAX_OUTPUT_LENGTH
372
+ ? stdout.slice(0, MAX_OUTPUT_LENGTH) + `\n\n[... truncated, output is ${stdout.length} chars]`
373
+ : stdout;
374
+
375
+ return { tool_use_id: id, content: output || '(no output)' };
376
+ } catch (err: unknown) {
377
+ const execErr = err as { status?: number; stdout?: string; stderr?: string; message?: string };
378
+ const stderr = execErr.stderr?.slice(0, 5000) ?? '';
379
+ const stdout = execErr.stdout?.slice(0, 5000) ?? '';
380
+ const exitCode = execErr.status ?? 1;
381
+
382
+ let content = `Command exited with code ${exitCode}`;
383
+ if (stdout) content += `\n\nSTDOUT:\n${stdout}`;
384
+ if (stderr) content += `\n\nSTDERR:\n${stderr}`;
385
+ if (!stdout && !stderr) content += `\n${execErr.message ?? ''}`;
386
+
387
+ return { tool_use_id: id, content, is_error: true };
388
+ }
389
+ }
390
+
391
+ /* ─── Dispatch / Consult ─────────────────────── */
392
+
393
+ async function dispatchTask(
394
+ id: string,
395
+ input: Record<string, unknown>,
396
+ onDispatch?: (roleId: string, task: string) => Promise<string>,
397
+ ): Promise<ToolResult> {
398
+ const roleId = String(input.roleId ?? '');
399
+ const task = String(input.task ?? '');
400
+
401
+ if (!roleId || !task) {
402
+ return { tool_use_id: id, content: 'Error: roleId and task are required', is_error: true };
403
+ }
404
+
405
+ if (!onDispatch) {
406
+ return { tool_use_id: id, content: 'Error: dispatch not available in this context', is_error: true };
407
+ }
408
+
409
+ const result = await onDispatch(roleId, task);
410
+ return { tool_use_id: id, content: result };
411
+ }
412
+
413
+ async function consultTask(
414
+ id: string,
415
+ input: Record<string, unknown>,
416
+ onConsult?: (roleId: string, question: string) => Promise<string>,
417
+ ): Promise<ToolResult> {
418
+ const roleId = String(input.roleId ?? '');
419
+ const question = String(input.question ?? '');
420
+
421
+ if (!roleId || !question) {
422
+ return { tool_use_id: id, content: 'Error: roleId and question are required', is_error: true };
423
+ }
424
+
425
+ if (!onConsult) {
426
+ return { tool_use_id: id, content: 'Error: consult not available in this context', is_error: true };
427
+ }
428
+
429
+ const result = await onConsult(roleId, question);
430
+ return { tool_use_id: id, content: result };
431
+ }
432
+
433
+ /* ─── Supervision Tools (SV-3, SV-6, SV-7) ──── */
434
+
435
+ const MAX_WATCH_DURATION = 300;
436
+ const DEFAULT_WATCH_DURATION = 120;
437
+
438
+ /** Find the waveId for a set of session IDs (for CEO directive injection) */
439
+ function findWaveIdForSessions(sessionIds: string[]): string | undefined {
440
+ for (const sid of sessionIds) {
441
+ const session = getSession(sid);
442
+ if (session?.waveId) return session.waveId;
443
+ }
444
+ return undefined;
445
+ }
446
+
447
+ /**
448
+ * heartbeat_watch: Block for N seconds collecting events from activity streams.
449
+ * Returns a DigestEngine summary. Early-returns on alert events.
450
+ * $0 LLM cost during wait — all blocking is server-side.
451
+ */
452
+ async function heartbeatWatch(
453
+ id: string,
454
+ input: Record<string, unknown>,
455
+ companyRoot: string,
456
+ ): Promise<ToolResult> {
457
+ const sessionIds = input.sessionIds as string[] | undefined;
458
+ if (!sessionIds || !Array.isArray(sessionIds) || sessionIds.length === 0) {
459
+ return { tool_use_id: id, content: 'Error: sessionIds array is required', is_error: true };
460
+ }
461
+
462
+ const durationSec = Math.min(
463
+ Math.max(Number(input.durationSec) || DEFAULT_WATCH_DURATION, 5),
464
+ MAX_WATCH_DURATION,
465
+ );
466
+ const alertOn = (input.alertOn as string[] | undefined) ?? ['msg:done', 'msg:error', 'msg:awaiting_input'];
467
+ const alertSet = new Set(alertOn);
468
+
469
+ // Collect current checkpoints (last known seq for each session)
470
+ const startCheckpoints = new Map<string, number>();
471
+ for (const sid of sessionIds) {
472
+ const events = ActivityStream.readAll(sid);
473
+ startCheckpoints.set(sid, events.length > 0 ? events[events.length - 1].seq + 1 : 0);
474
+ }
475
+
476
+ // Set up event collection with live subscriptions
477
+ const collectedEvents = new Map<string, ActivityEvent[]>();
478
+ for (const sid of sessionIds) {
479
+ collectedEvents.set(sid, []);
480
+ }
481
+
482
+ let earlyReturn = false;
483
+ const unsubscribers: Array<() => void> = [];
484
+
485
+ // Subscribe to live events for early alert detection
486
+ for (const sid of sessionIds) {
487
+ const stream = ActivityStream.getOrCreate(sid, 'unknown');
488
+ const handler = (event: ActivityEvent) => {
489
+ const events = collectedEvents.get(sid);
490
+ if (events) events.push(event);
491
+ if (alertSet.has(event.type)) {
492
+ earlyReturn = true;
493
+ }
494
+ };
495
+ stream.subscribe(handler);
496
+ unsubscribers.push(() => stream.unsubscribe(handler));
497
+ }
498
+
499
+ // Wait for duration or early return
500
+ await new Promise<void>((resolve) => {
501
+ const timeout = setTimeout(resolve, durationSec * 1000);
502
+ const checkInterval = setInterval(() => {
503
+ if (earlyReturn) {
504
+ clearTimeout(timeout);
505
+ clearInterval(checkInterval);
506
+ resolve();
507
+ }
508
+ }, 500); // Check every 500ms
509
+ // Ensure cleanup even if early
510
+ setTimeout(() => { clearInterval(checkInterval); }, durationSec * 1000 + 100);
511
+ });
512
+
513
+ // Unsubscribe all
514
+ for (const unsub of unsubscribers) unsub();
515
+
516
+ // If live subscription missed events (e.g., stream was not active), read from file
517
+ for (const sid of sessionIds) {
518
+ const fromSeq = startCheckpoints.get(sid) ?? 0;
519
+ const liveEvents = collectedEvents.get(sid) ?? [];
520
+ if (liveEvents.length === 0) {
521
+ // Fallback: read from JSONL
522
+ const fileEvents = ActivityStream.readFrom(sid, fromSeq);
523
+ collectedEvents.set(sid, fileEvents);
524
+ }
525
+ }
526
+
527
+ // Run DigestEngine
528
+ const result: DigestResult = digest(collectedEvents);
529
+
530
+ // SV: Inject pending CEO directives (Dispatch Protocol Principle 3: CEO directive = Priority 1)
531
+ const waveId = findWaveIdForSessions(sessionIds);
532
+ let ceoDirectiveText = '';
533
+ if (waveId) {
534
+ const pendingDirectives = supervisorHeartbeat.getPendingDirectives(waveId);
535
+ if (pendingDirectives.length > 0) {
536
+ // CEO directives override quiet tick gate — always surface them
537
+ result.significanceScore = 10;
538
+ for (const d of pendingDirectives) {
539
+ result.anomalies.push({
540
+ type: 'ceo_directive',
541
+ sessionId: 'ceo',
542
+ message: d.text,
543
+ severity: 10,
544
+ });
545
+ }
546
+ ceoDirectiveText = '\n\n### 🔴 [CEO DIRECTIVE] (PRIORITY 1 — process before anything else)\n' +
547
+ pendingDirectives.map(d => `- ${d.text}`).join('\n');
548
+ supervisorHeartbeat.markDirectivesDelivered(waveId);
549
+ }
550
+ }
551
+
552
+ // SV-10: Quiet tick gate (skipped if CEO directives pending)
553
+ if (result.significanceScore < 2 && result.anomalies.length === 0) {
554
+ const quietText = quietDigest(sessionIds.length, result.eventCount, result.errorCount);
555
+ return { tool_use_id: id, content: quietText };
556
+ }
557
+
558
+ return { tool_use_id: id, content: result.text + ceoDirectiveText };
559
+ }
560
+
561
+ /**
562
+ * amend_session: Send additional instructions to a running session (SV-6)
563
+ */
564
+ function amendSession(
565
+ id: string,
566
+ input: Record<string, unknown>,
567
+ onAmend?: (sessionId: string, instruction: string) => boolean,
568
+ ): ToolResult {
569
+ const sessionId = String(input.sessionId ?? '');
570
+ const instruction = String(input.instruction ?? '');
571
+
572
+ if (!sessionId || !instruction) {
573
+ return { tool_use_id: id, content: 'Error: sessionId and instruction are required', is_error: true };
574
+ }
575
+
576
+ if (!onAmend) {
577
+ return { tool_use_id: id, content: 'Error: amend_session not available in this context', is_error: true };
578
+ }
579
+
580
+ const success = onAmend(sessionId, instruction);
581
+ if (success) {
582
+ return { tool_use_id: id, content: `Session ${sessionId} amended. Instruction will be injected at next turn boundary.` };
583
+ }
584
+ return { tool_use_id: id, content: `Failed to amend session ${sessionId}. Session may not be running.`, is_error: true };
585
+ }
586
+
587
+ /**
588
+ * abort_session: Abort a running session immediately (SV-7)
589
+ */
590
+ function abortSession(
591
+ id: string,
592
+ input: Record<string, unknown>,
593
+ onAbort?: (sessionId: string) => boolean,
594
+ ): ToolResult {
595
+ const sessionId = String(input.sessionId ?? '');
596
+ const reason = String(input.reason ?? 'Aborted by supervisor');
597
+
598
+ if (!sessionId) {
599
+ return { tool_use_id: id, content: 'Error: sessionId is required', is_error: true };
600
+ }
601
+
602
+ if (!onAbort) {
603
+ return { tool_use_id: id, content: 'Error: abort_session not available in this context', is_error: true };
604
+ }
605
+
606
+ const success = onAbort(sessionId);
607
+ if (success) {
608
+ return { tool_use_id: id, content: `Session ${sessionId} aborted. Reason: ${reason}` };
609
+ }
610
+ return { tool_use_id: id, content: `Failed to abort session ${sessionId}. Session may not be running.`, is_error: true };
611
+ }