SLOPSHOPPER

wip-board

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

panebandcommandtoasttimer
v0.3.1MITupdated 2026-10-19steven0224-glitch/chan-mods/wip-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wip-board
│ ┃ wip ✕ › fix the failing auth test and add an audit log call │ ┃ 열린 wip 없음 — 멀티세션 작업을 시작하면 │ ┃ ~/.claude/wip/<슬러그>.md 로 생김 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wip │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wip
열린 wip 없음 — 멀티세션 작업을 시작하면 ~/.claude/wip/<슬러그>.md 로 생김
README

wip-board

Install

/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.

  • Above the prompt: 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).

Install

/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.

  • Above the prompt: 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).
markkindrule
⚠stalehas a 다음: line but the file is 14+ days old
?nonextno 다음: line
●activehas a 다음: line, touched within 14 days
◌held다음: starts with 대기 or mentions 보류 in its first 40 chars

File format

# <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.

Config

optiondefaultmeaning
dir~/.claude/wipfolder of wip notes; ~ is the home directory
"pluginConfigs": { "wip-board@chan-mods": { "options": { "dir": "~/notes/wip" } } }

Behaviour notes

  • Disk reads run off a timer ($.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.
  • One unreadable file is skipped and logged; the rest of the board still draws.
  • Uses Text bold, not weight, so it also renders on engines before 2.1.299.
Source 3 files
hooks/register.tsx 180 lines
1import { 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/&lt;슬러그&gt;.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}
180
hooks/parse.ts 70 lines
1import 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}
70
types/index.d.ts 21 lines
1// 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