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,796 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { assembleContext } from '../context-assembler.js';
6
+ import { getSubordinates } from '../org-tree.js';
7
+ import { readConfig, resolveCodeRoot } from '../../services/company-config.js';
8
+ import { getTokenLedger } from '../../services/token-ledger.js';
9
+ import { getSession } from '../../services/session-store.js';
10
+ import type { ExecutionRunner, RunnerConfig, RunnerCallbacks, RunnerHandle, RunnerResult } from './types.js';
11
+
12
+ /* ─── Dispatch Bridge Script (Python3) ────── */
13
+
14
+ const DISPATCH_SCRIPT = `#!/usr/bin/env python3
15
+ """dispatch-bridge: CLI runner가 하위 Role에게 작업을 할당하는 브릿지 스크립트.
16
+
17
+ 3가지 모드:
18
+ dispatch <roleId> "<task>" — Job 시작 (즉시 반환, 대기하지 않음)
19
+ dispatch --check <sessionId> — Session 상태 및 결과 조회
20
+ dispatch --wait <roleId> "<task>" — Job 시작 + 완료 대기 (최대 300초)
21
+
22
+ 환경변수:
23
+ DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
24
+ DISPATCH_PARENT_SESSION — 부모 Session ID (자동 설정)
25
+ DISPATCH_PARENT_JOB — (deprecated) 부모 Job ID, DISPATCH_PARENT_SESSION 사용
26
+ DISPATCH_SOURCE_ROLE — 현재 Role ID (자동 설정)
27
+ """
28
+ import sys, os, json, time, urllib.request, urllib.error
29
+ sys.stdout.reconfigure(line_buffering=True)
30
+
31
+ api = os.environ.get('DISPATCH_API_URL', 'http://localhost:3001')
32
+
33
+ def log(msg):
34
+ print(msg, flush=True)
35
+
36
+ def get_result(job_id, retries=3):
37
+ for attempt in range(retries):
38
+ try:
39
+ history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
40
+ events = history.get('events', [])
41
+ text_parts = []
42
+ for e in events:
43
+ if e['type'] == 'text':
44
+ text_parts.append(e['data'].get('text', ''))
45
+ elif e['type'] in ('msg:error', 'job:error'):
46
+ text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
47
+ result = ''.join(text_parts)
48
+ if result:
49
+ return result
50
+ if attempt < retries - 1:
51
+ log(f' Result empty, retrying in 2s... (attempt {attempt + 1}/{retries})')
52
+ time.sleep(2)
53
+ except Exception as e:
54
+ if attempt == retries - 1:
55
+ return f'ERROR: Failed to get result: {e}'
56
+ time.sleep(2)
57
+ return '(No text output — activity stream may still be writing. Check again with --check)'
58
+
59
+ def get_job_info(job_id):
60
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
61
+ return info
62
+
63
+ def get_status(job_id):
64
+ return get_job_info(job_id).get('status', 'unknown')
65
+
66
+ def start_job(role_id, task):
67
+ parent_session = os.environ.get('DISPATCH_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_JOB', ''))
68
+ source_role = os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo')
69
+ wave_id = os.environ.get('DISPATCH_WAVE_ID', '')
70
+ payload = {
71
+ 'type': 'assign',
72
+ 'roleId': role_id,
73
+ 'task': task,
74
+ 'sourceRole': source_role,
75
+ 'parentSessionId': parent_session if parent_session else None,
76
+ }
77
+ if wave_id:
78
+ payload['waveId'] = wave_id
79
+ body = json.dumps(payload).encode()
80
+ req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
81
+ resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
82
+ return resp.get('sessionId') or resp.get('jobId')
83
+
84
+ # Mode: --check <sessionId>
85
+ if len(sys.argv) >= 3 and sys.argv[1] == '--check':
86
+ job_id = sys.argv[2]
87
+ try:
88
+ info = get_job_info(job_id)
89
+ status = info.get('status', 'unknown')
90
+ if status == 'running':
91
+ log(f'Status: RUNNING — {job_id} is still working. Check again in 10-30s.')
92
+ elif status == 'awaiting_input':
93
+ log(f'Status: AWAITING_INPUT — subordinate has a question.')
94
+ log(info.get('output', '') or get_result(job_id))
95
+ elif status == 'done':
96
+ log(f'Status: DONE')
97
+ log(info.get('output', '') or get_result(job_id))
98
+ elif status == 'error':
99
+ log(f'Status: ERROR')
100
+ log(info.get('output', '') or get_result(job_id))
101
+ else:
102
+ log(f'Status: {status}')
103
+ except Exception as e:
104
+ log(f'ERROR: {e}')
105
+ sys.exit(0)
106
+
107
+ # Mode: dispatch <roleId> "<task>" (always immediate return)
108
+ args = sys.argv[1:]
109
+ # Accept --wait for backwards compat but ignore it (always async now)
110
+ if args and args[0] == '--wait':
111
+ args = args[1:]
112
+
113
+ # Usage check
114
+ if len(args) < 2:
115
+ log('Usage: dispatch <roleId> "<task>" — Start job (returns immediately)')
116
+ log(' dispatch --check <sessionId> — Check job status/result')
117
+ subs = os.environ.get('DISPATCH_SUBORDINATES', '')
118
+ if subs:
119
+ log(f'Available subordinates: {subs}')
120
+ sys.exit(1)
121
+
122
+ role_id = args[0]
123
+ task = ' '.join(args[1:])
124
+
125
+ # Start job
126
+ try:
127
+ job_id = start_job(role_id, task)
128
+ except Exception as e:
129
+ log(f'ERROR: Failed to start dispatch job: {e}')
130
+ sys.exit(1)
131
+
132
+ log(f'=== Dispatched to {role_id.upper()} ===')
133
+ log(f'Task: {task[:120]}')
134
+ log(f'Session ID: {job_id}')
135
+ log(f'')
136
+ log(f'⛔ Job is running async. Use --check to poll for result:')
137
+ log(f' python3 "$DISPATCH_CMD" --check {job_id}')
138
+ log(f'')
139
+ log(f'DO NOT re-dispatch the same task. Poll with --check every 10-30s until status is DONE.')
140
+ `;
141
+
142
+ /* ─── Consult Bridge Script (Python3) ────── */
143
+
144
+ const CONSULT_SCRIPT = `#!/usr/bin/env python3
145
+ """consult-bridge: CLI runner가 다른 Role에게 질문하는 브릿지 스크립트.
146
+
147
+ 사용법:
148
+ consult <roleId> "<question>" — Job 시작 (readOnly) + 결과 대기 (최대 90초)
149
+ consult --check <sessionId> — Session 결과 조회
150
+
151
+ 환경변수:
152
+ CONSULT_API_URL — API 서버 URL (default: http://localhost:3001)
153
+ CONSULT_PARENT_JOB — 부모 Session ID (자동 설정)
154
+ CONSULT_SOURCE_ROLE — 현재 Role ID (자동 설정)
155
+ """
156
+ import sys, os, json, time, urllib.request, urllib.error
157
+ sys.stdout.reconfigure(line_buffering=True)
158
+
159
+ api = os.environ.get('CONSULT_API_URL', os.environ.get('DISPATCH_API_URL', 'http://localhost:3001'))
160
+
161
+ def log(msg):
162
+ print(msg, flush=True)
163
+
164
+ def get_result(job_id, retries=3):
165
+ for attempt in range(retries):
166
+ try:
167
+ history = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}/history', timeout=10).read())
168
+ events = history.get('events', [])
169
+ text_parts = []
170
+ for e in events:
171
+ if e['type'] == 'text':
172
+ text_parts.append(e['data'].get('text', ''))
173
+ elif e['type'] in ('msg:error', 'job:error'):
174
+ text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
175
+ result = ''.join(text_parts)
176
+ if result:
177
+ return result
178
+ if attempt < retries - 1:
179
+ log(f' Result empty, retrying in 2s... (attempt {attempt + 1}/{retries})')
180
+ time.sleep(2)
181
+ except Exception as e:
182
+ if attempt == retries - 1:
183
+ return f'ERROR: Failed to get result: {e}'
184
+ time.sleep(2)
185
+ return '(No text output — activity stream may still be writing. Check again with --check)'
186
+
187
+ def get_job_info(job_id):
188
+ info = json.loads(urllib.request.urlopen(f'{api}/api/jobs/{job_id}', timeout=5).read())
189
+ return info
190
+
191
+ def get_status(job_id):
192
+ return get_job_info(job_id).get('status', 'unknown')
193
+
194
+ # Mode: --check <jobId>
195
+ if len(sys.argv) >= 3 and sys.argv[1] == '--check':
196
+ job_id = sys.argv[2]
197
+ try:
198
+ info = get_job_info(job_id)
199
+ status = info.get('status', 'unknown')
200
+ if status == 'running':
201
+ log(f'Job {job_id} is still running. Try again later.')
202
+ elif status == 'awaiting_input':
203
+ log(f'Job {job_id} is awaiting input.')
204
+ log(info.get('output', '') or get_result(job_id))
205
+ else:
206
+ log(f'=== Job {job_id}: {status} ===')
207
+ log(info.get('output', '') or get_result(job_id))
208
+ except Exception as e:
209
+ log(f'ERROR: {e}')
210
+ sys.exit(0)
211
+
212
+ # Mode: consult <roleId> "<question>"
213
+ if len(sys.argv) < 3:
214
+ log('Usage: consult <roleId> "<question>"')
215
+ log(' consult --check <sessionId>')
216
+ sys.exit(1)
217
+
218
+ role_id = sys.argv[1]
219
+ question = ' '.join(sys.argv[2:])
220
+ parent_session = os.environ.get('CONSULT_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_JOB', '')))
221
+ source_role = os.environ.get('CONSULT_SOURCE_ROLE', os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo'))
222
+
223
+ # Start job (readOnly + consult type)
224
+ task = f'[Consultation from {source_role}] {question}\\n\\nAnswer this question based on your role\\'s expertise and knowledge. Be concise and specific.'
225
+ body = json.dumps({
226
+ 'type': 'consult',
227
+ 'roleId': role_id,
228
+ 'task': task,
229
+ 'sourceRole': source_role,
230
+ 'readOnly': True,
231
+ 'parentSessionId': parent_session if parent_session else None,
232
+ }).encode()
233
+
234
+ try:
235
+ req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
236
+ resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
237
+ job_id = resp.get('sessionId') or resp.get('jobId')
238
+ except Exception as e:
239
+ log(f'ERROR: Failed to start consult job: {e}')
240
+ sys.exit(1)
241
+
242
+ log(f'=== Consulting {role_id.upper()} ===')
243
+ log(f'Question: {question[:120]}')
244
+ log(f'Session ID: {job_id}')
245
+ log(f'')
246
+ log(f'Consult job started. Use --check to get the answer:')
247
+ log(f' python3 "$CONSULT_CMD" --check {job_id}')
248
+ log(f'')
249
+ log(f'Poll every 10s until status is DONE.')
250
+ `;
251
+
252
+ /* ─── Supervision Bridge Script (Python3) — SV-14 ────── */
253
+
254
+ const SUPERVISION_SCRIPT = `#!/usr/bin/env python3
255
+ """supervision-bridge: C-Level이 부하 세션을 감시하는 브릿지 스크립트.
256
+
257
+ 사용법:
258
+ supervision watch ses-001,ses-002 --duration 120 — Long-poll watch (blocking)
259
+ supervision peers --wave xxx --role cto — Peer session discovery
260
+ supervision abort ses-001 --reason "Wrong direction" — Abort session
261
+ supervision amend ses-001 "New instructions here" — Amend session
262
+
263
+ 환경변수:
264
+ DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
265
+ """
266
+ import sys, os, json, urllib.request, urllib.error
267
+ sys.stdout.reconfigure(line_buffering=True)
268
+
269
+ api = os.environ.get('DISPATCH_API_URL', 'http://localhost:3001')
270
+
271
+ def log(msg):
272
+ print(msg, flush=True)
273
+
274
+ if len(sys.argv) < 2:
275
+ log('Usage: supervision <watch|peers|abort|amend> [args...]')
276
+ sys.exit(1)
277
+
278
+ cmd = sys.argv[1]
279
+
280
+ if cmd == 'watch':
281
+ sessions = sys.argv[2] if len(sys.argv) > 2 else ''
282
+ duration = '120'
283
+ alert_on = 'msg:done,msg:error'
284
+ i = 3
285
+ while i < len(sys.argv):
286
+ if sys.argv[i] == '--duration' and i + 1 < len(sys.argv):
287
+ duration = sys.argv[i + 1]
288
+ i += 2
289
+ elif sys.argv[i] == '--alert-on' and i + 1 < len(sys.argv):
290
+ alert_on = sys.argv[i + 1]
291
+ i += 2
292
+ else:
293
+ i += 1
294
+
295
+ if not sessions:
296
+ log('Error: session IDs required (comma-separated)')
297
+ sys.exit(1)
298
+
299
+ url = f'{api}/api/supervision/watch?sessions={sessions}&duration={duration}&alertOn={alert_on}'
300
+ try:
301
+ resp = json.loads(urllib.request.urlopen(url, timeout=int(duration) + 10).read())
302
+ log(resp.get('text', '(no digest)'))
303
+ if resp.get('anomalies'):
304
+ log(f'\\nAnomalies: {len(resp["anomalies"])}')
305
+ for a in resp['anomalies']:
306
+ log(f' [{a["type"]}] {a["message"]}')
307
+ except Exception as e:
308
+ log(f'ERROR: {e}')
309
+ sys.exit(0)
310
+
311
+ elif cmd == 'peers':
312
+ wave_id = ''
313
+ role_id = ''
314
+ i = 2
315
+ while i < len(sys.argv):
316
+ if sys.argv[i] == '--wave' and i + 1 < len(sys.argv):
317
+ wave_id = sys.argv[i + 1]
318
+ i += 2
319
+ elif sys.argv[i] == '--role' and i + 1 < len(sys.argv):
320
+ role_id = sys.argv[i + 1]
321
+ i += 2
322
+ else:
323
+ i += 1
324
+
325
+ if not wave_id or not role_id:
326
+ log('Usage: supervision peers --wave <waveId> --role <roleId>')
327
+ sys.exit(1)
328
+
329
+ try:
330
+ url = f'{api}/api/supervision/peers?waveId={wave_id}&roleId={role_id}'
331
+ resp = json.loads(urllib.request.urlopen(url, timeout=10).read())
332
+ peers = resp.get('peers', [])
333
+ if not peers:
334
+ log('No peer C-Level sessions found in this wave.')
335
+ else:
336
+ for p in peers:
337
+ log(f'[{p["roleId"]}] {p["sessionId"]} — {p["status"]} — {p["task"][:80]}')
338
+ except Exception as e:
339
+ log(f'ERROR: {e}')
340
+ sys.exit(0)
341
+
342
+ elif cmd == 'abort':
343
+ session_id = sys.argv[2] if len(sys.argv) > 2 else ''
344
+ reason = 'Aborted by supervisor'
345
+ i = 3
346
+ while i < len(sys.argv):
347
+ if sys.argv[i] == '--reason' and i + 1 < len(sys.argv):
348
+ reason = sys.argv[i + 1]
349
+ i += 2
350
+ else:
351
+ i += 1
352
+
353
+ if not session_id:
354
+ log('Usage: supervision abort <sessionId> [--reason "..."]')
355
+ sys.exit(1)
356
+
357
+ try:
358
+ body = json.dumps({'sessionId': session_id, 'reason': reason}).encode()
359
+ req = urllib.request.Request(f'{api}/api/jobs/{session_id}', method='DELETE')
360
+ urllib.request.urlopen(req, timeout=10)
361
+ log(f'Session {session_id} aborted. Reason: {reason}')
362
+ except Exception as e:
363
+ log(f'ERROR: {e}')
364
+ sys.exit(0)
365
+
366
+ elif cmd == 'amend':
367
+ session_id = sys.argv[2] if len(sys.argv) > 2 else ''
368
+ instruction = ' '.join(sys.argv[3:]) if len(sys.argv) > 3 else ''
369
+
370
+ if not session_id or not instruction:
371
+ log('Usage: supervision amend <sessionId> "<instruction>"')
372
+ sys.exit(1)
373
+
374
+ # Amend sends a message to the session with amendment instructions
375
+ body = json.dumps({
376
+ 'content': f'[SUPERVISION AMENDMENT] {instruction}',
377
+ }).encode()
378
+
379
+ try:
380
+ req = urllib.request.Request(
381
+ f'{api}/api/exec/session/{session_id}/message',
382
+ body,
383
+ {'Content-Type': 'application/json'},
384
+ )
385
+ resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
386
+ log(f'Session {session_id} amended with new instructions.')
387
+ except Exception as e:
388
+ log(f'ERROR: {e}')
389
+ sys.exit(0)
390
+
391
+ else:
392
+ log(f'Unknown command: {cmd}')
393
+ log('Usage: supervision <watch|peers|abort|amend> [args...]')
394
+ sys.exit(1)
395
+ `;
396
+
397
+ /* ─── Claude CLI Runner ──────────────────────── */
398
+
399
+ /**
400
+ * Claude Code CLI (`claude -p`)를 실행 엔진으로 사용.
401
+ *
402
+ * - Context Assembler가 조립한 시스템 프롬프트를 --system-prompt로 전달
403
+ * - claude -p (print mode)로 실행, stdout의 stream-json을 파싱
404
+ * - Claude Code가 내장 도구(Read, Write, Edit, Bash 등)를 자체적으로 실행
405
+ * - Dispatch Bridge: 하위 Role 할당 시 API를 통해 자식 Job 생성
406
+ * - 구독 기반이므로 API 비용 부담 없음
407
+ */
408
+ export class ClaudeCliRunner implements ExecutionRunner {
409
+ execute(config: RunnerConfig, callbacks: RunnerCallbacks): RunnerHandle {
410
+ const { companyRoot, roleId, task, sourceRole, orgTree, readOnly = false, teamStatus, attachments, targetRoles, presetId } = config;
411
+
412
+ // Note: Claude CLI doesn't support inline image attachments.
413
+ // Images will be ignored with a warning if passed.
414
+ if (attachments && attachments.length > 0) {
415
+ console.warn(`[ClaudeCliRunner] Warning: Image attachments (${attachments.length}) are not supported in CLI mode. Use EXECUTION_ENGINE=direct-api for vision support.`);
416
+ }
417
+
418
+ // 1. Context Assembly
419
+ const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles, presetId });
420
+
421
+ // Trace: capture assembled prompt for debugging
422
+ callbacks.onPromptAssembled?.(context.systemPrompt, task);
423
+
424
+ // 2. System prompt를 임시 파일로 저장 (CLI arg 길이 제한 대비)
425
+ const tmpDir = path.join(os.tmpdir(), 'tycono-engine');
426
+ fs.mkdirSync(tmpDir, { recursive: true });
427
+ const promptFile = path.join(tmpDir, `ctx-${roleId}-${Date.now()}.md`);
428
+ fs.writeFileSync(promptFile, context.systemPrompt);
429
+
430
+ // 3. Dispatch Bridge 스크립트 생성 (하위 Role이 있는 경우)
431
+ // readOnly(talk mode)에서도 dispatch 허용 — 하위 Role에 "확인해봐" 같은 지시 가능
432
+ const subordinates = getSubordinates(orgTree, roleId);
433
+
434
+ // 4. readOnly면 시스템 프롬프트에 쓰기 금지 지시 추가
435
+ let taskPrompt = task;
436
+ if (readOnly) {
437
+ const dispatchNote = subordinates.length > 0
438
+ ? ' 단, 하위 Role에 대한 dispatch(python3 "$DISPATCH_CMD")는 가능합니다.'
439
+ : '';
440
+ taskPrompt = `[READ-ONLY MODE: 파일 수정/생성 금지. 읽기와 분석만 수행.${dispatchNote}]\n\n${task}`;
441
+ }
442
+ const dispatchScript = path.join(tmpDir, `dispatch-${roleId}-${Date.now()}.py`);
443
+ if (subordinates.length > 0) {
444
+ fs.writeFileSync(dispatchScript, DISPATCH_SCRIPT, { mode: 0o755 });
445
+ }
446
+
447
+ // Consult Bridge — available to ALL roles (not just managers)
448
+ const consultScript = path.join(tmpDir, `consult-${roleId}-${Date.now()}.py`);
449
+ fs.writeFileSync(consultScript, CONSULT_SCRIPT, { mode: 0o755 });
450
+
451
+ // Supervision Bridge — for C-Level roles with subordinates + heartbeat enabled
452
+ const supervisionScript = path.join(tmpDir, `supervision-${roleId}-${Date.now()}.py`);
453
+ if (subordinates.length > 0) {
454
+ fs.writeFileSync(supervisionScript, SUPERVISION_SCRIPT, { mode: 0o755 });
455
+ }
456
+
457
+ // 5. Playwright MCP 설정 — 각 runner 인스턴스가 독립 브라우저 사용
458
+ const runnerOutputDir = path.join(tmpDir, `playwright-${roleId}-${Date.now()}`);
459
+ fs.mkdirSync(runnerOutputDir, { recursive: true });
460
+ const mcpConfig = JSON.stringify({
461
+ mcpServers: {
462
+ playwright: {
463
+ type: 'stdio',
464
+ command: process.env.PLAYWRIGHT_MCP_PATH || 'npx',
465
+ args: process.env.PLAYWRIGHT_MCP_PATH
466
+ ? ['--output-dir', runnerOutputDir]
467
+ : ['@anthropic-ai/mcp-playwright', '--output-dir', runnerOutputDir],
468
+ },
469
+ },
470
+ });
471
+
472
+ // 6. CLI args 구성
473
+ const maxTurns = config.maxTurns ?? 25;
474
+ const isResume = !!config.cliSessionId;
475
+ const args: string[] = isResume
476
+ ? [
477
+ '--resume', config.cliSessionId!,
478
+ '-p',
479
+ '--output-format', 'stream-json',
480
+ '--verbose',
481
+ '--dangerously-skip-permissions',
482
+ '--max-turns', String(maxTurns),
483
+ '--mcp-config', mcpConfig,
484
+ '--strict-mcp-config',
485
+ taskPrompt,
486
+ ]
487
+ : [
488
+ '-p',
489
+ '--system-prompt', fs.readFileSync(promptFile, 'utf-8'),
490
+ '--output-format', 'stream-json',
491
+ '--verbose',
492
+ '--dangerously-skip-permissions',
493
+ '--model', config.model ?? 'claude-opus-4-6',
494
+ '--max-turns', String(maxTurns),
495
+ '--mcp-config', mcpConfig,
496
+ '--strict-mcp-config',
497
+ taskPrompt,
498
+ ];
499
+
500
+ // Disallow Agent and Task tools to force use of dispatch bridge
501
+ // For roles with subordinates (C-Level), also disallow Edit/Write to enforce delegation
502
+ const disallowed = ['Agent', 'Task'];
503
+ if (subordinates.length > 0 && !readOnly) {
504
+ disallowed.push('Edit', 'Write', 'NotebookEdit');
505
+ }
506
+ args.push('--disallowed-tools', ...disallowed);
507
+
508
+ // 7. 프로세스 생성 — 중첩 세션 방지를 위해 CLAUDECODE 환경변수 제거
509
+ const cleanEnv = { ...process.env };
510
+ delete cleanEnv.CLAUDECODE;
511
+
512
+ // Dispatch Bridge 환경변수 설정
513
+ const apiPort = process.env.PORT || '3001';
514
+ cleanEnv.DISPATCH_API_URL = `http://localhost:${apiPort}`;
515
+ cleanEnv.DISPATCH_SOURCE_ROLE = roleId;
516
+ cleanEnv.DISPATCH_SUBORDINATES = subordinates.join(', ');
517
+ cleanEnv.DISPATCH_PARENT_SESSION = config.sessionId;
518
+ cleanEnv.DISPATCH_PARENT_JOB = config.sessionId; // deprecated, kept for backward compat
519
+ // BUG-W02 fix: propagate waveId to child dispatches via env
520
+ if (config.sessionId) {
521
+ const parentSes = getSession(config.sessionId);
522
+ if (parentSes?.waveId) cleanEnv.DISPATCH_WAVE_ID = parentSes.waveId;
523
+ }
524
+ // dispatch 명령어 경로를 PATH에 추가하지 않고 절대 경로로 사용
525
+ cleanEnv.DISPATCH_CMD = dispatchScript;
526
+ cleanEnv.CONSULT_CMD = consultScript;
527
+ cleanEnv.CONSULT_SOURCE_ROLE = roleId;
528
+ if (subordinates.length > 0) {
529
+ cleanEnv.SUPERVISION_CMD = supervisionScript;
530
+ }
531
+
532
+ const modelName = config.model ?? 'claude-opus-4-6';
533
+ const codeRoot = resolveCodeRoot(companyRoot);
534
+ // Run claude -p inside knowledge/ — CLAUDE.md is there, grep searches knowledge only
535
+ const knowledgeDir = path.join(companyRoot, 'knowledge');
536
+ const cwd = fs.existsSync(knowledgeDir) ? knowledgeDir : companyRoot;
537
+
538
+ // Inject paths so agents can reference code + AKB via absolute paths
539
+ cleanEnv.TYCONO_CODE_ROOT = codeRoot;
540
+ cleanEnv.TYCONO_AKB_ROOT = companyRoot;
541
+ cleanEnv.TYCONO_KNOWLEDGE_ROOT = knowledgeDir;
542
+ console.log(`[Runner] Spawning claude ${isResume ? '--resume ' + config.cliSessionId + ' ' : ''}-p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, sessionId=${config.sessionId}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
543
+
544
+ const proc = spawn('claude', args, {
545
+ cwd,
546
+ env: cleanEnv,
547
+ stdio: ['ignore', 'pipe', 'pipe'],
548
+ });
549
+
550
+ let output = '';
551
+ let turnCount = 0;
552
+ let totalInput = 0;
553
+ let totalOutput = 0;
554
+ let capturedCliSessionId: string | undefined;
555
+ const toolCalls: RunnerResult['toolCalls'] = [];
556
+ const dispatches: RunnerResult['dispatches'] = [];
557
+ const tokenLedger = getTokenLedger(companyRoot);
558
+
559
+ const promise = new Promise<RunnerResult>((resolve, reject) => {
560
+ let buffer = '';
561
+ let resolved = false;
562
+ let exitCode: number | null = null;
563
+ let exitSignal: string | null = null;
564
+
565
+ // Safety net: if 'exit' fires but 'close' doesn't follow within 5s,
566
+ // force resolve. This handles grandchild processes keeping stdout pipe open.
567
+ proc.on('exit', (code, signal) => {
568
+ exitCode = code;
569
+ exitSignal = signal ?? null;
570
+ setTimeout(() => {
571
+ if (!resolved) {
572
+ console.warn(`[Runner] Safety net: 'close' not fired 5s after 'exit' (code=${code}, signal=${signal}). Force resolving.`);
573
+ resolved = true;
574
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
575
+ try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
576
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
577
+ try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
578
+ resolve({
579
+ output,
580
+ turns: turnCount || 1,
581
+ totalTokens: { input: totalInput, output: totalOutput },
582
+ toolCalls,
583
+ dispatches,
584
+ cliSessionId: capturedCliSessionId,
585
+ });
586
+ }
587
+ }, 5000);
588
+ });
589
+
590
+ proc.stdout.on('data', (data: Buffer) => {
591
+ buffer += data.toString();
592
+
593
+ // stream-json: 줄 단위 JSON 파싱
594
+ const lines = buffer.split('\n');
595
+ buffer = lines.pop() ?? ''; // 마지막 불완전 줄은 버퍼에 보관
596
+
597
+ for (const line of lines) {
598
+ if (!line.trim()) continue;
599
+ try {
600
+ const event = JSON.parse(line);
601
+ processStreamEvent(event, callbacks, {
602
+ appendOutput: (t) => { output += t; },
603
+ addToolCall: (name, input) => {
604
+ toolCalls.push({ name, input });
605
+ // Dispatch detection removed — child jobs created by the Python
606
+ // dispatch bridge script via POST /api/jobs with parentSessionId.
607
+ // JobManager.startJob() now auto-emits dispatch:start on parent stream.
608
+ },
609
+ incrementTurn: () => { turnCount++; callbacks.onTurnComplete?.(turnCount); },
610
+ captureCliSessionId: (id) => { capturedCliSessionId = id; },
611
+ recordTokens: (input, out) => {
612
+ totalInput += input;
613
+ totalOutput += out;
614
+ tokenLedger.record({
615
+ ts: new Date().toISOString(),
616
+ sessionId: config.sessionId,
617
+ roleId,
618
+ model: modelName,
619
+ inputTokens: input,
620
+ outputTokens: out,
621
+ });
622
+ },
623
+ });
624
+ } catch {
625
+ // JSON 파싱 실패 — 일반 텍스트로 처리
626
+ output += line;
627
+ callbacks.onText?.(line);
628
+ }
629
+ }
630
+ });
631
+
632
+ proc.stderr.on('data', (data: Buffer) => {
633
+ callbacks.onError?.(data.toString());
634
+ });
635
+
636
+ proc.on('close', (code, signal) => {
637
+ if (resolved) {
638
+ console.log(`[Runner] 'close' fired after safety-net resolve (code=${code}, signal=${signal})`);
639
+ return;
640
+ }
641
+ resolved = true;
642
+ console.log(`[Runner] Done: code=${code}, signal=${signal}, output=${output.length}chars`);
643
+ // 버퍼에 남은 데이터 처리
644
+ if (buffer.trim()) {
645
+ try {
646
+ const event = JSON.parse(buffer);
647
+ processStreamEvent(event, callbacks, {
648
+ appendOutput: (t) => { output += t; },
649
+ addToolCall: (name, input) => { toolCalls.push({ name, input }); },
650
+ incrementTurn: () => { turnCount++; },
651
+ recordTokens: (input, out) => {
652
+ totalInput += input;
653
+ totalOutput += out;
654
+ tokenLedger.record({
655
+ ts: new Date().toISOString(),
656
+ sessionId: config.sessionId,
657
+ roleId,
658
+ model: modelName,
659
+ inputTokens: input,
660
+ outputTokens: out,
661
+ });
662
+ },
663
+ });
664
+ } catch {
665
+ output += buffer;
666
+ callbacks.onText?.(buffer);
667
+ }
668
+ }
669
+
670
+ // 임시 파일 정리
671
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
672
+ try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
673
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
674
+ try { fs.unlinkSync(supervisionScript); } catch { /* ignore */ }
675
+ try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
676
+
677
+ // 비정상 종료 시에도 결과 반환 (output이 있을 수 있으므로)
678
+ resolve({
679
+ output,
680
+ turns: turnCount || 1,
681
+ totalTokens: { input: totalInput, output: totalOutput },
682
+ toolCalls,
683
+ dispatches,
684
+ });
685
+ });
686
+
687
+ proc.on('error', (err) => {
688
+ if (resolved) return;
689
+ resolved = true;
690
+ try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
691
+ try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
692
+ try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
693
+ try { fs.unlinkSync(supervisionScript); } catch { /* ignore */ }
694
+ try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
695
+ reject(err);
696
+ });
697
+ });
698
+
699
+ return {
700
+ promise,
701
+ abort: () => proc.kill('SIGTERM'),
702
+ };
703
+ }
704
+ }
705
+
706
+ /* ─── Stream JSON Event Handler ──────────────── */
707
+
708
+ interface StreamHandlers {
709
+ appendOutput: (text: string) => void;
710
+ addToolCall: (name: string, input?: Record<string, unknown>) => void;
711
+ incrementTurn: () => void;
712
+ recordTokens?: (inputTokens: number, outputTokens: number) => void;
713
+ captureCliSessionId?: (id: string) => void;
714
+ }
715
+
716
+ function processStreamEvent(
717
+ event: Record<string, unknown>,
718
+ callbacks: RunnerCallbacks,
719
+ handlers: StreamHandlers,
720
+ ): void {
721
+ const type = event.type as string;
722
+
723
+ switch (type) {
724
+ case 'assistant': {
725
+ // stream-json format: { type: "assistant", message: { content: [...] } }
726
+ const message = event.message as Record<string, unknown> | undefined;
727
+ const content = message?.content ?? event.content;
728
+
729
+ if (Array.isArray(content)) {
730
+ for (const block of content as Record<string, unknown>[]) {
731
+ if (block.type === 'text' && typeof block.text === 'string') {
732
+ handlers.appendOutput(block.text);
733
+ callbacks.onText?.(block.text);
734
+ } else if (block.type === 'tool_use' && typeof block.name === 'string') {
735
+ handlers.addToolCall(block.name, block.input as Record<string, unknown>);
736
+ callbacks.onToolUse?.(block.name, block.input as Record<string, unknown>);
737
+ } else if (block.type === 'thinking' && typeof block.thinking === 'string') {
738
+ callbacks.onThinking?.(block.thinking);
739
+ }
740
+ }
741
+ }
742
+ // Turn tracking
743
+ handlers.incrementTurn();
744
+ break;
745
+ }
746
+
747
+ case 'result': {
748
+ // 최종 결과에서 토큰 사용량 추출
749
+ // modelUsage가 가장 정확 (모델별 cache 포함 상세)
750
+ // fallback: usage.input_tokens / output_tokens (cache 제외)
751
+ if (handlers.recordTokens) {
752
+ let inputTk = 0;
753
+ let outputTk = 0;
754
+
755
+ const modelUsage = event.modelUsage as Record<string, Record<string, number>> | undefined;
756
+ if (modelUsage) {
757
+ // Sum across all models (usually just one)
758
+ for (const mu of Object.values(modelUsage)) {
759
+ inputTk += (mu.inputTokens ?? 0) + (mu.cacheReadInputTokens ?? 0) + (mu.cacheCreationInputTokens ?? 0);
760
+ outputTk += mu.outputTokens ?? 0;
761
+ }
762
+ } else {
763
+ // Fallback to usage field
764
+ const usage = event.usage as Record<string, number> | undefined;
765
+ if (usage) {
766
+ inputTk = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
767
+ outputTk = usage.output_tokens ?? 0;
768
+ }
769
+ }
770
+
771
+ if (inputTk > 0 || outputTk > 0) {
772
+ handlers.recordTokens(inputTk, outputTk);
773
+ }
774
+ }
775
+ // Capture CLI session ID for --resume support
776
+ const sid = event.session_id ?? (event as Record<string, unknown>).sessionId;
777
+ if (typeof sid === 'string' && handlers.captureCliSessionId) {
778
+ handlers.captureCliSessionId(sid);
779
+ }
780
+ break;
781
+ }
782
+
783
+ case 'content_block_delta': {
784
+ const delta = event.delta as Record<string, unknown> | undefined;
785
+ if (delta && typeof delta.text === 'string') {
786
+ handlers.appendOutput(delta.text);
787
+ callbacks.onText?.(delta.text);
788
+ }
789
+ break;
790
+ }
791
+
792
+ default:
793
+ // system, ping, 기타 이벤트 무시
794
+ break;
795
+ }
796
+ }