Shows the open multi-session work in a wip folder: a /wip pane with each item's next step, and a one-line count above the prompt

/plugin install wip-board --marketplace steven0224-glitch/chan-mods
Older CLIs: claude plugin marketplace add steven0224-glitch/chan-mods then claude plugin install wip-board@chan-mods.
Shows the open multi-session work that lives in a folder of Markdown notes (one task per file), so the next step is on screen instead of in your head.
wip 9 · ⚠ 오래됨 2 · ? 다음 없음 3 · ◌ 대기 1 — /wip (only the kinds that need a look are called out)/wip: a pane grouped into sections, needs-attention first — ⚠ 오래됨, ? 다음 행동 없음, ● 진행 중, ◌ 대기 — each with a one-line hint of what to do. A row shows the title, a plain age (오늘, 어제, 5일 전, 3주 전), the file slug and an 이어서 button that fills the prompt with wip/<file> 읽고 다음 행동 이어서 진행; under it → <next step>, or a red note when the file has no 다음: line. Esc closes./wip band: hide or show the line above the prompt (remembered across sessions)./plugin install wip-board --marketplace steven0224-glitch/chan-mods
Older CLIs: claude plugin marketplace add steven0224-glitch/chan-mods then claude plugin install wip-board@chan-mods.
Shows the open multi-session work that lives in a folder of Markdown notes (one task per file), so the next step is on screen instead of in your head.
wip 9 · 대기 1 · 14일↑ 2 · 다음 없음 3 — /wip/wip: a pane listing every item, needs-attention first. Each row has a mark, the title, its age in days and an 이어서 button that fills the prompt with wip/<file> 읽고 다음 행동 이어서 진행. Esc closes./wip band: hide or show the line above the prompt (remembered across sessions).| mark | kind | rule |
|---|---|---|
| ⚠ | stale | has a 다음: line but the file is 14+ days old |
| ? | nonext | no 다음: line |
| ● | active | has a 다음: line, touched within 14 days |
| ◌ | held | 다음: starts with 대기 or mentions 보류 in its first 40 chars |
# <title> ← or frontmatter `title:`
상태: <where it stands>
다음: <the one next action>
README.md in the folder is skipped. Only # title and the 다음: line are read.
| option | default | meaning |
|---|---|---|
dir | ~/.claude/wip | folder of wip notes; ~ is the home directory |
"pluginConfigs": { "wip-board@chan-mods": { "options": { "dir": "~/notes/wip" } } }
$.clock.after(0, …)) so a slow cloud-synced file never blocks session start. The list refreshes after every main-loop turn and every 10 minutes.Text bold, not weight, so it also renders on engines before 2.1.299.hooks/register.tsx 180 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WipItem } from '../types'
5import { COLOR, HINT, KINDS, LABEL, MARK, ageDays, ageLabel, bandLine, parseWip, sortItems } from './parse.ts'
6
7const PANE = 'wip-board'
8const DEFAULT_DIR = '~/.claude/wip'
9const items = atom({ plugin: 'wip-board', key: 'items' } as const, [] as WipItem[])
10const isBandHidden = atom({ plugin: 'wip-board', key: 'isBandHidden' } as const, false)
11
12// `~` means the home directory, as in the shell
13async function resolveDir($: EngineInterface, dir: string): Promise<string | null> {
14 if (!dir.startsWith('~')) return dir
15 const home = await $.env.get('HOME')
16 return home ? home + dir.slice(1) : null
17}
18
19// ~/.claude/wip is a symlink into claude-shared (2-PC); list() follows the directory path
20async function loadItems($: EngineInterface, configured: string) {
21 const dir = await resolveDir($, configured)
22 if (!dir) return
23 try {
24 if (!(await $.fs.exists(dir))) return update($, items, () => [])
25 const entries = await $.fs.list(dir)
26 const now = await $.clock.now()
27 const files = entries.filter(f => f.name.endsWith('.md') && f.name.toLowerCase() !== 'readme.md')
28 // One unreadable file (OneDrive mid-sync) skips that file, not the whole board
29 const parsed = await Promise.all(
30 files.map(async f => {
31 try {
32 return parseWip(f.name, await $.fs.read(`${dir}/${f.name}`), f.mtimeMs, now)
33 } catch {
34 return null
35 }
36 }),
37 )
38 const ok = parsed.filter((p): p is WipItem => p !== null)
39 if (ok.length < parsed.length) $.ui.log(`wip ${parsed.length - ok.length}개 읽기 실패 — 건너뜀`)
40 await update($, items, () => sortItems(ok))
41 } catch (err) {
42 // Keep the last good list; a half-synced OneDrive file should not blank the board
43 $.ui.log('wip 읽기 실패: ' + String(err))
44 }
45}
46
47async function loadBandFlag($: EngineInterface) {
48 const saved = (await $.store.get('isBandHidden')) === true
49 await update($, isBandHidden, () => saved)
50}
51
52export const register: Register = (on, options) => {
53 const dir = typeof options.dir === 'string' && options.dir.trim() ? options.dir.trim() : DEFAULT_DIR
54
55 on('session.start', async ($, e, next) => {
56 await loadBandFlag($)
57 // OneDrive dataless files block reads indefinitely and mods API waits skip the hook limit: never hold session start on disk
58 $.clock.after(0, () => loadItems($, dir))
59 // Other sessions and the other PC edit wip files too
60 $.clock.every(600_000, () => loadItems($, dir))
61 try {
62 await $.command.register({
63 name: 'wip',
64 description: '열린 멀티세션 작업(wip) 보드 열기 · /wip band 로 프롬프트 위 줄 켜기/끄기',
65 argumentHint: '[band]',
66 immediate: true,
67 })
68 } catch (err) {
69 $.ui.log('/wip 등록 실패: ' + String(err))
70 }
71 return next(e)
72 })
73
74 // /clear, /resume and /branch reset $.state without a new session.start
75 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
76 await loadBandFlag($)
77 $.clock.after(0, () => loadItems($, dir))
78 return next(e)
79 })
80
81 // Claude updates or deletes wip files during turns
82 on('turn.complete', async ($, e, next) => {
83 if (!e.agentId) $.clock.after(0, () => loadItems($, dir))
84 return next(e)
85 })
86
87 on('command.run', { command: 'wip' }, async ($, e) => {
88 if (e.args.trim() === 'band') {
89 const hidden = !(await read($, isBandHidden))
90 await $.store.set('isBandHidden', hidden)
91 await update($, isBandHidden, () => hidden)
92 return { text: hidden ? 'wip 줄 숨김 · /wip band 로 다시 켬' : 'wip 줄 표시' }
93 }
94 await loadItems($, dir)
95 await $.ui.open({ id: PANE, title: 'wip', focus: true, closeOnEscape: true })
96 return {}
97 })
98
99 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
100 const list = await read($, items)
101 if (e.props.hasSurvey || list.length === 0 || (await read($, isBandHidden))) return next(e)
102 const { Box, Text } = $.ui.resolve(e)
103 // Keep what later mods draw in the band, under this line
104 const theirs = await next(e)
105 const line = <Text dimColor wrap="truncate-end">{bandLine(list)} — /wip</Text>
106 return <Box flexDirection="column">{theirs ? [line, theirs] : [line]}</Box>
107 })
108
109 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
110 const { Box, Text, Button } = $.ui.resolve(e)
111 const list = await read($, items)
112 const now = await $.clock.now()
113 if (list.length === 0) return <Text dimColor>열린 wip 없음 — 멀티세션 작업을 시작하면 ~/.claude/wip/<슬러그>.md 로 생김</Text>
114
115 // One section per kind, needs-attention first; empty sections are skipped
116 const sections = KINDS.map(kind => ({ kind, rows: list.filter(i => i.kind === kind) })).filter(s => s.rows.length > 0)
117
118 return (
119 <Box flexDirection="column" rowGap={1}>
120 <Text dimColor>열린 작업 {list.length}개 · Tab 으로 이어서 선택 → Enter 가 프롬프트에 넣음 · Esc 닫기</Text>
121 {sections.map(({ kind, rows }) => (
122 <Box key={kind} flexDirection="column">
123 <Box flexDirection="row" columnGap={1}>
124 <Text color={COLOR[kind]} bold>
125 {MARK[kind]} {LABEL[kind]} {rows.length}
126 </Text>
127 {HINT[kind] ? <Text dimColor>— {HINT[kind]}</Text> : null}
128 </Box>
129 {rows.map(item => {
130 const days = ageDays(item.mtimeMs, now)
131 return (
132 <Box key={item.file} flexDirection="column" paddingLeft={2}>
133 <Box flexDirection="row" columnGap={1}>
134 {/* Only the title gives way when the pane is narrow; age, slug and button keep their width */}
135 <Box flexGrow={1} flexShrink={1}>
136 {/* bold, not weight: weight needs 2.1.299+ and the terminal CLI may be older */}
137 <Text bold wrap="truncate-end">{item.title}</Text>
138 </Box>
139 <Box flexShrink={0}>
140 <Text color={kind === 'stale' ? 'yellow' : undefined} dimColor={kind !== 'stale'}>
141 {ageLabel(days)}
142 </Text>
143 </Box>
144 <Box flexShrink={0}>
145 <Text dimColor>{item.file.replace(/\.md$/, '').slice(0, 28)}</Text>
146 </Box>
147 <Button
148 key={'go-' + item.file}
149 label="이어서"
150 plain
151 onPress={async () => {
152 const text = `wip/${item.file} 읽고 다음 행동 이어서 진행`
153 const r = await $.prompt.fill({ text })
154 // Under a dialog the prompt box takes nothing: say what to type instead
155 if (!r.isFilled) $.ui.toast('프롬프트에 넣지 못함 — 직접 입력: ' + text)
156 }}
157 />
158 </Box>
159 {item.next ? (
160 <Text dimColor wrap="truncate-end">
161 {' → '}
162 {item.next}
163 </Text>
164 ) : (
165 <Text color="red" dimColor wrap="truncate-end">
166 {' → 다음: 줄 없음 — '}
167 {item.file}
168 {' 에 「다음: …」 한 줄 추가'}
169 </Text>
170 )}
171 </Box>
172 )
173 })}
174 </Box>
175 ))}
176 </Box>
177 )
178 })
179}
180hooks/parse.ts 70 lines1import type { WipItem, WipKind } from '../types'
2
3export const STALE_DAYS = 14
4const DAY_MS = 86_400_000
5
6const clean = (s: string) => s.replace(/\*\*/g, '').replace(/^#+\s*/, '').trim()
7
8// Title: frontmatter `title:` if the file opens with ---, else the first non-blank line
9function titleOf(lines: string[], file: string): string {
10 if (lines[0]?.trim() === '---') {
11 const end = lines.indexOf('---', 1)
12 const fm = lines.slice(1, end > 0 ? end : undefined)
13 const t = fm.find(l => /^title\s*:/.test(l))
14 if (t) return clean(t.replace(/^title\s*:/, '')).slice(0, 80)
15 }
16 const first = lines.find(l => l.trim() && l.trim() !== '---')
17 return (first ? clean(first) : file).slice(0, 80)
18}
19
20export function parseWip(file: string, text: string, mtimeMs: number, nowMs: number): WipItem {
21 const lines = text.split('\n')
22 const title = titleOf(lines, file.replace(/\.md$/, ''))
23 const raw = lines.find(l => /^\**다음\s*:/.test(l.trim()))
24 const next = raw ? clean(raw.trim().replace(/^\**다음\s*:\**/, '')) : ''
25 let kind: WipKind
26 if (!next) kind = 'nonext'
27 else if (/^대기/.test(next) || /보류/.test(next.slice(0, 40))) kind = 'held'
28 else if (ageDays(mtimeMs, nowMs) >= STALE_DAYS) kind = 'stale'
29 else kind = 'active'
30 return { file, title, next, mtimeMs, kind }
31}
32
33export const ageDays = (mtimeMs: number, nowMs: number) => Math.max(0, Math.floor((nowMs - mtimeMs) / DAY_MS))
34
35// 오늘 · 어제 · 5일 전 · 3주 전 · 2달 전
36export function ageLabel(days: number): string {
37 if (days === 0) return '오늘'
38 if (days === 1) return '어제'
39 if (days < 14) return `${days}일 전`
40 if (days < 60) return `${Math.floor(days / 7)}주 전`
41 return `${Math.floor(days / 30)}달 전`
42}
43
44// What each kind means, in the order the pane and the band show them (needs attention first)
45export const MARK: Record<WipKind, string> = { stale: '⚠', nonext: '?', active: '●', held: '◌' }
46export const COLOR: Record<WipKind, 'yellow' | 'red' | 'green' | 'gray'> = { stale: 'yellow', nonext: 'red', active: 'green', held: 'gray' }
47export const LABEL: Record<WipKind, string> = { stale: '오래됨', nonext: '다음 행동 없음', active: '진행 중', held: '대기' }
48export const HINT: Record<WipKind, string> = {
49 stale: `${STALE_DAYS}일 넘게 손대지 않음 — 이어가거나 대기로 돌리거나 지울 것`,
50 nonext: '파일에 다음: 줄이 없음 — 다음 행동 한 줄을 적을 것',
51 active: '',
52 held: '깨우는 조건이 올 때까지 둠',
53}
54export const KINDS: WipKind[] = ['stale', 'nonext', 'active', 'held']
55
56// Needs-attention first: stale, then no next step, then active, held last; newest first within each
57const RANK: Record<WipKind, number> = { stale: 0, nonext: 1, active: 2, held: 3 }
58export const sortItems = (items: WipItem[]) =>
59 [...items].sort((a, b) => RANK[a.kind] - RANK[b.kind] || b.mtimeMs - a.mtimeMs)
60
61// `wip 9 · ⚠ 오래됨 2 · ? 다음 없음 3 · ◌ 대기 1` — active needs no call-out
62export function bandLine(items: WipItem[]): string {
63 const n = (k: WipKind) => items.filter(i => i.kind === k).length
64 const parts = [`wip ${items.length}`]
65 if (n('stale')) parts.push(`${MARK.stale} ${LABEL.stale} ${n('stale')}`)
66 if (n('nonext')) parts.push(`${MARK.nonext} 다음 없음 ${n('nonext')}`)
67 if (n('held')) parts.push(`${MARK.held} ${LABEL.held} ${n('held')}`)
68 return parts.join(' · ')
69}
70types/index.d.ts 21 lines1// active: has a next step; held: next step starts with 대기 or says 보류;
2// stale: active but untouched for STALE_DAYS; nonext: no 다음: line at all
3export type WipKind = 'active' | 'held' | 'stale' | 'nonext'
4
5export type WipItem = {
6 file: string
7 title: string
8 next: string
9 mtimeMs: number
10 kind: WipKind
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'wip-board': {
16 items: WipItem[]
17 isBandHidden: boolean
18 }
19 }
20}
21