여러 세션의 업무 진행도를 업무 단위로 모아 입력창 위 한 줄과 /progress 패널로 보여 준다. 단계를 노드로 이어 그리고, 완료 뒤 남은 일과 닫히지 않은 서브에이전트 기록을 챙긴다. 남은 단계와 지난 기록으로 예상 남은 시간도 보여 준다

English · 한국어
A set of Claude Code mods that change the terminal UI and notifications. They are plugins with hook modules, installable one by one from this marketplace.
The UI text, notifications and summaries are written in Korean.
| Mod | What it does |
|---|---|
work-progress | Collects multi-step work from every open session into a line above the prompt and a /progress pane. Each task's steps are drawn as a chain of nodes that opens into a vertical flow. It also keeps follow-ups left after a task is done, what a paused task is waiting for, and subagent records that ended without being closed. Each open task shows the time likely left, judged from how long its own steps and your recent tasks' steps took. /progress 요약 writes a summary of the last 24 hours. |
plain-korean | A Korean readability bundle. An output style for short, plain Korean answers (conclusion first, no made-up abbreviations, nothing referred to by number alone), a guard that flags answers that slip into English and corrects the next one, a nudge to re-explain when you type "뭔 말" or "짧게", and the claude-warm theme. Installing it also installs plain-summary, turn-divider and compact-tool-rows. |
usage-band | Shows 5-hour and weekly usage and the Claude status page state in one line above the prompt. |
long-turn-notify | Sends a macOS notification and plays a voice line when a turn ran longer than 2 minutes, or when a question or permission prompt is waiting. macOS only. |
session-nickname | Gives each new session a random name so other sessions can address it with SendMessage. The name reaches the session list with the first prompt. |
turn-divider | Draws a divider above each of your prompts to separate questions from answers. |
plain-summary | Adds a two-sentence plain-language summary under long, report-style answers, written by a small model. |
compact-tool-rows | Shortens long Bash commands in tool rows to the start of the first line. |
work-progress pane:
업무 3개 · ● 작업 중 1 · □ 남은 일 1 · ✓ 오늘 완료 1
── ● 작업 중 1 ─────────────────────────────────────────────
▸ Theme builder spec
●━●━◉─○─○─○ 2/6 · 약 2시간 남음 · 이 단계 1시간째 · 5분 전 · @quiet-otter
지금: Write the requirements
최근: Bash Run the mod's tests · 방금
└ ● Benchmark AI app builders 1/8 · 최근: WebSearch Lovable docs
└ ‖ Seed starter audit 3/4 · 보류로 끝남 · only saving the file is left
└ ✓ 서브에이전트 5개 끝남
── □ 남은 일 1 ─────────────────────────────────────────────
▸ Fix lost attachments · 1일 전
□ push
── ✓ 오늘 완료 1 ───────────────────────────────────────────
▸ Release notes draft · 2시간 전
Click a task to open its steps as a flow, with the time each step took and its evidence:
▾ Theme builder spec
● Read the brief · 40분 — notes in brief.md
┃
● Benchmark · 1시간 20분 — 3 reports
┃
◉ Write the requirements ← 지금
│
○ Design
From the marketplace:
claude plugin marketplace add wonjangcloud9/wj-mods
claude plugin install work-progress@wj-mods
Install other mods the same way with <mod>@wj-mods. They load in sessions started after installing.
The Korean readability bundle installs plain-summary, turn-divider and compact-tool-rows with it:
claude plugin install plain-korean@wj-mods
The output style and the theme are not switched on for you. Choose plain-korean as the output style in /config, and claude-warm in /theme if you want the colors too.
From a local clone, list the mod folders in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, separated by :. Folders listed this way do not pull in dependencies, so list the bundle's three mods as well:
git clone https://github.com/wonjangcloud9/wj-mods.git ~/wj-mods
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/me/wj-mods/work-progress:/Users/me/wj-mods/usage-band"
}
}
long-turn-notify records its voice lines with the macOS say Yuna voice on first use and keeps them in ~/.claude/long-turn-notify/sounds/. Without that voice it shows the notification silently.plain-summary calls a small model once per long answer, which counts toward your usage.work-progress keeps its records in ~/.claude/work-progress/ on your machine.claude plugin test <mod folder>.MIT
hooks/register.tsx 167 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { readLive, readSessions, readWork, readWorks, writeLive, writeWork } from './files'
5import {
6 TOOL_DESCRIPTION,
7 TOOL_NAME,
8 TOOL_SCHEMA,
9 MAIN_LOOP,
10 closingNote,
11 describeCall,
12 guideText,
13 liveState,
14 parentsBySession,
15 progressOf,
16 pruneNotes,
17 runningIds,
18 sameBoard,
19 toBoard,
20 toReport,
21 unclosedChildren,
22} from './model'
23import { SUMMARY_ARGS, dailySummary } from './summary'
24import { densityOf, drawPane, drawSummary } from './view'
25
26const board = atom({ plugin: 'work-progress', key: 'board' } as const, null)
27const expanded = atom({ plugin: 'work-progress', key: 'expanded' } as const, null)
28const snapshot = atom({ plugin: 'work-progress', key: 'snapshot' } as const, null)
29const published = atom({ plugin: 'work-progress', key: 'live' } as const, null)
30const notes = atom({ plugin: 'work-progress', key: 'activity' } as const, null)
31
32const PANE = 'work-progress'
33const PANE_TITLE = '업무 진행도'
34const COMMAND = 'progress'
35const TOOL = 'mcp__work-progress__report'
36const REFRESH_MS = 10_000
37const CLOCK_STEP_MS = 60_000
38
39// 다른 세션은 이 세션의 서브에이전트와 도구 사용을 볼 수 없으므로, 바뀔 때마다 파일로 알린다.
40async function publishLive($: EngineInterface): Promise<void> {
41 const [list, seen] = await Promise.all([$.agent.list(), read($, notes)])
42 const live = liveState(list, seen ?? {})
43 await update($, notes, prev => (prev ? pruneNotes(prev, live) : prev))
44 const text = JSON.stringify(live)
45 if ((await read($, published)) === text) return
46 await writeLive($, await $.session.id(), live)
47 await update($, published, () => text)
48}
49
50async function noteActivity($: EngineInterface, call: Record<string, unknown>): Promise<void> {
51 const line = describeCall(call)
52 if (!line) return
53 const loop = typeof call.agentId === 'string' ? call.agentId : MAIN_LOOP
54 const at = await $.clock.now()
55 await update($, notes, prev => ({ ...prev, [loop]: { text: line, at } }))
56}
57
58// 다른 세션의 기록은 파일로만 들어오므로 주기적으로 다시 읽는다. 내용이 같으면 시각이 1분 넘게 흘렀을 때만 다시 그린다.
59async function refresh($: EngineInterface): Promise<void> {
60 await publishLive($).catch(() => undefined)
61 const [works, sessions, live, now] = await Promise.all([readWorks($), readSessions($), readLive($), $.clock.now()])
62 const next = toBoard(works, sessions, now, live)
63 await update($, board, prev => (prev && sameBoard(prev, next) && now - prev.at < CLOCK_STEP_MS ? prev : next))
64}
65
66async function sessionName($: EngineInterface, id: string): Promise<string> {
67 const live = (await readSessions($)).find(s => s.sessionId === id)
68 return live?.name ?? id.slice(0, 8)
69}
70
71// 메인 대화가 업무를 기록할 때, 그 아래 서브에이전트가 닫지 않고 끝난 기록을 알려 준다.
72async function leftOpen($: EngineInterface, parentId: string): Promise<string[]> {
73 const running = runningIds(await $.agent.list())
74 return closingNote(unclosedChildren(await readWorks($), parentId, running))
75}
76
77async function archive($: EngineInterface, id: string): Promise<void> {
78 const work = await readWork($, id)
79 if (!work) return
80 await writeWork($, { ...work, status: 'archived', updatedAt: await $.clock.now() })
81 await update($, expanded, () => null)
82 await refresh($)
83}
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 const result = await next(e)
88 await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA, isDeferred: false })
89 await $.command.register({
90 name: COMMAND,
91 description: '모든 세션의 업무 진행도를 패널로 연다. /progress 요약 은 최근 24시간 업무를 글로 정리한다',
92 })
93 await refresh($)
94 // hot reload 때도 session.start 가 다시 오지만 이미 만든 안내는 그대로 둔다.
95 const current = await read($, board)
96 await update($, snapshot, prev => prev ?? guideText(current))
97 $.clock.every(REFRESH_MS, () => void refresh($))
98 return result
99 })
100
101 on('tool.call', { tool: TOOL }, async ($, e) => {
102 const sessionId = await $.session.id()
103 const at = await $.clock.now()
104 const input = e as Record<string, unknown>
105 const id = typeof input.work_id === 'string' ? input.work_id : ''
106 const previous = id ? await readWork($, id.trim().toLowerCase()) : null
107 const agentId = typeof input.agentId === 'string' ? input.agentId : undefined
108 const parentId = agentId ? parentsBySession(await readWorks($)).get(sessionId)?.id : undefined
109 const by = { sessionId, sessionName: await sessionName($, sessionId), agentId, parentId, at }
110 const report = toReport(input, previous, by)
111 if ('error' in report) return { deny: report.error }
112
113 await writeWork($, report.work)
114 await refresh($)
115 const { done, total } = progressOf(report.work)
116 const children = agentId ? [] : await leftOpen($, report.work.id).catch(() => [])
117 const lines = [`기록함: ${report.work.title} ${done}/${total} (${report.work.status})`, ...report.notes, ...children]
118 return { result: lines.join('\n') }
119 }).catch(() => ({ deny: '진행 기록을 저장하지 못했어요. 작업은 그대로 이어 가세요.' }))
120
121 // 모든 도구 호출을 지켜보되 막지 않는다. 기록에 실패해도 도구는 그대로 돈다.
122 on('tool.call', async ($, e, next) => {
123 await noteActivity($, e as Record<string, unknown>).catch(() => undefined)
124 return next(e)
125 })
126
127 on('prompt.compose', async ($, e, next) => {
128 const result = await next(e)
129 const text = (await read($, snapshot)) ?? guideText(null)
130 return { sections: [...result.sections, { id: 'work-progress:guide', text, scope: 'session' as const }] }
131 })
132
133 on('command.run', { command: COMMAND }, async ($, e) => {
134 if (SUMMARY_ARGS.includes(e.args.trim().toLowerCase())) {
135 return { text: dailySummary(await readWorks($), await $.clock.now()) }
136 }
137 await refresh($)
138 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
139 return { text: opened.isPlaced ? '업무 진행도 패널을 열었어요.' : `패널을 열지 못했어요: ${opened.reason}` }
140 }).catch(() => ({ text: '업무 진행도 패널을 열지 못했어요.' }))
141
142 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143 const els = $.ui.resolve(e)
144 const [current, open] = await Promise.all([read($, board), read($, expanded)])
145 return drawPane(els, current, open, e.props.bodyColumns, {
146 onToggle: id => void update($, expanded, prev => (prev === id ? null : id)),
147 onArchive: id => void archive($, id),
148 })
149 })
150
151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152 const below = await next(e)
153 if (e.props.hasSurvey) return below
154 const current = await read($, board)
155 if (!current || (current.rows.length === 0 && current.untracked.length === 0)) return below
156
157 const els = $.ui.resolve(e)
158 const { Box } = els
159 return (
160 <Box flexDirection="column">
161 {drawSummary(els, current, densityOf(e.props.bodyColumns), true)}
162 {below}
163 </Box>
164 )
165 })
166}
167hooks/files.ts 64 lines1import type { EngineInterface } from 'claude-code'
2
3import type { LiveSession, SessionLive, Work } from '../types'
4import { toLiveSession, toSessionLive, toWork } from './model'
5
6type Dirs = { works: string; sessions: string; live: string }
7
8// 업무 기록은 세션마다 다른 mod 인스턴스가 함께 쓰므로 $.store 대신 업무별 파일로 나눈다.
9async function dirs($: EngineInterface): Promise<Dirs> {
10 const home = (await $.env.get('HOME')) ?? ''
11 return {
12 works: `${home}/.claude/work-progress/works`,
13 sessions: `${home}/.claude/sessions`,
14 live: `${home}/.claude/work-progress/live`,
15 }
16}
17
18async function readJsonFiles($: EngineInterface, dir: string): Promise<unknown[]> {
19 const entries = await $.fs.list(dir).catch(() => [])
20 const files = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
21 const values = await Promise.all(
22 files.map(entry =>
23 $.fs
24 .read(`${dir}/${entry.name}`)
25 .then(raw => JSON.parse(raw) as unknown)
26 .catch(() => null),
27 ),
28 )
29 return values
30}
31
32export async function readWorks($: EngineInterface): Promise<Work[]> {
33 const values = await readJsonFiles($, (await dirs($)).works)
34 return values.flatMap(value => toWork(value) ?? [])
35}
36
37export async function readWork($: EngineInterface, id: string): Promise<Work | null> {
38 const raw = await $.fs.read(`${(await dirs($)).works}/${id}.json`).catch(() => null)
39 if (raw === null) return null
40 try {
41 return toWork(JSON.parse(raw))
42 } catch {
43 return null
44 }
45}
46
47export async function writeWork($: EngineInterface, work: Work): Promise<void> {
48 await $.fs.write(`${(await dirs($)).works}/${work.id}.json`, `${JSON.stringify(work, null, 2)}\n`)
49}
50
51export async function readSessions($: EngineInterface): Promise<LiveSession[]> {
52 const values = await readJsonFiles($, (await dirs($)).sessions)
53 return values.flatMap(value => toLiveSession(value) ?? [])
54}
55
56export async function readLive($: EngineInterface): Promise<Map<string, SessionLive>> {
57 const values = await readJsonFiles($, (await dirs($)).live)
58 return new Map(values.flatMap(value => toSessionLive(value) ?? []).map(({ sessionId, live }) => [sessionId, live]))
59}
60
61export async function writeLive($: EngineInterface, sessionId: string, live: SessionLive): Promise<void> {
62 await $.fs.write(`${(await dirs($)).live}/${sessionId}.json`, `${JSON.stringify({ sessionId, ...live }, null, 2)}\n`)
63}
64hooks/model.ts 492 lines1import type {
2 Activity,
3 Board,
4 ChildRow,
5 LiveAgent,
6 LiveSession,
7 Phase,
8 SessionLive,
9 Step,
10 Work,
11 WorkRow,
12 WorkStatus,
13} from '../types'
14import { paceOf } from './pace'
15
16export { stepSpans, stepStartedAt } from './pace'
17
18const DAY_MS = 24 * 60 * 60_000
19const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/
20export const PHASE_ORDER: Phase[] = ['yourTurn', 'running', 'stalled', 'paused', 'followUp', 'done', 'dormant']
21const REPORTED_STATUS: WorkStatus[] = ['active', 'paused', 'done']
22const ACTIVE_AGENT = ['pending', 'running', 'waiting']
23const ACTIVITY_LENGTH = 80
24export const MAIN_LOOP = 'main'
25
26function text(value: unknown): string | undefined {
27 return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
28}
29
30function toStep(value: unknown): Step | null {
31 const { title, isDone, done, evidence, doneAt } = (value ?? {}) as Record<string, unknown>
32 const name = text(title)
33 if (!name) return null
34 const flag = typeof isDone === 'boolean' ? isDone : done === true
35 const proof = text(evidence)
36 const step: Step = proof ? { title: name, isDone: flag, evidence: proof } : { title: name, isDone: flag }
37 return flag && typeof doneAt === 'number' ? { ...step, doneAt } : step
38}
39
40function toSteps(value: unknown): Step[] {
41 return Array.isArray(value) ? value.flatMap(item => toStep(item) ?? []) : []
42}
43
44function toLines(value: unknown): string[] | undefined {
45 if (!Array.isArray(value)) return undefined
46 const lines = value.flatMap(item => text(item) ?? [])
47 return lines.length > 0 ? lines : undefined
48}
49
50export function toWork(value: unknown): Work | null {
51 const v = (value ?? {}) as Record<string, unknown>
52 const id = text(v.id)
53 const title = text(v.title)
54 const sessionId = text(v.sessionId)
55 const status = v.status as WorkStatus
56 if (!id || !title || !sessionId || typeof v.updatedAt !== 'number') return null
57 if (!['active', 'paused', 'done', 'archived'].includes(status)) return null
58 return {
59 id,
60 title,
61 steps: toSteps(v.steps),
62 now: text(v.now),
63 question: text(v.question),
64 waitingFor: text(v.waitingFor),
65 followups: toLines(v.followups),
66 status,
67 sessionId,
68 sessionName: text(v.sessionName) ?? sessionId.slice(0, 8),
69 agentId: text(v.agentId),
70 parentId: text(v.parentId),
71 createdAt: typeof v.createdAt === 'number' ? v.createdAt : v.updatedAt,
72 updatedAt: v.updatedAt,
73 doneAt: typeof v.doneAt === 'number' ? v.doneAt : undefined,
74 }
75}
76
77// ~/.claude/sessions/<pid>.json 은 엔진이 살아 있는 세션마다 남기는 기록이다.
78export function toLiveSession(value: unknown): LiveSession | null {
79 const { sessionId, name, status } = (value ?? {}) as Record<string, unknown>
80 const id = text(sessionId)
81 if (!id) return null
82 return { sessionId: id, name: text(name) ?? id.slice(0, 8), isBusy: status === 'busy' }
83}
84
85function baseName(path: unknown): string | undefined {
86 return text(path)?.split('/').filter(Boolean).pop()
87}
88
89function detailOf(call: Record<string, unknown>): string | undefined {
90 switch (call.tool) {
91 // 명령 본문에는 비밀값이 섞일 수 있어 설명이 없으면 프로그램 이름만 쓴다.
92 case 'Bash':
93 return text(call.description) ?? text(call.command)?.split(/\s+/)[0]
94 case 'Agent':
95 return text(call.description)
96 case 'Read':
97 case 'Edit':
98 case 'Write':
99 return baseName(call.file_path)
100 case 'NotebookEdit':
101 return baseName(call.notebook_path)
102 case 'Grep':
103 case 'Glob':
104 return text(call.pattern)
105 case 'WebSearch':
106 return text(call.query)
107 case 'Skill':
108 return text(call.skill)
109 default:
110 return undefined
111 }
112}
113
114// 도구 호출을 패널의 "최근" 한 줄로 바꾼다. 진행 기록 도구 자체는 활동으로 치지 않는다.
115export function describeCall(call: Record<string, unknown>): string | undefined {
116 const tool = text(call.tool)
117 if (!tool || tool === `mcp__work-progress__${TOOL_NAME}`) return undefined
118 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join(' ') : tool
119 const detail = detailOf(call)
120 return [...(detail ? `${name} ${detail}` : name)].slice(0, ACTIVITY_LENGTH).join('')
121}
122
123// 이 세션이 다른 세션에 알릴 상태. 아직 도는 서브에이전트와 각자의 최근 활동만 남긴다.
124export function liveState(
125 list: { id: string; description: string; status: string }[],
126 notes: Record<string, Activity>,
127): SessionLive {
128 const agents = list
129 .filter(agent => ACTIVE_AGENT.includes(agent.status))
130 .map(({ id, description }) => (notes[id] ? { id, description, activity: notes[id] } : { id, description }))
131 const main = notes[MAIN_LOOP]
132 return main ? { activity: main, agents } : { agents }
133}
134
135export function runningIds(list: { id: string; status: string }[]): Set<string> {
136 return new Set(list.filter(agent => ACTIVE_AGENT.includes(agent.status)).map(agent => agent.id))
137}
138
139// 끝난 서브에이전트의 활동은 버린다.
140export function pruneNotes(notes: Record<string, Activity>, live: SessionLive): Record<string, Activity> {
141 const keep = new Set([MAIN_LOOP, ...live.agents.map(agent => agent.id)])
142 return Object.fromEntries(Object.entries(notes).filter(([loop]) => keep.has(loop)))
143}
144
145function toActivity(value: unknown): Activity | undefined {
146 const { text: line, at } = (value ?? {}) as Record<string, unknown>
147 const said = text(line)
148 return said && typeof at === 'number' ? { text: said, at } : undefined
149}
150
151export function toSessionLive(value: unknown): { sessionId: string; live: SessionLive } | null {
152 const { sessionId, agents, activity } = (value ?? {}) as Record<string, unknown>
153 const id = text(sessionId)
154 if (!id || !Array.isArray(agents)) return null
155 const list: LiveAgent[] = agents.flatMap(item => {
156 const { id: agentId, description, activity: done } = (item ?? {}) as Record<string, unknown>
157 const key = text(agentId)
158 if (!key) return []
159 const recent = toActivity(done)
160 const agent = { id: key, description: text(description) ?? key }
161 return [recent ? { ...agent, activity: recent } : agent]
162 })
163 const main = toActivity(activity)
164 return { sessionId: id, live: main ? { activity: main, agents: list } : { agents: list } }
165}
166
167export function toId(value: unknown): string | undefined {
168 const raw = text(value)?.toLowerCase()
169 if (!raw) return undefined
170 const id = raw.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 64)
171 return ID_PATTERN.test(id) ? id : undefined
172}
173
174export type Reporter = { sessionId: string; sessionName: string; agentId?: string; parentId?: string; at: number }
175export type Report = { work: Work; notes: string[] } | { error: string }
176
177export function toReport(input: Record<string, unknown>, previous: Work | null, by: Reporter): Report {
178 const id = toId(input.work_id)
179 if (!id) return { error: 'work_id 는 영문 소문자, 숫자, 하이픈으로 적어 주세요.' }
180 const title = text(input.title) ?? previous?.title
181 if (!title) return { error: 'title 에 업무 목표를 한 줄로 적어 주세요.' }
182 const steps = toSteps(input.steps)
183 if (steps.length === 0) return { error: 'steps 에 전체 단계 목록을 한 개 이상 보내 주세요.' }
184
185 const asked = input.status as WorkStatus
186 const status = REPORTED_STATUS.includes(asked) ? asked : 'active'
187 const isNewlyDone = status === 'done' && previous?.status !== 'done'
188 // 메인 대화가 서브에이전트의 기록을 대신 닫아도 부모 업무 아래에 그대로 둔다.
189 const keepsSub = !by.agentId && previous !== null && isSubWork(previous)
190 const work: Work = {
191 id,
192 title,
193 steps: timeSteps(steps, previous, by.at),
194 now: text(input.now),
195 question: text(input.question),
196 waitingFor: text(input.waiting_for),
197 // 남은 일은 잊히지 않도록, 보내지 않으면 이전 목록을 그대로 둔다.
198 followups: 'followups' in input ? toLines(input.followups) : previous?.followups,
199 status,
200 sessionId: by.sessionId,
201 sessionName: by.sessionName,
202 agentId: by.agentId ?? (keepsSub ? previous.agentId : undefined),
203 parentId: by.agentId ? previous?.parentId ?? by.parentId : keepsSub ? previous.parentId : undefined,
204 createdAt: previous?.createdAt ?? by.at,
205 updatedAt: by.at,
206 doneAt: status === 'done' ? (isNewlyDone ? by.at : previous?.doneAt ?? by.at) : undefined,
207 }
208 const unproven = steps.filter(step => step.isDone && !step.evidence).map(step => step.title)
209 const notes = unproven.length > 0 ? [`근거 없이 완료로 표시한 단계: ${unproven.join(', ')}`] : []
210 return { work, notes }
211}
212
213// 단계를 처음 완료로 표시한 시각을 남긴다. 이전 기록에서 같은 이름, 없으면 같은 자리의 단계를 이어 본다.
214// 시각 없이 완료돼 있던 예전 기록의 단계는 지금 끝낸 것으로 치지 않는다.
215function timeSteps(steps: Step[], previous: Work | null, at: number): Step[] {
216 return steps.map((step, index) => {
217 if (!step.isDone) return { ...step, doneAt: undefined }
218 const before = previous?.steps.find(item => item.title === step.title) ?? previous?.steps[index]
219 if (before?.isDone) return before.doneAt === undefined ? { ...step, doneAt: undefined } : { ...step, doneAt: before.doneAt }
220 return { ...step, doneAt: at }
221 })
222}
223
224// 서브에이전트가 끝났는데 완료로 닫지 않은 기록. 부모 대화가 마무리했으면 대신 닫아야 한다.
225export function unclosedChildren(works: Work[], parentId: string, running: Set<string>): Work[] {
226 return works.filter(
227 work =>
228 work.parentId === parentId &&
229 (work.status === 'active' || work.status === 'paused') &&
230 !(work.agentId && running.has(work.agentId)),
231 )
232}
233
234export function closingNote(open: Work[]): string[] {
235 if (open.length === 0) return []
236 const lines = open.map(work => {
237 const { done, total } = progressOf(work)
238 const state = work.status === 'paused' ? '보류' : '진행 중'
239 const reason = work.waitingFor ?? work.now
240 return `- ${work.id} · ${work.title} · ${state} ${done}/${total}${reason ? ` · ${reason}` : ''}`
241 })
242 return [
243 `서브에이전트가 닫지 않고 끝난 기록 ${open.length}개가 이 업무 아래 남아 있어요. 그 일을 마무리했으면 각 work_id 로 status done 을 기록해 주세요.`,
244 ...lines,
245 ]
246}
247
248export function progressOf(work: Work): { done: number; total: number; percent: number } {
249 const total = work.steps.length
250 const done = work.steps.filter(step => step.isDone).length
251 return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) }
252}
253
254// 완료 업무는 하루 뒤 숨기지만, 남은 일이 있으면 그 일을 끝내거나 보관할 때까지 남긴다.
255function isVisible(work: Work, now: number): boolean {
256 if (work.status === 'archived') return false
257 if (work.status === 'done') return Boolean(work.followups) || now - (work.doneAt ?? work.updatedAt) < DAY_MS
258 return true
259}
260
261function newest(works: Work[]): Work | undefined {
262 return works.reduce<Work | undefined>((best, work) => (!best || best.updatedAt < work.updatedAt ? work : best), undefined)
263}
264
265function childOf(agent: LiveAgent, record: Work | undefined): ChildRow {
266 const recent = agent.activity ? { activity: agent.activity } : {}
267 if (!record) return { id: agent.id, title: agent.description, ...recent }
268 const { done, total } = progressOf(record)
269 return { id: agent.id, title: record.title, done, total, ...recent }
270}
271
272function finishedOf(record: Work): ChildRow {
273 const { done, total } = progressOf(record)
274 const reason = record.waitingFor ?? record.now
275 return { id: record.id, title: record.title, done, total, status: record.status, ...(reason ? { now: reason } : {}) }
276}
277
278export function isSubWork(work: Work): boolean {
279 return Boolean(work.agentId || work.parentId)
280}
281
282// 서브에이전트를 붙일 세션별 부모. 진행 중인 업무가 없으면 가장 최근에 보류한 업무를 쓴다.
283export function parentsBySession(works: Work[]): Map<string, Work> {
284 const parents = new Map<string, Work>()
285 for (const work of works) {
286 if (isSubWork(work) || (work.status !== 'active' && work.status !== 'paused')) continue
287 const seen = parents.get(work.sessionId)
288 const isBetter = !seen || (seen.status === work.status ? seen.updatedAt < work.updatedAt : work.status === 'active')
289 if (isBetter) parents.set(work.sessionId, work)
290 }
291 return parents
292}
293
294// 서브에이전트가 일하는 동안에는 메인 대화가 입력을 기다리거나 보류로 적어 두었어도 그 업무는 작업 중이다.
295// 멈춤과 보류는 하루 넘게 기록이 없으면 오래 멈춤으로 접는다.
296function phaseOf(work: Work, live: LiveSession | undefined, isCurrent: boolean, hasAgents: boolean, now: number): Phase {
297 if (work.status === 'done') return work.followups ? 'followUp' : 'done'
298 if (hasAgents) return 'running'
299 const isOld = now - work.updatedAt >= DAY_MS
300 if (work.status === 'paused') return isOld ? 'dormant' : 'paused'
301 if (!live || !isCurrent) return isOld ? 'dormant' : 'stalled'
302 return live.isBusy ? 'running' : 'yourTurn'
303}
304
305export function toBoard(
306 works: Work[],
307 sessions: LiveSession[],
308 now: number,
309 live: Map<string, SessionLive> = new Map(),
310): Board {
311 const bySession = new Map(sessions.map(s => [s.sessionId, s]))
312 // 끝난 세션이 남긴 상태는 믿지 않는다.
313 const stateOf = (sessionId: string) => (bySession.has(sessionId) ? live.get(sessionId) : undefined)
314 const agentsOf = (sessionId: string) => stateOf(sessionId)?.agents ?? []
315 const recordOf = (agentId: string) => newest(works.filter(work => work.agentId === agentId))
316
317 const parents = parentsBySession(works)
318 const paceOfWork = (work: Work) => {
319 if (work.status !== 'active' && work.status !== 'paused') return {}
320 const paceMs = paceOf(work, works.filter(other => isSubWork(other) === isSubWork(work)))
321 return paceMs === undefined ? {} : { paceMs }
322 }
323 // 한 세션이 업무를 여러 개 열어 두었으면 메인 대화가 가장 최근에 기록한 진행 중 업무만 그 세션의 현재 업무로 본다.
324 const latest = new Map([...parents].filter(([, work]) => work.status === 'active'))
325
326 const mainRows: WorkRow[] = works
327 .filter(work => !isSubWork(work) && isVisible(work, now))
328 .map(work => {
329 const isCurrent = latest.get(work.sessionId)?.id === work.id
330 const isParent = parents.get(work.sessionId)?.id === work.id
331 const running = isParent ? agentsOf(work.sessionId) : []
332 const runningIds = new Set(running.map(agent => agent.id))
333 const activity = isParent ? stateOf(work.sessionId)?.activity : undefined
334 const finished = works
335 .filter(record => record.parentId === work.id && !(record.agentId && runningIds.has(record.agentId)))
336 .sort((a, b) => a.createdAt - b.createdAt)
337 return {
338 work,
339 phase: phaseOf(work, bySession.get(work.sessionId), isCurrent, running.length > 0, now),
340 children: running.map(agent => childOf(agent, recordOf(agent.id))),
341 finished: finished.map(finishedOf),
342 ...(activity ? { activity } : {}),
343 ...paceOfWork(work),
344 }
345 })
346 // 서브에이전트의 기록은 부모 업무 아래에 붙는다. 부모가 없으면 그 서브에이전트가 도는 동안만 따로 보인다.
347 const orphanRows: WorkRow[] = works.flatMap(work => {
348 const { agentId } = work
349 if (!agentId || parents.has(work.sessionId) || work.status !== 'active') return []
350 const agent = agentsOf(work.sessionId).find(item => item.id === agentId)
351 if (!agent || recordOf(agentId) !== work) return []
352 return [
353 {
354 work,
355 phase: 'running' as const,
356 children: [],
357 finished: [],
358 ...(agent.activity ? { activity: agent.activity } : {}),
359 ...paceOfWork(work),
360 },
361 ]
362 })
363
364 const rows = [...mainRows, ...orphanRows].sort(
365 (a, b) => PHASE_ORDER.indexOf(a.phase) - PHASE_ORDER.indexOf(b.phase) || b.work.updatedAt - a.work.updatedAt,
366 )
367 const tracked = new Set(
368 rows.filter(row => row.phase === 'running' || row.phase === 'yourTurn').map(row => row.work.sessionId),
369 )
370 const untracked = sessions.filter(s => !tracked.has(s.sessionId))
371 return { rows, untracked, at: now }
372}
373
374export const PHASE_LABEL: Record<Phase, string> = {
375 yourTurn: '내 차례',
376 running: '작업 중',
377 stalled: '멈춤',
378 paused: '보류',
379 followUp: '남은 일',
380 done: '오늘 완료',
381 dormant: '오래 멈춤',
382}
383
384export function countPhases(board: Board): { phase: Phase; count: number }[] {
385 return PHASE_ORDER.map(phase => ({ phase, count: board.rows.filter(row => row.phase === phase).length })).filter(
386 item => item.count > 0,
387 )
388}
389
390export function bar(percent: number, width: number): { filled: string; rest: string } {
391 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
392 return { filled: '━'.repeat(filled), rest: '─'.repeat(width - filled) }
393}
394
395export function ago(at: number, now: number): string {
396 const minutes = Math.floor(Math.max(0, now - at) / 60_000)
397 if (minutes < 1) return '방금'
398 if (minutes < 60) return `${minutes}분 전`
399 const hours = Math.floor(minutes / 60)
400 if (hours < 24) return `${hours}시간 전`
401 return `${Math.floor(hours / 24)}일 전`
402}
403
404// 걸린 시간. 예: 40분, 1시간 20분, 2일
405export function span(ms: number): string {
406 const minutes = Math.max(1, Math.floor(ms / 60_000))
407 if (minutes < 60) return `${minutes}분`
408 const hours = Math.floor(minutes / 60)
409 if (hours >= 24) return `${Math.floor(hours / 24)}일`
410 const rest = minutes % 60
411 return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`
412}
413
414// 화면에 그릴 때 바뀌는 시각을 빼고 비교해, 내용이 같으면 다시 그리지 않는다.
415export function sameBoard(a: Board | null, b: Board): boolean {
416 if (!a) return false
417 return JSON.stringify({ ...a, at: 0 }) === JSON.stringify({ ...b, at: 0 })
418}
419
420export const TOOL_NAME = 'report'
421
422export const TOOL_DESCRIPTION =
423 '여러 단계짜리 업무의 목표, 전체 단계 목록, 지금 하는 일을 기록한다. 사용자는 이 기록으로 모든 세션의 진행도를 한 화면에서 본다. 매번 전체 단계 목록을 보낸다.'
424
425export const TOOL_SCHEMA = {
426 type: 'object',
427 properties: {
428 work_id: {
429 type: 'string',
430 description: '업무 식별자. 영문 소문자, 숫자, 하이픈. 이전 세션의 업무를 이어 가면 기존 id 를 그대로 쓴다.',
431 },
432 title: { type: 'string', description: '업무 목표 한 줄. 한국어 40자 안팎.' },
433 steps: {
434 type: 'array',
435 description: '전체 단계 목록. 끝난 단계도 빼지 않는다.',
436 items: {
437 type: 'object',
438 properties: {
439 title: { type: 'string', description: '단계 이름 한 줄' },
440 done: { type: 'boolean' },
441 evidence: { type: 'string', description: '완료 근거. 커밋 해시, 테스트 결과, 확인한 화면처럼 짧게.' },
442 },
443 required: ['title', 'done'],
444 },
445 },
446 now: { type: 'string', description: '지금 하는 일 한 줄' },
447 question: { type: 'string', description: '사용자 답을 기다리는 질문 한 줄. 없으면 생략.' },
448 waiting_for: { type: 'string', description: '보류한 이유나 기다리는 조건 한 줄. 예: 디자인 확정 뒤 구현' },
449 followups: {
450 type: 'array',
451 items: { type: 'string' },
452 description:
453 '완료한 뒤에도 이 업무 밖에서 할 일. 예: push, 배포 확인. 보내지 않으면 이전 목록을 그대로 두고, 다 끝냈으면 빈 목록을 보낸다.',
454 },
455 status: { type: 'string', enum: ['active', 'paused', 'done'], description: '기본은 active' },
456 },
457 required: ['work_id', 'title', 'steps'],
458}
459
460const GUIDE = `# 업무 진행 기록
461사용자는 여러 세션의 업무 진행도를 한 화면에서 본다. 그 화면은 \`mcp__work-progress__${TOOL_NAME}\` 기록으로 채워진다.
462- 3단계 이상이거나 여러 턴에 걸칠 작업을 시작하면 목표와 전체 단계 목록을 기록한다. 한 번에 끝나는 질문, 설명, 작은 수정에는 기록하지 않는다.
463- 단계를 끝냈을 때, 계획이 바뀌어 단계가 늘거나 줄 때, 사용자 답을 기다리며 턴을 끝내기 직전, 업무가 끝났을 때 다시 기록한다.
464- 단계 완료는 근거가 있을 때만 표시하고 evidence 에 짧게 적는다.
465- 보류로 기록할 때는 기다리는 이유를 waiting_for 에 적는다.
466- 완료로 닫을 때 push, 배포 확인처럼 이 업무 밖에서 할 일이 남으면 followups 에 적는다. 남은 일을 끝내면 그 일을 뺀 목록으로 다시 기록한다.
467- 이전 세션에서 하던 업무를 이어 가면 아래 목록의 work_id 를 그대로 쓴다.
468- 서브에이전트로 일할 때는 부모 대화의 work_id 를 쓰지 않고 맡은 일만 새 work_id 로 기록한다. 부모 업무 아래 한 줄로 보인다.`
469
470// 시스템 안내가 바뀌면 대화 전체의 prompt cache 가 깨지므로 세션 시작 때 한 번만 만든다.
471export function guideText(board: Board | null): string {
472 const rows = (board?.rows ?? []).filter(row => !isSubWork(row.work))
473 const open = rows.filter(row => row.phase !== 'done' && row.phase !== 'followUp')
474 const left = rows.filter(row => row.phase === 'followUp')
475 const openText =
476 open.length === 0
477 ? '진행 중인 업무: 없음'
478 : `세션 시작 때 진행 중이던 업무:\n${open
479 .map(({ work }) => {
480 const { done, total } = progressOf(work)
481 return `- ${work.id} · ${work.title} · ${done}/${total} 단계 · @${work.sessionName} · ${ago(work.updatedAt, board?.at ?? work.updatedAt)}`
482 })
483 .join('\n')}`
484 const leftText =
485 left.length === 0
486 ? ''
487 : `\n\n남은 일이 있는 완료 업무:\n${left
488 .map(({ work }) => `- ${work.id} · ${work.title} · 남은 일: ${(work.followups ?? []).join(', ')}`)
489 .join('\n')}`
490 return `${GUIDE}\n\n${openText}${leftText}`
491}
492hooks/summary.ts 46 lines1import type { Work } from '../types'
2import { isSubWork, progressOf } from './model'
3
4const DAY_MS = 24 * 60 * 60_000
5
6// /progress 뒤에 이 말을 붙이면 패널 대신 하루 요약을 글로 낸다.
7export const SUMMARY_ARGS = ['요약', '오늘', 'today', 'summary']
8
9function doneLines(work: Work, works: Work[]): string[] {
10 const subs = works.filter(record => record.parentId === work.id).length
11 const head = `- ${work.title} · ${work.steps.length}단계${subs > 0 ? ` · 서브 ${subs}` : ''}`
12 const proofs = work.steps.filter(step => step.isDone && step.evidence).map(step => ` ✓ ${step.title} — ${step.evidence}`)
13 return [head, ...proofs]
14}
15
16function openLine(work: Work): string {
17 const { done, total } = progressOf(work)
18 const state = work.status === 'paused' ? ' · 보류' : ''
19 const reason = work.waitingFor ? ` · 기다림: ${work.waitingFor}` : work.now ? ` · 지금: ${work.now}` : ''
20 return `- ${work.title} · ${done}/${total}${state}${reason}`
21}
22
23// 최근 24시간 동안 메인 대화가 끝낸 업무, 아직 하는 업무, 완료 뒤 남은 일을 글로 묶는다.
24// 서브에이전트 기록은 부모 업무의 서브 개수로만 센다.
25export function dailySummary(works: Work[], now: number): string {
26 const main = works.filter(work => !isSubWork(work))
27 const isRecent = (at: number) => now - at < DAY_MS
28 const finished = main
29 .filter(work => work.doneAt !== undefined && isRecent(work.doneAt) && (work.status === 'done' || work.status === 'archived'))
30 .sort((a, b) => (a.doneAt ?? 0) - (b.doneAt ?? 0))
31 const open = main
32 .filter(work => (work.status === 'active' || work.status === 'paused') && isRecent(work.updatedAt))
33 .sort((a, b) => b.updatedAt - a.updatedAt)
34 const left = main.filter(work => work.status === 'done' && work.followups)
35
36 if (finished.length + open.length + left.length === 0) return '최근 24시간에 기록된 업무가 없어요.'
37 const sections = [
38 finished.length > 0 ? [`완료 ${finished.length}`, ...finished.flatMap(work => doneLines(work, works))] : [],
39 open.length > 0 ? [`진행 중 ${open.length}`, ...open.map(openLine)] : [],
40 left.length > 0
41 ? [`남은 일 ${left.length}`, ...left.map(work => `- ${work.title}: ${(work.followups ?? []).join(', ')}`)]
42 : [],
43 ].filter(lines => lines.length > 0)
44 return ['업무 요약 · 최근 24시간', ...sections.map(lines => lines.join('\n'))].join('\n\n')
45}
46hooks/view.tsx 486 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Board, ChildRow, Phase, Work, WorkRow } from '../types'
4import { PHASE_LABEL, PHASE_ORDER, ago, bar, countPhases, progressOf, span, stepSpans, stepStartedAt } from './model'
5import { remainingMs } from './pace'
6
7type Els = Elements[RenderSurface]
8
9// wide 는 모든 글자, narrow 는 부가 정보를 빼고, tiny 는 아이콘과 숫자만 남긴다.
10export type Density = 'wide' | 'narrow' | 'tiny'
11
12const PHASE_ICON: Record<Phase, string> = {
13 yourTurn: '◆',
14 running: '●',
15 stalled: '○',
16 paused: '‖',
17 followUp: '□',
18 done: '✓',
19 dormant: '◌',
20}
21const PHASE_COLOR: Record<Phase, string> = {
22 yourTurn: 'warning',
23 running: 'suggestion',
24 stalled: 'subtle',
25 paused: 'subtle',
26 followUp: 'warning',
27 done: 'success',
28 dormant: 'subtle',
29}
30// 이 칸들은 업무를 제목 한 줄로 접어 두고, 누른 업무만 펼친다.
31const FOLDED: Phase[] = ['followUp', 'done', 'dormant']
32const BAR_WIDTH: Record<Density, number> = { wide: 10, narrow: 6, tiny: 4 }
33// 펼침 표시 한 칸과 공백 한 칸만큼 들여 써서 둘째 줄부터 제목과 줄을 맞춘다.
34const INDENT = 2
35const MAX_CHILDREN = 4
36// 지금 단계가 이보다 오래 걸리면 막대 옆에 알린다.
37const SLOW_STEP_MS = 30 * 60_000
38const MINUTE_MS = 60_000
39const HOUR_MS = 60 * MINUTE_MS
40const MARK = { open: '▾', closed: '▸' }
41// 덧붙이는 정보의 글자색. dimColor 는 어두운 배경에서 거의 안 보여서, 구분선 말고는 테마의 옅은 회색을 쓴다.
42const SOFT = 'subtle'
43
44export function densityOf(columns: number): Density {
45 if (columns >= 60) return 'wide'
46 if (columns >= 40) return 'narrow'
47 return 'tiny'
48}
49
50// 한글과 전각 문자는 터미널에서 두 칸을 차지한다.
51function cells(ch: string): number {
52 const code = ch.codePointAt(0) ?? 0
53 const isWide =
54 (code >= 0x1100 && code <= 0x115f) ||
55 (code >= 0x2e80 && code <= 0xa4cf) ||
56 (code >= 0xac00 && code <= 0xd7a3) ||
57 (code >= 0xf900 && code <= 0xfaff) ||
58 (code >= 0xff00 && code <= 0xff60) ||
59 (code >= 0xffe0 && code <= 0xffe6)
60 return isWide ? 2 : 1
61}
62
63export function widthOf(text: string): number {
64 return [...text].reduce((sum, ch) => sum + cells(ch), 0)
65}
66
67export function fit(text: string, width: number): string {
68 const chars = [...text]
69 if (widthOf(text) <= width) return text
70 let used = 0
71 let out = ''
72 for (const ch of chars) {
73 if (used + cells(ch) > width - 1) break
74 used += cells(ch)
75 out += ch
76 }
77 return `${out}…`
78}
79
80// 칸마다 위에 긋는 선. 폭에 맞춰 늘이고 앞쪽에 칸 이름을 넣는다.
81export function divider(label: string, columns: number): string {
82 const head = `── ${label} `
83 return `${head}${'─'.repeat(Math.max(2, columns - widthOf(head)))}`
84}
85
86function phaseCount(phase: Phase, count: number, density: Density): string {
87 return density === 'tiny' ? `${PHASE_ICON[phase]}${count}` : `${PHASE_ICON[phase]} ${PHASE_LABEL[phase]} ${count}`
88}
89
90function isLeftOpen(child: ChildRow): boolean {
91 return child.status === 'active' || child.status === 'paused'
92}
93
94export function drawSummary(els: Els, board: Board, density: Density, hasHint: boolean) {
95 const { Text } = els
96 const counts = countPhases(board)
97 const total = density === 'wide' ? `업무 ${board.rows.length}개` : `업무 ${board.rows.length}`
98 const untracked = density === 'wide' && board.untracked.length > 0 ? `기록 없는 세션 ${board.untracked.length}` : ''
99 return (
100 <Text wrap="truncate-end">
101 <Text bold>{total}</Text>
102 {counts.map(({ phase, count }) => (
103 <Text key={`count-${phase}`}>
104 <Text dimColor>{' · '}</Text>
105 <Text color={PHASE_COLOR[phase]}>{phaseCount(phase, count, density)}</Text>
106 </Text>
107 ))}
108 {untracked ? <Text color={SOFT}>{` · ${untracked}`}</Text> : ''}
109 {hasHint && density !== 'tiny' ? <Text color={SOFT}>{' /progress'}</Text> : ''}
110 </Text>
111 )
112}
113
114function drawHeader(els: Els, phase: Phase, count: number, columns: number) {
115 const { Text } = els
116 const label = phaseCount(phase, count, densityOf(columns))
117 const rule = divider(label, columns).slice(`── ${label} `.length)
118 return (
119 <Text wrap="truncate-end" key={`head-${phase}`}>
120 <Text dimColor>{'── '}</Text>
121 <Text color={PHASE_COLOR[phase]} bold>
122 {label}
123 </Text>
124 <Text dimColor>{` ${rule}`}</Text>
125 </Text>
126 )
127}
128
129// 서브에이전트가 완료로 닫지 않고 끝난 기록. 부모 업무를 접어 두어도 보인다.
130function drawUnclosed(els: Els, unclosed: ChildRow[]) {
131 const { Text } = els
132 return unclosed.map(child => (
133 <Text wrap="truncate-end" key={`open-${child.id}`}>
134 <Text color={SOFT}>{'└ '}</Text>
135 <Text color="warning">{child.status === 'paused' ? PHASE_ICON.paused : PHASE_ICON.stalled}</Text>
136 {` ${child.title}`}
137 {child.total ? ` ${child.done}/${child.total}` : ''}
138 <Text color="warning">{child.status === 'paused' ? ' · 보류로 끝남' : ' · 닫지 않고 끝남'}</Text>
139 {child.now ? <Text color={SOFT}>{` · ${child.now}`}</Text> : ''}
140 </Text>
141 ))
142}
143
144// 부모 업무 아래 서브에이전트 줄. 끝난 서브에이전트는 한 줄로 묶었다가 업무를 펼치면 하나씩 보인다.
145function drawChildren(els: Els, row: WorkRow, isOpen: boolean, density: Density) {
146 const { Box, Text } = els
147 const { children, finished } = row
148 if (children.length === 0 && finished.length === 0) return ''
149 const unclosed = finished.filter(isLeftOpen)
150 const closed = finished.filter(child => !isLeftOpen(child))
151 if (density === 'tiny') {
152 const parts = [
153 ...(children.length > 0 ? [`서브 ${children.length}`] : []),
154 ...(closed.length > 0 ? [`${PHASE_ICON.done}${closed.length}`] : []),
155 ...(unclosed.length > 0 ? [`안 닫힘 ${unclosed.length}`] : []),
156 ]
157 return (
158 <Box marginLeft={INDENT}>
159 <Text color={SOFT}>{`└ ${parts.join(' ')}`}</Text>
160 </Box>
161 )
162 }
163 const hidden = children.length - MAX_CHILDREN
164 return (
165 <Box marginLeft={INDENT} flexDirection="column">
166 {children.slice(0, MAX_CHILDREN).map(child => (
167 <Text wrap="truncate-end" key={`child-${child.id}`}>
168 <Text color={SOFT}>{'└ '}</Text>
169 <Text color={PHASE_COLOR.running}>{PHASE_ICON.running}</Text>
170 {` ${child.title}`}
171 {child.total ? <Text bold>{` ${child.done}/${child.total}`}</Text> : ''}
172 {child.activity ? <Text color={SOFT}>{` · 최근: ${child.activity.text}`}</Text> : ''}
173 </Text>
174 ))}
175 {hidden > 0 ? <Text color={SOFT}>{` 서브에이전트 ${hidden}개 더`}</Text> : ''}
176 {drawUnclosed(els, unclosed)}
177 {isOpen ? (
178 closed.map(child => (
179 <Text color={SOFT} wrap="truncate-end" key={`finished-${child.id}`}>
180 {'└ '}
181 <Text color={PHASE_COLOR.done}>{PHASE_ICON.done}</Text>
182 {` ${child.title}`}
183 {child.total ? ` ${child.done}/${child.total}` : ''}
184 </Text>
185 ))
186 ) : closed.length > 0 ? (
187 <Text color={SOFT}>
188 {'└ '}
189 <Text color={PHASE_COLOR.done}>{PHASE_ICON.done}</Text>
190 {` 서브에이전트 ${closed.length}개 끝남`}
191 </Text>
192 ) : (
193 ''
194 )}
195 </Box>
196 )
197}
198
199function drawFollowups(els: Els, work: Work) {
200 const { Box, Text } = els
201 if (!work.followups) return ''
202 return (
203 <Box marginLeft={INDENT} flexDirection="column">
204 {work.followups.map((item, index) => (
205 <Text wrap="truncate-end" key={`followup-${index}`}>
206 <Text color={PHASE_COLOR.followUp}>{PHASE_ICON.followUp}</Text>
207 {` ${item}`}
208 </Text>
209 ))}
210 </Box>
211 )
212}
213
214type StepKind = 'done' | 'current' | 'todo'
215
216// 끝낸 단계 ●, 지금 단계 ◉, 남은 단계 ○. 완료한 업무에는 지금 단계가 없다.
217function stepKinds(work: Work, phase: Phase): StepKind[] {
218 const current = phase === 'done' || phase === 'followUp' ? -1 : work.steps.findIndex(step => !step.isDone)
219 return work.steps.map((step, index) => (step.isDone ? 'done' : index === current ? 'current' : 'todo'))
220}
221
222const NODE: Record<StepKind, string> = { done: '●', current: '◉', todo: '○' }
223
224// 끝낸 단계에서 끝낸 단계나 지금 단계로 가는 구간만 지나온 길로 친다. 순서를 건너뛰어 끝낸 단계 뒤는 가는 선이다.
225function isWalked(kinds: StepKind[], index: number): boolean {
226 return kinds[index] === 'done' && kinds[index + 1] !== undefined && kinds[index + 1] !== 'todo'
227}
228
229// 단계를 노드로 이은 한 줄. 지나온 구간은 굵은 선, 남은 구간은 가는 선으로 잇는다.
230// 자리가 모자라면 선을 빼고 노드만, 그래도 모자라면 막대로 그린다.
231function drawChain(els: Els, work: Work, phase: Phase, room: number, density: Density) {
232 const { Text } = els
233 const kinds = stepKinds(work, phase)
234 const total = kinds.length
235 const isJoined = total * 2 - 1 <= room
236 if (total === 0 || (!isJoined && total > room)) {
237 const meter = bar(progressOf(work).percent, BAR_WIDTH[density])
238 return [
239 <Text color={PHASE_COLOR[phase]} key="bar-filled">
240 {meter.filled}
241 </Text>,
242 <Text color={SOFT} key="bar-rest">
243 {meter.rest}
244 </Text>,
245 ]
246 }
247 return kinds.map((kind, index) => {
248 const walked = isWalked(kinds, index)
249 const link = isJoined && index < total - 1 ? (walked ? '━' : '─') : ''
250 return (
251 <Text key={`node-${index}`}>
252 <Text color={kind === 'todo' ? SOFT : PHASE_COLOR[phase]} bold={kind === 'current'}>
253 {NODE[kind]}
254 </Text>
255 {link ? (
256 <Text color={walked ? PHASE_COLOR[phase] : SOFT}>
257 {link}
258 </Text>
259 ) : (
260 ''
261 )}
262 </Text>
263 )
264 })
265}
266
267// 펼친 업무의 단계를 위에서 아래로 흐르는 그림으로 그린다. 노드 사이를 세로선으로 잇는다.
268function drawSteps(els: Els, work: Work, phase: Phase) {
269 const { Text } = els
270 const spans = stepSpans(work)
271 const kinds = stepKinds(work, phase)
272 return work.steps.flatMap((step, index) => {
273 const kind = kinds[index] ?? 'todo'
274 const took = spans[index]
275 const node = (
276 <Text color={kind === 'todo' ? SOFT : undefined} bold={kind === 'current'} wrap="truncate-end" key={`step-${index}`}>
277 <Text color={kind === 'todo' ? SOFT : PHASE_COLOR[phase]}>{NODE[kind]}</Text>
278 {` ${step.title}`}
279 {took ? <Text color={SOFT}>{` · ${span(took)}`}</Text> : ''}
280 {step.evidence ? <Text color={SOFT}>{` — ${step.evidence}`}</Text> : ''}
281 {kind === 'current' ? <Text color={PHASE_COLOR[phase]}>{' ← 지금'}</Text> : ''}
282 </Text>
283 )
284 if (index === work.steps.length - 1) return [node]
285 const walked = isWalked(kinds, index)
286 const link = (
287 <Text color={walked ? PHASE_COLOR[phase] : SOFT} key={`link-${index}`}>
288 {walked ? '┃' : '│'}
289 </Text>
290 )
291 return [node, link]
292 })
293}
294
295// 업무 줄 아래 한 줄. 사용자 답을 기다리면 질문, 보류면 기다리는 이유, 아니면 지금 하는 일이나 다음 단계를 보인다.
296function detailOf(row: WorkRow): { label: string; text: string; isAsk: boolean } | undefined {
297 const { work, phase } = row
298 if (phase === 'yourTurn' && work.question) return { label: '질문: ', text: work.question, isAsk: true }
299 if (work.waitingFor) return { label: '기다림: ', text: work.waitingFor, isAsk: false }
300 if (work.now) return { label: '지금: ', text: work.now, isAsk: false }
301 const next = work.steps.find(step => !step.isDone)
302 return next && phase !== 'done' && phase !== 'followUp' ? { label: '다음 단계: ', text: next.title, isAsk: false } : undefined
303}
304
305// 이름표는 옅은 회색, 내용은 본문 색으로 쓴다.
306function drawLabeled(els: Els, label: string, text: string, tail = '') {
307 const { Text } = els
308 return (
309 <Text wrap="truncate-end">
310 <Text color={SOFT}>{label}</Text>
311 {text}
312 {tail ? <Text color={SOFT}>{tail}</Text> : ''}
313 </Text>
314 )
315}
316
317// 지금 단계가 오래 걸리면 "이 단계 1시간째"를 돌려준다. 누가 일하는 중일 때만 센다.
318function slowStep(row: WorkRow, now: number): string {
319 const { work, phase } = row
320 if (phase !== 'running' && phase !== 'yourTurn') return ''
321 if (work.steps.every(step => step.isDone)) return ''
322 const started = stepStartedAt(work)
323 if (started === undefined || now - started < SLOW_STEP_MS) return ''
324 return `이 단계 ${span(now - started)}째`
325}
326
327// 남은 단계와 단계 하나에 걸리는 시간으로 본 예상. 예상이라 10분이 안 되면 분, 1시간이 안 되면 5분, 그 위는 10분 단위로 끊는다.
328function etaText(row: WorkRow, now: number): string {
329 if (row.paceMs === undefined) return ''
330 const ms = remainingMs(row.work, row.paceMs, row.phase, now)
331 if (ms === undefined) return ''
332 const unit = ms < 10 * MINUTE_MS ? MINUTE_MS : ms < HOUR_MS ? 5 * MINUTE_MS : 10 * MINUTE_MS
333 return `약 ${span(Math.round(ms / unit) * unit)} 남음`
334}
335
336type RowActions = { onToggle: (id: string) => void; onArchive: (id: string) => void }
337
338// 완료·남은 일·오래 멈춤 칸의 업무는 제목 한 줄로 접는다. 남은 일과 닫히지 않은 서브 기록은 접어도 보인다.
339function drawFolded(els: Els, row: WorkRow, now: number, columns: number, actions: RowActions) {
340 const { Box, Button, Text } = els
341 const { work, phase } = row
342 const density = densityOf(columns)
343 const unclosed = row.finished.filter(isLeftOpen)
344 const { done, total } = progressOf(work)
345 const parts = [
346 ...(phase === 'dormant' ? [`${done}/${total}`] : []),
347 ...(row.finished.length > 0 ? [`서브 ${row.finished.length}`] : []),
348 ago(work.doneAt ?? work.updatedAt, now),
349 ]
350 const tail = density === 'tiny' ? '' : ` · ${parts.join(' · ')}`
351 const title = fit(work.title, Math.max(4, columns - INDENT - widthOf(tail)))
352 return (
353 <Box flexDirection="column" key={`row-${work.id}`}>
354 <Button key={`toggle-${work.id}`} plain onPress={() => actions.onToggle(work.id)}>
355 <Text wrap="truncate-end">
356 <Text color={SOFT}>{MARK.closed}</Text> <Text color={phase === 'followUp' ? undefined : SOFT}>{title}</Text>
357 <Text color={SOFT}>{tail}</Text>
358 </Text>
359 </Button>
360 {phase === 'followUp' ? drawFollowups(els, work) : ''}
361 {phase === 'dormant' && work.waitingFor && density !== 'tiny' ? (
362 <Box marginLeft={INDENT}>
363 {drawLabeled(els, '기다림: ', work.waitingFor)}
364 </Box>
365 ) : (
366 ''
367 )}
368 {unclosed.length > 0 && density !== 'tiny' ? (
369 <Box marginLeft={INDENT} flexDirection="column">
370 {drawUnclosed(els, unclosed)}
371 </Box>
372 ) : (
373 ''
374 )}
375 </Box>
376 )
377}
378
379function drawRow(els: Els, row: WorkRow, isOpen: boolean, now: number, columns: number, actions: RowActions) {
380 const { Box, Button, Text } = els
381 const { work, phase } = row
382 if (FOLDED.includes(phase) && !isOpen) return drawFolded(els, row, now, columns, actions)
383 const density = densityOf(columns)
384 const { done, total } = progressOf(work)
385 const title = fit(work.title, Math.max(4, columns - INDENT))
386 const slow = density === 'tiny' ? '' : slowStep(row, now)
387 const eta = density === 'tiny' ? '' : etaText(row, now)
388 const detail = detailOf(row)
389 const where = density === 'tiny' ? ago(work.updatedAt, now) : `${ago(work.updatedAt, now)} · @${work.sessionName}`
390 // 노드 줄은 개수, 예상 시간, 오래 걸림 알림, 시각과 세션 이름을 뺀 자리에 맞춘다.
391 const room =
392 columns - INDENT - widthOf(` ${done}/${total}${eta ? ` · ${eta}` : ''}${slow ? ` · ${slow}` : ''} · ${where}`)
393
394 return (
395 <Box flexDirection="column" key={`row-${work.id}`}>
396 <Button key={`toggle-${work.id}`} plain onPress={() => actions.onToggle(work.id)}>
397 <Text wrap="truncate-end">
398 <Text color={SOFT}>{isOpen ? MARK.open : MARK.closed}</Text> <Text bold>{title}</Text>
399 </Text>
400 </Button>
401 <Box marginLeft={INDENT}>
402 <Text wrap="truncate-end">
403 {drawChain(els, work, phase, room, density)}
404 <Text bold>{` ${done}/${total}`}</Text>
405 {eta ? <Text>{` · ${eta}`}</Text> : ''}
406 {slow ? <Text color="warning">{` · ${slow}`}</Text> : ''}
407 <Text color={SOFT}>{` · ${where}`}</Text>
408 </Text>
409 </Box>
410 {detail ? (
411 <Box marginLeft={INDENT}>
412 {detail.isAsk ? (
413 <Text color="warning" wrap="truncate-end">{`${detail.label}${detail.text}`}</Text>
414 ) : (
415 drawLabeled(els, detail.label, detail.text)
416 )}
417 </Box>
418 ) : (
419 ''
420 )}
421 {row.activity && density !== 'tiny' ? (
422 <Box marginLeft={INDENT}>
423 {drawLabeled(els, '최근: ', row.activity.text, ` · ${ago(row.activity.at, now)}`)}
424 </Box>
425 ) : (
426 ''
427 )}
428 {drawChildren(els, row, isOpen, density)}
429 {isOpen ? (
430 <Box marginLeft={INDENT} flexDirection="column">
431 {drawSteps(els, work, phase)}
432 </Box>
433 ) : (
434 ''
435 )}
436 {isOpen ? drawFollowups(els, work) : ''}
437 {isOpen ? (
438 <Box marginLeft={INDENT}>
439 <Button key={`archive-${work.id}`} label="보관" onPress={() => actions.onArchive(work.id)} />
440 </Box>
441 ) : (
442 ''
443 )}
444 </Box>
445 )
446}
447
448// 할 일이 남은 칸을 위에, 접어 두는 칸을 아래에 둔다. 칸마다 이름과 개수를 적은 선을 긋는다.
449export function drawPane(els: Els, board: Board | null, expanded: string | null, columns: number, actions: RowActions) {
450 const { Box, Text } = els
451 const density = densityOf(columns)
452 if (!board || (board.rows.length === 0 && board.untracked.length === 0)) {
453 return <Text color={SOFT}>기록된 업무가 없어요.</Text>
454 }
455
456 const draw = (row: WorkRow) => drawRow(els, row, expanded === row.work.id, board.at, columns, actions)
457 const groups = PHASE_ORDER.map(phase => ({ phase, rows: board.rows.filter(row => row.phase === phase) })).filter(
458 group => group.rows.length > 0,
459 )
460 const open = groups.filter(group => !FOLDED.includes(group.phase))
461 const folded = groups.filter(group => FOLDED.includes(group.phase))
462 const untracked = board.untracked.map(s => `@${s.name}${s.isBusy ? ' 작업 중' : ''}`).join(', ')
463 return (
464 <Box flexDirection="column" rowGap={1}>
465 {drawSummary(els, board, density, false)}
466 {open.length === 0 ? <Text color={SOFT}>진행 중인 업무가 없어요.</Text> : ''}
467 {open.map(({ phase, rows }) => (
468 <Box flexDirection="column" key={`group-${phase}`}>
469 {drawHeader(els, phase, rows.length, columns)}
470 <Box flexDirection="column" rowGap={1}>
471 {rows.map(draw)}
472 </Box>
473 </Box>
474 ))}
475 {untracked && density !== 'tiny' ? <Text color={SOFT} wrap="truncate-end">{`기록 없는 세션: ${untracked}`}</Text> : ''}
476 {folded.map(({ phase, rows }) => (
477 <Box flexDirection="column" key={`group-${phase}`}>
478 {drawHeader(els, phase, rows.length, columns)}
479 {rows.map(draw)}
480 </Box>
481 ))}
482 {density === 'wide' ? <Text color={SOFT}>업무 줄을 누르면 단계 목록이 펼쳐져요.</Text> : ''}
483 </Box>
484 )
485}
486hooks/pace.ts 69 lines1import type { Phase, Work } from '../types'
2
3const MINUTE_MS = 60_000
4// 이 업무를 이만큼 쟀으면 이 업무의 속도만 본다. 그보다 적으면 지난 업무 기록과 섞는다.
5const OWN_SAMPLES = 2
6// 섞어도 이보다 적으면 예상하지 않는다.
7const MIN_SAMPLES = 3
8// 지난 기록은 최근에 기록한 업무만 본다. 일하는 방식이 바뀌면 오래된 기록은 맞지 않는다.
9const HISTORY_WORKS = 40
10
11// 지금 단계를 시작한 시각. 예전 기록이라 완료 시각을 모르면 undefined 다.
12export function stepStartedAt(work: Work): number | undefined {
13 const done = work.steps.filter(step => step.isDone)
14 if (done.length === 0) return work.createdAt
15 const times = done.flatMap(step => (step.doneAt === undefined ? [] : [step.doneAt]))
16 return times.length > 0 ? Math.max(...times) : undefined
17}
18
19// 단계마다 걸린 시간. 끝낸 순서대로 앞 단계를 끝낸 시각부터 잰다. 1분이 안 되거나 모르면 undefined 다.
20export function stepSpans(work: Work): (number | undefined)[] {
21 const order = work.steps
22 .map((step, index) => ({ index, at: step.doneAt }))
23 .filter((item): item is { index: number; at: number } => item.at !== undefined)
24 .sort((a, b) => a.at - b.at)
25 const spans: (number | undefined)[] = work.steps.map(() => undefined)
26 let from = work.createdAt
27 for (const { index, at } of order) {
28 spans[index] = at - from >= MINUTE_MS ? at - from : undefined
29 from = at
30 }
31 return spans
32}
33
34function measured(work: Work): number[] {
35 return stepSpans(work).filter((ms): ms is number => ms !== undefined)
36}
37
38// 자리를 비운 시간처럼 튀는 값에 덜 흔들리도록 평균 대신 중앙값을 쓴다.
39function median(values: number[]): number {
40 const sorted = [...values].sort((a, b) => a - b)
41 const mid = Math.floor(sorted.length / 2)
42 const upper = sorted[mid] ?? 0
43 return sorted.length % 2 === 1 ? upper : ((sorted[mid - 1] ?? upper) + upper) / 2
44}
45
46// 단계 하나에 걸리는 시간. others 는 같은 종류의 다른 업무다. 서브에이전트와 메인 대화는 속도가 달라 섞지 않는다.
47export function paceOf(work: Work, others: Work[]): number | undefined {
48 const own = measured(work)
49 if (own.length >= OWN_SAMPLES) return median(own)
50 const history = others
51 .filter(other => other.id !== work.id)
52 .sort((a, b) => b.updatedAt - a.updatedAt)
53 .slice(0, HISTORY_WORKS)
54 .flatMap(measured)
55 const pool = [...own, ...history]
56 return pool.length >= MIN_SAMPLES ? median(pool) : undefined
57}
58
59// 남은 단계를 마치는 데 걸릴 시간. 일하는 중이면 지금 단계에 이미 쓴 시간을 뺀다.
60// 남은 단계가 없거나 예상한 시간을 이미 넘겼으면 undefined 다.
61export function remainingMs(work: Work, paceMs: number, phase: Phase, now: number): number | undefined {
62 const left = work.steps.filter(step => !step.isDone).length
63 if (left === 0) return undefined
64 const started = phase === 'running' || phase === 'yourTurn' ? stepStartedAt(work) : undefined
65 const spent = started === undefined ? 0 : Math.min(Math.max(0, now - started), paceMs)
66 const ms = left * paceMs - spent
67 return ms >= MINUTE_MS ? ms : undefined
68}
69types/index.d.ts 79 lines1// doneAt 은 그 단계를 처음 완료로 기록한 시각이다. 단계마다 걸린 시간을 여기서 계산한다.
2export type Step = { title: string; isDone: boolean; evidence?: string; doneAt?: number }
3
4export type WorkStatus = 'active' | 'paused' | 'done' | 'archived'
5
6// 업무 하나가 파일 하나다. 여러 세션이 이어 쓰므로 마지막으로 기록한 세션만 남긴다.
7export type Work = {
8 id: string
9 title: string
10 steps: Step[]
11 now?: string
12 question?: string
13 // 보류한 이유나 기다리는 조건. 예: 디자인 확정 뒤 구현
14 waitingFor?: string
15 // 완료한 뒤에도 이 업무 밖에서 해야 할 일. 예: push, 배포 확인
16 followups?: string[]
17 status: WorkStatus
18 sessionId: string
19 sessionName: string
20 // 서브에이전트가 기록했으면 그 서브에이전트 id. 메인 대화의 기록에는 없다.
21 agentId?: string
22 // 서브에이전트의 기록이면, 기록할 때 그 세션이 하던 메인 업무의 id. 끝난 뒤에도 부모 아래에 묶는다.
23 parentId?: string
24 createdAt: number
25 updatedAt: number
26 doneAt?: number
27}
28
29export type LiveSession = { sessionId: string; name: string; isBusy: boolean }
30
31// 마지막으로 쓴 도구를 사람이 읽는 한 줄로 바꾼 것
32export type Activity = { text: string; at: number }
33
34export type LiveAgent = { id: string; description: string; activity?: Activity }
35
36// 세션이 10초마다 ~/.claude/work-progress/live 에 남기는 상태. 다른 세션은 이 파일로만 안다.
37export type SessionLive = { activity?: Activity; agents: LiveAgent[] }
38
39// 부모 업무 아래 한 줄. 서브에이전트가 기록을 남겼으면 그 진행도를 붙인다.
40// 끝난 서브에이전트의 줄에는 그 기록의 상태를 붙인다. done 이 아니면 기록을 닫지 않고 끝난 것이다.
41export type ChildRow = {
42 id: string
43 title: string
44 done?: number
45 total?: number
46 activity?: Activity
47 status?: WorkStatus
48 now?: string
49}
50
51// running: 세션이 일하는 중, yourTurn: 세션이 사용자 입력을 기다림, stalled: 세션이 없거나 다른 업무로 넘어감
52// followUp: 완료했지만 남은 일이 있음, dormant: 멈춤이나 보류인 채 하루 넘게 기록이 없음
53export type Phase = 'yourTurn' | 'running' | 'stalled' | 'paused' | 'followUp' | 'done' | 'dormant'
54
55// children 은 도는 서브에이전트, finished 는 이 업무 아래 끝난 서브에이전트 기록
56// paceMs 는 이 업무와 지난 업무 기록으로 본 단계 하나에 걸리는 시간이다. 기록이 모자라면 없다.
57export type WorkRow = {
58 work: Work
59 phase: Phase
60 children: ChildRow[]
61 finished: ChildRow[]
62 activity?: Activity
63 paceMs?: number
64}
65
66export type Board = { rows: WorkRow[]; untracked: LiveSession[]; at: number }
67
68declare module 'claude-code' {
69 interface PluginState {
70 'work-progress': {
71 board: Board | null
72 expanded: string | null
73 snapshot: string | null
74 live: string | null
75 activity: Record<string, Activity> | null
76 }
77 }
78}
79