Show a live checklist page of what the current Claude Code session has done, is doing now, and has left.

<h1 align="center"><img src="docs/logo.png" alt="deadhd" width="420"></h1>
Claude Code 세션을 여러 개 띄워 놓고 일하다 보면, 다른 세션을 보고 돌아왔을 때 이 세션이 어디까지 진행했는지 놓치기 쉽습니다. deadhd 는 세션의 진행 상황을 체크리스트 페이지로 띄워 두고, 터미널 로그를 거슬러 올라가지 않아도 한눈에 확인할 수 있게 해 주는 스킬입니다. 1.8.0 부터는 훅이 세션 상태를, 1.9.0 부터는 허브가 여러 세션을 함께 보여 줍니다.

Orca 처럼 인앱 브라우저를 갖춘 터미널 앱에서 Claude Code 를 쓰면서, 진행 상황 페이지를 터미널 옆 패널에 띄워 두는 용도로 만들었습니다. 작업 중인 세션에서 /deadhd 를 입력하면 완료한 단계, 진행 중인 단계, 남은 단계를 흐름도와 카드로 정리한 페이지가 열리고, 작업이 진행되는 동안 페이지가 계속 갱신됩니다. 긴 작업을 맡겨 두고 자리를 비웠다가 돌아와도 터미널 로그를 거슬러 올라가지 않고 페이지 한 장으로 진행 상황을 확인할 수 있습니다.
Orca 안에서 실행하면 기본 설정(auto)으로 터미널 옆 탭에 바로 열립니다. Orca 를 쓰지 않는 환경에서는 시스템 브라우저나 Claude 데스크톱 앱의 인앱 브라우저로 열 수 있습니다. 열기 위치는 설정에서 고릅니다.
작업이 길어지면 페이지 위쪽에 이 세션이 지금 무엇을 기다리는지 알려 주는 상태 띠가 붙고, 이 띠는 플러그인 훅이 유지합니다. 세션을 여러 개 띄워 두었다면 허브 페이지에서 이 컴퓨터의 세션을 한 장에 모아 봅니다.


테마 9종을 3×3 갤러리로 묶은 화면입니다. 레인이 여러 개인 흐름, 막힌 작업, 자정을 넘기는 완료 예상, 모든 단계를 마친 상태를 함께 볼 수 있습니다. 순흑 위 순백의 흑백 테마 ink 가 더해졌고, system 은 운영체제 설정을 따르므로 갤러리에서 빠집니다. /deadhd setup 에서 기본 테마를 고르거나, /deadhd --theme <테마> 로 이번 실행만 바꿀 수 있습니다.

페이지 왼쪽 위 버튼으로 같은 진행 상황을 네 가지 보기로 바꿔 볼 수 있습니다. Claude 아티팩트 패널이나 좁게 나눈 Orca 탭처럼 폭이 좁은 곳에서는 컴팩트 보기가 한눈에 읽기 쉽습니다.
| 보기 | 구성 |
|---|---|
| 원본 | 흐름도와 단계별 카드를 모두 보여 주는 기본 페이지입니다 |
| 스트립 | 단계 흐름을 한 줄의 점으로 줄이고, 진행 중인 단계 카드만 펼칩니다. 나머지 단계는 완료·병행·대기로 접어 둡니다 |
| 타임라인 | 단계를 한 줄씩 세로로 쌓고, 단계마다 완료 시각이나 예상 시간을 붙입니다. 진행 중인 단계는 카드로 보여 줍니다 |
| 타일 | 완료 수, 현재 단계의 남은 시간, 완료 예상 시각을 숫자로 먼저 보여 주고, 단계별 상태를 구간 막대로 보여 줍니다 |
컴팩트 보기를 고르면 자동·가로·세로 버튼이 나타납니다. 자동은 탭이 5:4보다 넓고 폭이 760px 이상이면 가로 배치로, 그렇지 않으면 세로 배치로 그리며, 탭 크기를 바꾸면 바로 다시 배치합니다. 고른 보기와 배치는 15초 자동 새로 고침 뒤에도 유지되고, 모든 테마와 영어 화면에서 동작합니다.
보기는 버튼을 누를 때 페이지에 이미 들어 있는 데이터로 브라우저가 그립니다. 모델이 쓰는 데이터는 그대로이므로 보기를 추가해도 토큰 사용량은 늘지 않습니다.

같은 진행 상황을 세 가지 방식으로 볼 수 있습니다. 페이지의 보기 모드와는 다른 설정으로, Claude Code 에서는 페이지를 열지 않고 터미널 안에서 바로 봅니다.
| 표시 방식 | 어디에 | 구성 |
|---|---|---|
band | 프롬프트 위 한 줄 | 완료 수와 진행 막대, 지금 하는 단계와 경과 시간, 막힘·남음만 압축합니다. 막힌 단계가 있거나 차례를 기다리면 둘째 줄이 붙고, 브라우저 탭을 열지 않아 가장 가볍습니다 |
statusline | 프롬프트 아래 한 줄 | 밴드보다 항목이 많습니다. 티켓 키와 제목, 다음 단계, 완료 예상, 마지막 갱신, 백그라운드 작업·압축 횟수까지 한 줄에 담습니다 |
html | 브라우저 페이지 | 지금까지의 진행 페이지입니다 |
/deadhd setup 에서 고르거나 /deadhd --view <값> 으로 바꿉니다. 우선순위는 --view 값, 이 세션의 상태 파일에 기록된 값, 설정값, 기본값 html 순이라, --view band 로 한 번 실행하면 그 세션은 이후 렌더에서도 밴드로 그립니다.
밴드와 상태줄은 deadhd 플러그인의 mod(skills/deadhd/hooks/register.tsx)가 스킬이 쓴 같은 상태 파일을 읽어 그립니다. Claude Code 전용이고, Claude Code 가 아닌 환경(Codex $deadhd 등)에서는 묻지 않고 html 로 동작합니다. 밴드는 /deadhd-band, 상태줄은 /deadhd-statusline 으로 접고 펼치며, 현재 표시 방식과 맞지 않는 명령을 부르면 어떻게 바꾸는지 한 줄로 알려 줍니다. 페이지는 /deadhd-open 이나 밴드·상태줄의 열기 버튼으로 엽니다.
band·statusline 에서는 페이지 파일을 곧바로 쓰지 않습니다. 상태 요약과 허브는 매 갱신마다 다시 쓰고, 페이지는 처음 열 때(/deadhd-open, 열기 버튼) 만들고 그 뒤로는 계속 씁니다. 허브에서 아직 페이지가 없는 세션은 링크 대신 표시 방식 이름(밴드/상태줄)을 보여 줍니다.

밴드는 완료 수와 진행 막대, 지금 하는 단계와 경과 시간, 막힘·남음만 한 줄에 압축합니다. 막힌 단계가 있거나 차례를 기다리면 둘째 줄이 붙고, /deadhd-band 로 접으면 완료 수와 열기·펼치기 버튼만 남습니다.

상태줄은 같은 상태 파일을 더 많은 항목으로 담습니다. 티켓 키와 제목, 다음 단계, 마지막 갱신, 막힘, 남음, 대기, 백그라운드, 압축이 한 줄로 나오고, 터미널 폭을 넘으면 제목부터 잘려 … 이 붙습니다. 줄 끝에는 ↗ /deadhd-open 안내와 열기 버튼이 붙어, 밴드와 같은 방법으로 페이지를 열 수 있습니다.
에픽의 하위 작업처럼 작업 단위가 넷 이상 병렬로 돌면, 데이터의 board 에 레인을 채워 페이지 아래에 작업 × 단계 표를 붙입니다. 레인마다 진행 단계, 작업자, 마지막 신호, 남은 시간, 티켓 전이 기록이 한 줄로 나오고, 완료·진행·정체·막힘·선행 대기·남음이 몇 건인지 위에 요약합니다. 정체 판정은 마지막 신호 뒤 얼마나 조용했는지(stallAfter, 기본 15분)를 브라우저가 직접 재므로, 모델이 갱신을 멈춘 레인도 정체로 드러나 주의 띠에 올라옵니다. DeepSeek 통로·서브에이전트·리더 세션처럼 작업자를 나눠 돌리는 하네스 세션과, 기본 서브에이전트에 일을 넘기는 보통 세션이 같은 형식을 씁니다. board 가 없는 세션은 지금까지처럼 흐름도만 그립니다.

done 으로 분류하고, 시도했지만 검증하지 못한 단계는 now 또는 blocked 로 표시합니다.template.html 에 고정되어 있고, 모델은 JSON 데이터만 작성합니다. render.py 가 데이터를 검증한 뒤 HTML 을 생성합니다.themes.css 로 분리되어 있어 세션 페이지와 허브가 같은 파일을 씁니다.완료, 진행 중, 남은 작업 같은 라벨)도 영어로 표시됩니다. 데이터의 lang 필드로 정해지며, 없으면 한국어입니다. (지남) 이 붙습니다. 내일 이나 날짜로 붙는 표기는 페이지를 보고 있는 시점의 시각을 기준으로 정해지므로, 자정이 지나거나 창을 다시 열면 자동으로 바뀝니다.serve.py)로 열립니다. Orca 같은 인앱 브라우저가 페이지 안 file:// 이동을 막기 때문입니다. 처음 열 때 자동으로 뜨고, /tmp 의 deadhd-*.html 만 제공합니다.hooks/hooks.json 의 훅이 세션 이벤트(권한 요청, 답변 종료, 도구 실행, 압축, 세션 종료)를 받아 /tmp/deadhd-state/<세션 ID>.json 에 상태를 쓰고 페이지를 다시 렌더합니다. 페이지 상단에 권한 승인 대기 · 12분째, 입력 대기 · 23분째, 작업 중 · 마지막 도구 Bash 8초 전, 신호 없음, 세션 종료 띠가 붙습니다. 모델이 갱신을 잊어도 띠는 훅이 유지합니다.~/.claude/settings.json 에 같은 훅을 넣습니다. ${CLAUDE_PLUGIN_ROOT} 자리에 스킬 폴더의 절대 경로를 씁니다. 훅이 없으면 페이지에 상태 띠가 붙지 않고, 허브에서는 그 세션이 훅 없음 또는 완료 로 나옵니다.{
"hooks": {
"Notification": [{ "matcher": "permission_prompt|idle_prompt", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }],
"SessionStart": [{ "matcher": "compact", "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "python3 \"$HOME/.claude/skills/deadhd/state.py\"" }] }]
}
}
~/.config/deadhd/history.jsonl 에 쌓이고, 3건부터 보정 완료 예상이 원래 값 옆에 표시됩니다. 기록되는 것은 단계 id·예상·실제 분 수뿐입니다.한 컴퓨터에서 도는 세션들을 한 장에 모은 페이지입니다. /deadhd hub 로 열고, 세션 페이지 오른쪽 위의 허브 ↗ 버튼으로도 갑니다. 타일에는 상태 배지, 제목과 키, 단계별 미니 스트립, 6/12 완료, 지금 하는 일, 완료 예상이 함께 나옵니다.
허브 ↗ 버튼도 같은 탭에서 허브로 돌아옵니다.serve.py, 포트 47320, DEADHD_PORT 로 변경). Orca 내장 브라우저가 페이지 안 file:// 이동을 막기 때문입니다. 서버는 /tmp 의 deadhd-*.html 만 제공하고, 심볼릭 링크는 따르지 않으며, 외부 사이트가 읽지 못하도록 Host 헤더를 검사합니다. 처음 페이지를 열 때 자동으로 뜨고 세션이 끝나도 남습니다. 끄려면 python3 ~/.claude/skills/deadhd/serve.py --stop(플러그인으로 설치했다면 플러그인 캐시 안의 같은 경로)을 실행합니다.render.py 가 세션 페이지를 렌더할 때와 훅 이벤트마다 다시 씁니다(/tmp/deadhd-hub.html, DEADHD_HUB 로 바꿀 수 있습니다).python3 (표준 라이브러리만 사용)표시 방식 band·statusline 은 Claude Code 의 플러그인 mod 위에서만 그려집니다. 그 밖의 환경에서는 html 만 씁니다.
Claude Code 에서 다음 명령을 실행합니다.
/plugin marketplace add lcalmsky/deadhd
/plugin install deadhd@deadhd
플러그인으로 설치하면 호출 이름은 /deadhd:deadhd 이고, 훅은 이 방식으로 설치할 때만 따라옵니다.
<a id="orca-install"></a>
Orca 를 사용한다면 공유 링크 를 열고 Open in Orca 를 누릅니다. Orca 에서 포함된 파일을 확인한 뒤 설치 위치를 고를 수 있습니다. 설치하면 호출 이름은 /deadhd 입니다.
Orca 공유 링크는 게시할 때 올린 파일을 바꿀 수 없는 묶음으로 보관합니다. 지금 링크에는 v1.11.0 이 들어 있고, 그 뒤의 릴리즈는 반영되지 않습니다. 최신 버전은 플러그인이나 스킬 폴더 방식으로 설치합니다.
git clone https://github.com/lcalmsky/deadhd.git
cp -r deadhd/skills/deadhd ~/.claude/skills/
이 방식으로 설치하면 호출 이름은 /deadhd 입니다. skills/deadhd/.claude-plugin/ 이 함께 복사되므로, 이 폴더는 다음 세션부터 deadhd@skills-dir 플러그인으로 자동으로 읽혀 밴드·상태줄이 붙습니다. 플러그인 설치 없이도 mod 가 따라오고, 상태 띠를 만드는 명령 훅은 따라오지 않습니다. 플러그인 설치(위 절)에서는 같은 mod 를 마켓플레이스 진입점으로 씁니다.
두 설치를 함께 쓰지 마세요. 함께 두면 deadhd 플러그인이 두 번 로드되어 같은 명령과 같은 상태 키, 같은 상태 파일을 보는 타이머가 둘이 됩니다.
| 설치 방식 | 업데이트 방법 |
|---|---|
| 플러그인 | 서드파티 마켓플레이스는 자동 업데이트가 기본으로 꺼져 있습니다. /plugin 에서 deadhd 를 골라 Update now 를 누르거나 claude plugin update deadhd@deadhd 를 실행합니다. /plugin 의 Marketplaces 탭에서 deadhd 마켓플레이스의 Enable auto-update 를 켜 두면 새 버전이 자동으로 설치됩니다. 업데이트 뒤에는 /reload-plugins 를 실행하거나 새 세션을 시작합니다 |
| Orca | 공유 링크는 게시 시점의 고정 사본이라 다시 열어도 새 버전이 설치되지 않습니다. 최신 버전은 플러그인이나 스킬 폴더 방식으로 설치합니다 |
| 스킬 폴더 | 클론한 저장소에서 git pull 한 뒤 skills/deadhd 를 다시 복사합니다 |
| 입력 | 동작 |
|---|---|
/deadhd | 진행 상황 페이지를 브라우저 탭으로 엽니다 (-h 와 같음) |
/deadhd -o | Orca 아티팩트(공유 링크가 있는 웹 페이지)로 게시합니다. orca CLI 로그인이 필요하고, 실패하면 브라우저 탭으로 엽니다 |
/deadhd -c | Claude 아티팩트로 게시합니다 |
/deadhd setup | 기본 열기 위치·표시 방식·테마·글꼴을 다시 고릅니다 |
/deadhd hub | 이 컴퓨터의 세션을 모은 허브 페이지를 엽니다 |
/deadhd --open <모드> | 이번 실행만 다른 위치로 엽니다 |
/deadhd --view <표시 방식> | 이번 실행만 다른 표시 방식으로 그립니다 |
/deadhd --theme <테마> | 이번 실행만 다른 테마로 렌더합니다 |
/deadhd --font <프리셋> | 이번 실행만 다른 제목 글꼴로 렌더합니다 |
/deadhd off | 페이지 갱신을 중지합니다 |
Claude Code 에서 밴드나 상태줄로 보고 있으면 다음 명령도 씁니다.
| 입력 | 동작 |
|---|---|
/deadhd-band | 프롬프트 위 밴드를 접거나 펼칩니다 |
/deadhd-statusline | 프롬프트 아래 상태줄을 접거나 펼칩니다 |
/deadhd-open | 진행 페이지를 만들어 브라우저로 엽니다 |
명령 대신 "진행 상황 띄워줘", "체크리스트로 보여줘" 처럼 요청해도 됩니다.
처음 실행할 때 열기 위치, 표시 방식, 테마, 글꼴을 한 번 묻습니다. 고른 값은 다음 실행부터 그대로 쓰입니다.
| 값 | 동작 |
|---|---|
auto | Orca 안에서 실행 중이면 Orca 탭으로, 아니면 시스템 브라우저로 엽니다 |
orca | orca 명령이 있으면 Orca 탭으로 엽니다 |
browser | Orca 를 건너뛰고 시스템 브라우저로 엽니다 |
desktop | 페이지를 열지 않고 경로만 출력합니다. Claude 데스크톱 앱에서 그 경로를 누르면 인앱 브라우저 패널로 열립니다. 출력된 http 주소를 눌러도 됩니다 |
표시 방식은 다음 값을 씁니다. Claude Code 가 아닌 환경에서는 이 질문을 하지 않고 html 로 동작합니다.
| 값 | 동작 |
|---|---|
band | 프롬프트 위 한 줄 (권장) |
statusline | 프롬프트 아래 한 줄 |
html | 브라우저 페이지 |
테마는 다음 값을 씁니다.
| 값 | 동작 |
|---|---|
system | 운영체제의 라이트/다크 설정을 따릅니다 (권장) |
light | 항상 라이트 테마로 표시합니다 |
dark | 항상 다크 테마(오로라)로 표시합니다 |
neon | 사이버펑크 네온: 검보라 배경에 시안·마젠타·형광 노랑 |
synthwave | 신스웨이브 선셋: 진보라 위 핑크·오렌지 레트로 노을 |
matrix | 매트릭스 터미널: 검정 위 초록 단색, 모든 글자 고정폭 |
nord | 노르드 아크틱: 오래 띄워 둬도 눈이 덜 피곤한 차분한 톤 |
paper | 페이퍼 노트북: 따뜻한 종이 질감의 라이트 테마, 명조 제목 |
sakura | 사쿠라: 벚꽃 분홍 라이트 테마, 손글씨 제목 |
ink | 잉크: 순흑 위 순백의 흑백 테마. 상태를 색 대신 채움·테두리·빗금으로 구분합니다 |
제목 글꼴은 프리셋 이름 하나로 고릅니다. 프리셋에는 글꼴·굵기·자간이 묶여 있고, 기본값 default 는 테마가 정한 제목 글꼴을 그대로 씁니다. /deadhd setup 에서 고르거나 /deadhd --font <프리셋> 으로 이번 실행만 바꿀 수 있습니다. 본문 글꼴은 바뀌지 않습니다.
| 값 | 글꼴 | 굵기 · 자간 |
|---|---|---|
default | 테마가 정한 글꼴 | 테마에 따름 |
pretendard | Pretendard Variable | 800 · -0.03em |
noto-sans | Noto Sans KR | 900 · -0.03em |
plex-sans | IBM Plex Sans KR | 700 · -0.02em |
gothic-a1 | Gothic A1 | 900 · -0.03em |
nanum-gothic | Nanum Gothic | 800 · -0.02em |
noto-serif | Noto Serif KR | 900 · -0.02em |
nanum-myeongjo | Nanum Myeongjo | 800 · -0.02em |
hahmlet | Hahmlet | 900 · -0.02em |
gowun-batang | Gowun Batang | 700 · -0.01em |
do-hyeon | Do Hyeon | 400 · -0.04em |
black-han-sans | Black Han Sans | 400 · 0 |
설정 파일은 ~/.config/deadhd/config.json 이고 XDG_CONFIG_HOME 을 지정하면 그 아래에 만들어집니다. /deadhd setup 으로 언제든 열기 위치, 테마, 글꼴을 다시 고를 수 있습니다.
~/.config/deadhd/writing-rules.md 를 두면 페이지 문장을 쓸 때 그 파일의 작성 규칙을 따릅니다. 없으면 기본 규칙(제목은 명사구, 분야 용어 사용, 의인화 금지)을 씁니다. 자기 작성 가이드 파일에 심볼릭 링크로 연결해도 됩니다.
긴 작업을 시작한 뒤 한 번 호출하면, 이후 단계의 상태가 바뀔 때마다 Claude 가 데이터를 갱신합니다. 데이터 형식의 전체 예시는 skills/deadhd/example.json 에 있습니다.
deadhd 가 만들고 읽는 파일입니다.
| 경로 | 내용 |
|---|---|
/tmp/deadhd-<slug>.json | 진행 데이터. 모델이 작성합니다 |
/tmp/deadhd-<slug>.html | 진행 페이지 |
/tmp/deadhd-hub.html | 허브 페이지 |
/tmp/deadhd-state/<세션 ID>.json | 세션 상태. 훅과 render.py 가 씁니다. 7일이 지난 파일은 정리합니다 |
~/.config/deadhd/config.json | 설정(열기 위치·테마·글꼴) |
~/.config/deadhd/history.jsonl | 예상·실제 이력 |
~/.config/deadhd/writing-rules.md | 작성 규칙(선택) |
서버는 127.0.0.1 의 47320 포트를 씁니다. 다음 환경변수로 바꿀 수 있습니다.
| 변수 | 기본값 |
|---|---|
DEADHD_PORT | 47320 |
DEADHD_HUB | /tmp/deadhd-hub.html |
DEADHD_STATE_DIR | /tmp/deadhd-state |
DEADHD_HISTORY | ~/.config/deadhd/history.jsonl |
DEADHD_CONFIG | ~/.config/deadhd/config.json |
XDG_CONFIG_HOME | 지정하면 설정과 이력이 $XDG_CONFIG_HOME/deadhd/ 아래에 만들어집니다 |
DEADHD_NOW | 페이지가 쓰는 시각을 고정합니다. 테스트와 예시 그림 재생성에 씁니다 |
python3 -m unittest skills/deadhd/test_render.py
예시 그림 열네 장은 Chrome 을 설치한 환경에서 다음 명령으로 다시 만듭니다.
python3 docs/capture_themes.py && python3 docs/capture_themes.py --lang en # themes.png, themes.en.png
python3 docs/capture_views.py && python3 docs/capture_views.py --lang en # views.png, views.en.png
python3 docs/capture_hub.py && python3 docs/capture_hub.py --lang en # hub.png, hub.en.png, live.png, live.en.png
python3 docs/capture_board.py && python3 docs/capture_board.py --lang en # board.png, board.en.png
python3 docs/capture_mods.py && python3 docs/capture_mods.py --lang en # band.png, band.en.png, statusline.png, statusline.en.png
skills/deadhd/hooks/register.tsx 1324 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey } from 'claude-code'
3
4import type { DeadhdLang, DeadhdState, DeadhdStep, DeadhdView } from '../types'
5
6const DEFAULT_STATE_DIR = '/tmp/deadhd-state'
7const REFRESH_MS = 5000
8
9/** PromptHint hands the tree no width, so this is the budget its pieces are cut to. */
10const STATUS_COLS = 240
11/** Cells the progress bar draws; `done/total` is rounded onto them. */
12const BAR_CELLS = 6
13const SEP = ' · '
14const OPEN_TIMEOUT_MS = 15000
15
16/** The slash command each line leads with, the one that folds it. */
17const BAND_CMD = '/deadhd-band'
18const STATUS_CMD = '/deadhd-statusline'
19
20/** The command a status line names at its end, so the page has a keyboard way open too. */
21const OPEN_CMD = '/deadhd-open'
22
23/** With no state file and no config the session draws in this view, as render.py decides too. */
24export const DEFAULT_VIEW: DeadhdView = 'html'
25
26const VIEWS: readonly DeadhdView[] = ['html', 'band', 'statusline']
27
28/** The theme keys a piece may name; `plain` leaves the text the person's own color. */
29export type Tone =
30 | 'plain'
31 | Extract<
32 ThemeKey,
33 | 'claude'
34 | 'permission'
35 | 'text'
36 | 'success'
37 | 'subtle'
38 | 'planMode'
39 | 'warning'
40 | 'error'
41 | 'suggestion'
42 | 'inactive'
43 | 'autoAccept'
44 | 'merged'
45 | 'ide'
46 | 'remember'
47 >
48
49/** One drawn piece of a line: `text` is what shows, `tone` what colors it. */
50export type Segment = { text: string; tone: Tone; bold?: boolean }
51
52/** One field of a line: its pieces drawn side by side, a separator before the next field. */
53type Field = { tag: string; parts: Segment[] }
54
55const WORDS: Record<
56 DeadhdLang,
57 {
58 blocked: string
59 left: string
60 allDone: string
61 next: string
62 eta: string
63 background: string
64 compacted: string
65 collapse: string
66 expand: string
67 open: string
68 folded: string
69 unfolded: string
70 statusFolded: string
71 statusUnfolded: string
72 bandOnly: string
73 statusOnly: string
74 permission: string
75 input: string
76 opened: string
77 noPage: string
78 openFailed: string
79 desktopPath: string
80 noBrowser: string
81 }
82> = {
83 ko: {
84 blocked: '막힘',
85 left: '남음',
86 allDone: '완료',
87 next: '다음',
88 eta: '예상',
89 background: '백그라운드',
90 compacted: '압축',
91 collapse: '접기',
92 expand: '펼치기',
93 open: '열기',
94 folded: '밴드를 접었어요',
95 unfolded: '밴드를 펼쳤어요',
96 statusFolded: '상태줄을 접었어요',
97 statusUnfolded: '상태줄을 펼쳤어요',
98 bandOnly: '지금은 밴드 모드가 아니에요. /deadhd setup 에서 밴드를 고르면 보여요.',
99 statusOnly: '지금은 상태줄 모드가 아니에요. /deadhd setup 에서 상태줄을 고르면 보여요.',
100 permission: '권한 승인 대기',
101 input: '입력 대기',
102 opened: 'HTML 페이지를 열었어요',
103 noPage: '아직 HTML 페이지가 없어요',
104 openFailed: 'HTML 페이지를 열지 못했어요',
105 desktopPath: 'Claude 데스크톱 앱에서 이 경로를 눌러 주세요:',
106 noBrowser: '열 수 있는 브라우저를 찾지 못했어요:',
107 },
108 en: {
109 blocked: 'blocked',
110 left: 'left',
111 allDone: 'all done',
112 next: 'next',
113 eta: 'ETA',
114 background: 'background',
115 compacted: 'compacted',
116 collapse: 'Collapse',
117 expand: 'Expand',
118 open: 'Open',
119 folded: 'Band collapsed.',
120 unfolded: 'Band expanded.',
121 statusFolded: 'Collapsed the status line',
122 statusUnfolded: 'Expanded the status line',
123 bandOnly: 'The band is not drawn in this view. Choose band with /deadhd setup.',
124 statusOnly: 'The status line is not drawn in this view. Choose statusline with /deadhd setup.',
125 permission: 'waiting for permission',
126 input: 'waiting for input',
127 opened: 'Opened the HTML.',
128 noPage: 'No HTML page yet.',
129 openFailed: 'Could not open the HTML',
130 desktopPath: 'Press this path in the Claude desktop app to open the page:',
131 noBrowser: 'Could not find a browser to open the page:',
132 },
133}
134
135const live = atom({ plugin: 'deadhd', key: 'state' } as const, null)
136const collapsed = atom({ plugin: 'deadhd', key: 'collapsed' } as const, false)
137const shown = atom({ plugin: 'deadhd', key: 'view' } as const, DEFAULT_VIEW)
138
139/**
140 * The text each mode last asked a repaint for, so a refresh whose line did not
141 * move stays quiet. A hot reload losing it costs one extra repaint.
142 */
143const painted: Partial<Record<'band' | 'status', string>> = {}
144
145/**
146 * The cells each mode's line may take, as the surface last measured them: the
147 * band's region without its buttons, the status line's viewport without the
148 * button beside it. A refresh cuts the line it compares to the same width; the
149 * fixed budget stands in until a drawing has measured one.
150 */
151const widths: Record<'band' | 'status', number> = { band: STATUS_COLS, status: STATUS_COLS }
152
153/**
154 * Code point ranges that take two cells in a terminal's monospace metric:
155 * East Asian Wide, plus the emoji blocks a terminal draws with emoji
156 * presentation (a symbol below U+1F300 like `✅` or `⏳` is one of these).
157 */
158const WIDE: readonly (readonly [number, number])[] = [
159 [0x1100, 0x115f],
160 [0x231a, 0x231b],
161 [0x23e9, 0x23f3],
162 [0x25fd, 0x25fe],
163 [0x2614, 0x2615],
164 [0x2648, 0x2653],
165 [0x267f, 0x267f],
166 [0x2693, 0x2693],
167 [0x26a1, 0x26a1],
168 [0x26aa, 0x26ab],
169 [0x26bd, 0x26be],
170 [0x26c4, 0x26c5],
171 [0x26ce, 0x26ce],
172 [0x26d4, 0x26d4],
173 [0x26ea, 0x26ea],
174 [0x26f2, 0x26f3],
175 [0x26f5, 0x26f5],
176 [0x26fa, 0x26fa],
177 [0x26fd, 0x26fd],
178 [0x2705, 0x2705],
179 [0x270a, 0x270b],
180 [0x2728, 0x2728],
181 [0x274c, 0x274c],
182 [0x274e, 0x274e],
183 [0x2753, 0x2755],
184 [0x2757, 0x2757],
185 [0x2795, 0x2797],
186 [0x27b0, 0x27b0],
187 [0x27bf, 0x27bf],
188 [0x2b1b, 0x2b1c],
189 [0x2b50, 0x2b50],
190 [0x2b55, 0x2b55],
191 [0x2e80, 0xa4cf],
192 [0xac00, 0xd7a3],
193 [0xf900, 0xfaff],
194 [0xfe30, 0xfe4f],
195 [0xff00, 0xff60],
196 [0xffe0, 0xffe6],
197 [0x1f300, 0x1faff],
198 [0x20000, 0x3fffd],
199]
200
201/** Code point ranges that take no cell of their own (combining and zero width). */
202const ZERO: readonly (readonly [number, number])[] = [
203 [0x0300, 0x036f],
204 [0x200b, 0x200f],
205 [0xfe00, 0xfe0f],
206]
207
208/** The variation selector that asks for a glyph's emoji drawing. */
209const VS16 = 0xfe0f
210
211const within = (code: number, ranges: readonly (readonly [number, number])[]): boolean =>
212 ranges.some(([low, high]) => code >= low && code <= high)
213
214/**
215 * How many terminal cells `text` takes, one code point at a time. A symbol
216 * drawn as text (`✓`, `▶`, `↗`, `▓`) is one cell, an emoji-presentation one is
217 * two, and a base character followed by U+FE0F is drawn as the emoji it selects.
218 */
219export function cellWidth(text: string): number {
220 const points = Array.from(text, code => code.codePointAt(0) ?? 0)
221 let cells = 0
222
223 for (let at = 0; at < points.length; at += 1) {
224 const point = points[at] ?? 0
225
226 if (within(point, ZERO)) {
227 // The selector itself is no cell; the character it follows gains the one
228 // that makes it an emoji.
229 const before = points[at - 1]
230
231 if (point === VS16 && before !== undefined && !within(before, WIDE) && !within(before, ZERO)) {
232 cells += 1
233 }
234
235 continue
236 }
237
238 cells += within(point, WIDE) ? 2 : 1
239 }
240
241 return cells
242}
243
244/** Cells a band control takes when drawn: `[ label ]`, brackets and padding included. */
245const buttonCols = (label: string): number => cellWidth(label) + 4
246
247/** The Text props a tone names: the theme key itself, or the text's own color. */
248const paint = (tone: Tone): { color?: ThemeKey } => (tone === 'plain' ? {} : { color: tone })
249
250const record = (value: unknown): Record<string, unknown> =>
251 typeof value === 'object' && value !== null && !Array.isArray(value)
252 ? (value as Record<string, unknown>)
253 : {}
254
255const count = (value: unknown): number =>
256 typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
257
258const text = (value: unknown, fallback: string): string =>
259 typeof value === 'string' ? value : fallback
260
261/** A value the config or a state file may spell as a view; anything else has no reading. */
262const asView = (value: unknown): DeadhdView | null =>
263 typeof value === 'string' && (VIEWS as readonly string[]).includes(value)
264 ? (value as DeadhdView)
265 : null
266
267/** The view in force: the state file's own, else the config file's, else the default. */
268export function resolveView(stateView: unknown, configView: unknown): DeadhdView {
269 return asView(stateView) ?? asView(configView) ?? DEFAULT_VIEW
270}
271
272/** A lane or column as the data file may spell it; a non-integer has no reading. */
273const asIndex = (value: unknown): number | null => {
274 if (typeof value === 'number' && Number.isFinite(value)) {
275 return value >= 0 ? Math.floor(value) : null
276 }
277
278 if (typeof value === 'string' && /^\d+$/.test(value)) {
279 return Number(value)
280 }
281
282 return null
283}
284
285/** The words the lines add for a status the session waits in; null while it works or has ended. */
286export function statusLabel(status: string, lang: DeadhdLang): string | null {
287 if (status === '' || status === 'working' || status === 'ended') {
288 return null
289 }
290
291 const words = WORDS[lang]
292
293 if (status === 'waiting_permission') {
294 return words.permission
295 }
296
297 if (status === 'idle') {
298 return words.input
299 }
300
301 return status
302}
303
304/** Whole minutes between an ISO instant and `now`, or null when either does not read as one. */
305const minutesBetween = (from: string | null, now: number): number | null => {
306 if (from === null) {
307 return null
308 }
309
310 const at = Date.parse(from)
311
312 if (!Number.isFinite(at) || now < at) {
313 return null
314 }
315
316 return Math.floor((now - at) / 60000)
317}
318
319/** `8분째` / `8m in`: how long a step has been running. */
320const since = (minutes: number, lang: DeadhdLang): string =>
321 lang === 'en' ? `${minutes}m in` : `${minutes}분째`
322
323/** `8분 전` / `8m ago`: how long since the state file was written. */
324const ago = (minutes: number, lang: DeadhdLang): string =>
325 lang === 'en' ? `${minutes}m ago` : `${minutes}분 전`
326
327/** The `HH:MM` an eta carries, in the offset its writer used; null for anything else. */
328const clockOf = (eta: string | null): string | null => {
329 if (eta === null) {
330 return null
331 }
332
333 const match = /T(\d{2}:\d{2})/.exec(eta)
334
335 return match === null ? null : (match[1] ?? null)
336}
337
338/** The first line of a command's stderr, trimmed; `''` when it wrote none. */
339const firstLine = (output: string): string => (output.split('\n')[0] ?? '').trim()
340
341/** `label` cut to `room` cells, its ellipsis counted, or `…` when not even one cell is left. */
342const cut = (label: string, room: number): string => {
343 if (cellWidth(label) <= room) {
344 return label
345 }
346
347 if (room <= 1) {
348 return '…'
349 }
350
351 let kept = ''
352 let used = 0
353
354 for (const code of label) {
355 const wide = cellWidth(code)
356
357 if (used + wide > room - 1) {
358 break
359 }
360
361 kept += code
362 used += wide
363 }
364
365 return `${kept.trimEnd()}…`
366}
367
368const barParts = (done: number, total: number): Segment[] => {
369 const cells = Math.max(0, Math.min(BAR_CELLS, Math.round((done / total) * BAR_CELLS)))
370 const parts: Segment[] = []
371
372 if (cells > 0) {
373 parts.push({ text: '▓'.repeat(cells), tone: 'success' })
374 }
375
376 if (cells < BAR_CELLS) {
377 parts.push({ text: '░'.repeat(BAR_CELLS - cells), tone: 'subtle' })
378 }
379
380 return parts
381}
382
383/** The glyph and color a marked step carries: work running in parallel draws its own. */
384const stepMark = (state: string, running: string): Segment =>
385 state === 'side'
386 ? { text: '◇', tone: 'autoAccept' }
387 : { text: running, tone: 'suggestion', bold: true }
388
389/** The piece for the step a line marks, colored and marked by that step's own state. */
390const stepField = (step: DeadhdStep, lang: DeadhdLang, now: number, running: string): Field => {
391 const mark = stepMark(step.state, running)
392 const minutes = minutesBetween(step.startedAt, now)
393 const tail = minutes === null ? '' : ` ${since(minutes, lang)}`
394
395 return {
396 tag: 'current',
397 parts: [
398 { text: `${mark.text} ${step.label}${tail}`, tone: mark.tone, bold: mark.bold === true },
399 ],
400 }
401}
402
403/** The piece for a session waiting on the person, or null while this session works. */
404const waitField = (state: DeadhdState, now: number): Field | null => {
405 if (state.status !== 'waiting_permission' && state.status !== 'idle') {
406 return null
407 }
408
409 const word = statusLabel(state.status, state.lang) ?? state.status
410 const minutes = minutesBetween(state.since, now)
411 const tail = minutes === null ? '' : ` ${since(minutes, state.lang)}`
412 const asking = state.status === 'waiting_permission'
413
414 return {
415 tag: 'wait',
416 parts: [
417 { text: `${asking ? '🔐' : '⌨️'} ${word}${tail}`, tone: asking ? 'warning' : 'merged', bold: true },
418 ],
419 }
420}
421
422/** The band's first row: how far the run has come, and what is on it. */
423const bandFields = (state: DeadhdState, now: number): Field[] => {
424 const words = WORDS[state.lang]
425 const fields: Field[] = [{ tag: 'name', parts: [{ text: BAND_CMD, tone: 'claude', bold: true }] }]
426
427 if (state.total > 0) {
428 fields.push({
429 tag: 'bar',
430 parts: [
431 ...barParts(state.done, state.total),
432 { text: ' ', tone: 'plain' },
433 { text: `${state.done}/${state.total}`, tone: 'success', bold: true },
434 ],
435 })
436 }
437
438 if (state.current !== null) {
439 fields.push(stepField(state.current, state.lang, now, '▶'))
440 }
441
442 if (state.blocked > 0) {
443 fields.push({
444 tag: 'stuck',
445 parts: [{ text: `${words.blocked} ${state.blocked}`, tone: 'error', bold: true }],
446 })
447 }
448
449 if (state.left > 0) {
450 fields.push({ tag: 'leftCount', parts: [{ text: `${words.left} ${state.left}`, tone: 'text' }] })
451 }
452
453 if (state.allDone) {
454 fields.push({ tag: 'allDone', parts: [{ text: words.allDone, tone: 'success', bold: true }] })
455 }
456
457 return fields
458}
459
460/** The band's second row: the step that got stuck and the wait, when either is on. */
461const bandAlertFields = (state: DeadhdState, now: number): Field[] => {
462 const words = WORDS[state.lang]
463 const fields: Field[] = []
464
465 if (state.stuck !== null) {
466 const note = state.stuck.sub === '' ? state.stuck.label : `${state.stuck.label} (${state.stuck.sub})`
467
468 fields.push({
469 tag: 'blocked',
470 parts: [
471 { text: `⛔ ${words.blocked}: `, tone: 'error', bold: true },
472 { text: note, tone: 'error', bold: true },
473 ],
474 })
475 }
476
477 const wait = waitField(state, now)
478
479 if (wait !== null) {
480 fields.push(wait)
481 }
482
483 return fields
484}
485
486/** The status line's pieces, in the order the long row draws them. */
487const statusFields = (state: DeadhdState, now: number): Field[] => {
488 const words = WORDS[state.lang]
489 const fields: Field[] = [{ tag: 'name', parts: [{ text: STATUS_CMD, tone: 'claude', bold: true }] }]
490
491 if (state.total > 0) {
492 fields.push({
493 tag: 'count',
494 parts: [
495 { text: `✅ ${state.done}/${state.total}`, tone: 'success', bold: true },
496 { text: ' ', tone: 'plain' },
497 ...barParts(state.done, state.total),
498 ],
499 })
500 }
501
502 const heading: Segment[] = []
503
504 if (state.key !== null) {
505 heading.push({
506 text: state.title === '' ? state.key : `${state.key} `,
507 tone: 'permission',
508 bold: true,
509 })
510 }
511
512 if (state.title !== '') {
513 heading.push({ text: state.title, tone: 'text' })
514 }
515
516 if (heading.length > 0) {
517 fields.push({ tag: 'title', parts: heading })
518 }
519
520 if (state.current !== null) {
521 fields.push(stepField(state.current, state.lang, now, '▶️'))
522 }
523
524 if (state.next !== null) {
525 fields.push({
526 tag: 'next',
527 parts: [{ text: `⏭ ${words.next} ${state.next.label}`, tone: 'inactive' }],
528 })
529 }
530
531 const eta = clockOf(state.eta)
532
533 if (eta !== null) {
534 fields.push({ tag: 'eta', parts: [{ text: `⏱ ${words.eta} ${eta}`, tone: 'planMode' }] })
535 }
536
537 const stale = minutesBetween(state.updatedAt, now)
538
539 if (stale !== null) {
540 fields.push({ tag: 'ago', parts: [updatePiece(stale, state.lang)] })
541 }
542
543 if (state.blocked > 0) {
544 const parts: Segment[] = [
545 { text: `⛔ ${words.blocked} ${state.blocked}`, tone: 'error', bold: true },
546 ]
547
548 if (state.stuck !== null) {
549 parts.push({ text: `: ${state.stuck.label}`, tone: 'error', bold: true })
550 }
551
552 fields.push({ tag: 'blocked', parts })
553 }
554
555 if (state.left > 0) {
556 fields.push({ tag: 'left', parts: [{ text: `⏳ ${words.left} ${state.left}`, tone: 'text' }] })
557 }
558
559 const wait = waitField(state, now)
560
561 if (wait !== null) {
562 fields.push(wait)
563 }
564
565 if (state.background > 0) {
566 fields.push({
567 tag: 'background',
568 parts: [{ text: `🧵 ${words.background} ${state.background}`, tone: 'ide' }],
569 })
570 }
571
572 if (state.compactions > 0) {
573 fields.push({
574 tag: 'compacted',
575 parts: [{ text: `🗜 ${words.compacted} ${state.compactions}`, tone: 'remember' }],
576 })
577 }
578
579 fields.push({ tag: 'openCmd', parts: [{ text: `↗ ${OPEN_CMD}`, tone: 'suggestion' }] })
580
581 return fields
582}
583
584/** The last-update piece: quiet while the skill keeps up, louder as it falls behind. */
585const updatePiece = (minutes: number, lang: DeadhdLang): Segment => {
586 const body = ago(minutes, lang)
587
588 if (minutes > 30) {
589 return { text: `🛑 ${body}`, tone: 'error' }
590 }
591
592 if (minutes > 10) {
593 return { text: `⚠️ ${body}`, tone: 'warning' }
594 }
595
596 return { text: `🔄 ${body}`, tone: 'subtle' }
597}
598
599const fieldWidth = (field: Field): number =>
600 field.parts.reduce((cells, part) => cells + cellWidth(part.text), 0)
601
602/** Cells the whole line takes, its separators counted. */
603const lineWidth = (fields: readonly Field[]): number =>
604 fields.length === 0
605 ? 0
606 : fields.reduce((cells, field) => cells + fieldWidth(field), 0) +
607 (fields.length - 1) * cellWidth(SEP)
608
609/** Cuts a field's last piece to the cells left for it, or drops that piece when none are. */
610const cutLast = (field: Field, room: number): void => {
611 const last = field.parts[field.parts.length - 1]
612
613 if (last === undefined) {
614 return
615 }
616
617 const fixed = field.parts
618 .slice(0, -1)
619 .reduce((cells, part) => cells + cellWidth(part.text), 0)
620 const left = room - fixed
621
622 if (left <= 1) {
623 field.parts.pop()
624
625 return
626 }
627
628 field.parts[field.parts.length - 1] = { ...last, text: cut(last.text, left) }
629}
630
631/**
632 * Brings a line inside `maxCols` cells, giving way in the order the fields can
633 * spare it: the title first, then the next step, then the blocked step's label,
634 * the step a line marks, and the command a status line ends with last of all.
635 */
636const fit = (fields: Field[], maxCols: number): Field[] => {
637 const shrink = (tag: string): void => {
638 const field = fields.find(one => one.tag === tag)
639
640 if (field === undefined) {
641 return
642 }
643
644 cutLast(field, maxCols - (lineWidth(fields) - fieldWidth(field)))
645 }
646
647 const drop = (tag: string): void => {
648 const at = fields.findIndex(one => one.tag === tag)
649
650 if (at >= 0) {
651 fields.splice(at, 1)
652 }
653 }
654
655 if (lineWidth(fields) > maxCols) shrink('title')
656 if (lineWidth(fields) > maxCols) drop('next')
657 if (lineWidth(fields) > maxCols) shrink('blocked')
658 if (lineWidth(fields) > maxCols) drop('blocked')
659 if (lineWidth(fields) > maxCols) shrink('current')
660 if (lineWidth(fields) > maxCols) drop('current')
661 if (lineWidth(fields) > maxCols) drop('openCmd')
662
663 return fields.filter(field => field.parts.length > 0)
664}
665
666/** One line's fields as the pieces a drawing lays out, a separator between the fields. */
667const flatten = (fields: readonly Field[]): Segment[] => {
668 const parts: Segment[] = []
669
670 fields.forEach((field, index) => {
671 if (index > 0) {
672 parts.push({ text: SEP, tone: 'subtle' })
673 }
674
675 for (const part of field.parts) {
676 parts.push(part)
677 }
678 })
679
680 return parts
681}
682
683/** The band's first row, cut to `maxCols` cells. */
684export function bandLine(state: DeadhdState | null, now: number, maxCols: number): Segment[] {
685 if (state === null) {
686 return []
687 }
688
689 return flatten(fit(bandFields(state, now), maxCols))
690}
691
692/** The band's second row, empty when neither a stuck step nor a wait is on. */
693export function bandAlertLine(state: DeadhdState | null, now: number, maxCols: number): Segment[] {
694 if (state === null) {
695 return []
696 }
697
698 return flatten(fit(bandAlertFields(state, now), maxCols))
699}
700
701const segmentsText = (parts: readonly Segment[]): string => parts.map(part => part.text).join('')
702
703/** The status line's pieces, cut to `maxCols` cells. */
704export function statusLine(state: DeadhdState | null, now: number, maxCols: number): Segment[] {
705 if (state === null) {
706 return []
707 }
708
709 return flatten(fit(statusFields(state, now), maxCols))
710}
711
712/** The status line as plain text: the same pieces a drawing lays out, joined. */
713export function formatLine(state: DeadhdState | null, now: number, maxCols: number): string {
714 return segmentsText(statusLine(state, now, maxCols))
715}
716
717/** The folded band's one line: the command and how far the run has come, nothing else. */
718function foldedSegments(state: DeadhdState): Segment[] {
719 const parts: Segment[] = [{ text: BAND_CMD, tone: 'claude', bold: true }]
720
721 if (state.total > 0) {
722 parts.push(
723 { text: ' ', tone: 'plain' },
724 { text: `✓ ${state.done}/${state.total}`, tone: 'success' },
725 )
726 }
727
728 return parts
729}
730
731/** The folded status line's one piece: the command, the count, and the way to the page. */
732function statusFoldedSegments(state: DeadhdState): Segment[] {
733 const parts: Segment[] = [{ text: STATUS_CMD, tone: 'claude', bold: true }]
734
735 if (state.total > 0) {
736 parts.push(
737 { text: ' ', tone: 'plain' },
738 { text: `✅ ${state.done}/${state.total}`, tone: 'success', bold: true },
739 )
740 }
741
742 parts.push({ text: SEP, tone: 'subtle' }, { text: `↗ ${OPEN_CMD}`, tone: 'suggestion' })
743
744 return parts
745}
746
747/** The data file's items in lane·column order; a column the data leaves out follows its lane's last. */
748const readSteps = (data: unknown): DeadhdStep[] => {
749 const board = record(data)
750 const raw = board.items
751
752 if (!Array.isArray(raw)) {
753 return []
754 }
755
756 const items: readonly unknown[] = raw
757 const lanes: readonly unknown[] = Array.isArray(board.lanes) ? board.lanes : []
758
759 const laneOf = (value: unknown): number => {
760 const direct = asIndex(value)
761
762 if (direct !== null) {
763 return direct
764 }
765
766 if (typeof value === 'string') {
767 const at = lanes.findIndex(
768 lane => lane === value || record(lane).id === value || record(lane).label === value,
769 )
770
771 if (at >= 0) {
772 return at
773 }
774 }
775
776 return 0
777 }
778
779 const filled = new Map<number, number>()
780
781 return items
782 .map((item, index) => {
783 const source = record(item)
784 const lane = laneOf(source.lane)
785 const given = asIndex(source.col)
786 const col = given ?? filled.get(lane) ?? 0
787
788 filled.set(lane, col + 1)
789
790 const step: DeadhdStep = {
791 state: text(source.state, ''),
792 label: text(source.label, ''),
793 sub: text(source.sub, ''),
794 startedAt: typeof source.startedAt === 'string' ? source.startedAt : null,
795 lane,
796 col,
797 }
798
799 return { step, index }
800 })
801 .sort(
802 (left, right) =>
803 left.step.lane - right.step.lane ||
804 left.step.col - right.step.col ||
805 left.index - right.index,
806 )
807 .map(one => one.step)
808}
809
810/** The first `left` step after `current`, in lane·column order. */
811const nextStep = (steps: readonly DeadhdStep[], current: DeadhdStep | null): DeadhdStep | null => {
812 const from = current === null ? 0 : steps.indexOf(current) + 1
813
814 for (let at = Math.max(0, from); at < steps.length; at += 1) {
815 const step = steps[at]
816
817 if (step !== undefined && step.state === 'left') {
818 return step
819 }
820 }
821
822 return null
823}
824
825/** The non-empty string a state file field holds, or null. */
826const path = (value: unknown): string | null =>
827 typeof value === 'string' && value !== '' ? value : null
828
829/**
830 * One session's state file and data file together, as the two lines read them.
831 * A shape this build does not know, or a data file that is missing or broken,
832 * costs the parts that read it, never the whole line.
833 */
834export function normalize(raw: unknown, data?: unknown): DeadhdState | null {
835 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
836 return null
837 }
838
839 const source = record(raw)
840 const summary = record(source.summary)
841 const counts = record(summary.counts)
842 const steps = readSteps(data)
843 const nowLabel = text(summary.nowLabel, '')
844 // The hooks rewrite the state file on every event, so its own write time stays
845 // fresh while the skill stalls. The data file's last write is the honest clock,
846 // and a state file that carries none (an older one) falls back to `updatedAt`.
847 const dataAt = typeof summary.dataAt === 'string' && summary.dataAt !== '' ? summary.dataAt : null
848 const done = count(counts.done)
849 const now = count(counts.now)
850 const side = count(counts.side)
851 const left = count(counts.left)
852 const blocked = count(counts.blocked)
853
854 // Without a data file the summary still names what runs, its nowLabel falling
855 // back to the blocked step; the step's own state and start time are then lost.
856 const summaryStep: DeadhdStep | null =
857 steps.length === 0 && nowLabel !== ''
858 ? {
859 state: now > 0 ? 'now' : 'blocked',
860 label: nowLabel,
861 sub: '',
862 startedAt: null,
863 lane: 0,
864 col: 0,
865 }
866 : null
867 const running = steps.find(step => step.state === 'now') ?? steps.find(step => step.state === 'side') ?? null
868
869 return {
870 status: text(source.status, 'working'),
871 lang: summary.lang === 'en' ? 'en' : 'ko',
872 key: typeof summary.key === 'string' && summary.key !== '' ? summary.key : null,
873 title: text(summary.title, ''),
874 done,
875 now,
876 side,
877 left,
878 blocked,
879 total: count(summary.total),
880 allDone: summary.allDone === true,
881 current: running ?? (now > 0 ? summaryStep : null),
882 next: nextStep(steps, running),
883 stuck: steps.find(step => step.state === 'blocked') ?? (now === 0 && blocked > 0 ? summaryStep : null),
884 eta: typeof summary.eta === 'string' ? summary.eta : null,
885 updatedAt: dataAt ?? (typeof source.updatedAt === 'string' ? source.updatedAt : null),
886 since: typeof source.since === 'string' ? source.since : null,
887 background: Array.isArray(source.backgroundTasks) ? source.backgroundTasks.length : 0,
888 compactions: count(record(source.compactions).count),
889 view: asView(source.view),
890 data: path(source.data),
891 out: path(source.out),
892 }
893}
894
895/** One session as the two files leave it; null when the state file is not there or is not JSON. */
896async function load($: EngineInterface): Promise<DeadhdState | null> {
897 try {
898 const dir = (await $.env.get('DEADHD_STATE_DIR')) ?? DEFAULT_STATE_DIR
899 const session = await $.session.id()
900 const raw: unknown = JSON.parse(await $.fs.read(`${dir}/${session}.json`))
901 const data = path(record(raw).data)
902 let board: unknown = null
903
904 if (data !== null) {
905 try {
906 board = JSON.parse(await $.fs.read(data))
907 } catch {
908 board = null
909 }
910 }
911
912 return normalize(raw, board)
913 } catch {
914 return null
915 }
916}
917
918/** The deadhd config file's path, by the rule config.py's `config_path()` writes it. */
919async function configPath($: EngineInterface): Promise<string | null> {
920 const override = await $.env.get('DEADHD_CONFIG')
921
922 if (typeof override === 'string' && override !== '') {
923 return override
924 }
925
926 const xdg = await $.env.get('XDG_CONFIG_HOME')
927
928 if (typeof xdg === 'string' && xdg !== '') {
929 return `${xdg}/deadhd/config.json`
930 }
931
932 const home = await $.env.get('HOME')
933
934 return typeof home === 'string' && home !== '' ? `${home}/.config/deadhd/config.json` : null
935}
936
937/** The view the deadhd config file saves, or null when it saves none this build knows. */
938async function configView($: EngineInterface): Promise<DeadhdView | null> {
939 const file = await configPath($)
940
941 if (file === null) {
942 return null
943 }
944
945 try {
946 const raw: unknown = JSON.parse(await $.fs.read(file))
947
948 return asView(record(raw).view)
949 } catch {
950 return null
951 }
952}
953
954/** The view in force for this session: the state file's, else the config's, else `html`. */
955async function viewOf($: EngineInterface, state: DeadhdState | null): Promise<DeadhdView> {
956 return resolveView(state?.view ?? null, await configView($))
957}
958
959/**
960 * The skill folder beside this module. It is the plugin root for a
961 * skill-folder install and `${root}/skills/deadhd` for a marketplace one;
962 * the one that holds render.py answers.
963 */
964async function skillDir($: EngineInterface): Promise<string> {
965 const root = $.plugin.root
966
967 try {
968 await $.fs.stat(`${root}/render.py`)
969
970 return root
971 } catch {
972 return `${root}/skills/deadhd`
973 }
974}
975
976/**
977 * Whether the fresh state asks for the person: a new blockage, or a wait for
978 * permission. A turn's end also reads as `idle`, so an idle status alone must
979 * not unfold a line the person folded.
980 */
981const needsAttention = (fresh: DeadhdState, previous: DeadhdState): boolean =>
982 fresh.blocked > previous.blocked ||
983 (fresh.status === 'waiting_permission' && previous.status !== 'waiting_permission')
984
985/**
986 * The line the current view would draw now, as plain text: what a refresh
987 * compares against the last to tell whether a repaint would move anything.
988 * A folded line carries no elapsed time, so it reads the same every interval.
989 */
990const paintText = (state: DeadhdState, now: number, folded: boolean, view: DeadhdView): string => {
991 const mode = view === 'band' ? 'band' : 'status'
992 const cells = widths[mode]
993
994 return view === 'band'
995 ? folded
996 ? segmentsText(foldedSegments(state))
997 : segmentsText(bandLine(state, now, cells))
998 : folded
999 ? segmentsText(statusFoldedSegments(state))
1000 : formatLine(state, now, cells)
1001}
1002
1003/** The kind and the path `open.sh` names on its `opened:` line, or null. */
1004const openedOf = (output: string): { kind: string; path: string } | null => {
1005 const match = /^opened: (\S+) (.*)$/m.exec(output)
1006
1007 return match === null ? null : { kind: match[1] ?? '', path: (match[2] ?? '').trim() }
1008}
1009
1010/** What opening the page answers, in the words the button toasts and `/deadhd-open` returns. */
1011async function openText($: EngineInterface, state: DeadhdState | null): Promise<string> {
1012 const words = WORDS[state?.lang ?? 'ko']
1013 const out = state?.out ?? null
1014 const data = state?.data ?? null
1015
1016 if (out === null || data === null) {
1017 return words.noPage
1018 }
1019
1020 const dir = await skillDir($)
1021
1022 try {
1023 // A session drawing in a band or status line has no page yet; this is what writes one.
1024 const session = await $.session.id()
1025 const page = await $.process.run(
1026 ['python3', `${dir}/render.py`, '--page', '--session', session, data, out],
1027 { timeoutMs: OPEN_TIMEOUT_MS },
1028 )
1029
1030 if (page.exitCode !== 0) {
1031 const reason = firstLine(page.stderr)
1032
1033 return `${words.openFailed}: exit ${page.exitCode}${reason === '' ? '' : `: ${reason}`}`
1034 }
1035
1036 const opened = await $.process.run(['bash', `${dir}/open.sh`, out], { timeoutMs: OPEN_TIMEOUT_MS })
1037
1038 if (opened.exitCode !== 0) {
1039 const reason = firstLine(opened.stderr)
1040
1041 return `${words.openFailed}: exit ${opened.exitCode}${reason === '' ? '' : `: ${reason}`}`
1042 }
1043
1044 // The exit code alone is not the answer: `desktop` and `none` exit 0 with the
1045 // page unopened, so the path goes into the sentence the person reads.
1046 const result = openedOf(opened.stdout)
1047
1048 if (result === null || result.kind === 'orca-tab' || result.kind === 'browser') {
1049 return words.opened
1050 }
1051
1052 return result.kind === 'desktop'
1053 ? `${words.desktopPath} ${result.path}`
1054 : `${words.noBrowser} ${result.path}`
1055 } catch (error) {
1056 return `${words.openFailed}: ${firstLine(String(error))}`
1057 }
1058}
1059
1060/**
1061 * Reads the session's two files, the view in force, and, when the state
1062 * changed, puts it in the atoms the render hooks draw from. A new blockage or
1063 * a wait for permission opens a folded line again. An interval whose line moved (`8분째`
1064 * becoming `9분째`) asks the mode's component to draw again, so the clock keeps
1065 * up without a state write. Its own failures are swallowed: a refresh that
1066 * cannot read or write leaves the last line standing.
1067 */
1068async function refresh($: EngineInterface): Promise<void> {
1069 try {
1070 const fresh = await load($)
1071 const view = await viewOf($, fresh)
1072 const previous = await read($, live)
1073 const previousView = await read($, shown)
1074
1075 if (JSON.stringify(previous) !== JSON.stringify(fresh)) {
1076 await update($, live, () => fresh)
1077 }
1078
1079 // The view is re-read every interval, so a change in the config reaches the
1080 // lines without a restart; the hook reads this atom rather than the file.
1081 if (previousView !== view) {
1082 await update($, shown, () => view)
1083 }
1084
1085 if (fresh !== null && previous !== null && needsAttention(fresh, previous)) {
1086 await update($, collapsed, () => false)
1087 }
1088
1089 if (fresh === null || view === 'html') {
1090 return
1091 }
1092
1093 const mode = view === 'band' ? 'band' : 'status'
1094 const folded = await read($, collapsed)
1095 const now = await $.clock.now()
1096 const line = paintText(fresh, now, folded, view)
1097 const before = painted[mode]
1098
1099 painted[mode] = line
1100
1101 if (before !== undefined && before !== line) {
1102 $.ui.invalidate('ui.render')
1103 }
1104 } catch {
1105 // Left as it stands.
1106 }
1107}
1108
1109export const register: Register = on => {
1110 on('session.start', async ($, e, next) => {
1111 await refresh($)
1112
1113 if ((await read($, shown)) === 'statusline') {
1114 // The status line comes from the PromptHint hook; clear any line an
1115 // earlier build pinned with `$.ui.status`.
1116 $.ui.status(undefined)
1117 }
1118
1119 await $.command.register({
1120 name: 'deadhd-band',
1121 description: 'Fold or open the deadhd band above the prompt.',
1122 })
1123 await $.command.register({
1124 name: 'deadhd-statusline',
1125 description: 'Fold or open the deadhd status line under the prompt.',
1126 })
1127 await $.command.register({
1128 name: 'deadhd-open',
1129 description: 'Open the deadhd progress page in a browser.',
1130 })
1131
1132 $.clock.every(REFRESH_MS, () => {
1133 void refresh($)
1134 })
1135
1136 return next(e)
1137 })
1138
1139 on('turn.complete', async ($, e, next) => {
1140 const ran = await next(e)
1141
1142 void refresh($)
1143
1144 return ran
1145 })
1146
1147 on('tool.call', async ($, e, next) => {
1148 const ran = await next(e)
1149
1150 // A tool call never waits on this: refresh reads the state, the config and the
1151 // data JSON, and the timer keeps the line current anyway. It swallows its own
1152 // failures, so nothing here needs a catch.
1153 void refresh($)
1154
1155 return ran
1156 })
1157
1158 on('command.run', { command: 'deadhd-open' }, async $ => {
1159 const state = await read($, live)
1160
1161 return { text: await openText($, state) }
1162 })
1163
1164 on('command.run', { command: 'deadhd-band' }, async $ => {
1165 const state = await read($, live)
1166 const words = WORDS[state?.lang ?? 'ko']
1167
1168 if ((await viewOf($, state)) !== 'band') {
1169 return { text: words.bandOnly }
1170 }
1171
1172 const wasFolded = await read($, collapsed)
1173
1174 await update($, collapsed, () => !wasFolded)
1175
1176 return { text: wasFolded ? words.unfolded : words.folded }
1177 })
1178
1179 on('command.run', { command: 'deadhd-statusline' }, async $ => {
1180 const state = await read($, live)
1181 const words = WORDS[state?.lang ?? 'ko']
1182
1183 if ((await viewOf($, state)) !== 'statusline') {
1184 return { text: words.statusOnly }
1185 }
1186
1187 const wasFolded = await read($, collapsed)
1188
1189 await update($, collapsed, () => !wasFolded)
1190
1191 return { text: wasFolded ? words.statusUnfolded : words.statusFolded }
1192 })
1193
1194 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1195 if ((await read($, shown)) !== 'statusline') {
1196 return next(e)
1197 }
1198
1199 const state = await read($, live)
1200skills/deadhd/types/index.d.ts 93 lines1/**
2 * deadhd's `$.state` contract: the normalized progress the band and the status
3 * line draw from, which view this session draws in, and whether the person
4 * folded the line down for this session.
5 */
6
7export type DeadhdLang = 'ko' | 'en'
8
9/** Where the session's progress is drawn. */
10export type DeadhdView = 'html' | 'band' | 'statusline'
11
12/**
13 * One step of the session's data file, as the band reads it.
14 *
15 * A step the data spells wrongly costs the pieces that read it, never the line.
16 */
17export type DeadhdStep = {
18 /** The skill's own word for the step: `now`, `side`, `left`, `blocked` or `done`. */
19 state: string
20 label: string
21 /** The one-to-three-word note drawn under the step; `''` when the data has none. */
22 sub: string
23 /** When the step started, ISO 8601 with an offset, or null when the data omits it. */
24 startedAt: string | null
25 /** The step's lane and column, the order the page draws it in. */
26 lane: number
27 col: number
28}
29
30/**
31 * The session's state file and data file together, normalized: what the band
32 * and the status line read.
33 *
34 * Absent or unreadable values are folded to the neutral ones (`0`, `''`,
35 * `null`), so a file the skill wrote differently costs a part of the line,
36 * never the plugin. A data file that is missing or broken costs the steps
37 * alone: the summary still draws.
38 */
39export type DeadhdState = {
40 /** The skill's own word: `working`, `waiting_permission`, `idle`, `ended`, ... */
41 status: string
42 lang: DeadhdLang
43 /** The ticket or program key of the run, or null when the summary has none. */
44 key: string | null
45 title: string
46 done: number
47 now: number
48 side: number
49 left: number
50 blocked: number
51 total: number
52 allDone: boolean
53 /** The step the lines mark as running: the first `now`, else the first `side`. */
54 current: DeadhdStep | null
55 /** The first `left` step after `current`, in lane·column order. */
56 next: DeadhdStep | null
57 /** The first `blocked` step the data names. */
58 stuck: DeadhdStep | null
59 /** The completion estimate, an ISO 8601 instant with an offset, or null. */
60 eta: string | null
61 /**
62 * When the skill last wrote the data file, ISO 8601 with an offset; the lines
63 * count `N분 전` from here. The state file's own write time stands in when the
64 * summary carries none, since the hooks rewrite it on every event.
65 */
66 updatedAt: string | null
67 /** When the current status began, ISO 8601 with an offset. */
68 since: string | null
69 /** How many background tasks the session's last turn left running. */
70 background: number
71 /** How many times the session has compacted. */
72 compactions: number
73 /** The view the state file names, or null when it names none render.py knows. */
74 view: DeadhdView | null
75 /** The absolute path of the data file the page is rendered from, or null. */
76 data: string | null
77 /** The absolute path of the rendered page, or null before one is rendered. */
78 out: string | null
79}
80
81declare module 'claude-code' {
82 interface PluginState {
83 deadhd: {
84 /** The last state read, or null while the session has none. */
85 state: DeadhdState | null
86 /** True while the line is folded to its one-word summary; kept for the session. */
87 collapsed: boolean
88 /** The view in force: the state file's, else the config file's, else `html`. */
89 view: DeadhdView
90 }
91 }
92}
93