사이드 패널에 서브에이전트 상태·경과 시간·모델·effort을 실시간 표시하고 대화 보기·중지

Claude Code mod. 사이드 패널에 서브에이전트를 실시간으로 보여 준다.
haiku/medium; 현재 도구·토큰·도구 횟수는 옵션). Codex 를 부르는 백그라운드 셸은 gpt-6-luna/low · 프롬프트 첫 줄└ 로 이어 붙는다(Finished 는 들여쓰기 없음)run_in_background)도 bash/powershell 행으로 보인다. 둘째 줄은 $ 명령. 끝은 엔진의 작업 알림(task-notification)으로 잡으므로, 알림이 늦게 오면 경과 시간도 그만큼 길게 찍힌다color: 색으로 칠한다(API가 색을 주지 않아 ~/.claude/plugins/installed_plugins.json → agents/<이름>.md 를 직접 읽음, 내장 에이전트는 색 없음)main 으로 복귀x (평소 흐림, x 에 마우스를 올리면 빨강)를 5초 안에 두 번 누르면 TaskStop 으로 중지. 시간은 첫 줄 오른쪽 끝에 붙는다▸ Finished · N 으로 접힌다(누르면 펼침, 최근 끝난 것부터). clear 는 끝난 행을 지운다/subagent-pane(축약 /sap) 으로 열고 닫는다(머리줄 close 도 닫기). 처음 서브에이전트가 생기면 자동으로 열린다(터미널 144칸 이상일 때)토큰은 에이전트의 턴이 끝날 때 집계된다(실행 중에는 0).
/config 의 플러그인 설정(또는 settings.json pluginConfigs["subagent-pane"].options):
showTool (기본 꺼짐): 둘째 줄에 지금 쓰는 도구(▸ Read)showTokens (기본 꺼짐): 둘째 줄에 토큰(입력↑ 출력↓)showToolCount (기본 꺼짐): 둘째 줄에 도구 호출 횟수(N tools)Claude Code 2.1.287 이상(mods).
/plugin marketplace add duddudcns/claude-plugins
/plugin install subagent-pane@duddudcns
비공개 저장소라 GitHub 접근 권한과 로그인(gh auth login)이 필요하다.
claude plugin validate . / claude plugin test ..debug 파일을 두면 이벤트와 행을 .debug-dump.json 에 기록한다(실제 세션 확인용).claude -p --plugin-dir <이 폴더> "<Agent 도구를 쓰는 프롬프트>" 후 .debug-dump.json 확인.hooks/register.tsx 412 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5import { agentFileCandidates, parseColor, parseEffort, toDisplayColor } from './colors'
6import type { Definition } from './colors'
7import { codexCall, drivePath, firstLine, scriptPathOf } from './codex'
8import { modelLabel, setEffort, setShellInfo, treePrefix, treeOrder, stopOutcome, padTo, reconcileShells, addAgent, addShell, backgroundTaskIdOf, finishTask, parseNotification, displayWidth, endTool, finishTurn, fitHead, formatElapsed, formatTokens, isLive, setColor, shortType, splitSections, startTool, syncStatus, truncateTo } from './model'
9
10const PANE = 'subagents'
11const TITLE = 'Subagents'
12const TICK_MS = 1000
13const STOP_ARM_MS = 5000
14// The desktop's rule: longer than any pane, its box clips the rest.
15const RULE_LENGTH = 400
16
17const agents = atom({ plugin: 'subagent-pane', key: 'agents' } as const, [])
18const tick = atom({ plugin: 'subagent-pane', key: 'tick' } as const, 0)
19const showFinished = atom({ plugin: 'subagent-pane', key: 'showFinished' } as const, false)
20
21// No status icon: the elapsed time's colour is the state (live yellow, done green,
22// failed red, killed dim).
23const STATUS_COLOR: Record<string, string> = { completed: 'green', failed: 'red' }
24
25// Module variables reset on every reload; the rows live in $.state and survive.
26let opened = false
27
28// The engine follows $ only into functions of this same file, so every helper that takes $ lives here.
29
30// Development trace: on only while `.debug` exists in the plugin folder.
31// Each event is appended and the last 200 plus the current rows are written to `.debug-dump.json`.
32let isTracing = false
33const events: unknown[] = []
34
35async function startTrace($: EngineInterface): Promise<void> {
36 isTracing = await $.fs.read(`${$.plugin.root}/.debug`).then(() => true, () => false)
37}
38
39async function trace($: EngineInterface, kind: string, data: Record<string, unknown>): Promise<void> {
40 if (!isTracing) return
41 events.push({ at: await $.clock.now(), kind, ...data })
42 const rows = await read($, agents)
43 await $.fs.write(`${$.plugin.root}/.debug-dump.json`, JSON.stringify({ events: events.slice(-200), rows }, null, 1))
44}
45
46// One lookup per agent type per load; built-in types simply find no file.
47const definitions = new Map<string, Definition>()
48
49async function resolveDefinition($: EngineInterface, type: string): Promise<Definition> {
50 const hit = definitions.get(type)
51 if (hit !== undefined) return hit
52 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
53 const configDir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${home}/.claude`).replace(/\\/g, '/')
54 const installed = await $.fs.read(`${configDir}/plugins/installed_plugins.json`).catch(() => '{}')
55 let found: Definition = {}
56 for (const path of agentFileCandidates(type, configDir, '.', installed)) {
57 const text = await $.fs.read(path).catch(() => undefined)
58 if (text === undefined) continue
59 found = { color: toDisplayColor(parseColor(text)), effort: parseEffort(text) }
60 break
61 }
62 definitions.set(type, found)
63 return found
64}
65
66// The commands toggle: the engine's pane record survives reloads, the module's does not.
67async function togglePane($: EngineInterface) {
68 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
69 if (isUp) {
70 await $.ui.close({ id: PANE })
71 return { text: '서브에이전트 패널을 닫았습니다.' }
72 }
73 await $.ui.open({ id: PANE, title: TITLE })
74 opened = true
75 return { text: '서브에이전트 패널을 열었습니다.' }
76}
77
78export const register: Register = (on, options) => {
79 // All off by default: the detail line names the model and its effort.
80 const showTokens = options.showTokens === true
81 const showToolCount = options.showToolCount === true
82 const showTool = options.showTool === true
83 // Stop takes two presses: the first arms it for STOP_ARM_MS, the second calls TaskStop.
84 const armed = new Map<string, number>()
85
86 on('session.start', async ($, e, next) => {
87 await startTrace($)
88 await $.command.register({ name: 'subagent-pane', description: '서브에이전트 실시간 패널 열기/닫기' })
89 await $.command.register({ name: 'sap', description: '서브에이전트 패널 열기/닫기 (/subagent-pane 축약)' })
90 $.clock.every(TICK_MS, () => {
91 void (async () => {
92 const list = await read($, agents)
93 if (!list.some(row => isLive(row.status))) return
94 const now = await $.clock.now()
95 const engine = await $.agent.list()
96 await update($, agents, rows => syncStatus(rows, engine, now))
97 await trace($, 'list', { engine: engine.map(a => ({ id: a.id, status: a.status, type: a.type })) })
98 await update($, tick, () => now)
99 })().catch(() => undefined)
100 })
101 return next(e)
102 })
103
104 on('command.run', { command: 'subagent-pane' }, togglePane)
105 on('command.run', { command: 'sap' }, togglePane)
106
107 on('agent.spawn', async ($, e, next) => {
108 const result = await next(e)
109 if (result.agentId === undefined) return result
110 const startedAt = await $.clock.now()
111 await update($, agents, rows =>
112 addAgent(rows, {
113 id: result.agentId!,
114 type: e.subagentType,
115 description: e.description,
116 startedAt,
117 model: result.model,
118 parentId: e.parentAgentId,
119 }),
120 )
121 await update($, tick, () => startedAt)
122 // The colour lookup reads files: never hold the spawn for it.
123 const id = result.agentId
124 void resolveDefinition($, e.subagentType)
125 .then(async def => {
126 if (def.color) await update($, agents, rows => setColor(rows, id, def.color!))
127 if (def.effort) await update($, agents, rows => setEffort(rows, id, def.effort!))
128 })
129 .catch(() => undefined)
130 await trace($, 'spawn', { id: result.agentId, type: e.subagentType, background: e.background, parent: e.parentAgentId })
131 if (!opened) {
132 opened = true
133 void $.ui.open({ id: PANE, title: TITLE })
134 }
135 return result
136 }).catch(($, e, next) => next(e)) // a display bug must never block a spawn
137
138 // The effort a subagent really runs on is in its model requests: it fills in what the definition file left out.
139 on('turn.step', async function* ($, e, next) {
140 const effort = e.agentId === undefined || e.effort === undefined ? undefined : String(e.effort)
141 if (e.agentId !== undefined && effort !== undefined) {
142 const id = e.agentId
143 const row = (await read($, agents)).find(r => r.id === id)
144 if (row !== undefined && row.effort !== effort) await update($, agents, rows => setEffort(rows, id, effort))
145 }
146 return yield* next(e)
147 })
148
149 on('tool.call', async ($, e, next) => {
150 const id = e.agentId
151 if (id !== undefined) {
152 await update($, agents, rows => startTool(rows, id, e.tool))
153 await trace($, 'tool', { id, tool: e.tool })
154 }
155 try {
156 const ran = await next(e)
157 // A backgrounded shell keeps running after the call returns: give it a row of its own.
158 if ((e.tool === 'Bash' || e.tool === 'PowerShell') && e.run_in_background) {
159 const taskId = backgroundTaskIdOf(ran)
160 if (taskId !== undefined) {
161 const startedAt = await $.clock.now()
162 const shell = e.tool === 'Bash' ? 'bash' : 'powershell'
163 await update($, agents, rows =>
164 addShell(rows, { id: taskId, shell, command: e.command, description: e.description, startedAt, parentId: id }),
165 )
166 await trace($, 'shell', { id: taskId, shell, parent: id })
167 void describeCodex($, e.command)
168 .then(info => (info ? update($, agents, rows => setShellInfo(rows, taskId, info)) : undefined))
169 .catch(() => undefined)
170 }
171 }
172 return ran
173 } finally {
174 if (id !== undefined) await update($, agents, rows => endTool(rows, id, e.tool))
175 }
176 }).catch(($, e, next) => next(e))
177
178 // The engine appends a notification row when a background task ends: that is a shell's end.
179 on('session.append', async ($, e, next) => {
180 const ran = await next(e)
181 if ('kind' in e.origin && e.origin.kind === 'task-notification') {
182 const text = e.message.content.map(block => (block.type === 'text' ? block.text : '')).join('\n')
183 const note = parseNotification(text)
184 if (note !== undefined) {
185 const now = await $.clock.now()
186 await update($, agents, rows => finishTask(rows, note.id, note.status ?? 'completed', now))
187 await trace($, 'notification', { id: note.id, status: note.status })
188 }
189 }
190 return ran
191 }).catch(($, e, next) => next(e))
192
193 // At each main turn's end the engine lists the background work still in flight.
194 on('classic.Stop', async ($, e, next) => {
195 const ran = await next(e)
196 if (e.background_tasks !== undefined) {
197 const live = new Set(e.background_tasks.map(task => task.id))
198 const now = await $.clock.now()
199 await update($, agents, rows => reconcileShells(rows, live, now))
200 }
201 return ran
202 })
203
204 on('turn.complete', async ($, e, next) => {
205 const id = e.agentId
206 if (id !== undefined) {
207 const now = await $.clock.now()
208 await update($, agents, rows => finishTurn(rows, id, e.reason, now, e.usage))
209 await trace($, 'complete', { id, reason: e.reason, usage: e.usage })
210 }
211 return next(e)
212 })
213
214 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215 const { Box, Text, Button } = $.ui.resolve(e)
216 const rows = await read($, agents)
217 const isExpanded = await read($, showFinished)
218 await read($, tick)
219 const now = await $.clock.now()
220 const { live, finished } = splitSections(rows)
221 const viewed = e.props?.view?.agentId
222 // Rows fill the pane exactly: the time ends at the right edge.
223 const cols = Math.max(12, e.props?.bodyColumns ?? 80)
224 // Only the terminal draws one cell per column: padding with spaces lines things up there.
225 // Elsewhere (the desktop's proportional text) flex does the aligning.
226 const isCellGrid = e.surface === 'terminal'
227
228 const header = (
229 <Box flexDirection="row">
230 <Text bold>{live.length > 0 ? `${live.length} running` : 'idle'}</Text>
231 <Text> </Text>
232 {viewed !== undefined && <Button plain dimColor onPress={() => void $.ui.view({})}>main</Button>}
233 {viewed !== undefined && <Text> </Text>}
234 <Button plain dimColor role="dismiss" onPress={() => void $.ui.close({ id: PANE })}>close</Button>
235 </Box>
236 )
237
238 const pressStop = async (row: AgentRow) => {
239 // Decide and record synchronously, with the time this drawing was made: an await before
240 // arming let a fast second press also see "not armed" and only arm again.
241 const expiry = armed.get(row.id)
242 if (expiry === undefined || expiry < now) {
243 armed.set(row.id, now + STOP_ARM_MS)
244 await trace($, 'press', { id: row.id, step: 'arm' })
245 await update($, tick, () => now + 1)
246 return
247 }
248 armed.delete(row.id)
249 await trace($, 'press', { id: row.id, step: 'stop' })
250 const ran = await $.tool.call({
251 tool: 'TaskStop',
252 task_id: row.id,
253 consent: `The user pressed "stop" twice on subagent ${row.type} (${row.description}) in the subagent pane.`,
254 })
255 await trace($, 'stop', { id: row.id, ran: JSON.parse(JSON.stringify(ran)) })
256 if ('deny' in ran && ran.deny) {
257 $.ui.toast(`Stop failed: ${ran.deny}`)
258 return
259 }
260 const outcome = stopOutcome('isError' in ran && ran.isError === true, ('text' in ran ? ran.text : '') ?? '')
261 if ('error' in outcome) {
262 $.ui.toast(`Stop failed: ${outcome.error}`)
263 return
264 }
265 const at2 = await $.clock.now()
266 await update($, agents, rows => finishTask(rows, row.id, outcome.settle, at2))
267 }
268
269 // Exactly two lines per agent: every text is cut to the pane width here, never wrapped.
270 const agentRow = (row: AgentRow, depth = 0) => {
271 const elapsed = formatElapsed((row.endedAt ?? now) - row.startedAt)
272 const { head: indent, tail: tailIndent } = treePrefix(depth)
273 const expiry = armed.get(row.id)
274 const isArmed = expiry !== undefined && expiry >= now
275 const canStop = isLive(row.status)
276 // Armed, the description gives its place to the confirmation so the line keeps its width.
277 const desc = canStop && isArmed ? 'press x again to stop' : row.description
278 const head = fitHead({ indent, type: shortType(row.type), desc, elapsed }, cols)
279 const tokens = showTokens && row.tokensIn + row.tokensOut > 0 ? `${formatTokens(row.tokensIn)}↑ ${formatTokens(row.tokensOut)}↓` : ''
280 const toolCount = showToolCount ? `${row.toolCount} tools` : ''
281 const detail = row.kind === 'shell'
282 ? row.model
283 ? [modelLabel(row.model, row.effort), row.note].filter(Boolean).join(' · ')
284 : `$ ${(row.command ?? '').replace(/\s+/g, ' ')}`
285 : [showTool && row.tool ? `▸ ${row.tool}` : '', modelLabel(shortModel(row.model), row.effort), toolCount, tokens].filter(Boolean).join(' · ')
286 const viewButton = (label: string) => (
287 <Button key={`view-${row.id}`} plain onPress={() => void $.ui.view({ agentId: row.id })}>
288 <Text bold={row.id === viewed} color={row.color}>{label}</Text>
289 </Button>
290 )
291 const stopButton = (
292 // Its own keyed Box: hover lights the x only while the pointer is on the x itself.
293 <Box key={`stopbox-${row.id}`}>
294 <Button key={`stop-${row.id}`} plain dimColor={!isArmed} onPress={() => void pressStop(row)}>
295 <Text color={isArmed ? 'red' : undefined} hover={{ color: 'red', dimColor: false }}>x</Text>
296 </Button>
297 </Box>
298 )
299 if (!isCellGrid) {
300 // The growing middle part pushes the time and the x to the right edge; it gives way when the row is narrow.
301 return (
302 <Box key={row.id} flexDirection="column">
303 <Box flexDirection="row" gap={1}>
304 {depth > 0 && <Box paddingLeft={2 * (depth - 1)}><Text dimColor>└</Text></Box>}
305 {viewButton(shortType(row.type))}
306 <Box flexGrow={1} flexShrink={1} overflow="hidden">
307 {desc ? <Text color={canStop && isArmed ? 'red' : undefined} wrap="truncate-end">{desc}</Text> : null}
308 </Box>
309 <Text color={canStop ? 'yellow' : STATUS_COLOR[row.status]} dimColor={row.status === 'killed'}>{elapsed}</Text>
310 </Box>
311 <Box flexDirection="row" gap={1} paddingLeft={2 * depth + 2}>
312 <Box flexGrow={1} flexShrink={1} overflow="hidden">
313 {detail ? <Text dimColor wrap="truncate-end">{detail}</Text> : null}
314 </Box>
315 {canStop && stopButton}
316 </Box>
317 </Box>
318 )
319 }
320 return (
321 <Box key={row.id} flexDirection="column">
322 <Box flexDirection="row">
323 {indent && <Text dimColor>{indent}</Text>}
324 {viewButton(head.type)}
325 <Text color={canStop && isArmed ? 'red' : undefined} wrap="truncate">
326 {head.desc ? ` ${head.desc}` : ''}
327 </Text>
328 <Text color={canStop ? 'yellow' : STATUS_COLOR[row.status]} dimColor={row.status === 'killed'}>{' '.repeat(head.gap)}{elapsed}</Text>
329 </Box>
330 {canStop ? (
331 // The stop control sits at the end of line two: faint at rest, red while the pointer is on it.
332 <Box flexDirection="row">
333 <Text dimColor>{padTo(truncateTo(`${tailIndent} ${detail}`, cols - 2), cols - 2)} </Text>
334 {stopButton}
335 </Box>
336 ) : (
337 <Text dimColor wrap="truncate">{truncateTo(`${tailIndent} ${detail}`, cols)}</Text>
338 )}
339 </Box>
340 )
341 }
342
343 // A blank line, then the label running into a rule to the pane's edge, sets the finished group apart.
344 const finishedLabel = `${isExpanded ? '▾' : '▸'} Finished · ${finished.length}`
345 const ruleWidth = Math.max(0, cols - displayWidth(finishedLabel) - ' clear '.length)
346 const finishedBar = finished.length > 0 && (
347 <Box flexDirection="column">
348 <Text> </Text>
349 {/* Centred: on the desktop the buttons stand taller than the one-row rule, which would hug their top. */}
350 <Box flexDirection="row" alignItems="center">
351 <Button key="finished" plain dimColor onPress={() => void update($, showFinished, open => !open)}>
352 {finishedLabel}
353 </Button>
354 <Text> </Text>
355 <Button key="clear" plain dimColor onPress={() => void update($, agents, list => list.filter(row => isLive(row.status)))}>
356 clear
357 </Button>
358 {isCellGrid
359 ? <Text dimColor> {'─'.repeat(ruleWidth)}</Text>
360 : (
361 // One row high and clipped: the overlong rule wraps out of sight instead of onto a second line.
362 <Box flexGrow={1} flexShrink={1} height={1} overflow="hidden" marginLeft={1}>
363 {/* An em dash, not '─': the desktop font draws the box-drawing line near the top of the row. */}
364 <Text dimColor>{'—'.repeat(RULE_LENGTH)}</Text>
365 </Box>
366 )}
367 </Box>
368 </Box>
369 )
370
371 // Finished sits at the pane's bottom: an empty block takes whatever height is left above it.
372 // The Finished area keeps a minimum height (40% of the pane, at least 6 lines), so opening or
373 // closing its list never moves its bar; a longer open list runs past the bottom and the pane scrolls.
374 const bodyRows = e.props?.scroll?.bodyRows ?? 0
375 const finishedHeight = finished.length > 0 && bodyRows > 0 ? Math.max(6, Math.floor(bodyRows * 0.4)) : 0
376 const used = 1 + (rows.length === 0 ? 1 : 0) + live.length * 2
377 const spacer = finishedHeight > 0 ? Math.max(0, bodyRows - used - finishedHeight) : 0
378
379 return (
380 <Box flexDirection="column">
381 {header}
382 {rows.length === 0 && <Text dimColor>No subagents yet.</Text>}
383 {treeOrder(live).map(node => agentRow(node.row, node.depth))}
384 {spacer > 0 && <Box key="spacer" height={spacer} />}
385 {finished.length > 0 && (
386 <Box key="finished-area" flexDirection="column" minHeight={finishedHeight || undefined}>
387 {finishedBar}
388 {isExpanded && finished.map(row => agentRow(row))}
389 </Box>
390 )}
391 </Box>
392 )
393 })
394}
395
396const shortModel = (model?: string): string => {
397 if (!model) return ''
398 const m = model.match(/(opus|sonnet|haiku|fable)/i)
399 return m?.[1]?.toLowerCase() ?? model
400}
401
402// Codex runs through a wrapper script: its model, effort and task are read back from the command, the script's
403// defaults and the prompt file, so the shell row can say more than the opaque command line.
404async function describeCodex($: EngineInterface, command: string): Promise<{ model?: string; effort?: string; note?: string } | undefined> {
405 const script = scriptPathOf(command)
406 const scriptText = script === undefined ? undefined : await $.fs.read(script).catch(() => undefined)
407 const call = codexCall(command, scriptText)
408 if (call === undefined) return undefined
409 const prompt = call.promptFile === undefined ? undefined : await $.fs.read(drivePath(call.promptFile)).catch(() => undefined)
410 return { model: call.model, effort: call.effort, note: prompt === undefined ? undefined : firstLine(prompt) }
411}
412hooks/colors.ts 50 lines1// The mod API gives no agent colour, so it is read from the agent's definition file
2// (`color:` in its frontmatter), the same field the Claude Code UI draws from.
3
4// Claude Code's agent colours; the ANSI names pass through, the rest as hex.
5const COLORS: Record<string, string> = {
6 red: 'red',
7 green: 'green',
8 yellow: 'yellow',
9 blue: 'blue',
10 cyan: 'cyan',
11 purple: '#b083f0',
12 orange: '#f0883e',
13 pink: '#f778ba',
14}
15
16export const toDisplayColor = (name: string | undefined): string | undefined =>
17 name === undefined ? undefined : COLORS[name.toLowerCase()]
18
19export const parseColor = (markdown: string): string | undefined => {
20 const front = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)
21 const line = front?.[1]?.match(/^color:\s*([A-Za-z]+)\s*$/m)
22 return line?.[1]
23}
24
25export const parseEffort = (markdown: string): string | undefined => {
26 const front = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/)
27 return front?.[1]?.match(/^effort:\s*(\S+)\s*$/m)?.[1]
28}
29
30type Installed = { plugins?: Record<string, { installPath?: string }[]> }
31
32export const agentFileCandidates = (type: string, configDir: string, cwd: string, installedJson: string): string[] => {
33 const colon = type.lastIndexOf(':')
34 if (colon < 0) return [`${cwd}/.claude/agents/${type}.md`, `${configDir}/agents/${type}.md`]
35 const plugin = type.slice(0, colon)
36 const name = type.slice(colon + 1)
37 let installed: Installed
38 try {
39 installed = JSON.parse(installedJson) as Installed
40 } catch {
41 return []
42 }
43 const key = Object.keys(installed.plugins ?? {}).find(k => k.startsWith(`${plugin}@`))
44 const path = key ? installed.plugins?.[key]?.[0]?.installPath : undefined
45 return path ? [`${path}/agents/${name}.md`] : []
46}
47
48export type Definition = { color?: string; effort?: string }
49
50hooks/codex.ts 32 lines1// A Codex run shows as a plain background shell: its model, effort and task live in the wrapper's
2// arguments or script, so they are read back from the command line.
3
4export type CodexCall = { model?: string; effort?: string; promptFile?: string }
5
6const tokens = (command: string): string[] =>
7 [...command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
8
9export const scriptPathOf = (command: string): string | undefined => tokens(command).find(t => /\.sh$/.test(t))
10
11const EFFORT = /model_reasoning_effort=\\?["']?(\w+)/
12
13export const codexCall = (command: string, scriptText?: string): CodexCall | undefined => {
14 if (/\bcodex\s+exec\b/.test(command)) {
15 const model = command.match(/(?:^|\s)(?:-m|--model)\s+["']?([^\s"']+)/)?.[1]
16 const effort = command.match(EFFORT)?.[1]
17 return { model, effort }
18 }
19 const script = scriptPathOf(command)
20 if (script === undefined || scriptText === undefined || !/\bcodex\s+exec\b/.test(scriptText)) return undefined
21 // Positional arguments after the script: prompt, log, model, effort.
22 const args = tokens(command).slice(tokens(command).indexOf(script) + 1)
23 const dflt = (n: number): string | undefined => scriptText.match(new RegExp(`\\$\\{${n}:-([^}]+)\\}`))?.[1]
24 return { model: args[2] ?? dflt(3), effort: args[3] ?? dflt(4), promptFile: args[0] }
25}
26
27// A Git Bash path (/c/Users/x) the engine's file reader does not know: spell it as a drive path.
28export const drivePath = (path: string): string => path.replace(/^\/([a-zA-Z])\//, (_, d: string) => `${d.toUpperCase()}:/`)
29
30export const firstLine = (text: string): string =>
31 (text.split(/\r?\n/).find(line => line.trim() !== '') ?? '').trim()
32hooks/model.ts 242 lines1import type { AgentRow } from '../types'
2
3// Rows kept in the pane; older finished agents fall off first.
4export const MAX_ROWS = 50
5
6type Spawned = Pick<AgentRow, 'id' | 'type' | 'description' | 'startedAt'> & Partial<Pick<AgentRow, 'model' | 'parentId'>>
7
8type Usage = {
9 input_tokens: number
10 output_tokens: number
11 cache_read_input_tokens: number
12 cache_creation_input_tokens: number
13 model: string
14}
15
16const LIVE = new Set(['pending', 'running', 'waiting', 'idle'])
17
18export const isLive = (status: string): boolean => LIVE.has(status)
19
20const patch = (list: AgentRow[], id: string, fn: (row: AgentRow) => AgentRow): AgentRow[] => {
21 const at = list.findIndex(row => row.id === id)
22 const current = list[at]
23 if (current === undefined) return list
24 const next = list.slice()
25 next[at] = fn(current)
26 return next
27}
28
29const trim = (list: AgentRow[]): AgentRow[] => {
30 if (list.length <= MAX_ROWS) return list
31 const drop = list.findIndex(row => !isLive(row.status))
32 return trim(list.filter((_, i) => i !== (drop < 0 ? 0 : drop)))
33}
34
35export const addAgent = (list: AgentRow[], spawned: Spawned): AgentRow[] => {
36 // A resumed agent keeps its id: wake the existing row instead of adding one.
37 if (list.some(row => row.id === spawned.id)) {
38 return patch(list, spawned.id, row => ({ ...row, status: 'running', endedAt: undefined }))
39 }
40 const row: AgentRow = { ...spawned, status: 'running', toolCount: 0, tokensIn: 0, tokensOut: 0 }
41 return trim([...list, row])
42}
43
44type ShellStart = { id: string; shell: 'bash' | 'powershell'; command: string; description?: string; startedAt: number; parentId?: string }
45
46export const addShell = (list: AgentRow[], shell: ShellStart): AgentRow[] => {
47 if (list.some(row => row.id === shell.id)) return list
48 const row: AgentRow = {
49 id: shell.id,
50 kind: 'shell',
51 type: shell.shell,
52 description: shell.description || shell.command,
53 command: shell.command,
54 status: 'running',
55 startedAt: shell.startedAt,
56 parentId: shell.parentId,
57 toolCount: 0,
58 tokensIn: 0,
59 tokensOut: 0,
60 }
61 return trim([...list, row])
62}
63
64const ENDED = new Set(['completed', 'failed', 'killed'])
65
66// A task notification ends the row once; a repeat keeps the first end time.
67export const finishTask = (list: AgentRow[], id: string, status: string, now: number): AgentRow[] =>
68 patch(list, id, row => ({
69 ...row,
70 status: ENDED.has(status) ? status : 'completed',
71 endedAt: row.endedAt ?? now,
72 tool: undefined,
73 }))
74
75// A backgrounded Bash/PowerShell call answers with the task id it runs under.
76export const backgroundTaskIdOf = (ran: unknown): string | undefined => {
77 const result = (ran as { result?: { backgroundTaskId?: unknown } } | undefined)?.result
78 return typeof result?.backgroundTaskId === 'string' ? result.backgroundTaskId : undefined
79}
80
81// A shell's end notification can go missing; the engine's list of in-flight background
82// work (at each main turn's end) is the backstop: a live shell row not in it has ended.
83export const reconcileShells = (list: AgentRow[], liveIds: Set<string>, now: number): AgentRow[] =>
84 list.map(row =>
85 row.kind === 'shell' && isLive(row.status) && !liveIds.has(row.id)
86 ? { ...row, status: 'completed', endedAt: row.endedAt ?? now }
87 : row,
88 )
89
90// What a TaskStop answer means for the row. A stop that worked, or one refused because the
91// task already ended, settles the row at once: a shell's end notification may never come.
92export const stopOutcome = (isError: boolean, text: string): { settle: string } | { error: string } => {
93 if (!isError) return { settle: 'killed' }
94 const ended = text.match(/is not running \(status: (\w+)\)/)?.[1]
95 if (ended) return { settle: ENDED.has(ended) ? ended : 'completed' }
96 if (/No task found/.test(text)) return { settle: 'completed' }
97 return { error: text.replace(/<\/?tool_use_error>/g, '').trim() }
98}
99
100export const parseNotification = (text: string): { id: string; status?: string } | undefined => {
101 const id = text.match(/<task-id>([^<]+)<\/task-id>/)?.[1]?.trim()
102 if (!id) return undefined
103 return { id, status: text.match(/<status>([^<]+)<\/status>/)?.[1]?.trim() }
104}
105
106export const setColor = (list: AgentRow[], id: string, color: string): AgentRow[] =>
107 patch(list, id, row => ({ ...row, color }))
108
109export const setEffort = (list: AgentRow[], id: string, effort: string): AgentRow[] =>
110 patch(list, id, row => ({ ...row, effort }))
111
112// A shell row that runs Codex learns its model, effort and task once they are read back.
113export const setShellInfo = (list: AgentRow[], id: string, info: { model?: string; effort?: string; note?: string }): AgentRow[] =>
114 patch(list, id, row => ({ ...row, ...Object.fromEntries(Object.entries(info).filter(([, v]) => v !== undefined && v !== '')) }))
115
116// "haiku/medium": the model with its effort when the definition names one.
117export const modelLabel = (model: string | undefined, effort: string | undefined): string =>
118 [model, effort].filter(Boolean).join('/')
119
120export const startTool = (list: AgentRow[], id: string, tool: string): AgentRow[] =>
121 patch(list, id, row => ({ ...row, tool, toolCount: row.toolCount + 1 }))
122
123// Parallel calls finish out of order: only clear the tool if it is still the shown one.
124export const endTool = (list: AgentRow[], id: string, tool: string): AgentRow[] =>
125 patch(list, id, row => (row.tool === tool ? { ...row, tool: undefined } : row))
126
127const REASON_STATUS: Record<string, string> = { answer: 'completed', aborted: 'killed', error: 'failed', refusal: 'failed' }
128
129export const finishTurn = (list: AgentRow[], id: string, reason: string, now: number, usage?: Usage): AgentRow[] =>
130 patch(list, id, row => ({
131 ...row,
132 status: REASON_STATUS[reason] ?? 'completed',
133 endedAt: now,
134 tool: undefined,
135 model: usage?.model ?? row.model,
136 tokensIn: row.tokensIn + (usage ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0),
137 tokensOut: row.tokensOut + (usage?.output_tokens ?? 0),
138 }))
139
140// The engine's list is the truth for status (teammates go idle, stops show as killed).
141export const syncStatus = (list: AgentRow[], engine: { id: string; status: string }[], now: number): AgentRow[] => {
142 let next = list
143 for (const info of engine) {
144 next = patch(next, info.id, row => {
145 if (row.status === info.status) return row
146 const ended = !isLive(info.status)
147 return { ...row, status: info.status, endedAt: ended ? (row.endedAt ?? now) : undefined }
148 })
149 }
150 return next
151}
152
153// `subagents:runner` shows as `runner`: the plugin prefix only costs width.
154export const shortType = (type: string): string => type.slice(type.lastIndexOf(':') + 1)
155
156// Live rows as a tree: each row directly followed by the rows it spawned, in start order.
157// A row whose parent is not in the list (main's, or a parent already gone) starts at depth 0.
158// A child's first line hangs off its parent with └; its second line keeps the same width in spaces.
159export const treePrefix = (depth: number): { head: string; tail: string } =>
160 depth <= 0 ? { head: '', tail: '' } : { head: `${' '.repeat(depth - 1)}└ `, tail: ' '.repeat(depth) }
161
162export const treeOrder = (list: AgentRow[]): { row: AgentRow; depth: number }[] => {
163 const ids = new Set(list.map(row => row.id))
164 const out: { row: AgentRow; depth: number }[] = []
165 const visit = (row: AgentRow, depth: number) => {
166 out.push({ row, depth })
167 for (const child of list.filter(c => c.parentId === row.id)) visit(child, depth + 1)
168 }
169 for (const row of list.filter(r => r.parentId === undefined || !ids.has(r.parentId))) visit(row, 0)
170 return out
171}
172
173export const splitSections =(list: AgentRow[]): { live: AgentRow[]; finished: AgentRow[] } => ({
174 live: list.filter(row => isLive(row.status)),
175 finished: list.filter(row => !isLive(row.status)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0)),
176})
177
178// Terminal cells: Hangul, CJK, fullwidth forms and emoji take two.
179const WIDE: [number, number][] = [
180 [0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
181 [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff], [0x20000, 0x3fffd],
182]
183
184const charWidth = (ch: string): number => {
185 const code = ch.codePointAt(0) ?? 0
186 return WIDE.some(([lo, hi]) => code >= lo && code <= hi) ? 2 : 1
187}
188
189export const displayWidth = (text: string): number => [...text].reduce((sum, ch) => sum + charWidth(ch), 0)
190
191export const truncateTo = (text: string, width: number): string => {
192 if (displayWidth(text) <= width) return text
193 if (width <= 0) return ''
194 let out = ''
195 let used = 0
196 for (const ch of text) {
197 const w = charWidth(ch)
198 if (used + w > width - 1) break
199 out += ch
200 used += w
201 }
202 return `${out}…`
203}
204
205// Pads with spaces to a display width (Hangul counts two).
206export const padTo = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - displayWidth(text)))
207
208type Head = { indent: string; type: string; desc: string; elapsed: string }
209
210// At least this many blank cells between the description's end and the elapsed time,
211// so a cut description never reads as running into the time.
212export const MIN_GAP = 3
213
214// The first row line is: indent, type, [space + desc], gap (>= MIN_GAP), elapsed at the right edge.
215// It fills exactly `cols` so an agent stays two lines: the description gives way first, then the type.
216export const fitHead = (head: Head, cols: number): { type: string; desc: string; gap: number } => {
217 const fixed = displayWidth(head.indent) + displayWidth(head.type) + MIN_GAP + displayWidth(head.elapsed)
218 if (fixed > cols) {
219 return { type: truncateTo(head.type, displayWidth(head.type) - (fixed - cols)), desc: '', gap: MIN_GAP }
220 }
221 const room = cols - fixed - 1
222 const desc = room >= 2 ? truncateTo(head.desc, room) : ''
223 const used = fixed - MIN_GAP + (desc ? 1 + displayWidth(desc) : 0)
224 return { type: head.type, desc, gap: cols - used }
225}
226
227const pad2 = (n: number) => String(n).padStart(2, '0')
228
229export const formatElapsed = (ms: number): string => {
230 const s = Math.floor(Math.max(0, ms) / 1000)
231 if (s < 60) return `${s}s`
232 if (s < 3600) return `${Math.floor(s / 60)}m${pad2(s % 60)}s`
233 return `${Math.floor(s / 3600)}h${pad2(Math.floor((s % 3600) / 60))}m`
234}
235
236export const formatTokens = (n: number): string => {
237 if (n < 1000) return String(n)
238 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
239 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
240 return `${(n / 1_000_000).toFixed(1)}M`
241}
242types/index.d.ts 28 lines1export type AgentRow = {
2 id: string
3 type: string
4 description: string
5 status: string
6 startedAt: number
7 endedAt?: number
8 model?: string
9 effort?: string
10 // A shell row's task: the first line of the prompt file a Codex run was given.
11 note?: string
12 parentId?: string
13 color?: string
14 // 'shell' for a background Bash/PowerShell task; absent for a subagent.
15 kind?: 'shell'
16 command?: string
17 tool?: string
18 toolCount: number
19 tokensIn: number
20 tokensOut: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'subagent-pane': { agents: AgentRow[]; tick: number; showFinished: boolean }
26 }
27}
28