Live dashboard pane (Korean UI): the main session, subagents, agent teams and workflows, each with what it is doing, progress (done/total), tokens and run…

Claude Code 세션에서 지금 돌아가는 모든 작업을 한 창에서 보여 주는 mod입니다. 메인 세션, 서브에이전트, 에이전트 팀, 워크플로와 그 에이전트마다 무엇을 하고 있는지, 진행 상황(완료/전체), 쓴 토큰과 실행 시간을 표시하고, 프롬프트 위에는 5시간·주간 사용 한도 게이지를 띄웁니다. (화면 문구는 한국어)
A Claude Code mod: a live pane of everything running in the session — the main session, subagents, agent teams and workflows — with what each is doing, progress (done/total), tokens and run time, detail tabs, and 5-hour / weekly limit gauges above the prompt. The UI text is in Korean.
대시보드 · 3개 실행 중 ↑입력 ↓출력 · 시간
☐ 메인 세션 (2/5) ↑230k ↓4.1k
대시보드 탭 구현 중
☐ 에이전트 팀 alpha (1/4) ↑80k ↓6k · 4m10s
1명 작업 중
☐ researcher · 자료 조사 (0/2) ↑40k ↓3k · 4m10s
☐ 탐색 에이전트 · 코드 구조 탐색 (0/1) ↑41k ↓2k · 1m20s
코드 찾는 중
☐ 워크플로 · review-changes (1/3) ↑50k ↓4k · 1m20s
2개 실행 중
☑ review:bugs (1/1) ↑20k ↓2k · 40s
☐ review:perf (0/1) ↑30k ↓2k · 1m20s
plan.ts 읽는 중
완료
☑ 범용 에이전트 · 문서 조사 (1/1) ↑20k ↓1k · 40s
Claude Code 터미널 세션의 프롬프트에서:
/plugin install session-dashboard --marketplace comdoly-life/claude-session-dashboard
셸에서:
claude plugin install session-dashboard --marketplace comdoly-life/claude-session-dashboard
마켓플레이스를 추가할지 물으면 y, 이어서 설치 범위(scope)를 고릅니다. 이미 열려 있는 세션에는 /reload-plugins 로 불러옵니다.
(완료/전체): 워크플로는 스크립트를 미리 읽어 센 에이전트 수(결과에 따라 늘어나면 +), 메인 세션·서브에이전트는 할 일 목록, 팀은 작업 목록, 목록이 없는 에이전트는 0/1 → 1/1.↑입력 ↓출력)과 실행 시간, 실행 중인 항목 아래 줄에 지금 하는 일을 짧게 (도구 이름이나 실행 경로 대신 "plan.ts 읽는 중", "코드 찾는 중"처럼).× 나 맨 위 [ × 닫기 ] 로 닫고, ← 전체 로 보드로 돌아갑니다.Read 3/3 ✓ › Summarize 0/1), 에이전트 목록(클릭하면 그 에이전트 탭)./dash 로 대시보드 창을 열고 닫습니다. 세션이 시작되면 자동으로 열립니다.SubagentStart/SubagentStop, 작업 목록)를 받아 세션 메모리에만 기록합니다.~/.claude/projects/<프로젝트>/<세션>/subagents/workflows/<run>/journal.jsonl)과 저장된 스크립트를 5초마다 읽어, 플러그인을 다시 불러오기 전에 시작한 실행도 보여 줍니다 (HOME, CLAUDE_CONFIG_DIR 환경 변수로 위치를 찾습니다).$.store)에는 한도 게이지 기록과 최근 훅 오류만 남깁니다.⚠ 줄로 보여 줍니다.while 반복 등)는 최소값과 + 로 표시하고 실제로 늘어나면 따라갑니다.claude plugin validate .
claude plugin test .
hooks/register.tsx 가 훅과 그리기를, hooks/board.ts 가 보드 모델, hooks/view.tsx 가 화면, hooks/workflow.ts·hooks/plan.ts 가 워크플로 추적과 스크립트 분석, hooks/format.ts 가 표기를 맡습니다.
hooks/register.tsx 597 lines1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register, SessionUsage } from 'claude-code'
3
4import type { Activity, LimitWindow, Meter, Tabs, TaskItem, Todo, WorkflowCall } from '../types'
5import { ALL, buildBoard, closeTab, openTab, showTab } from './board'
6import {
7 MAIN,
8 activityOf,
9 addUsage,
10 brief,
11 describeCall,
12 fit,
13 freshActivity,
14 isCurrent,
15 limitSegment,
16 recordLimit,
17 sumTokens,
18 tokenText,
19} from './format'
20import { drawPane } from './view'
21import {
22 bindAgent,
23 callOfScript,
24 endWorkflow,
25 finishTask,
26 keepCall,
27 readJournal,
28 readNotice,
29 readWorkflowCall,
30 refuseTask,
31 scriptPaths,
32 startTask,
33 syncRun,
34 taskLabel,
35} from './workflow'
36
37const PANE = 'session-dashboard'
38const TITLE = '대시보드'
39const STORE_KEY = 'limit-windows'
40const FAULTS_KEY = 'faults'
41const TICK_MS = 5000
42const MAX_FINISHED = 6
43const MAX_TASKS = 6
44const MAX_RECENT = 12
45const MAX_FAULTS = 5
46const MAX_RUNS = 12
47/** A run whose journal and agents have been still this long has stopped unseen. */
48const STALE_MS = 15 * 60_000
49const BAND_GAP = ' '
50// The Agent tool's own call spans a foreground subagent's run, and the
51// Workflow tool's launches a run's agents: a tool.call hook on either would
52// sit above those runs, and a hook is never run beneath its own frame.
53const NOT_AGENT = /^(?!(?:Agent|Task|Workflow)$)/
54
55const activity = atom({ plugin: 'session-dashboard', key: 'activity' } as const, {})
56const meter = atom({ plugin: 'session-dashboard', key: 'meter' } as const, { limits: [] })
57const tick = atom({ plugin: 'session-dashboard', key: 'tick' } as const, 0)
58const calls = atom({ plugin: 'session-dashboard', key: 'calls' } as const, {})
59const workflows = atom({ plugin: 'session-dashboard', key: 'workflows' } as const, [])
60const tasks = atom({ plugin: 'session-dashboard', key: 'tasks' } as const, {})
61const tabs = atom({ plugin: 'session-dashboard', key: 'tabs' } as const, { open: [], active: ALL })
62const faults = atom({ plugin: 'session-dashboard', key: 'faults' } as const, [])
63
64type Engine = EngineInterface
65
66/** Where this session keeps its transcript folder, once found. */
67const place: { id?: string; dir?: string } = {}
68/** Each run's journal time when last read, and what its saved script said. */
69const journals = new Map<string, number>()
70const scripts = new Map<string, WorkflowCall | null>()
71let isSyncing = false
72
73async function touch($: Engine, key: string, fn: (one: Activity, now: number) => Activity) {
74 const now = await $.clock.now()
75 await update($, activity, all => ({ ...all, [key]: { ...fn(all[key] ?? freshActivity(now), now), lastAt: now } }))
76}
77
78/** Keeps what failed where the board shows it, the store and the debug log. */
79async function fault($: Engine, where: string, error: unknown) {
80 const message = (error instanceof Error ? error.message : String(error)).slice(0, 200)
81 try {
82 $.ui.log(`session-dashboard ${where}: ${message}`, { to: 'debug' })
83 const at = await $.clock.now()
84 await update($, faults, list => [...list, { at, where, message }].slice(-MAX_FAULTS))
85 await $.store.set(FAULTS_KEY, await read($, faults))
86 } catch {
87 // Nowhere left to say it.
88 }
89}
90
91/** Runs a piece of bookkeeping; a failure costs the board a figure, never the event. */
92async function safe($: Engine, where: string, work: () => Promise<unknown>) {
93 try {
94 await work()
95 } catch (error) {
96 await fault($, where, error)
97 }
98}
99
100async function recordMeter($: Engine, usage: Pick<SessionUsage, 'rateLimits' | 'cost' | 'context'>) {
101 const now = await $.clock.now()
102 const prev = await read($, meter)
103 const saved = prev.limits.length > 0 ? [] : await loadSaved($)
104 const known = [...prev.limits, ...saved]
105 const fresh = usage.rateLimits.map(reading =>
106 recordLimit(known.find(one => one.kind === reading.kind), reading, now),
107 )
108 const kept = known.filter(one => isCurrent(one, now) && !fresh.some(f => f.kind === one.kind))
109 const limits = [...fresh, ...kept].sort((a, b) => order(a.kind) - order(b.kind))
110 const next: Meter = { limits }
111 if (usage.cost !== undefined) next.costUsd = usage.cost.usd
112 if (usage.context.percent !== undefined) next.contextPercent = usage.context.percent
113 await update($, meter, () => next)
114 if (fresh.length > 0) await $.store.set(STORE_KEY, limits)
115}
116
117async function loadSaved($: Engine): Promise<LimitWindow[]> {
118 const saved = await $.store.get(STORE_KEY)
119 return Array.isArray(saved) ? (saved as LimitWindow[]) : []
120}
121
122function order(kind: string): number {
123 const at = ['five_hour', 'seven_day'].indexOf(kind)
124 return at === -1 ? 9 : at
125}
126
127/** The first of `paths` that reads: the script a call names instead of sending. */
128async function scriptFile($: Engine, paths: readonly string[]): Promise<string | undefined> {
129 for (const path of paths) {
130 const text = await $.fs.read(path).catch(() => undefined)
131 if (typeof text === 'string') return text
132 }
133 return undefined
134}
135
136/** A classic event's transcript path names the session's folder exactly. */
137function noteTranscript(path: unknown) {
138 if (typeof path !== 'string' || !path.endsWith('.jsonl')) return
139 place.dir = path.slice(0, -'.jsonl'.length)
140 place.id = place.dir.split('/').pop()
141}
142
143/** The session's own folder (`<config>/projects/<project>/<session id>`), where runs keep their journals. */
144async function sessionDir($: Engine): Promise<string | undefined> {
145 const id = await $.session.id()
146 if (place.id === id && place.dir !== undefined) return place.dir
147 const home = await $.env.get('HOME')
148 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? undefined : `${home}/.claude`)
149 if (config === undefined) return undefined
150 for (const where of new Set([await $.session.root(), await $.session.cwd()])) {
151 const dir = `${config}/projects/${where.replace(/[^A-Za-z0-9]/g, '-')}/${id}`
152 if (await $.fs.exists(`${dir}.jsonl`).catch(() => false)) {
153 place.id = id
154 place.dir = dir
155 return dir
156 }
157 }
158 return undefined
159}
160
161/**
162 * Reads the session's workflow runs off disk: each run's journal (which
163 * agent it started, under what label and phase, and which returned) and its
164 * saved script (name, phases, plan). A run is shown whether or not a hook saw
165 * it start: one started before this plugin loaded, or while it reloaded.
166 */
167async function syncRuns($: Engine) {
168 if (isSyncing) return
169 isSyncing = true
170 try {
171 const dir = await sessionDir($)
172 if (dir === undefined) return
173 const root = `${dir}/subagents/workflows`
174 if (!(await $.fs.exists(root).catch(() => false))) return
175 const runs = (await $.fs.list(root).catch(() => [])).filter(entry => entry.kind === 'dir' && entry.name.startsWith('wf_'))
176 if (runs.length === 0) return
177 const saved = await $.fs.list(`${dir}/workflows/scripts`).catch(() => [])
178 const stamped = await Promise.all(
179 runs.map(async run => ({ run, stat: await $.fs.stat(`${root}/${run.name}/journal.jsonl`).catch(() => undefined) })),
180 )
181 const newest = stamped
182 .filter((one): one is { run: (typeof runs)[number]; stat: NonNullable<typeof one.stat> } => one.stat !== undefined)
183 .sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
184 .slice(0, MAX_RUNS)
185 for (const { run, stat } of newest) {
186 const runId = run.name
187 if (journals.get(runId) === stat.mtimeMs) continue
188 const entries = readJournal(await $.fs.read(`${root}/${runId}/journal.jsonl`))
189 const script = saved.find(entry => entry.kind === 'file' && entry.name.endsWith(`-${runId}.js`))
190 if (!scripts.has(runId)) {
191 const text = script === undefined ? undefined : await $.fs.read(`${dir}/workflows/scripts/${script.name}`).catch(() => undefined)
192 scripts.set(runId, typeof text === 'string' ? (callOfScript(text, await read($, calls)) ?? null) : null)
193 }
194 const call = scripts.get(runId) ?? undefined
195 const at = Math.min(stat.mtimeMs, await $.clock.now())
196 const startedAt = script?.mtimeMs !== undefined && script.mtimeMs > 0 ? script.mtimeMs : at
197 await update($, workflows, list => syncRun(list, { runId, entries, startedAt, at, ...(call !== undefined && { call }) }))
198 journals.set(runId, stat.mtimeMs)
199 }
200 await settleStale($, newest.map(one => ({ runId: one.run.name, at: one.stat.mtimeMs })))
201 } catch (error) {
202 await fault($, 'workflow sync', error)
203 } finally {
204 isSyncing = false
205 }
206}
207
208/** A run still open whose journal and agents have all been still for long stopped without a word. */
209async function settleStale($: Engine, runs: readonly { runId: string; at: number }[]) {
210 const now = await $.clock.now()
211 const all = await read($, activity)
212 const flows = await read($, workflows)
213 for (const { runId, at } of runs) {
214 const flow = flows.find(one => one.runId === runId)
215 if (flow === undefined || flow.status !== 'running' || now - at < STALE_MS) continue
216 const heard = Math.max(at, ...flow.tasks.map(t => (t.agentId === undefined ? 0 : (all[t.agentId]?.lastAt ?? 0))))
217 if (now - heard < STALE_MS) continue
218 await update($, workflows, list => endWorkflow(list, { runId, status: 'stopped' }, now))
219 }
220}
221
222function todosOf(value: unknown): Todo[] | undefined {
223 if (!Array.isArray(value)) return undefined
224 return value.flatMap((row): Todo[] => {
225 if (typeof row !== 'object' || row === null) return []
226 const { content, status, activeForm } = row as Record<string, unknown>
227 if (typeof content !== 'string' || (status !== 'pending' && status !== 'in_progress' && status !== 'completed')) return []
228 return [{ content, status, ...(typeof activeForm === 'string' && { activeForm }) }]
229 })
230}
231
232/** Follows the session's task list through the calls that change it. */
233async function noteTasks($: Engine, tool: string, input: Record<string, unknown>, output: unknown, by: string) {
234 const now = await $.clock.now()
235 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : undefined)
236 if (tool === 'TaskCreate') {
237 const created = (output as { task?: { id?: unknown; subject?: unknown } } | undefined)?.task
238 if (typeof created?.id !== 'string') return
239 const id = created.id
240 const subject = typeof created.subject === 'string' ? created.subject : (text('subject') ?? id)
241 const activeForm = text('activeForm')
242 await update($, tasks, all => ({
243 ...all,
244 [id]: { id, subject, status: all[id]?.status ?? 'pending', by, at: now, ...(activeForm !== undefined && { activeForm }) },
245 }))
246 } else if (tool === 'TaskUpdate') {
247 const id = text('taskId')
248 if (id === undefined) return
249 const status = text('status')
250 await update($, tasks, all => {
251 const { [id]: known, ...rest } = all
252 if (status === 'deleted') return rest
253 const base: TaskItem = known ?? { id, subject: text('subject') ?? id, status: 'pending', at: now }
254 const next: TaskItem = { ...base }
255 if (status === 'pending' || status === 'in_progress' || status === 'completed') next.status = status
256 const subject = text('subject')
257 if (subject !== undefined) next.subject = subject
258 const owner = text('owner')
259 if (owner !== undefined) next.owner = owner
260 const activeForm = text('activeForm')
261 if (activeForm !== undefined) next.activeForm = activeForm
262 return { ...rest, [id]: next }
263 })
264 }
265}
266
267export const register: Register = on => {
268 on('session.start', async ($, e, next) => {
269 await $.command.register({
270 name: 'dash',
271 description: '세션 대시보드(서브에이전트·팀·워크플로·토큰)를 열거나 닫습니다',
272 })
273 await safe($, 'meter', async () => recordMeter($, await $.session.usage()))
274 $.clock.every(TICK_MS, () => {
275 void update($, tick, n => n + 1)
276 void syncRuns($)
277 })
278 void syncRuns($)
279 void $.ui.open({ id: PANE, title: TITLE })
280
281 return next(e)
282 })
283
284 on('command.run', { command: 'dash' }, async $ => {
285 const pane = (await $.ui.panes()).find(one => one.id === PANE)
286 if (pane?.isShown === true) {
287 await $.ui.close({ id: PANE })
288 return { text: '대시보드를 닫았습니다.' }
289 }
290 await $.ui.open({ id: PANE, title: TITLE })
291
292 return { text: '대시보드를 열었습니다.' }
293 })
294
295 on('session.measure', async ($, e, next) => {
296 await safe($, 'meter', () => recordMeter($, e))
297
298 return next(e)
299 })
300
301 on('turn.start', async ($, e, next) => {
302 await safe($, 'turn.start', () =>
303 touch($, e.agentId ?? MAIN, one => {
304 const { endedAt: _, ...rest } = one
305 return { ...rest, isBusy: true }
306 }),
307 )
308
309 return next(e)
310 })
311
312 on('turn.step', async function* ($, e, next) {
313 const result = yield* next(e)
314 const usage = result.usage
315 if (usage !== null) {
316 await safe($, 'turn.step', () => touch($, e.agentId ?? MAIN, one => ({ ...one, tokens: addUsage(one.tokens, usage) })))
317 }
318
319 return result
320 })
321
322 on('turn.complete', async ($, e, next) => {
323 const agentId = e.agentId
324 const usage = e.usage
325 await safe($, 'turn.complete', async () => {
326 // A loop whose steps passed this plugin by is counted at its end.
327 await touch($, agentId ?? MAIN, (one, now) => ({
328 ...one,
329 isBusy: false,
330 running: [],
331 endedAt: now,
332 tokens: one.tokens.requests === 0 && usage !== undefined ? addUsage(one.tokens, usage) : one.tokens,
333 }))
334 if (agentId === undefined) return
335 const isTask = (await read($, workflows)).some(flow => flow.tasks.some(t => t.agentId === agentId))
336 if (!isTask) return
337 const now = await $.clock.now()
338 const tokens = (await read($, activity))[agentId]?.tokens ?? freshActivity(now).tokens
339 await update($, workflows, list => finishTask(list, agentId, { isFailed: e.reason !== 'answer', tokens, now }))
340 })
341
342 return next(e)
343 })
344
345 on('classic.SubagentStart', async ($, e, next) => {
346 await safe($, 'SubagentStart', async () => {
347 noteTranscript(e.transcript_path)
348 await touch($, e.agent_id, (one, now) => {
349 const { stoppedAt: _, ...rest } = one
350 return { ...rest, kind: e.agent_type, firstSeen: Math.min(one.firstSeen, now) }
351 })
352 if (e.agent_type === 'workflow-subagent') await syncRuns($)
353 })
354
355 return next(e)
356 })
357
358 on('classic.SubagentStop', async ($, e, next) => {
359 await safe($, 'SubagentStop', async () => {
360 noteTranscript(e.transcript_path)
361 const reply = typeof e.last_assistant_message === 'string' ? e.last_assistant_message.slice(0, 600) : undefined
362 await touch($, e.agent_id, (one, now) => ({
363 ...one,
364 isBusy: false,
365 running: [],
366 stoppedAt: now,
367 ...(reply !== undefined && { reply }),
368 }))
369 })
370
371 return next(e)
372 })
373
374 on('classic.TaskCreated', async ($, e, next) => {
375 await safe($, 'TaskCreated', async () => {
376 const at = await $.clock.now()
377 const id = e.task_id
378 await update($, tasks, all => ({
379 ...all,
380 [id]: { ...all[id], id, subject: e.task_subject, status: all[id]?.status ?? 'pending', at: all[id]?.at ?? at },
381 }))
382 })
383
384 return next(e)
385 })
386
387 on('classic.TaskCompleted', async ($, e, next) => {
388 await safe($, 'TaskCompleted', async () => {
389 const at = await $.clock.now()
390 const id = e.task_id
391 await update($, tasks, all => ({
392 ...all,
393 [id]: { ...all[id], id, subject: all[id]?.subject ?? e.task_subject, status: 'completed' as const, at: all[id]?.at ?? at },
394 }))
395 })
396
397 return next(e)
398 })
399
400 on('tool.check', { tool: 'Workflow' }, async ($, e, next) => {
401 const verdict = await next(e)
402 const id = e.tool_use_id
403 if (id === undefined || verdict.decision === 'deny') return verdict
404 await safe($, 'Workflow check', async () => {
405 const call = readWorkflowCall(e.input, await scriptFile($, scriptPaths(e.input)))
406 if (call !== undefined) await update($, calls, all => keepCall(all, id, call))
407 })
408
409 return verdict
410 })
411
412 // 서브에이전트·팀원·워크플로 에이전트가 받은 지시를 한 줄로: 세부 탭의 "맡은 일"
413 on('agent.spawn', async ($, e, next) => {
414 const result = await next(e)
415 const agentId = result.agentId
416 const task = brief(String(e.prompt ?? ''))
417 if (agentId !== undefined && task !== '') {
418 await safe($, 'agent.spawn', () => touch($, agentId, one => ({ ...one, task })))
419 }
420
421 return result
422 })
423
424 // 메인 세션의 "맡은 일": 사람이 직접(터미널·원격·SDK) 보낸 마지막 요청. 슬래시 명령, 보이지 않는 알림,
425 // 다른 세션이 보낸 메시지는 덮어쓰지 않는다.
426 on('prompt.submit', async ($, e, next) => {
427 const kind = e.origin.kind
428 const isOwn = kind === 'composer' || kind === 'bridge' || kind === 'sdk'
429 if (isOwn && e.command === undefined && e.isMeta !== true) {
430 const task = brief(e.text)
431 if (task !== '') await safe($, 'prompt.submit', () => touch($, MAIN, one => ({ ...one, task })))
432 }
433
434 return next(e)
435 })
436
437 on('agent.spawn', { workflow: { runId: /^wf_/ } }, async ($, e, next) => {
438 const run = e.workflow
439 if (run === undefined) return next(e)
440 const key = e.tool_use_id ?? ''
441 const { runId, agentIndex: index } = run
442 await safe($, 'workflow spawn', async () => {
443 const now = await $.clock.now()
444 const call = key === '' ? undefined : (await read($, calls))[key]
445 const label = taskLabel(String(e.description ?? ''), String(e.prompt ?? ''))
446 await update($, workflows, list => startTask(list, { key, runId, index, label, now, ...(call && { call }) }))
447 })
448 const result = await next(e)
449 await safe($, 'workflow spawn', async () => {
450 if (result.agentId !== undefined) {
451 const agentId = result.agentId
452 await update($, workflows, list => bindAgent(list, runId, key, index, agentId))
453 } else if (result.deny !== undefined) {
454 const now = await $.clock.now()
455 await update($, workflows, list => refuseTask(list, runId, key, index, now))
456 }
457 })
458
459 return result
460 })
461
462 on('session.append', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
463 const stored = await next(e)
464 await safe($, 'task notification', async () => {
465 const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
466 const notice = readNotice(text)
467 if (notice === undefined) return
468 if (notice.runId !== undefined) journals.delete(notice.runId)
469 await syncRuns($)
470 const now = await $.clock.now()
471 await update($, workflows, list => endWorkflow(list, notice, now))
472 })
473
474 return stored
475 })
476
477 on('tool.call', { tool: NOT_AGENT }, async ($, e, next) => {
478 const key = e.agentId ?? MAIN
479 const input = e as unknown as Record<string, unknown>
480 const call = { id: e.tool_use_id, text: describeCall(e.tool, input), doing: activityOf(e.tool, input) }
481 const todos = e.tool === 'TodoWrite' ? todosOf(input.todos) : undefined
482 await safe($, 'tool.call', () =>
483 touch($, key, one => ({
484 ...one,
485 isBusy: true,
486 running: [...one.running, call].slice(-8),
487 ...(todos !== undefined && { todos }),
488 })),
489 )
490 let output: unknown
491 try {
492 const result = await next(e)
493 if (result.deny === undefined && result.isError !== true) output = result.result
494 return result
495 } finally {
496 await safe($, 'tool.call', async () => {
497 await touch($, key, one => ({
498 ...one,
499 running: one.running.filter(c => c.id !== call.id),
500 last: call.text,
501 recent: [...(one.recent ?? []), call.text].slice(-MAX_RECENT),
502 tools: one.tools + 1,
503 }))
504 if (output !== undefined) await noteTasks($, e.tool, input, output, key)
505 })
506 }
507 })
508
509 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
510 const kit = $.ui.resolve(e)
511 const { Box, Text } = kit
512 try {
513 await read($, tick)
514 const now = await $.clock.now()
515 const all = await read($, activity)
516 const { costUsd, contextPercent } = await read($, meter)
517 const agents = await $.agent.list().catch((): AgentInfo[] => [])
518 const flows = await read($, workflows)
519 const taskList = await read($, tasks)
520 const tabState = await read($, tabs)
521 const faultList = await read($, faults)
522 const board = buildBoard({ now, agents, activity: all, flows, tasks: taskList, maxTasks: MAX_TASKS, maxFinished: MAX_FINISHED })
523 const total = sumTokens(Object.values(all).map(one => one.tokens))
524 const totals = [
525 `합계 ${tokenText(total) || '0'}`,
526 costUsd === undefined ? '' : `$${costUsd.toFixed(2)}`,
527 contextPercent === undefined ? '' : `컨텍스트 ${contextPercent}%`,
528 ]
529 .filter(Boolean)
530 .join(' · ')
531 const press = (change: (state: Tabs) => Tabs) => {
532 void update($, tabs, change).catch(error => fault($, 'tab', error))
533 }
534 return drawPane({
535 kit,
536 now,
537 width: e.props.bodyColumns,
538 board,
539 tabs: tabState,
540 agents,
541 activity: all,
542 flows,
543 tasks: taskList,
544 faults: faultList,
545 totals,
546 open: (key, title) => press(state => openTab(state, { key, title: fit(title, 24) })),
547 close: key => press(state => closeTab(state, key)),
548 show: key => press(state => showTab(state, key)),
549 })
550 } catch (error) {
551 const message = error instanceof Error ? error.message : String(error)
552 void $.store.set(FAULTS_KEY, [{ at: Date.now(), where: 'render', message }]).catch(() => undefined)
553 return (
554 <Box flexDirection="column">
555 <Text color="error">대시보드를 그리지 못했습니다.</Text>
556 <Text dimColor wrap="wrap">
557 {message}
558 </Text>
559 </Box>
560 )
561 }
562 })
563
564 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
565 if (e.props.hasSurvey) return next(e)
566 await read($, tick)
567 const now = await $.clock.now()
568 const windows = (await read($, meter)).limits.filter(one => isCurrent(one, now))
569 if (windows.length === 0) return next(e)
570
571 const { Box, Text } = $.ui.resolve(e)
572 const room = Math.floor((e.props.bodyColumns - BAND_GAP.length * (windows.length - 1)) / windows.length)
573
574 // The blank row above keeps the gauges apart from the transcript's output.
575 return (
576 <Box flexDirection="column" marginTop={1}>
577 <Text wrap="truncate-end">
578 {windows.map((window, index) => {
579 const segment = limitSegment(window, now, room)
580 return (
581 <Text>
582 {index > 0 ? BAND_GAP : ''}
583 <Text weight="bold">{segment.label}</Text>{' '}
584 {segment.bar !== undefined && <Text color={segment.tone}>{segment.bar.filled}</Text>}
585 {segment.bar !== undefined && <Text dimColor>{`${segment.bar.empty} `}</Text>}
586 <Text color={segment.tone} weight="bold">{segment.percent}</Text>
587 {segment.reset !== undefined && <Text dimColor>{` ${segment.reset}`}</Text>}
588 {segment.warn !== undefined && <Text color={segment.tone}>{` ${segment.warn}`}</Text>}
589 </Text>
590 )
591 })}
592 </Text>
593 </Box>
594 )
595 })
596}
597hooks/board.ts 343 lines1import type { AgentInfo, AgentStatus } from 'claude-code'
2
3import type { Activity, Tab, Tabs, TaskItem, Todo, Tokens, Workflow, WorkflowTask } from '../types'
4import { MAIN, ZERO_TOKENS, sumTokens } from './format'
5import { finishedAt, progress, standing, visibleTasks } from './workflow'
6
7/** Where an item of the board stands: `idle` is alive but waiting (a teammate, the main session). */
8export type ItemStatus = 'running' | 'idle' | 'done' | 'failed'
9
10export type ItemKind = 'main' | 'agent' | 'team' | 'teammate' | 'flow' | 'task'
11
12export type Count = { done: number; total: number; isOpen?: boolean }
13
14/** One row of the board and what its detail tab opens on. */
15export type Item = {
16 /** The tab key: `main`, `agent:<id>`, `team`, `flow:<key>` or `task:<flow key>:<index>`. */
17 key: string
18 kind: ItemKind
19 title: string
20 sub?: string
21 status: ItemStatus
22 progress?: Count
23 tokens: Tokens
24 startedAt?: number
25 endedAt?: number
26 /** What it is doing now, in plain words. */
27 doing?: string
28 children: Item[]
29 /** Settled children left off the board. */
30 folded?: number
31 /** A shorter label for its tab, when the title leads with its kind. */
32 tab?: string
33}
34
35export type Board = { main: Item; running: Item[]; finished: Item[]; hidden: number }
36
37export type BoardInput = {
38 now: number
39 agents: readonly AgentInfo[]
40 activity: Readonly<Record<string, Activity>>
41 flows: readonly Workflow[]
42 tasks: Readonly<Record<string, TaskItem>>
43 /** Rows a running workflow lists before it folds its settled agents. */
44 maxTasks: number
45 /** Settled items the board keeps before it counts the rest. */
46 maxFinished: number
47}
48
49const MAX_DEPTH = 4
50
51/** The engine's agent types in Korean; a plugin's or a custom one keeps its own name. */
52const TYPE_LABELS: Record<string, string> = {
53 'general-purpose': '범용 에이전트',
54 claude: '범용 에이전트',
55 Explore: '탐색 에이전트',
56 Plan: '계획 에이전트',
57 'claude-code-guide': 'Claude Code 안내',
58 'statusline-setup': '상태 줄 설정',
59 'workflow-subagent': '워크플로 에이전트',
60 teammate: '팀원',
61 fork: '포크',
62}
63
64export function typeLabel(type: string): string {
65 return TYPE_LABELS[type] ?? type
66}
67
68/**
69 * The board: the main session, then everything that runs (the agent team,
70 * subagents and their children, workflows and their agents, agents only a
71 * SubagentStart named), then what settled, newest first.
72 */
73export function buildBoard(input: BoardInput): Board {
74 const { now, agents, activity, flows, tasks } = input
75 const allTasks = Object.values(tasks)
76 const linked = new Set(flows.flatMap(flow => flow.tasks.map(t => t.agentId ?? '')))
77 const listed = new Set(agents.map(agent => agent.id))
78 const live = (id: string | undefined, fallback: Tokens = ZERO_TOKENS) => {
79 const tokens = id === undefined ? undefined : activity[id]?.tokens
80 return tokens !== undefined && tokens.requests > 0 ? tokens : fallback
81 }
82
83 const mainOne = activity[MAIN]
84 const main: Item = {
85 key: 'main',
86 kind: 'main',
87 title: '메인 세션',
88 status: mainOne?.isBusy === true ? 'running' : 'idle',
89 tokens: mainOne?.tokens ?? ZERO_TOKENS,
90 doing: doingOf(mainOne, allTasks.find(t => t.status === 'in_progress' && (t.by === MAIN || t.owner === undefined))) ??
91 (mainOne?.isBusy === true ? '생각 중' : '대기 중'),
92 children: [],
93 }
94 const mainCount = todoCount(mainOne?.todos) ?? taskCount(allTasks)
95 if (mainCount !== undefined) main.progress = mainCount
96
97 const tops: Item[] = []
98
99 const teammates = agents.filter(isTeammate)
100 if (teammates.length > 0) tops.push(teamItem(teammates, allTasks, activity))
101
102 const subagents = agents.filter(agent => !isTeammate(agent))
103 const ids = new Set(subagents.map(agent => agent.id))
104 const childrenOf = new Map<string, AgentInfo[]>()
105 for (const agent of subagents) {
106 const parent = agent.parentId !== undefined && ids.has(agent.parentId) ? agent.parentId : ''
107 childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), agent])
108 }
109 const agentItem = (agent: AgentInfo, depth: number): Item => {
110 const one = activity[agent.id]
111 const status = statusOf(agent.status)
112 const item: Item = {
113 key: `agent:${agent.id}`,
114 kind: 'agent',
115 title: agent.name ?? typeLabel(agent.type),
116 sub: agent.description,
117 status,
118 progress: todoCount(one?.todos) ?? single(status),
119 tokens: one?.tokens ?? ZERO_TOKENS,
120 children: depth >= MAX_DEPTH ? [] : (childrenOf.get(agent.id) ?? []).map(child => agentItem(child, depth + 1)),
121 }
122 if (one !== undefined) item.startedAt = one.firstSeen
123 const end = one?.stoppedAt ?? one?.endedAt
124 if (isSettled(status) && end !== undefined) item.endedAt = end
125 const doing = status === 'running' ? (doingOf(one) ?? '생각 중') : undefined
126 if (doing !== undefined) item.doing = doing
127 return item
128 }
129 tops.push(...(childrenOf.get('') ?? []).map(agent => agentItem(agent, 0)))
130
131 for (const flow of flows) tops.push(flowItem(flow, now, input.maxTasks, activity, live))
132
133 // A loop only a SubagentStart named: a workflow agent before its run's
134 // journal is read, or one of a run this session never filed.
135 for (const [id, one] of Object.entries(activity)) {
136 if (id === MAIN || listed.has(id) || linked.has(id) || one.kind === undefined) continue
137 const status: ItemStatus = one.stoppedAt !== undefined ? 'done' : 'running'
138 const item: Item = {
139 key: `agent:${id}`,
140 kind: 'agent',
141 title: typeLabel(one.kind),
142 status,
143 progress: todoCount(one.todos) ?? single(status),
144 tokens: one.tokens,
145 startedAt: one.firstSeen,
146 children: [],
147 }
148 if (one.stoppedAt !== undefined) item.endedAt = one.stoppedAt
149 const doing = status === 'running' ? (doingOf(one) ?? '생각 중') : undefined
150 if (doing !== undefined) item.doing = doing
151 tops.push(item)
152 }
153
154 const running = tops
155 .filter(item => !isSettled(item.status))
156 .sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0))
157 const settled = tops
158 .filter(item => isSettled(item.status))
159 .sort((a, b) => (b.endedAt ?? b.startedAt ?? 0) - (a.endedAt ?? a.startedAt ?? 0))
160 return {
161 main,
162 running,
163 finished: settled.slice(0, input.maxFinished),
164 hidden: Math.max(0, settled.length - input.maxFinished),
165 }
166}
167
168function flowItem(
169 flow: Workflow,
170 now: number,
171 maxTasks: number,
172 activity: Readonly<Record<string, Activity>>,
173 live: (id: string | undefined, fallback?: Tokens) => Tokens,
174): Item {
175 const standingNow = standing(flow, now)
176 const count = progress(flow, standingNow)
177 const status: ItemStatus = standingNow === 'running' ? 'running' : standingNow === 'done' ? 'done' : 'failed'
178 const { shown, folded } = visibleTasks(flow, maxTasks)
179 const item: Item = {
180 key: `flow:${flow.key}`,
181 kind: 'flow',
182 title: `워크플로 · ${flow.name}`,
183 tab: flow.name,
184 status,
185 progress: { done: count.done, total: count.total, isOpen: count.isOpen },
186 tokens: sumTokens(flow.tasks.map(task => live(task.agentId, task.tokens))),
187 startedAt: flow.startedAt,
188 children: status === 'running' ? shown.map(task => taskItem(flow, task, activity, live)) : [],
189 folded: status === 'running' ? folded : 0,
190 }
191 if (status !== 'running') {
192 const end = finishedAt(flow)
193 if (end !== undefined) item.endedAt = end
194 } else {
195 item.doing = [
196 count.running > 0 ? `${count.running}개 실행 중` : '',
197 count.waiting > 0 ? `${count.waiting}개 대기` : '',
198 count.failed > 0 ? `${count.failed}개 실패` : '',
199 ]
200 .filter(Boolean)
201 .join(' · ')
202 }
203 return item
204}
205
206export function taskItem(
207 flow: Workflow,
208 task: WorkflowTask,
209 activity: Readonly<Record<string, Activity>>,
210 live: (id: string | undefined, fallback?: Tokens) => Tokens,
211): Item {
212 const status: ItemStatus = task.status === 'running' ? 'running' : task.status === 'done' ? 'done' : 'failed'
213 const item: Item = {
214 key: `task:${flow.key}:${task.index}`,
215 kind: 'task',
216 title: task.label,
217 status,
218 progress: single(status),
219 tokens: live(task.agentId, task.tokens),
220 startedAt: task.startedAt,
221 children: [],
222 }
223 if (task.phase !== undefined) item.sub = task.phase
224 if (task.endedAt !== undefined) item.endedAt = task.endedAt
225 if (status === 'running') item.doing = doingOf(task.agentId === undefined ? undefined : activity[task.agentId]) ?? '생각 중'
226 return item
227}
228
229function teamItem(members: readonly AgentInfo[], tasks: readonly TaskItem[], activity: Readonly<Record<string, Activity>>): Item {
230 const names = (agent: AgentInfo) => [agent.name, agent.teammateId?.split('@')[0]].filter((n): n is string => n !== undefined)
231 const children = members.map((agent): Item => {
232 const one = activity[agent.id]
233 const status = statusOf(agent.status)
234 const owned = tasks.filter(t => t.owner !== undefined && names(agent).includes(t.owner))
235 const item: Item = {
236 key: `agent:${agent.id}`,
237 kind: 'teammate',
238 title: agent.name ?? agent.teammateId ?? typeLabel(agent.type),
239 sub: agent.description,
240 status,
241 progress: taskCount(owned) ?? todoCount(one?.todos) ?? single(status),
242 tokens: one?.tokens ?? ZERO_TOKENS,
243 children: [],
244 }
245 if (one !== undefined) item.startedAt = one.firstSeen
246 const end = one?.stoppedAt ?? one?.endedAt
247 if (isSettled(status) && end !== undefined) item.endedAt = end
248 const doing =
249 status === 'running'
250 ? (doingOf(one, owned.find(t => t.status === 'in_progress')) ?? '생각 중')
251 : status === 'idle'
252 ? '대기 중'
253 : undefined
254 if (doing !== undefined) item.doing = doing
255 return item
256 })
257 const status: ItemStatus = children.some(c => c.status === 'running')
258 ? 'running'
259 : children.some(c => c.status === 'idle')
260 ? 'idle'
261 : children.every(c => c.status === 'done')
262 ? 'done'
263 : 'failed'
264 const team = members.map(agent => agent.teammateId?.split('@')[1]).find((name): name is string => name !== undefined)
265 const starts = children.map(c => c.startedAt).filter((at): at is number => at !== undefined)
266 const ends = children.map(c => c.endedAt).filter((at): at is number => at !== undefined)
267 const item: Item = {
268 key: 'team',
269 kind: 'team',
270 title: team === undefined ? '에이전트 팀' : `에이전트 팀 ${team}`,
271 status,
272 progress: taskCount(tasks) ?? { done: children.filter(c => isSettled(c.status)).length, total: children.length },
273 tokens: sumTokens(children.map(c => c.tokens)),
274 children,
275 }
276 if (starts.length > 0) item.startedAt = Math.min(...starts)
277 if (isSettled(status) && ends.length > 0) item.endedAt = Math.max(...ends)
278 const busy = children.filter(c => c.status === 'running').length
279 item.doing = busy > 0 ? `${busy}명 작업 중` : '모두 대기 중'
280 return item
281}
282
283/**
284 * What a loop is doing: the task or todo it marked in progress, then the
285 * call it has in flight, both in plain words.
286 */
287export function doingOf(one: Activity | undefined, task?: TaskItem): string | undefined {
288 const todo = one?.todos?.find(t => t.status === 'in_progress')
289 const plan = task?.activeForm ?? task?.subject ?? todo?.activeForm ?? todo?.content
290 const call = one?.running.at(-1)?.doing
291 const parts = [plan, call].filter((part): part is string => part !== undefined && part !== '')
292 return parts.length === 0 ? undefined : parts.join(' · ')
293}
294
295export function todoCount(todos: readonly Todo[] | undefined): Count | undefined {
296 if (todos === undefined || todos.length === 0) return undefined
297 return { done: todos.filter(t => t.status === 'completed').length, total: todos.length }
298}
299
300export function taskCount(tasks: readonly TaskItem[]): Count | undefined {
301 if (tasks.length === 0) return undefined
302 return { done: tasks.filter(t => t.status === 'completed').length, total: tasks.length }
303}
304
305/** An agent with no list of its own is one task: 0/1 until it settles. */
306function single(status: ItemStatus): Count {
307 return { done: status === 'done' ? 1 : 0, total: 1 }
308}
309
310export function statusOf(status: AgentStatus): ItemStatus {
311 if (status === 'completed') return 'done'
312 if (status === 'failed' || status === 'killed') return 'failed'
313 return status === 'idle' ? 'idle' : 'running'
314}
315
316export function isSettled(status: ItemStatus): boolean {
317 return status === 'done' || status === 'failed'
318}
319
320export function isTeammate(agent: AgentInfo): boolean {
321 return agent.teammateId !== undefined || agent.type === 'teammate'
322}
323
324/** The tab key of the board itself. */
325export const ALL = 'all'
326const MAX_TABS = 6
327
328/** Opens (or retitles) a detail tab and shows it; the oldest of too many goes. */
329export function openTab(tabs: Tabs, tab: Tab): Tabs {
330 const isOpen = tabs.open.some(one => one.key === tab.key)
331 const open = isOpen ? tabs.open.map(one => (one.key === tab.key ? tab : one)) : [...tabs.open, tab].slice(-MAX_TABS)
332 return { open, active: tab.key }
333}
334
335/** Closes a tab; closing the one shown goes back to the board. */
336export function closeTab(tabs: Tabs, key: string): Tabs {
337 return { open: tabs.open.filter(one => one.key !== key), active: tabs.active === key ? ALL : tabs.active }
338}
339
340export function showTab(tabs: Tabs, key: string): Tabs {
341 return key === ALL || tabs.open.some(one => one.key === key) ? { ...tabs, active: key } : tabs
342}
343hooks/format.ts 298 lines1import type { ModelUsage } from 'claude-code'
2
3import type { Activity, LimitWindow, Tokens } from '../types'
4
5export const MAIN = 'main'
6
7const HOUR = 3_600_000
8const WINDOW_MS: Record<string, number> = { five_hour: 5 * HOUR, seven_day: 168 * HOUR }
9const WINDOW_LABEL: Record<string, string> = {
10 five_hour: '5시간',
11 seven_day: '주간',
12 spend_limit: '지출',
13}
14const MAX_SAMPLES = 600
15const SAME_RESET_MS = 5 * 60_000
16const MIN_BAR = 6
17const MAX_BAR = 24
18const DETAIL_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt']
19
20export const ZERO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 }
21
22export function freshActivity(now: number): Activity {
23 return { running: [], tools: 0, tokens: ZERO_TOKENS, firstSeen: now, isBusy: false }
24}
25
26export function addUsage(tokens: Tokens, usage: ModelUsage): Tokens {
27 return {
28 input: tokens.input + usage.input_tokens,
29 output: tokens.output + usage.output_tokens,
30 cacheRead: tokens.cacheRead + usage.cache_read_input_tokens,
31 cacheWrite: tokens.cacheWrite + usage.cache_creation_input_tokens,
32 requests: tokens.requests + 1,
33 }
34}
35
36export function sumTokens(list: readonly Tokens[]): Tokens {
37 return list.reduce(
38 (sum, t) => ({
39 input: sum.input + t.input,
40 output: sum.output + t.output,
41 cacheRead: sum.cacheRead + t.cacheRead,
42 cacheWrite: sum.cacheWrite + t.cacheWrite,
43 requests: sum.requests + t.requests,
44 }),
45 ZERO_TOKENS,
46 )
47}
48
49/** Every input token the model read: uncached, cache-written and cache-read. */
50export function inputTotal(t: Tokens): number {
51 return t.input + t.cacheRead + t.cacheWrite
52}
53
54export function tokenText(t: Tokens): string {
55 return t.requests === 0 ? '' : `↑${compact(inputTotal(t))} ↓${compact(t.output)}`
56}
57
58export function compact(n: number): string {
59 const scaled = (x: number) => (x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, ''))
60 if (n < 1000) return String(n)
61 if (n < 1e6) return `${scaled(n / 1e3)}k`
62 if (n < 1e9) return `${scaled(n / 1e6)}M`
63 return `${scaled(n / 1e9)}B`
64}
65
66export function duration(ms: number): string {
67 const s = Math.max(0, Math.floor(ms / 1000))
68 const d = Math.floor(s / 86_400)
69 const h = Math.floor((s % 86_400) / 3600)
70 const m = Math.floor((s % 3600) / 60)
71 if (d > 0) return `${d}d${h}h`
72 if (h > 0) return `${h}h${String(m).padStart(2, '0')}m`
73 if (m > 0) return `${m}m${String(s % 60).padStart(2, '0')}s`
74 return `${s}s`
75}
76
77export function toolLabel(tool: string): string {
78 const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
79 return mcp ? `${mcp[1]}·${mcp[2]}` : tool
80}
81
82/** One line naming a tool call: the tool and its most telling argument, paths cut to their last part. */
83export function describeCall(tool: string, input: Readonly<Record<string, unknown>>): string {
84 for (const key of DETAIL_KEYS) {
85 const value = input[key]
86 if (typeof value !== 'string' || value.trim() === '') continue
87 const detail = key.endsWith('path') ? lastPart(value) : shortPaths(value.trim().split('\n')[0] ?? '')
88 return `${toolLabel(tool)} ${detail}`.slice(0, 200)
89 }
90 return toolLabel(tool)
91}
92
93/** A path as its last part: `/repo/src/game.ts` is `game.ts`. */
94export function lastPart(path: string): string {
95 const trimmed = path.replace(/\/+$/, '')
96 return trimmed.slice(trimmed.lastIndexOf('/') + 1) || path
97}
98
99/**
100 * A line with each absolute or home path in it cut to its last part; a URL,
101 * a path built on a variable and `/dev/...` are left whole.
102 */
103export function shortPaths(text: string): string {
104 return text.replace(/(?<![\w.:/~$})-])~?\/(?!dev\/)[^\s'"`;|&()<>]+/g, lastPart)
105}
106
107const EDITS = new Set(['Edit', 'MultiEdit', 'NotebookEdit'])
108const PLANS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
109
110/**
111 * What a loop is doing, in plain words for the board: no tool names or
112 * paths, a file named by its last part; a Bash call by its own description.
113 */
114export function activityOf(tool: string, input: Readonly<Record<string, unknown>>): string {
115 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string).trim() : '')
116 const file = lastPart(text('file_path') || text('notebook_path'))
117 if (tool === 'Bash' || tool === 'PowerShell') return firstLine(text('description')) || '명령 실행 중'
118 if (tool === 'Read') return file === '' ? '파일 읽는 중' : `${file} 읽는 중`
119 if (EDITS.has(tool)) return file === '' ? '파일 수정 중' : `${file} 수정 중`
120 if (tool === 'Write') return file === '' ? '파일 작성 중' : `${file} 작성 중`
121 if (tool === 'Grep' || tool === 'Glob' || tool === 'LSP') return '코드 찾는 중'
122 if (tool === 'WebSearch') return '웹 검색 중'
123 if (tool === 'WebFetch') return '웹 페이지 읽는 중'
124 if (PLANS.has(tool)) return '작업 목록 정리 중'
125 if (tool === 'Skill') return '스킬 불러오는 중'
126 if (tool === 'ToolSearch') return '도구 준비 중'
127 if (tool === 'SendMessage') return '메시지 보내는 중'
128 if (tool === 'AskUserQuestion') return '질문에 답 기다리는 중'
129 if (tool === 'Artifact') return '페이지 게시 중'
130 const mcp = /^mcp__(.+?)__/.exec(tool)
131 return mcp === null ? '작업 중' : `${mcp[1]} 작업 중`
132}
133
134function firstLine(text: string): string {
135 return shortPaths(text.split('\n')[0] ?? '').slice(0, 120)
136}
137
138/**
139 * A request or an agent's instructions in one line: its first sentence (or
140 * line), a leading `[tag]` left off, paths cut to their last part (a relative
141 * one too when it names a file, `src/a/b.ts` being `b.ts`), at most `width` cells.
142 */
143export function brief(text: string, width = 120): string {
144 const body = text.replace(/^\s*\[[^\]\n]{1,80}\]\s*/, '')
145 const line = (body.split('\n').find(one => one.trim() !== '') ?? '').trim().replace(/\s+/g, ' ')
146 const sentence = /^.+?[.!?。!?](?=\s|$)/.exec(line)?.[0] ?? line
147 const short = shortPaths(sentence.length >= 12 ? sentence : line).replace(
148 /(?<![\w./~$-])(?:[\w.-]+\/)+([\w-]+\.[A-Za-z]\w{0,7})(?![\w/])/g,
149 '$1',
150 )
151 return fit(short, width)
152}
153
154/** `text` cut to `width` terminal cells, an ellipsis marking the cut. */
155export function fit(text: string, width: number): string {
156 if (cells(text) <= width) return text
157 let out = ''
158 for (const char of text) {
159 if (cells(out + char) > width - 1) break
160 out += char
161 }
162 return `${out}…`
163}
164
165/** A short hash of a text (djb2), to know a script again without keeping it. */
166export function hashOf(text: string): number {
167 let hash = 5381
168 for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0
169 return hash >>> 0
170}
171
172export function windowLabel(kind: string): string {
173 return WINDOW_LABEL[kind] ?? kind
174}
175
176export function windowSpan(window: LimitWindow, now: number): { start: number; end: number } | undefined {
177 const span = WINDOW_MS[window.kind]
178 const end = window.resetsAt === undefined ? NaN : Date.parse(window.resetsAt)
179 if (span === undefined || Number.isNaN(end)) return undefined
180 return { start: end - span, end: Math.max(end, now) }
181}
182
183/**
184 * Folds a fresh reading into a window's history: samples from before the
185 * window began (an earlier window) are dropped, and a sample is added only
186 * when the percentage moved, since usage carries forward until it does.
187 */
188export function recordLimit(
189 prev: LimitWindow | undefined,
190 reading: { kind: string; percentUsed: number; resetsAt?: string },
191 now: number,
192): LimitWindow {
193 const next: LimitWindow = { kind: reading.kind, percentUsed: reading.percentUsed, samples: [] }
194 if (reading.resetsAt !== undefined) next.resetsAt = reading.resetsAt
195 const span = windowSpan(next, now)
196 const history = isSameWindow(prev?.resetsAt, reading.resetsAt) ? (prev?.samples ?? []) : []
197 const kept = history.filter(s => s.at <= now && (span === undefined || s.at >= span.start))
198 const last = kept[kept.length - 1]
199 next.samples = last?.percent === reading.percentUsed ? kept : [...kept, { at: now, percent: reading.percentUsed }]
200 next.samples = next.samples.slice(-MAX_SAMPLES)
201 return next
202}
203
204/** Two readings of one window name the same reset, give or take a drift. */
205function isSameWindow(a: string | undefined, b: string | undefined): boolean {
206 if (a === undefined || b === undefined) return a === b
207 return Math.abs(Date.parse(a) - Date.parse(b)) < SAME_RESET_MS
208}
209
210/** A window that has reset since it was read says nothing about now. */
211export function isCurrent(window: LimitWindow, now: number): boolean {
212 const end = window.resetsAt === undefined ? NaN : Date.parse(window.resetsAt)
213 return Number.isNaN(end) || end > now
214}
215
216export type Tone = 'success' | 'warning' | 'error'
217
218export function tone(percent: number): Tone {
219 return percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'success'
220}
221
222export function gauge(percent: number, width: number): { filled: string; empty: string } {
223 const cells = Math.max(1, width)
224 const filled = Math.min(cells, Math.round((Math.max(0, percent) / 100) * cells))
225 return { filled: '█'.repeat(filled), empty: '░'.repeat(cells - filled) }
226}
227
228/** Terminal cells a text takes: Hangul and other wide letters take two. */
229export function cells(text: string): number {
230 let count = 0
231 for (const char of text) {
232 const code = char.codePointAt(0) ?? 0
233 const isWide =
234 (code >= 0x1100 && code <= 0x115f) ||
235 (code >= 0x2e80 && code <= 0xa4cf) ||
236 (code >= 0xac00 && code <= 0xd7a3) ||
237 (code >= 0xf900 && code <= 0xfaff) ||
238 (code >= 0xff00 && code <= 0xff60)
239 count += isWide ? 2 : 1
240 }
241 return count
242}
243
244export type Segment = {
245 label: string
246 percent: string
247 tone: Tone
248 bar?: { filled: string; empty: string }
249 reset?: string
250 warn?: string
251}
252
253/**
254 * One window as the band draws it in `room` cells: its label, a gauge when
255 * there is room for one, the percentage used, the time to its reset, and a
256 * warning when the pace so far runs out of limit before the reset.
257 */
258export function limitSegment(window: LimitWindow, now: number, room: number): Segment {
259 const end = window.resetsAt === undefined ? NaN : Date.parse(window.resetsAt)
260 const hits = pace(window, now)?.hitsInMs
261 const segment: Segment = {
262 label: windowLabel(window.kind),
263 percent: `${Math.round(window.percentUsed)}%`,
264 tone:
265 window.percentUsed >= 80 || (hits !== undefined && hits <= 0)
266 ? 'error'
267 : hits !== undefined
268 ? 'warning'
269 : tone(window.percentUsed),
270 }
271 if (!Number.isNaN(end)) segment.reset = `⟳${duration(end - now)}`
272 if (hits !== undefined) segment.warn = hits <= 0 ? '⚠ 한도 도달' : `⚠ ${duration(hits)} 후 소진`
273
274 const words = [segment.label, segment.percent, segment.reset, segment.warn].filter(Boolean)
275 const fixed = words.reduce((sum, word) => sum + cells(word ?? ''), 0) + words.length
276 const bar = Math.min(MAX_BAR, room - fixed)
277 if (bar >= MIN_BAR) segment.bar = gauge(window.percentUsed, bar)
278 return segment
279}
280
281/**
282 * How the window is going: how much of it has passed, and either where usage
283 * lands at the reset at the pace so far or, when that is past 100%, how long
284 * until the limit is hit.
285 */
286export function pace(window: LimitWindow, now: number): { elapsed: number; projected?: number; hitsInMs?: number } | undefined {
287 const span = windowSpan(window, now)
288 if (span === undefined) return undefined
289 const length = span.end - span.start
290 const passed = Math.min(length, Math.max(0, now - span.start))
291 const elapsed = Math.round((passed / length) * 100)
292 if (passed < length * 0.05 || window.percentUsed <= 0) return { elapsed }
293 const projected = Math.round((window.percentUsed / passed) * length)
294 if (projected <= 100) return { elapsed, projected }
295 const perMs = window.percentUsed / passed
296 return { elapsed, projected, hitsInMs: (100 - window.percentUsed) / perMs }
297}
298hooks/view.tsx 575 lines1import type { AgentInfo, EngineInterface, RenderChildren } from 'claude-code'
2
3import type { Activity, Fault, Tabs, TaskItem, Tokens, Workflow } from '../types'
4import type { Board, Count, Item, ItemStatus } from './board'
5import { ALL, doingOf, isSettled, statusOf, taskCount, taskItem, todoCount, typeLabel } from './board'
6import { MAIN, ZERO_TOKENS, brief, cells, compact, duration, fit, gauge, inputTotal, tokenText } from './format'
7import { finishedAt, progress, standing } from './workflow'
8
9/** The surface's element table, as `$.ui.resolve(e)` hands it out. */
10export type Kit = ReturnType<EngineInterface['ui']['resolve']>
11
12/** What a drawing reads, and what its presses do. */
13export type Scene = {
14 kit: Kit
15 now: number
16 width: number
17 board: Board
18 tabs: Tabs
19 agents: readonly AgentInfo[]
20 activity: Readonly<Record<string, Activity>>
21 flows: readonly Workflow[]
22 tasks: Readonly<Record<string, TaskItem>>
23 faults: readonly Fault[]
24 totals: string
25 open: (key: string, title: string) => void
26 close: (key: string) => void
27 show: (key: string) => void
28}
29
30const RECENT_ROWS = 6
31const REPLY_ROWS = 4
32const SUMMARY_LABEL = 8
33const SUMMARY_NAMES = 2
34const FAULT_MS = 10 * 60_000
35const LABEL = 6
36const TAB_TITLE = 14
37const BAR = 10
38
39const LOOK: Record<ItemStatus, { box: string; color: string }> = {
40 running: { box: '☐', color: 'claude' },
41 idle: { box: '☐', color: 'inactive' },
42 done: { box: '☑', color: 'success' },
43 failed: { box: '☒', color: 'error' },
44}
45
46const WORD: Record<ItemStatus, string> = { running: '실행 중', idle: '대기 중', done: '완료', failed: '실패·중단' }
47
48/** A settled item's own color: green once done, red once failed; none while it runs. */
49function inkOf(status: ItemStatus): { color?: string } {
50 return status === 'done' ? { color: 'success' } : status === 'failed' ? { color: 'error' } : {}
51}
52
53export function countText(count: Count | undefined): string {
54 return count === undefined ? '' : ` (${count.done}/${count.total}${count.isOpen === true ? '+' : ''})`
55}
56
57/** The tokens an item spent and how long it ran: what sits right of its title. */
58export function costText(item: Pick<Item, 'tokens' | 'startedAt' | 'endedAt' | 'kind'>, now: number): string {
59 const ran = item.kind === 'main' || item.startedAt === undefined ? '' : duration((item.endedAt ?? now) - item.startedAt)
60 return [tokenText(item.tokens), ran].filter(Boolean).join(' · ')
61}
62
63/** The pane: the tab bar, then the board or the tab it shows. */
64export function drawPane(scene: Scene) {
65 const { Box } = scene.kit
66 const shown = scene.tabs.open.find(tab => tab.key === scene.tabs.active)
67 return (
68 <Box flexDirection="column">
69 {scene.tabs.open.length > 0 && drawTabBar(scene)}
70 {shown === undefined ? drawBoard(scene) : drawDetail(scene, shown.key)}
71 </Box>
72 )
73}
74
75function drawTabBar(scene: Scene) {
76 const { Box, Text, Button } = scene.kit
77 const isBoard = scene.tabs.active === ALL || !scene.tabs.open.some(tab => tab.key === scene.tabs.active)
78 return (
79 <Box flexDirection="row" flexWrap="wrap" marginBottom={1}>
80 <Button key="tab:all" label="전체" {...(isBoard && { variant: 'primary' as const })} onPress={() => scene.show(ALL)}>
81 전체
82 </Button>
83 {scene.tabs.open.map(tab => (
84 <Box flexDirection="row">
85 <Text> </Text>
86 <Button
87 key={`tab:${tab.key}`}
88 label={fit(tab.title, TAB_TITLE)}
89 {...(scene.tabs.active === tab.key && { variant: 'primary' as const })}
90 onPress={() => scene.show(tab.key)}
91 >
92 {fit(tab.title, TAB_TITLE)}
93 </Button>
94 <Button key={`close:${tab.key}`} plain label="×" onPress={() => scene.close(tab.key)}>
95 ×
96 </Button>
97 </Box>
98 ))}
99 </Box>
100 )
101}
102
103/** The board: every loop of the session on one row each, running ones first. */
104function drawBoard(scene: Scene) {
105 const { Box, Text } = scene.kit
106 const { board } = scene
107 const busy = board.running.filter(item => item.status === 'running').length
108 const fault = scene.faults.at(-1)
109 return (
110 <Box flexDirection="column">
111 <Box flexDirection="row" justifyContent="space-between">
112 <Text weight="bold" color="claude">
113 대시보드{busy > 0 ? ` · ${busy}개 실행 중` : ''}
114 </Text>
115 <Text dimColor>↑입력 ↓출력 · 시간</Text>
116 </Box>
117 {itemRow(scene, board.main, 0)}
118 {board.running.map(item => itemRow(scene, item, 0))}
119 {board.running.length === 0 && <Text dimColor> 실행 중인 서브 작업 없음</Text>}
120 {board.finished.length > 0 && (
121 <Box marginTop={1}>
122 <Text dimColor>완료</Text>
123 </Box>
124 )}
125 {board.finished.map(item => itemRow(scene, item, 0))}
126 {board.hidden > 0 && <Text dimColor>{` +${board.hidden}개 더 완료`}</Text>}
127 <Box marginTop={1}>
128 <Text dimColor wrap="truncate-end">
129 {scene.totals}
130 </Text>
131 </Box>
132 {fault !== undefined && scene.now - fault.at < FAULT_MS && (
133 <Text color="warning" wrap="truncate-end">
134 {`⚠ ${fault.where}: ${fault.message}`}
135 </Text>
136 )}
137 </Box>
138 )
139}
140
141/**
142 * One item: a checkbox that ticks once it is done, its title (a press opens
143 * its tab), its progress, and on the right its tokens and time; under a
144 * running one what it is doing, then its children.
145 */
146function itemRow(scene: Scene, item: Item, depth: number): RenderChildren {
147 const { Box, Text, Button } = scene.kit
148 const pad = ' '.repeat(depth)
149 const look = LOOK[item.status]
150 const ink = inkOf(item.status)
151 const right = costText(item, scene.now)
152 const count = countText(item.progress)
153 const name = item.sub !== undefined && item.sub !== '' && item.sub !== item.title ? `${item.title} · ${item.sub}` : item.title
154 const room = Math.max(6, scene.width - cells(pad) - 2 - cells(count) - (right === '' ? 0 : cells(right) + 1))
155 return (
156 <Box flexDirection="column">
157 <Box flexDirection="row">
158 <Box flexGrow={1} flexShrink={1} flexDirection="row">
159 {pad !== '' && <Text>{pad}</Text>}
160 <Button
161 key={`open:${item.key}`}
162 plain
163 label={item.title}
164 onPress={() => scene.open(item.key, item.tab ?? item.title)}
165 >
166 <Text color={look.color}>{look.box}</Text>{' '}
167 <Text {...ink} {...(depth === 0 && { weight: 'bold' as const })}>
168 {fit(name, room)}
169 </Text>
170 <Text {...ink} {...(isSettled(item.status) ? {} : { dimColor: true })}>
171 {count}
172 </Text>
173 </Button>
174 </Box>
175 <Box flexShrink={0}>
176 <Text {...ink} {...(isSettled(item.status) ? {} : { dimColor: true })}>
177 {right === '' ? '' : ` ${right}`}
178 </Text>
179 </Box>
180 </Box>
181 {!isSettled(item.status) && item.doing !== undefined && item.doing !== '' && (
182 <Text dimColor wrap="truncate-end">
183 {`${pad} ${item.doing}`}
184 </Text>
185 )}
186 {item.children.map(child => itemRow(scene, child, depth + 1))}
187 {(item.folded ?? 0) > 0 && <Text dimColor>{`${pad} +${item.folded}개 완료`}</Text>}
188 </Box>
189 )
190}
191
192/** A detail tab: the item's header and close, then what is known of it. */
193function drawDetail(scene: Scene, key: string) {
194 const { Box, Text, Button } = scene.kit
195 const body = key === 'team' ? teamDetail(scene) : key.startsWith('flow:') ? flowDetail(scene, key.slice(5)) : agentDetail(scene, key)
196 return (
197 <Box flexDirection="column">
198 <Box flexDirection="row" justifyContent="space-between">
199 <Button key="back" plain label="← 전체" onPress={() => scene.show(ALL)}>
200 <Text dimColor>← 전체</Text>
201 </Button>
202 <Button key={`close-top:${key}`} label="× 닫기" onPress={() => scene.close(key)}>
203 × 닫기
204 </Button>
205 </Box>
206 {body ?? <Text dimColor>이 항목은 더 이상 세션에 없습니다.</Text>}
207 </Box>
208 )
209}
210
211function header(scene: Scene, item: Pick<Item, 'title' | 'sub' | 'status' | 'progress' | 'tokens' | 'startedAt' | 'endedAt' | 'kind'>) {
212 const { Box, Text } = scene.kit
213 const look = LOOK[item.status]
214 const right = costText(item, scene.now)
215 return (
216 <Box flexDirection="column" marginTop={1}>
217 <Box flexDirection="row">
218 <Box flexGrow={1} flexShrink={1}>
219 <Text wrap="truncate-end">
220 <Text color={look.color}>{look.box}</Text> <Text weight="bold" {...inkOf(item.status)}>{item.title}</Text>
221 <Text dimColor>{countText(item.progress)}</Text>
222 </Text>
223 </Box>
224 <Box flexShrink={0}>
225 <Text dimColor>{right === '' ? '' : ` ${right}`}</Text>
226 </Box>
227 </Box>
228 {item.sub !== undefined && item.sub !== '' && (
229 <Text dimColor wrap="wrap">
230 {` ${item.sub}`}
231 </Text>
232 )}
233 </Box>
234 )
235}
236
237/**
238 * A tab's opening lines: what the item was asked to do and what it is doing
239 * now, in a word or a sentence, before the figures below.
240 */
241function summary(scene: Scene, task: string | undefined, now: string) {
242 const { Box, Text } = scene.kit
243 const row = (label: string, value: string) => (
244 <Box flexDirection="row">
245 <Box flexShrink={0}>
246 <Text weight="bold" color="claude">
247 {label + ' '.repeat(Math.max(1, SUMMARY_LABEL - cells(label)))}
248 </Text>
249 </Box>
250 <Box flexGrow={1} flexShrink={1}>
251 <Text wrap="wrap">{value}</Text>
252 </Box>
253 </Box>
254 )
255 return (
256 <Box flexDirection="column" marginTop={1}>
257 {task !== undefined && task !== '' && row('맡은 일', task)}
258 {row('지금', now)}
259 </Box>
260 )
261}
262
263/** What a settled agent ended with: done with its last words, or why it stopped. */
264function endedText(status: ItemStatus, reply: string | undefined): string {
265 if (status === 'failed') return '실패·중단'
266 const last = reply === undefined ? '' : brief(reply, 90)
267 return last === '' ? '끝남' : `끝남 · ${last}`
268}
269
270/** A few names, then how many more. */
271function names(list: readonly string[]): string {
272 const shown = list.slice(0, SUMMARY_NAMES).join(', ')
273 return list.length > SUMMARY_NAMES ? `${shown} 외 ${list.length - SUMMARY_NAMES}개` : shown
274}
275
276function field(scene: Scene, label: string, value: RenderChildren) {
277 const { Box, Text } = scene.kit
278 return (
279 <Box flexDirection="row">
280 <Box flexShrink={0}>
281 <Text dimColor>{label + ' '.repeat(Math.max(1, LABEL + 1 - cells(label)))}</Text>
282 </Box>
283 <Box flexGrow={1} flexShrink={1}>
284 <Text wrap="truncate-end">{value}</Text>
285 </Box>
286 </Box>
287 )
288}
289
290function section(scene: Scene, title: string, rows: RenderChildren[]) {
291 const { Box, Text } = scene.kit
292 return (
293 <Box flexDirection="column" marginTop={1}>
294 <Text weight="bold">{title}</Text>
295 {rows}
296 </Box>
297 )
298}
299
300function tokenParts(tokens: Tokens): string {
301 if (tokens.requests === 0) return '아직 없음'
302 return [
303 `↑${compact(inputTotal(tokens))} ↓${compact(tokens.output)}`,
304 `새 입력 ${compact(tokens.input)}`,
305 `캐시 읽기 ${compact(tokens.cacheRead)}`,
306 `캐시 쓰기 ${compact(tokens.cacheWrite)}`,
307 `요청 ${tokens.requests}회`,
308 ].join(' · ')
309}
310
311function timeText(status: ItemStatus, startedAt: number | undefined, endedAt: number | undefined, now: number): string {
312 if (startedAt === undefined) return WORD[status]
313 return `${WORD[status]} · ${duration((isSettled(status) ? (endedAt ?? now) : now) - startedAt)}`
314}
315
316function checkRow(scene: Scene, done: boolean, text: string, note?: string) {
317 const { Text } = scene.kit
318 return (
319 <Text wrap="truncate-end">
320 {' '}
321 <Text color={done ? 'success' : 'inactive'}>{done ? '☑' : '☐'}</Text>{' '}
322 <Text {...(done ? { color: 'success' } : {})}>{text}</Text>
323 {note !== undefined && note !== '' && <Text dimColor>{` · ${note}`}</Text>}
324 </Text>
325 )
326}
327
328/** The main session, a subagent, a teammate or a workflow agent. */
329function agentDetail(scene: Scene, key: string): RenderChildren | undefined {
330 const { Text } = scene.kit
331 const { now } = scene
332 let id: string | undefined
333 let flow: Workflow | undefined
334 let item: Item | undefined
335 if (key === 'main') {
336 id = MAIN
337 item = scene.board.main
338 } else if (key.startsWith('agent:')) {
339 id = key.slice(6)
340 } else if (key.startsWith('task:')) {
341 const cut = key.lastIndexOf(':')
342 const flowKey = key.slice(5, cut)
343 const index = Number(key.slice(cut + 1))
344 flow = scene.flows.find(one => one.key === flowKey)
345 const task = flow?.tasks.find(t => t.index === index)
346 if (flow === undefined || task === undefined) return undefined
347 id = task.agentId
348 item = taskItem(flow, task, scene.activity, (agentId, fallback = ZERO_TOKENS) => {
349 const tokens = agentId === undefined ? undefined : scene.activity[agentId]?.tokens
350 return tokens !== undefined && tokens.requests > 0 ? tokens : fallback
351 })
352 }
353 const one = id === undefined ? undefined : scene.activity[id]
354 const agent = id === undefined ? undefined : scene.agents.find(a => a.id === id)
355 if (item === undefined) {
356 if (agent === undefined && one === undefined) return undefined
357 const status: ItemStatus = agent !== undefined ? statusOf(agent.status) : one?.stoppedAt !== undefined ? 'done' : 'running'
358 item = {
359 key,
360 kind: 'agent',
361 title: agent?.name ?? typeLabel(agent?.type ?? one?.kind ?? '에이전트'),
362 status,
363 tokens: one?.tokens ?? ZERO_TOKENS,
364 children: [],
365 ...(agent?.description !== undefined && { sub: agent.description }),
366 ...(one !== undefined && { startedAt: one.firstSeen }),
367 ...(isSettled(status) && (one?.stoppedAt ?? one?.endedAt) !== undefined && { endedAt: (one?.stoppedAt ?? one?.endedAt) as number }),
368 }
369 const count = todoCount(one?.todos)
370 if (count !== undefined) item.progress = count
371 }
372 const owned = Object.values(scene.tasks).filter(t =>
373 id === MAIN ? t.by === MAIN || t.owner === undefined : t.owner !== undefined && [agent?.name, agent?.teammateId?.split('@')[0]].includes(t.owner),
374 )
375 const parent = agent?.parentId === undefined ? undefined : scene.agents.find(a => a.id === agent.parentId)
376 const rawKind = agent?.type ?? one?.kind ?? (flow !== undefined ? 'workflow-subagent' : undefined)
377 const kind =
378 rawKind === undefined
379 ? key === 'main'
380 ? '메인 루프'
381 : undefined
382 : typeLabel(rawKind) === rawKind
383 ? rawKind
384 : `${typeLabel(rawKind)} (${rawKind})`
385 const doing = doingOf(one, owned.find(t => t.status === 'in_progress'))
386 const task = one?.task ?? (flow !== undefined ? item.title : agent?.description)
387 const current = isSettled(item.status)
388 ? endedText(item.status, one?.reply)
389 : (doing ?? (item.status === 'idle' ? '대기 중' : '생각 중'))
390 const todos = one?.todos ?? []
391 const recent = (one?.recent ?? []).slice(-RECENT_ROWS).reverse()
392 const reply = (one?.reply ?? '').split('\n').filter(line => line.trim() !== '').slice(0, REPLY_ROWS)
393 return [
394 header(scene, item),
395 summary(scene, task, current),
396 field(scene, '상태', timeText(item.status, item.startedAt, item.endedAt, scene.now)),
397 kind !== undefined && field(scene, '종류', kind),
398 flow !== undefined && field(scene, '소속', `워크플로 ${flow.name}${item.sub !== undefined ? ` · ${item.sub} 단계` : ''}`),
399 parent !== undefined && field(scene, '상위', parent.name ?? parent.type),
400 field(scene, '토큰', tokenParts(item.tokens)),
401 one !== undefined && field(scene, '도구', `${one.tools}회 호출`),
402 (one?.running.length ?? 0) > 0 &&
403 section(
404 scene,
405 '실행 중인 호출',
406 (one?.running ?? []).map(call => <Text wrap="truncate-end">{` ⚙ ${call.text}`}</Text>),
407 ),
408 todos.length > 0 &&
409 section(scene, `할 일${countText(todoCount(todos))}`, todos.map(t => checkRow(scene, t.status === 'completed', t.status === 'in_progress' ? (t.activeForm ?? t.content) : t.content))),
410 todos.length === 0 &&
411 owned.length > 0 &&
412 section(scene, `작업 목록${countText(taskCount(owned))}`, owned.map(t => checkRow(scene, t.status === 'completed', t.subject, t.owner))),
413 recent.length > 0 && section(scene, '최근 호출', recent.map(text => <Text dimColor wrap="truncate-end">{` ↳ ${text}`}</Text>)),
414 reply.length > 0 && section(scene, '마지막 응답', reply.map(line => <Text wrap="truncate-end">{` ${line}`}</Text>)),
415 id !== undefined && id !== MAIN && <Text dimColor>{`ID ${id}`}</Text>,
416 ]
417}
418
419/** A workflow run: its plan and phases, then every agent it started. */
420function flowDetail(scene: Scene, flowKey: string): RenderChildren | undefined {
421 const { Box, Text, Button } = scene.kit
422 const { now } = scene
423 const flow = scene.flows.find(one => one.key === flowKey)
424 if (flow === undefined) return undefined
425 const status = standing(flow, now)
426 const count = progress(flow, status)
427 const itemStatus: ItemStatus = status === 'running' ? 'running' : status === 'done' ? 'done' : 'failed'
428 const live = (agentId: string | undefined, fallback: Tokens = ZERO_TOKENS) => {
429 const tokens = agentId === undefined ? undefined : scene.activity[agentId]?.tokens
430 return tokens !== undefined && tokens.requests > 0 ? tokens : fallback
431 }
432 const tasks = flow.tasks.map(task => taskItem(flow, task, scene.activity, live))
433 const end = status === 'running' ? undefined : finishedAt(flow)
434 const tokens: Tokens = tasks.reduce(
435 (sum, t) => ({
436 input: sum.input + t.tokens.input,
437 output: sum.output + t.tokens.output,
438 cacheRead: sum.cacheRead + t.tokens.cacheRead,
439 cacheWrite: sum.cacheWrite + t.tokens.cacheWrite,
440 requests: sum.requests + t.tokens.requests,
441 }),
442 ZERO_TOKENS,
443 )
444 const item: Item = {
445 key: `flow:${flow.key}`,
446 kind: 'flow',
447 title: `워크플로 · ${flow.name}`,
448 status: itemStatus,
449 progress: { done: count.done, total: count.total, isOpen: count.isOpen },
450 tokens,
451 startedAt: flow.startedAt,
452 children: [],
453 ...(end !== undefined && { endedAt: end }),
454 }
455 // 지금: 실행 중인 에이전트들이 가장 많이 속한 단계와 그 이름들
456 const running = flow.tasks.filter(t => t.status === 'running')
457 const tally = new Map<string, number>()
458 for (const t of running) {
459 if (t.phase !== undefined) tally.set(t.phase, (tally.get(t.phase) ?? 0) + 1)
460 }
461 let phaseNow: string | undefined
462 for (const [phase, n] of tally) {
463 if (phaseNow === undefined || n > (tally.get(phaseNow) ?? 0)) phaseNow = phase
464 }
465 const current =
466 status === 'running'
467 ? running.length > 0
468 ? `${phaseNow === undefined ? '' : `${phaseNow} 단계 · `}${names(running.map(t => t.label))} 실행 중`
469 : count.waiting > 0
470 ? '다음 에이전트를 기다리는 중'
471 : '마무리 중'
472 : status === 'done'
473 ? `끝남 · 에이전트 ${count.done}개 완료`
474 : `실패·중단 · ${count.done}/${count.total} 완료`
475 const bar = gauge(count.percent, BAR)
476 const phases = phaseCounts(flow)
477 const stats = [
478 count.running > 0 ? `${count.running}개 실행 중` : '',
479 count.waiting > 0 ? `${count.waiting}개 대기` : '',
480 count.failed > 0 ? `${count.failed}개 실패` : '',
481 count.cached > 0 ? `${count.cached}개 재사용` : '',
482 ].filter(Boolean)
483 return [
484 header(scene, item),
485 summary(scene, flow.description ?? flow.name, current),
486 field(scene, '상태', timeText(itemStatus, flow.startedAt, end, now)),
487 field(
488 scene,
489 '진행',
490 <Text>
491 <Text color="claude">{bar.filled}</Text>
492 <Text dimColor>{bar.empty}</Text>
493 {` ${count.done}/${count.total}${count.isOpen ? '+' : ''}${count.isOpen ? '' : ` ${count.percent}%`}`}
494 {stats.length > 0 && <Text dimColor>{` · ${stats.join(' · ')}`}</Text>}
495 </Text>,
496 ),
497 phases.length > 0 && field(scene, '단계', phases.join(' › ')),
498 flow.plan !== undefined &&
499 field(scene, '계획', `${flow.plan.total}개${flow.plan.isOpen ? '+ (에이전트 결과에 따라 늘어남)' : ''}`),
500 field(scene, '토큰', tokenParts(tokens)),
501 section(
502 scene,
503 `에이전트${countText(item.progress)}`,
504 tasks.map(task => (
505 <Box flexDirection="row">
506 <Box flexGrow={1} flexShrink={1} flexDirection="row">
507 <Text> </Text>
508 <Button
509 key={`open:${task.key}`}
510 plain
511 label={task.title}
512 onPress={() => scene.open(task.key, task.title)}
513 >
514 <Text color={LOOK[task.status].color}>{LOOK[task.status].box}</Text>{' '}
515 <Text {...inkOf(task.status)}>{fit(task.title, Math.max(6, scene.width - 30))}</Text>
516 {task.sub !== undefined && <Text dimColor>{` ${task.sub}`}</Text>}
517 </Button>
518 </Box>
519 <Box flexShrink={0}>
520 <Text dimColor>{` ${costText(task, now)}`}</Text>
521 </Box>
522 </Box>
523 )),
524 ),
525 flow.runId !== undefined && <Text dimColor>{`실행 ID ${flow.runId}`}</Text>,
526 ]
527}
528
529/** The phases of a run with how many of each one's agents are done. */
530function phaseCounts(flow: Workflow): string[] {
531 const named = [...flow.phases, ...flow.tasks.map(t => t.phase ?? '')].filter((p, i, all) => p !== '' && all.indexOf(p) === i)
532 return named.map(phase => {
533 const tasks = flow.tasks.filter(t => t.phase === phase)
534 if (tasks.length === 0) return phase
535 const done = tasks.filter(t => t.status === 'done').length
536 return `${phase} ${done}/${tasks.length}${done === tasks.length ? ' ✓' : ''}`
537 })
538}
539
540/** The agent team: its members, then the team's task list. */
541function teamDetail(scene: Scene): RenderChildren | undefined {
542 const { Text } = scene.kit
543 const team = [...scene.board.running, ...scene.board.finished].find(item => item.key === 'team')
544 if (team === undefined) return undefined
545 const tasks = Object.values(scene.tasks)
546 const active = tasks.filter(t => t.status === 'in_progress').map(t => t.activeForm ?? t.subject)
547 const count = taskCount(tasks)
548 const task =
549 active.length > 0
550 ? names(active)
551 : count !== undefined
552 ? `작업 목록 ${count.done}/${count.total} 완료`
553 : `팀원 ${team.children.length}명`
554 const busy = team.children.filter(member => member.status === 'running')
555 const current = isSettled(team.status)
556 ? endedText(team.status, undefined)
557 : busy.length > 0
558 ? names(busy.map(member => `${member.title}: ${member.doing ?? '생각 중'}`))
559 : '모두 대기 중'
560 return [
561 header(scene, team),
562 summary(scene, task, current),
563 field(scene, '상태', timeText(team.status, team.startedAt, team.endedAt, scene.now)),
564 field(scene, '토큰', tokenParts(team.tokens)),
565 section(scene, `구성원 ${team.children.length}명`, team.children.map(member => itemRow(scene, member, 1))),
566 tasks.length > 0 &&
567 section(
568 scene,
569 `작업 목록${countText(taskCount(tasks))}`,
570 tasks.map(t => checkRow(scene, t.status === 'completed', t.status === 'in_progress' ? (t.activeForm ?? t.subject) : t.subject, t.owner)),
571 ),
572 tasks.length === 0 && <Text dimColor> 아직 작업 목록이 없습니다.</Text>,
573 ]
574}
575hooks/workflow.ts 447 lines1import type { Tokens, Workflow, WorkflowCall, WorkflowStatus, WorkflowTask } from '../types'
2import { ZERO_TOKENS, hashOf, shortPaths, sumTokens } from './format'
3import { planOf } from './plan'
4
5const MAX_WORKFLOWS = 12
6const MAX_CALLS = 20
7const MAX_LABEL = 120
8/** A run whose agents have all settled and that starts none for this long is done. */
9export const QUIET_MS = 60_000
10
11/**
12 * What a Workflow call's input says about the run: its script's meta, or its
13 * name, and how many agents the script starts. `file` is the script a
14 * `scriptPath` or `name` call runs, when it could be read.
15 */
16export function readWorkflowCall(input: unknown, file?: string): WorkflowCall | undefined {
17 if (typeof input !== 'object' || input === null) return undefined
18 const args = input as Record<string, unknown>
19 const script = typeof args.script === 'string' ? args.script : file
20 const named = (): WorkflowCall | undefined => {
21 if (typeof args.name === 'string' && args.name !== '') return { name: args.name, phases: [] }
22 if (typeof args.scriptPath !== 'string' || args.scriptPath === '') return undefined
23 const base = args.scriptPath.split('/').pop() ?? args.scriptPath
24 return { name: base.replace(/\.[^.]+$/, ''), phases: [] }
25 }
26 const call = (script === undefined ? undefined : metaOf(script)) ?? named()
27 if (call === undefined) return undefined
28 if (script !== undefined) {
29 call.plan = planOf(script, args.args)
30 call.hash = hashOf(script)
31 }
32 if (args.args !== undefined) call.args = args.args
33 return call
34}
35
36/** What a run's saved script says about it: its meta and plan, `args` from the call that ran it. */
37export function callOfScript(script: string, calls: Readonly<Record<string, WorkflowCall>>): WorkflowCall | undefined {
38 const meta = metaOf(script)
39 if (meta === undefined) return undefined
40 const hash = hashOf(script)
41 const call = Object.values(calls).find(one => one.hash === hash)
42 return { ...meta, plan: planOf(script, call?.args), hash }
43}
44
45/** Where a call that names its script rather than sending it keeps that script. */
46export function scriptPaths(input: unknown): string[] {
47 if (typeof input !== 'object' || input === null) return []
48 const args = input as Record<string, unknown>
49 if (typeof args.script === 'string') return []
50 if (typeof args.scriptPath === 'string' && args.scriptPath !== '') return [args.scriptPath]
51 if (typeof args.name !== 'string' || !/^[\w.-]+$/.test(args.name)) return []
52 return [`.claude/workflows/${args.name}.js`, `.claude/workflows/${args.name}.mjs`]
53}
54
55/** The `export const meta = { name, description, phases }` literal a workflow script begins with. */
56export function metaOf(script: string): WorkflowCall | undefined {
57 const head = /export\s+const\s+meta\s*=\s*\{/.exec(script)
58 if (head === null) return undefined
59 const open = head.index + head[0].length - 1
60 const body = script.slice(open + 1, closing(script, open))
61 const top = topLevel(body)
62 const name = stringField(top, 'name')
63 if (name === undefined) return undefined
64 const call: WorkflowCall = { name, phases: [] }
65 const description = stringField(top, 'description')
66 if (description !== undefined) call.description = description
67 const phases = /\bphases\s*:\s*\[/.exec(body)
68 if (phases !== null) {
69 const from = phases.index + phases[0].length - 1
70 const list = body.slice(from + 1, closing(body, from))
71 call.phases = [...list.matchAll(/\btitle\s*:\s*(['"`])((?:\\.|(?!\1)[^\\])*)\1/g)].map(m => unescape(m[2] ?? ''))
72 }
73 return call
74}
75
76/** Where the bracket opened at `open` closes, strings skipped; the text's end when it never does. */
77function closing(text: string, open: number): number {
78 let depth = 0
79 let quote: string | undefined
80 for (let at = open; at < text.length; at += 1) {
81 const char = text[at]
82 if (quote !== undefined) {
83 if (char === '\\') at += 1
84 else if (char === quote) quote = undefined
85 continue
86 }
87 if (char === '"' || char === "'" || char === '`') quote = char
88 else if (char === '{' || char === '[' || char === '(') depth += 1
89 else if (char === '}' || char === ']' || char === ')') {
90 depth -= 1
91 if (depth === 0) return at
92 }
93 }
94 return text.length
95}
96
97/** An object body with its nested objects and lists left out, strings kept. */
98function topLevel(body: string): string {
99 let out = ''
100 let depth = 0
101 let quote: string | undefined
102 for (let at = 0; at < body.length; at += 1) {
103 const char = body[at] ?? ''
104 if (quote !== undefined) {
105 if (depth === 0) out += char
106 if (char === '\\') {
107 if (depth === 0) out += body[at + 1] ?? ''
108 at += 1
109 } else if (char === quote) quote = undefined
110 continue
111 }
112 if (char === '"' || char === "'" || char === '`') quote = char
113 if (char === '{' || char === '[' || char === '(') depth += 1
114 if (depth === 0) out += char
115 if (char === '}' || char === ']' || char === ')') depth = Math.max(0, depth - 1)
116 }
117 return out
118}
119
120function stringField(text: string, key: string): string | undefined {
121 const found = new RegExp(`\\b${key}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1)[^\\\\])*)\\1`).exec(text)
122 return found === null ? undefined : unescape(found[2] ?? '')
123}
124
125function unescape(text: string): string {
126 return text.replace(/\\n/g, ' ').replace(/\\(.)/g, '$1').trim()
127}
128
129/** A workflow agent's line: its description, else its prompt's first line. */
130export function taskLabel(description: string, prompt: string): string {
131 const text = description.trim() !== '' ? description : (prompt.trim().split('\n')[0] ?? '')
132 return shortPaths(text.trim()).slice(0, MAX_LABEL) || '작업'
133}
134
135export function keepCall(all: Record<string, WorkflowCall>, id: string, call: WorkflowCall): Record<string, WorkflowCall> {
136 const entries = [...Object.entries(all).filter(([key]) => key !== id), [id, call] as const]
137 return Object.fromEntries(entries.slice(-MAX_CALLS))
138}
139
140function find(list: readonly Workflow[], key: string, runId: string): number {
141 if (runId !== '') {
142 const byRun = list.findIndex(one => one.runId === runId)
143 if (byRun !== -1) return byRun
144 }
145 return key === '' ? -1 : list.findIndex(one => one.key === key)
146}
147
148/**
149 * Files an agent a run's `agent()` started: under its run, found by run id
150 * and then by the Workflow call that started it, or under a new entry.
151 * The same place in a run (a resume replaying it) takes the task over.
152 */
153export function startTask(
154 list: readonly Workflow[],
155 start: { key: string; runId: string; index: number; label: string; call?: WorkflowCall; now: number },
156): Workflow[] {
157 const { key, runId, index, label, call, now } = start
158 const at = find(list, key, runId)
159 const task: WorkflowTask = { index, label, status: 'running', startedAt: now, tokens: ZERO_TOKENS }
160 const base: Workflow =
161 at === -1
162 ? {
163 key: key !== '' ? key : runId,
164 name: call?.name ?? (runId !== '' ? runId : 'workflow'),
165 phases: call?.phases ?? [],
166 status: 'running',
167 startedAt: now,
168 tasks: [],
169 }
170 : (list[at] as Workflow)
171 const { endedAt: _, ...open } = base
172 const next: Workflow = { ...open, status: 'running', tasks: [...base.tasks.filter(t => t.index !== index), task] }
173 next.tasks.sort((a, b) => a.index - b.index)
174 if (runId !== '') next.runId = runId
175 if (call !== undefined) {
176 next.name = call.name
177 next.phases = call.phases
178 if (call.description !== undefined) next.description = call.description
179 if (call.plan !== undefined) next.plan = call.plan
180 }
181 const rest = list.filter((_one, i) => i !== at)
182 return trim([...rest, next])
183}
184
185/**
186 * Names the agent a started task runs as, so its end can find it; a row the
187 * run's journal filed for the same agent first is folded into this one.
188 */
189export function bindAgent(list: readonly Workflow[], runId: string, key: string, index: number, agentId: string): Workflow[] {
190 const at = find(list, key, runId)
191 if (at === -1) return [...list]
192 return list.map((one, i) => {
193 if (i !== at) return one
194 const twin = one.tasks.find(t => t.agentId === agentId && t.index !== index)
195 const tasks = one.tasks
196 .filter(t => t !== twin)
197 .map(t => (t.index !== index ? t : { ...t, agentId, ...(twin?.phase !== undefined && { phase: twin.phase }) }))
198 return { ...one, tasks }
199 })
200}
201
202/** Settles the task an agent ran, as done or failed, with what it cost. */
203export function finishTask(
204 list: readonly Workflow[],
205 agentId: string,
206 end: { isFailed: boolean; tokens: Tokens; now: number },
207): Workflow[] {
208 return list.map(one =>
209 one.tasks.some(t => t.agentId === agentId)
210 ? {
211 ...one,
212 tasks: one.tasks.map(t =>
213 t.agentId === agentId && t.status === 'running'
214 ? { ...t, status: end.isFailed ? 'failed' : 'done', endedAt: end.now, tokens: end.tokens }
215 : t,
216 ),
217 }
218 : one,
219 )
220}
221
222/** Fails the task a refused spawn left running. */
223export function refuseTask(list: readonly Workflow[], runId: string, key: string, index: number, now: number): Workflow[] {
224 const at = find(list, key, runId)
225 return list.map((one, i) =>
226 i !== at
227 ? one
228 : { ...one, tasks: one.tasks.map(t => (t.index === index ? { ...t, status: 'failed', endedAt: now } : t)) },
229 )
230}
231
232/** One line of a run's `journal.jsonl`: `started`, `result` and the rest. */
233export type JournalEntry = { type: string; agentId?: string; label?: string; phase?: string; result?: unknown }
234
235export function readJournal(text: string): JournalEntry[] {
236 const entries: JournalEntry[] = []
237 for (const line of text.split('\n')) {
238 if (line.trim() === '') continue
239 try {
240 const value: unknown = JSON.parse(line)
241 if (typeof value !== 'object' || value === null) continue
242 const row = value as Record<string, unknown>
243 if (typeof row.type !== 'string') continue
244 const entry: JournalEntry = { type: row.type }
245 if (typeof row.agentId === 'string') entry.agentId = row.agentId
246 if (typeof row.label === 'string') entry.label = row.label
247 if (typeof row.phase === 'string') entry.phase = row.phase
248 if ('result' in row) entry.result = row.result === null ? null : true
249 entries.push(entry)
250 } catch {
251 // A line still being written reads whole on the next pass.
252 }
253 }
254 return entries
255}
256
257/**
258 * Brings a run in line with its journal on disk: each agent it started (by
259 * agent id, with its label and phase) and each that returned, a null return
260 * being a failed or skipped one. A run the hooks never saw is filed here.
261 * What the journal shows is dated by when it was last written (`at`).
262 */
263export function syncRun(
264 list: readonly Workflow[],
265 run: { runId: string; entries: readonly JournalEntry[]; call?: WorkflowCall; startedAt: number; at: number },
266): Workflow[] {
267 const at = list.findIndex(one => one.runId === run.runId)
268 const base: Workflow =
269 at === -1
270 ? { key: run.runId, runId: run.runId, name: run.runId, phases: [], status: 'running', startedAt: run.startedAt, tasks: [] }
271 : (list[at] as Workflow)
272 const tasks = [...base.tasks]
273 for (const entry of run.entries) {
274 const agentId = entry.agentId
275 if (agentId === undefined) continue
276 const found = tasks.findIndex(t => t.agentId === agentId)
277 if (entry.type === 'started') {
278 const label = entry.label === undefined ? undefined : taskLabel(entry.label, '')
279 if (found !== -1) {
280 const task = tasks[found] as WorkflowTask
281 tasks[found] = { ...task, ...(entry.phase !== undefined && { phase: entry.phase }), ...(label !== undefined && task.label === '작업' && { label }) }
282 continue
283 }
284 const index = Math.max(0, ...tasks.map(t => t.index)) + 1
285 const task: WorkflowTask = { index, label: label ?? '작업', status: 'running', agentId, startedAt: run.at, tokens: ZERO_TOKENS }
286 if (entry.phase !== undefined) task.phase = entry.phase
287 tasks.push(task)
288 continue
289 }
290 const task = found === -1 ? undefined : tasks[found]
291 if (task === undefined || task.status !== 'running') continue
292 if (entry.type === 'result') {
293 tasks[found] = { ...task, status: entry.result === null ? 'failed' : 'done', endedAt: run.at }
294 } else if (/error|fail/i.test(entry.type)) {
295 tasks[found] = { ...task, status: 'failed', endedAt: run.at }
296 }
297 }
298 const next: Workflow = { ...base, tasks }
299 const call = run.call
300 if (call !== undefined && (next.name === run.runId || next.plan === undefined)) {
301 next.name = call.name
302 next.phases = call.phases
303 if (call.description !== undefined) next.description = call.description
304 if (call.plan !== undefined) next.plan = call.plan
305 }
306 if (at === -1) return trim([...list, next])
307 return list.map((one, i) => (i === at ? next : one))
308}
309
310export type Notice = { toolUseId?: string; runId?: string; status: WorkflowStatus }
311
312/** What a background task's notification says: which call or run, and how it ended. */
313export function readNotice(text: string): Notice | undefined {
314 const tag = (name: string) => new RegExp(`<${name}>([^<]*)</${name}>`).exec(text)?.[1]?.trim()
315 const word = tag('status')?.toLowerCase()
316 const status: WorkflowStatus | undefined =
317 word === undefined
318 ? undefined
319 : /^(completed|done|success)/.test(word)
320 ? 'done'
321 : /^(failed|error)/.test(word)
322 ? 'failed'
323 : /^(killed|stopped|cancel)/.test(word)
324 ? 'stopped'
325 : undefined
326 if (status === undefined) return undefined
327 const notice: Notice = { status }
328 const toolUseId = tag('tool-use-id')
329 if (toolUseId !== undefined && toolUseId !== '') notice.toolUseId = toolUseId
330 const runId = /\bwf_[A-Za-z0-9_-]+/.exec(text)?.[0]
331 if (runId !== undefined) notice.runId = runId
332 return notice.toolUseId === undefined && notice.runId === undefined ? undefined : notice
333}
334
335/** Ends the run a notification names; the agents it left running end with it. */
336export function endWorkflow(list: readonly Workflow[], notice: Notice, now: number): Workflow[] {
337 const at = find(list, notice.toolUseId ?? '', notice.runId ?? '')
338 if (at === -1) return [...list]
339 return list.map((one, i) =>
340 i !== at
341 ? one
342 : {
343 ...one,
344 status: notice.status,
345 endedAt: now,
346 tasks: one.tasks.map(t =>
347 t.status === 'running' ? { ...t, status: notice.status === 'done' ? 'done' : 'failed', endedAt: now } : t,
348 ),
349 },
350 )
351}
352
353/** Keeps the newest runs, dropping settled ones first. */
354function trim(list: Workflow[]): Workflow[] {
355 if (list.length <= MAX_WORKFLOWS) return list
356 const drop = new Set(
357 list
358 .filter(one => one.status !== 'running')
359 .sort((a, b) => a.startedAt - b.startedAt)
360 .slice(0, list.length - MAX_WORKFLOWS),
361 )
362 return list.filter(one => !drop.has(one)).slice(-MAX_WORKFLOWS)
363}
364
365/**
366 * Where a run stands now: its notification's word, or done once every agent
367 * it started has settled and it has started none for a while.
368 */
369export function standing(flow: Workflow, now: number): WorkflowStatus {
370 if (flow.status !== 'running' || flow.tasks.length === 0) return flow.status
371 if (flow.tasks.some(t => t.status === 'running')) return 'running'
372 const last = Math.max(...flow.tasks.map(t => t.endedAt ?? t.startedAt))
373 return now - last >= QUIET_MS ? 'done' : 'running'
374}
375
376export function finishedAt(flow: Workflow): number | undefined {
377 if (flow.endedAt !== undefined) return flow.endedAt
378 const ends = flow.tasks.map(t => t.endedAt).filter((at): at is number => at !== undefined)
379 return ends.length === 0 ? undefined : Math.max(...ends)
380}
381
382export type Progress = {
383 /** Agents that finished their work, those the journal replayed among them. */
384 done: number
385 failed: number
386 running: number
387 /** Agents the script starts that have not started yet. */
388 waiting: number
389 /** Agents replayed from a resumed run's journal: done, never raised. */
390 cached: number
391 total: number
392 /** The total is a floor: more agents hang on what the run's agents return. */
393 isOpen: boolean
394 /** How far along the run is, 0 to 100. */
395 percent: number
396 tokens: Tokens
397}
398
399/**
400 * Where a run's agents stand against all the run starts: the plan its script
401 * gave, or the agents seen, whichever is more; a done run counts the agents
402 * it did start, since a branch it skipped never comes.
403 */
404export function progress(flow: Workflow, status: WorkflowStatus): Progress {
405 const count = (state: WorkflowTask['status']) => flow.tasks.filter(t => t.status === state).length
406 const seen = Math.max(flow.tasks.length, ...flow.tasks.map(t => t.index))
407 const cached = seen - flow.tasks.length
408 const isRunning = status === 'running'
409 const total = status === 'done' ? seen : Math.max(seen, flow.plan?.total ?? 0)
410 const done = count('done') + cached
411 return {
412 done,
413 failed: count('failed'),
414 running: count('running'),
415 waiting: isRunning ? total - seen : 0,
416 cached,
417 total,
418 isOpen: isRunning && (flow.plan === undefined || flow.plan.isOpen),
419 percent: total === 0 ? 0 : Math.floor((done / total) * 100),
420 tokens: sumTokens(flow.tasks.map(t => t.tokens)),
421 }
422}
423
424/**
425 * How the pane lays the runs out, newest first: every running run open, or
426 * the newest when none runs; the settled runs before it as passing lines.
427 */
428export function arrange(list: readonly Workflow[], now: number, keep: number): { open: Workflow[]; passed: Workflow[]; hidden: number } {
429 const newest = [...list].sort((a, b) => b.startedAt - a.startedAt)
430 const running = newest.filter(one => standing(one, now) === 'running')
431 const open = running.length > 0 ? running : newest.slice(0, 1)
432 const rest = newest.filter(one => !open.includes(one))
433 return { open, passed: rest.slice(0, keep), hidden: Math.max(0, rest.length - keep) }
434}
435
436/** The tasks an open run lists in `room` rows: all that run, then the latest settled. */
437export function visibleTasks(flow: Workflow, room: number): { shown: WorkflowTask[]; folded: number } {
438 if (flow.tasks.length <= room) return { shown: flow.tasks, folded: 0 }
439 const running = flow.tasks.filter(t => t.status === 'running')
440 const settled = flow.tasks
441 .filter(t => t.status !== 'running')
442 .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
443 .slice(0, Math.max(0, room - running.length))
444 const shown = [...running.slice(0, room), ...settled].sort((a, b) => a.index - b.index)
445 return { shown, folded: flow.tasks.length - shown.length }
446}
447hooks/plan.ts 505 lines1import type { WorkflowPlan } from '../types'
2
3/** How deep a count follows names, helpers and their callers before it gives up. */
4const MAX_DEPTH = 8
5const OPENERS = '([{'
6const CLOSERS = ')]}'
7const NAME = /^[A-Za-z_$][\w$]*$/
8const ITERATES = new Set(['map', 'flatMap', 'forEach', 'filter', 'reduce', 'reduceRight'])
9const KEEPS_SIZE = new Set(['map', 'fill', 'reverse', 'sort', 'toSorted', 'toReversed'])
10
11/** A count, or undefined where it hangs on what the run's agents return. */
12type Count = number | undefined
13
14type Helper = { name: string; at: number; from: number; to: number }
15
16type Script = {
17 code: string
18 args: unknown
19 bindings: Map<string, string | null>
20 helpers: Helper[]
21}
22
23/**
24 * How many agents a workflow script starts, read before it runs: each
25 * `agent()` call counted as often as the lists, `args`, `Array.from` lengths
26 * and loops around it repeat it. A repeat that hangs on what agents return
27 * (their findings, a `while`) makes the count a floor, `isOpen`.
28 */
29export function planOf(script: string, args?: unknown): WorkflowPlan {
30 const code = mask(script)
31 const at = { code, args, bindings: bindingsOf(code), helpers: helpersOf(code) }
32 let total = 0
33 let isOpen = /(?<![\w$.])workflow\s*\(/.test(code)
34 for (const site of code.matchAll(/(?<![\w$.])agent\s*\(/g)) {
35 const times = repeats(at, site.index, 0)
36 if (times === undefined) isOpen = true
37 else total += times
38 }
39 return { total, isOpen }
40}
41
42/** How often the code at `p` runs: the repeats around it, times its helper's calls. */
43function repeats(at: Script, p: number, depth: number): Count {
44 if (depth > MAX_DEPTH) return undefined
45 const helper = at.helpers
46 .filter(one => one.from <= p && p < one.to)
47 .sort((a, b) => b.from - a.from)[0]
48 let times: Count = 1
49 for (const open of enclosing(at.code, p)) {
50 if (helper !== undefined && open < helper.from) break
51 const factor = factorAt(at, open, p, depth)
52 if (factor === 0) return 0
53 times = factor === undefined || times === undefined ? undefined : times * factor
54 }
55 if (helper === undefined || times === 0) return times
56 let calls: Count = 0
57 for (const use of at.code.matchAll(new RegExp(`(?<![\\w$.])${escape(helper.name)}(?![\\w$])(?!\\s*:)`, 'g'))) {
58 if (use.index === helper.at) continue
59 const each = repeats(at, use.index, depth + 1)
60 calls = each === undefined || calls === undefined ? undefined : calls + each
61 }
62 return times === undefined || calls === undefined ? (calls === 0 ? 0 : undefined) : times * calls
63}
64
65/** How often the bracket opened at `open` runs what `p` sits in. */
66function factorAt(at: Script, open: number, p: number, depth: number): Count {
67 const { code } = at
68 const before = code.slice(0, open).trimEnd()
69 if (code[open] === '(') {
70 if (/(?<![\w$])while$/.test(before)) return undefined
71 if (/\.\s*catch$/.test(before)) return 0
72 const method = /\.\s*([A-Za-z_$][\w$]*)$/.exec(before)
73 if (method !== null && ITERATES.has(method[1] ?? '')) {
74 return sizeOf(at, receiverBefore(code, method.index), depth + 1)
75 }
76 if (method !== null && /^(some|every|find|findIndex|findLast)$/.test(method[1] ?? '')) return undefined
77 const place = argIndex(code, open, p)
78 if (/(?<![\w$.])pipeline$/.test(before) && place >= 1) {
79 return sizeOf(at, argText(code, open, 0), depth + 1)
80 }
81 if (/(?<![\w$.])Array\s*\.\s*from$/.test(before) && place >= 1) {
82 return lengthOf(at, argText(code, open, 0), depth + 1)
83 }
84 return 1
85 }
86 if (code[open] === '{') {
87 if (/(?<![\w$])(do|catch)$/.test(before)) return /do$/.test(before) ? undefined : 0
88 if (!before.endsWith(')')) return 1
89 const head = matchOpen(code, before.length - 1)
90 const word = code.slice(0, head).trimEnd()
91 if (/(?<![\w$])catch$/.test(word)) return 0
92 if (/(?<![\w$])while$/.test(word)) return undefined
93 if (/(?<![\w$])for(\s+await)?$/.test(word)) return loopCount(at, code.slice(head + 1, before.length - 1), depth + 1)
94 }
95 return 1
96}
97
98/** How many times a `for` header runs its body. */
99function loopCount(at: Script, header: string, depth: number): Count {
100 const over = /^\s*(?:const|let|var)?\s*(?:[\w$]+|\[[^\]]*\]|\{[^}]*\})\s+of\s+([\s\S]+)$/.exec(header)
101 if (over !== null) return sizeOf(at, over[1] ?? '', depth)
102 const parts = splitTop(header, ';')
103 if (parts.length !== 3) return undefined
104 const [init = '', test = '', step = ''] = parts
105 const start = /^\s*(?:let|var)?\s*([\w$]+)\s*=\s*([\s\S]+)$/.exec(init)
106 const name = start?.[1]
107 if (start === null || name === undefined) return undefined
108 const from = numberOf(at, start[2] ?? '', depth)
109 const bound = new RegExp(`^\\s*${escape(name)}\\s*(<=?)\\s*([\\s\\S]+)$`).exec(test)
110 const to = bound === null ? undefined : numberOf(at, bound[2] ?? '', depth)
111 const isStep = new RegExp(`^\\s*(?:${escape(name)}\\s*\\+\\+|\\+\\+\\s*${escape(name)}|${escape(name)}\\s*\\+=\\s*1)\\s*$`).test(step)
112 if (from === undefined || to === undefined || !isStep || bound === null) return undefined
113 return Math.max(0, to - from + (bound[1] === '<=' ? 1 : 0))
114}
115
116/** How many items a list expression holds. */
117function sizeOf(at: Script, text: string, depth: number): Count {
118 if (depth > MAX_DEPTH) return undefined
119 const t = bare(text)
120 if (t === '') return undefined
121 if (t.startsWith('[') && matchClose(t, 0) === t.length - 1) {
122 let sum: Count = 0
123 const items = splitTop(t.slice(1, -1), ',')
124 if (items.length > 0 && (items[items.length - 1] ?? '').trim() === '') items.pop()
125 for (const item of items) {
126 const spread = /^\s*\.\.\.([\s\S]+)$/.exec(item)
127 const each = spread === null ? 1 : sizeOf(at, spread[1] ?? '', depth + 1)
128 sum = each === undefined || sum === undefined ? undefined : sum + each
129 }
130 return sum
131 }
132 if (isPath(t)) {
133 const value = valueAt(at, t, depth)
134 return Array.isArray(value) ? value.length : typeof value === 'string' ? sizeOf(at, value, depth + 1) : undefined
135 }
136 if (!t.endsWith(')')) return undefined
137 const open = matchOpen(t, t.length - 1)
138 const callee = t.slice(0, open).trimEnd()
139 const inner = t.slice(open + 1, -1)
140 if (/^Array\s*\.\s*from$/.test(callee)) return lengthOf(at, splitTop(inner, ',')[0] ?? '', depth + 1)
141 if (/^(new\s+)?Array$/.test(callee)) {
142 const parts = splitTop(inner, ',')
143 return parts.length === 1 ? numberOf(at, parts[0] ?? '', depth + 1) : parts.length
144 }
145 const keys = /^Object\s*\.\s*(keys|values|entries)$/.exec(callee)
146 if (keys !== null) return keysOf(at, inner, depth + 1)
147 // A run's results come back one per item, a failed one as null.
148 if (/^(parallel|pipeline|Promise\s*\.\s*all(Settled)?)$/.test(callee)) {
149 return sizeOf(at, splitTop(inner, ',')[0] ?? '', depth + 1)
150 }
151 const method = /\.\s*([A-Za-z_$][\w$]*)$/.exec(callee)
152 if (method === null) return undefined
153 const receiver = callee.slice(0, method.index)
154 const name = method[1] ?? ''
155 if (KEEPS_SIZE.has(name) || (name === 'filter' && inner.trim() === 'Boolean')) return sizeOf(at, receiver, depth + 1)
156 if (name === 'concat') {
157 let sum = sizeOf(at, receiver, depth + 1)
158 for (const part of splitTop(inner, ',')) {
159 const each = sizeOf(at, part, depth + 1)
160 sum = each === undefined || sum === undefined ? undefined : sum + each
161 }
162 return sum
163 }
164 if (name === 'slice') {
165 const size = sizeOf(at, receiver, depth + 1)
166 const [a, b] = splitTop(inner, ',')
167 const from = a === undefined || a.trim() === '' ? 0 : numberOf(at, a, depth + 1)
168 const to = b === undefined ? size : numberOf(at, b, depth + 1)
169 if (size === undefined || from === undefined || to === undefined) return undefined
170 const clamp = (n: number) => (n < 0 ? Math.max(0, size + n) : Math.min(size, n))
171 return Math.max(0, clamp(to) - clamp(from))
172 }
173 return undefined
174}
175
176/** `{ length: N }`, the first argument of `Array.from`, or a list's size. */
177function lengthOf(at: Script, text: string, depth: number): Count {
178 const t = bare(text)
179 const length = /^\{\s*length\s*:\s*([\s\S]+?)\s*,?\s*\}$/.exec(t)
180 return length === null ? sizeOf(at, t, depth) : numberOf(at, length[1] ?? '', depth)
181}
182
183function numberOf(at: Script, text: string, depth: number): Count {
184 if (depth > MAX_DEPTH) return undefined
185 const t = bare(text)
186 if (/^\d+$/.test(t)) return Number(t)
187 const pick = /^Math\s*\.\s*(min|max)\s*\(([\s\S]*)\)$/.exec(t)
188 if (pick !== null) {
189 const values = splitTop(pick[2] ?? '', ',').map(part => numberOf(at, part, depth + 1))
190 if (values.length === 0 || values.some(v => v === undefined)) return undefined
191 return pick[1] === 'min' ? Math.min(...(values as number[])) : Math.max(...(values as number[]))
192 }
193 const length = /^([\s\S]+?)\s*\.\s*length$/.exec(t)
194 if (length !== null) return sizeOf(at, length[1] ?? '', depth + 1)
195 if (!isPath(t)) return undefined
196 const value = valueAt(at, t, depth)
197 if (typeof value === 'number' && Number.isInteger(value)) return value
198 return typeof value === 'string' ? numberOf(at, value, depth + 1) : undefined
199}
200
201function keysOf(at: Script, text: string, depth: number): Count {
202 const value = isPath(bare(text)) ? valueAt(at, bare(text), depth) : bare(text)
203 if (typeof value === 'object' && value !== null && !Array.isArray(value)) return Object.keys(value).length
204 if (Array.isArray(value)) return value.length
205 if (typeof value !== 'string') return undefined
206 const t = bare(value)
207 if (!t.startsWith('{') || matchClose(t, 0) !== t.length - 1) return undefined
208 const entries = splitTop(t.slice(1, -1), ',').filter(part => part.trim() !== '')
209 return entries.some(part => part.trim().startsWith('...')) ? undefined : entries.length
210}
211
212/**
213 * What a path like `args.files` or `DIMENSIONS` stands for: a value from the
214 * call's `args`, or the source text a `const` binds, followed key by key.
215 */
216function valueAt(at: Script, path: string, depth: number): unknown {
217 if (depth > MAX_DEPTH) return undefined
218 const [root = '', ...keys] = path.replace(/\?/g, '').split(/\s*\.\s*|\s*\[\s*|\s*\]\s*/).filter(Boolean)
219 let value: unknown = root === 'args' ? at.args : at.bindings.get(root)
220 if (value === null) return undefined
221 for (const [place, key] of keys.entries()) {
222 if (typeof value === 'string') {
223 const t = bare(value)
224 if (isPath(t)) return valueAt(at, [t, ...keys.slice(place)].join('.'), depth + 1)
225 value = propertyOf(at, t, key)
226 } else if (typeof value === 'object' && value !== null) {
227 value = (value as Record<string, unknown>)[key]
228 } else {
229 return undefined
230 }
231 }
232 return value
233}
234
235/** The source text of `key` in an object literal, or undefined. */
236function propertyOf(at: Script, object: string, key: string): string | undefined {
237 if (!object.startsWith('{') || matchClose(object, 0) !== object.length - 1) return undefined
238 for (const part of splitTop(object.slice(1, -1), ',')) {
239 const entry = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([\s\S]*))?$/.exec(part)
240 if (entry?.[1] !== key) continue
241 return entry[2] ?? (at.bindings.get(key) ?? undefined)
242 }
243 return undefined
244}
245
246/** Each `const` name and the source text it binds; a name bound twice is unknown (null). */
247function bindingsOf(code: string): Map<string, string | null> {
248 const bound = new Map<string, string | null>()
249 const bind = (name: string, text: string) => bound.set(name, bound.has(name) ? null : text)
250 for (const one of code.matchAll(/(?<![\w$.])const\s+([A-Za-z_$][\w$]*)\s*=(?![=>])/g)) {
251 const from = one.index + one[0].length
252 bind(one[1] ?? '', code.slice(from, expressionEnd(code, from)))
253 }
254 for (const one of code.matchAll(/(?<![\w$.])const\s*\{([^{}]*)\}\s*=(?![=>])/g)) {
255 const from = one.index + one[0].length
256 const source = bare(code.slice(from, expressionEnd(code, from)))
257 if (!isPath(source)) continue
258 for (const part of splitTop(one[1] ?? '', ',')) {
259 const entry = /^\s*([A-Za-z_$][\w$]*)\s*(?::\s*([A-Za-z_$][\w$]*))?\s*(?:=[\s\S]*)?$/.exec(part)
260 if (entry?.[1] !== undefined) bind(entry[2] ?? entry[1], `${source}.${entry[1]}`)
261 }
262 }
263 return bound
264}
265
266/** Every named function and arrow, with the span its body takes. */
267function helpersOf(code: string): Helper[] {
268 const helpers: Helper[] = []
269 for (const one of code.matchAll(/(?<![\w$.])function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g)) {
270 const params = one.index + one[0].length - 1
271 const body = code.indexOf('{', matchClose(code, params))
272 if (body === -1) continue
273 const name = one[1] ?? ''
274 helpers.push({ name, at: one.index + one[0].indexOf(name, 8), from: body + 1, to: matchClose(code, body) })
275 }
276 for (const one of code.matchAll(/(?<![\w$.])(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?/g)) {
277 let from = one.index + one[0].length
278 if (code.startsWith('function', from)) {
279 const params = code.indexOf('(', from)
280 const body = params === -1 ? -1 : code.indexOf('{', matchClose(code, params))
281 if (body === -1) continue
282 from = body
283 } else {
284 const params = code[from] === '(' ? matchClose(code, from) + 1 : (/^[A-Za-z_$][\w$]*/.exec(code.slice(from))?.[0].length ?? -1) + from
285 const arrow = /^\s*=>\s*/.exec(code.slice(params))
286 if (params < from || arrow === null) continue
287 from = params + arrow[0].length
288 }
289 const name = one[1] ?? ''
290 const at = one.index + one[0].indexOf(name, 3)
291 if (code[from] === '{') helpers.push({ name, at, from: from + 1, to: matchClose(code, from) })
292 else helpers.push({ name, at, from, to: expressionEnd(code, from) })
293 }
294 return helpers
295}
296
297/** The open brackets around `p`, innermost first. */
298function enclosing(code: string, p: number): number[] {
299 const stack: number[] = []
300 for (let i = 0; i < p; i += 1) {
301 const char = code[i] ?? ''
302 if (OPENERS.includes(char)) stack.push(i)
303 else if (CLOSERS.includes(char)) stack.pop()
304 }
305 return stack.reverse()
306}
307
308/** Which argument of the call opened at `open` holds `p`, from 0. */
309function argIndex(code: string, open: number, p: number): number {
310 let place = 0
311 for (let i = open + 1; i < p; i += 1) {
312 const char = code[i] ?? ''
313 if (OPENERS.includes(char)) {
314 const close = matchClose(code, i)
315 if (close >= p) break
316 i = close
317 } else if (char === ',') place += 1
318 }
319 return place
320}
321
322function argText(code: string, open: number, place: number): string {
323 return splitTop(code.slice(open + 1, matchClose(code, open)), ',')[place] ?? ''
324}
325
326/** The member chain a `.method` hangs from, e.g. `Array.from({ length: 3 })`. */
327function receiverBefore(code: string, dot: number): string {
328 let i = dot - 1
329 for (;;) {
330 while (i >= 0 && /\s/.test(code[i] ?? '')) i -= 1
331 const char = code[i] ?? ''
332 if (char === ')' || char === ']') i = matchOpen(code, i) - 1
333 else if (/[\w$]/.test(char)) {
334 while (i >= 0 && /[\w$]/.test(code[i] ?? '')) i -= 1
335 } else break
336 let j = i
337 while (j >= 0 && /\s/.test(code[j] ?? '')) j -= 1
338 const link = code[j] ?? ''
339 const next = code[i + 1] ?? ''
340 if (link === '.' || ((link === ')' || link === ']' || /[\w$]/.test(link)) && /[([]/.test(next))) {
341 i = link === '.' ? j - (code[j - 1] === '?' ? 2 : 1) : j
342 continue
343 }
344 break
345 }
346 return code.slice(i + 1, dot)
347}
348
349/** Where the expression starting at `from` ends: a `;`, a `,` or a closing bracket at its level, or a line that does not go on. */
350function expressionEnd(code: string, from: number): number {
351 for (let i = from; i < code.length; i += 1) {
352 const char = code[i] ?? ''
353 if (OPENERS.includes(char)) i = matchClose(code, i)
354 else if (CLOSERS.includes(char) || char === ';' || char === ',') return i
355 else if (char === '\n') {
356 const rest = code.slice(i).trimStart()
357 if (!/^([.?:+\-*/%&|^<>]|=[=>])/.test(rest) && code.slice(from, i).trim() !== '') return i
358 }
359 }
360 return code.length
361}
362
363function splitTop(text: string, by: string): string[] {
364 const parts: string[] = []
365 let depth = 0
366 let start = 0
367 for (let i = 0; i < text.length; i += 1) {
368 const char = text[i] ?? ''
369 if (OPENERS.includes(char)) depth += 1
370 else if (CLOSERS.includes(char)) depth -= 1
371 else if (char === by && depth === 0) {
372 parts.push(text.slice(start, i))
373 start = i + 1
374 }
375 }
376 const last = text.slice(start)
377 if (parts.length > 0 || last.trim() !== '') parts.push(last)
378 return parts
379}
380
381function matchClose(text: string, open: number): number {
382 let depth = 0
383 for (let i = open; i < text.length; i += 1) {
384 const char = text[i] ?? ''
385 if (OPENERS.includes(char)) depth += 1
386 else if (CLOSERS.includes(char)) {
387 depth -= 1
388 if (depth === 0) return i
389 }
390 }
391 return text.length
392}
393
394function matchOpen(text: string, close: number): number {
395 let depth = 0
396 for (let i = close; i >= 0; i -= 1) {
397 const char = text[i] ?? ''
398 if (CLOSERS.includes(char)) depth += 1
399 else if (OPENERS.includes(char)) {
400 depth -= 1
401 if (depth === 0) return i
402 }
403 }
404 return 0
405}
406
407/** An expression with `await`, a trailing `;` and wrapping parentheses left off. */
408function bare(text: string): string {
409 let t = text.trim().replace(/;$/, '').trim().replace(/^await\s+/, '')
410 while (t.startsWith('(') && matchClose(t, 0) === t.length - 1) t = t.slice(1, -1).trim()
411 return t
412}
413
414function isPath(text: string): boolean {
415 return /^[A-Za-z_$][\w$]*(?:\s*\??\.\s*[A-Za-z_$][\w$]*|\s*\[\s*\d+\s*\])*$/.test(text) && NAME.test(text.split(/[.[?\s]/)[0] ?? '')
416}
417
418function escape(text: string): string {
419 return text.replace(/[$]/g, '\\$')
420}
421
422/**
423 * The script with every string, template and regex body and every comment
424 * blanked (quotes and newlines kept), so brackets and names read as code.
425 */
426export function mask(src: string): string {
427 const out = src.split('')
428 const blank = (from: number, to: number) => {
429 for (let i = from; i < to; i += 1) if (out[i] !== '\n') out[i] = ' '
430 }
431 const quoted = (at: number): number => {
432 const quote = src[at]
433 for (let i = at + 1; i < src.length; i += 1) {
434 if (src[i] === '\\') i += 1
435 else if (src[i] === quote || (src[i] === '\n' && quote !== '`')) return i
436 }
437 return src.length
438 }
439 // A template's `${...}` holds code that may hold more templates.
440 const template = (at: number): number => {
441 for (let i = at + 1; i < src.length; i += 1) {
442 if (src[i] === '\\') i += 1
443 else if (src[i] === '`') return i
444 else if (src[i] === '$' && src[i + 1] === '{') i = code(i + 2, true)
445 }
446 return src.length
447 }
448 const code = (from: number, isNested: boolean): number => {
449 let depth = 0
450 let last = ''
451 for (let i = from; i < src.length; i += 1) {
452 const char = src[i] ?? ''
453 if (char === '/' && src[i + 1] === '/') {
454 const end = src.indexOf('\n', i)
455 blank(i, end === -1 ? src.length : end)
456 i = end === -1 ? src.length : end - 1
457 continue
458 }
459 if (char === '/' && src[i + 1] === '*') {
460 const end = src.indexOf('*/', i + 2)
461 blank(i, end === -1 ? src.length : end + 2)
462 i = end === -1 ? src.length : end + 1
463 continue
464 }
465 if (char === '"' || char === "'") {
466 const end = quoted(i)
467 blank(i + 1, end)
468 i = end
469 last = 'x'
470 continue
471 }
472 if (char === '`') {
473 const end = template(i)
474 blank(i + 1, end)
475 i = end
476 last = 'x'
477 continue
478 }
479 if (char === '/' && (last === '' || /[(,=:[!&|?{};+\-*%<>~^]/.test(last) || /(?<![\w$])(return|typeof|case|do|else|in|of|yield|await)$/.test(src.slice(from, i).trimEnd()))) {
480 let inClass = false
481 let end = i + 1
482 for (; end < src.length && src[end] !== '\n'; end += 1) {
483 if (src[end] === '\\') end += 1
484 else if (src[end] === '[') inClass = true
485 else if (src[end] === ']') inClass = false
486 else if (src[end] === '/' && !inClass) break
487 }
488 blank(i + 1, end)
489 i = end
490 last = 'x'
491 continue
492 }
493 if (isNested && char === '{') depth += 1
494 if (isNested && char === '}') {
495 if (depth === 0) return i
496 depth -= 1
497 }
498 if (!/\s/.test(char)) last = char
499 }
500 return src.length
501 }
502 code(0, false)
503 return out.join('')
504}
505types/index.d.ts 137 lines1/** A tool call in flight: how the detail tab names it, and the plain words the board shows. */
2export type RunningCall = { id: string; text: string; doing: string }
3
4export type Tokens = {
5 input: number
6 output: number
7 cacheRead: number
8 cacheWrite: number
9 requests: number
10}
11
12/** One line of a loop's TodoWrite list. */
13export type Todo = { content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm?: string }
14
15/** What one loop (the main thread, a subagent, a teammate, a workflow agent) has done this session. */
16export type Activity = {
17 running: RunningCall[]
18 last?: string
19 /** The calls it made, newest last, as the detail tab lists them. */
20 recent?: string[]
21 tools: number
22 tokens: Tokens
23 firstSeen: number
24 /** The last time any of its events came. */
25 lastAt?: number
26 isBusy: boolean
27 endedAt?: number
28 todos?: Todo[]
29 /** What it was asked to do, in one line: an agent's spawn prompt, the main session's last request. */
30 task?: string
31 /** The agent type its SubagentStart named (`Explore`, `workflow-subagent`, ...). */
32 kind?: string
33 /** When its SubagentStop came, and what it last said. */
34 stoppedAt?: number
35 reply?: string
36}
37
38/** One task of the session's task list (TaskCreate / TaskUpdate, the team's shared list). */
39export type TaskItem = {
40 id: string
41 subject: string
42 status: 'pending' | 'in_progress' | 'completed'
43 owner?: string
44 activeForm?: string
45 /** The loop that created it: `main` or an agent id. */
46 by?: string
47 at: number
48}
49
50export type LimitSample = { at: number; percent: number }
51
52export type LimitWindow = {
53 kind: string
54 percentUsed: number
55 resetsAt?: string
56 samples: LimitSample[]
57}
58
59export type Meter = {
60 limits: LimitWindow[]
61 costUsd?: number
62 contextPercent?: number
63}
64
65export type TaskStatus = 'running' | 'done' | 'failed'
66
67/** One `agent()` call of a workflow run. */
68export type WorkflowTask = {
69 index: number
70 label: string
71 status: TaskStatus
72 agentId?: string
73 phase?: string
74 startedAt: number
75 endedAt?: number
76 tokens: Tokens
77}
78
79export type WorkflowStatus = 'running' | 'done' | 'failed' | 'stopped'
80
81/**
82 * How many agents a run's script starts, read from the script before it
83 * runs; `isOpen` when more hang on what its agents return.
84 */
85export type WorkflowPlan = { total: number; isOpen: boolean }
86
87/** One Workflow tool call and the agents its run started. */
88export type Workflow = {
89 /** The Workflow call's tool_use_id, or the run id when the call is unknown. */
90 key: string
91 runId?: string
92 name: string
93 description?: string
94 phases: string[]
95 plan?: WorkflowPlan
96 status: WorkflowStatus
97 startedAt: number
98 endedAt?: number
99 tasks: WorkflowTask[]
100}
101
102/** What a Workflow call said about itself before it ran. */
103export type WorkflowCall = {
104 name: string
105 description?: string
106 phases: string[]
107 plan?: WorkflowPlan
108 /** The call's `args`, which a plan read later from the run's saved script needs. */
109 args?: unknown
110 /** A hash of the script the call runs, to know its saved copy again. */
111 hash?: number
112}
113
114/** A detail tab: what it shows, by the board's item key, and its label. */
115export type Tab = { key: string; title: string }
116
117/** The pane's tabs: `active` is `all` for the board, else an open tab's key. */
118export type Tabs = { open: Tab[]; active: string }
119
120/** A hook or a drawing that failed: shown on the board, never swallowed. */
121export type Fault = { at: number; where: string; message: string }
122
123declare module 'claude-code' {
124 interface PluginState {
125 'session-dashboard': {
126 activity: Record<string, Activity>
127 meter: Meter
128 tick: number
129 calls: Record<string, WorkflowCall>
130 workflows: Workflow[]
131 tasks: Record<string, TaskItem>
132 tabs: Tabs
133 faults: Fault[]
134 }
135 }
136}
137