SLOPSHOPPER

session-dashboard

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…

newpanebandguardcommandprompt
v0.2.0no licenseupdated 2026-10-16comdoly-life/claude-session-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ 대시보드 ✕ › fix the failing auth test and add an audit log call │ ┃ 대시보드 ↑입력 ↓출력 · 시간 │ ┃ 메인 세션 ↑97.4k ↓1.5k ⏺ Read(src/auth.ts) │ ┃ 대기 중 ⎿ Read 6 lines │ ┃ 실행 중인 서브 작업 없음 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 합계 ↑97.4k ↓1.5k · $0.42 · 컨텍스트 49% ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dash │ ⎿ session-dashboard: 대시보드를 열었습니다. │ │ 5시간 ███████░░░░░░░░░░░░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5시간 ███████░░░░░░░░░░░░░░░░░ 31%
Pane · 대시보드
대시보드 ↑입력 ↓출력 · 시간 메인 세션 ↑97.4k ↓1.5k 대기 중 실행 중인 서브 작업 없음 합계 ↑97.4k ↓1.5k · $0.42 · 컨텍스트 49%
README

Claude Session Dashboard

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

설치 / Install

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), 에이전트 목록(클릭하면 그 에이전트 탭).
  • 한도 게이지: 프롬프트 위 한 줄에 5시간·주간(·지출) 사용률, 리셋까지 남은 시간, 지금 속도로 리셋 전에 다 쓸 것 같으면 경고.
  • /dash 로 대시보드 창을 열고 닫습니다. 세션이 시작되면 자동으로 열립니다.

동작 방식

  • 엔진 이벤트(턴, 도구 호출, 에이전트 시작, SubagentStart/SubagentStop, 작업 목록)를 받아 세션 메모리에만 기록합니다.
  • 워크플로는 이벤트와 함께 세션 폴더의 실행 기록(~/.claude/projects/<프로젝트>/<세션>/subagents/workflows/<run>/journal.jsonl)과 저장된 스크립트를 5초마다 읽어, 플러그인을 다시 불러오기 전에 시작한 실행도 보여 줍니다 (HOME, CLAUDE_CONFIG_DIR 환경 변수로 위치를 찾습니다).
  • 네트워크로 아무것도 보내지 않습니다. 플러그인 저장소($.store)에는 한도 게이지 기록과 최근 훅 오류만 남깁니다.
  • 훅에서 오류가 나면 보드 아래에 ⚠ 줄로 보여 줍니다.

한계

  • 워크플로의 전체 개수는 스크립트를 실행하지 않고 읽어 셉니다. 반복 횟수가 에이전트 결과에 달린 경우 (찾은 문제마다 검증, while 반복 등)는 최소값과 + 로 표시하고 실제로 늘어나면 따라갑니다.
  • 워크플로 이름, 단계·라벨, 에이전트 설명처럼 스크립트나 모델이 정한 이름은 그대로(대개 영어) 표시합니다.
  • Claude Code 2.1.300 의 함수 훅 mod API로 만들고 시험했습니다.

개발

claude plugin validate .
claude plugin test .

hooks/register.tsx 가 훅과 그리기를, hooks/board.ts 가 보드 모델, hooks/view.tsx 가 화면, hooks/workflow.ts·hooks/plan.ts 가 워크플로 추적과 스크립트 분석, hooks/format.ts 가 표기를 맡습니다.

Source 7 files
hooks/register.tsx 597 lines
1import { 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}
597
hooks/board.ts 343 lines
1import 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}
343
hooks/format.ts 298 lines
1import 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}
298
hooks/view.tsx 575 lines
1import 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}
575
hooks/workflow.ts 447 lines
1import 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}
447
hooks/plan.ts 505 lines
1import 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}
505
types/index.d.ts 137 lines
1/** 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