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.
- package/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- 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
|
+
}
|