SLOPSHOPPER

deadhd

Draw this session's deadhd progress as a band above the prompt or a status line under it, from the state file the deadhd skill writes.

newbandspinnerguardcommandtoast
★ 24v1.11.0MITupdated 2026-10-09lcalmsky/deadhd/skills/deadhd
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deadhd
› fix the failing auth test and add an audit log call ⏺ 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 › /deadhd-band ⎿ deadhd: 지금은 밴드 모드가 아니에요. /deadhd setup 에서 밴드를 고르면 보여요. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<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 데스크톱 앱의 인앱 브라우저로 열 수 있습니다. 열기 위치는 설정에서 고릅니다.

작업이 길어지면 페이지 위쪽에 이 세션이 지금 무엇을 기다리는지 알려 주는 상태 띠가 붙고, 이 띠는 플러그인 훅이 유지합니다. 세션을 여러 개 띄워 두었다면 허브 페이지에서 이 컴퓨터의 세션을 한 장에 모아 봅니다.

한눈에 보기

  • 진행 페이지 — 완료한 단계, 진행 중인 단계, 남은 단계를 흐름도와 카드로 보여 주고, 단계마다 근거가 되는 파일·PR·커밋·테스트 통과 건수를 붙입니다.
  • 상태 띠 — 권한 승인 대기, 입력 대기, 마지막 도구, 압축 같은 세션 상태를 페이지 위쪽 띠로 알려 줍니다. 플러그인 훅이 유지합니다.
  • 보정 완료 예상 — 단계를 마칠 때마다 처음 적은 예상과 실제 소요가 이력에 쌓이고, 3건부터 보정한 완료 예상 시각을 함께 보여 줍니다.
  • 허브 — 이 컴퓨터에서 도는 세션을 한 장에 모아 상태 순으로 정렬합니다.
  • 테마 10종과 글꼴 프리셋 12종 — 테마와 제목 글꼴을 골라 쓸 수 있습니다.
  • 보기 모드 4종 — 같은 진행 상황을 원본·스트립·타임라인·타일로 바꿔 봅니다.
  • 표시 방식 3종 — Claude Code 에서는 페이지 대신 프롬프트 위 밴드나 아래 상태줄로도 볼 수 있습니다.

상태 띠가 붙은 진행 페이지

허브

테마

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

deadhd 테마 갤러리

보기 모드

페이지 왼쪽 위 버튼으로 같은 진행 상황을 네 가지 보기로 바꿔 볼 수 있습니다. Claude 아티팩트 패널이나 좁게 나눈 Orca 탭처럼 폭이 좁은 곳에서는 컴팩트 보기가 한눈에 읽기 쉽습니다.

보기구성
원본흐름도와 단계별 카드를 모두 보여 주는 기본 페이지입니다
스트립단계 흐름을 한 줄의 점으로 줄이고, 진행 중인 단계 카드만 펼칩니다. 나머지 단계는 완료·병행·대기로 접어 둡니다
타임라인단계를 한 줄씩 세로로 쌓고, 단계마다 완료 시각이나 예상 시간을 붙입니다. 진행 중인 단계는 카드로 보여 줍니다
타일완료 수, 현재 단계의 남은 시간, 완료 예상 시각을 숫자로 먼저 보여 주고, 단계별 상태를 구간 막대로 보여 줍니다

컴팩트 보기를 고르면 자동·가로·세로 버튼이 나타납니다. 자동은 탭이 5:4보다 넓고 폭이 760px 이상이면 가로 배치로, 그렇지 않으면 세로 배치로 그리며, 탭 크기를 바꾸면 바로 다시 배치합니다. 고른 보기와 배치는 15초 자동 새로 고침 뒤에도 유지되고, 모든 테마와 영어 화면에서 동작합니다.

보기는 버튼을 누를 때 페이지에 이미 들어 있는 데이터로 브라우저가 그립니다. 모델이 쓰는 데이터는 그대로이므로 보기를 추가해도 토큰 사용량은 늘지 않습니다.

deadhd 보기 모드

표시 방식

같은 진행 상황을 세 가지 방식으로 볼 수 있습니다. 페이지의 보기 모드와는 다른 설정으로, 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 가 없는 세션은 지금까지처럼 흐름도만 그립니다.

레인 보드

동작 방식

  • 완료 표시는 세션의 실행 결과로 확인된 단계에만 붙습니다. 테스트 통과, 파일 작성, PR 머지처럼 결과가 남은 단계만 done 으로 분류하고, 시도했지만 검증하지 못한 단계는 now 또는 blocked 로 표시합니다.
  • 각 단계에는 근거가 되는 파일 경로, PR, 커밋, 테스트 통과 건수가 함께 표시됩니다.
  • 페이지 디자인은 template.html 에 고정되어 있고, 모델은 JSON 데이터만 작성합니다. render.py 가 데이터를 검증한 뒤 HTML 을 생성합니다.
  • 테마와 글꼴 CSS 는 themes.css 로 분리되어 있어 세션 페이지와 허브가 같은 파일을 씁니다.
  • 사용자가 영어로 대화하면 페이지의 고정 문구(완료, 진행 중, 남은 작업 같은 라벨)도 영어로 표시됩니다. 데이터의 lang 필드로 정해지며, 없으면 한국어입니다.
  • 단계별 시작·완료 시각과 남은 시간 추정치가 있으면 진행 바 아래에 완료 예상 시각이 표시됩니다. 기준은 렌더 시각이 아니라 데이터 파일이 마지막으로 수정된 시각이라, 훅이 데이터를 바꾸지 않고 페이지를 다시 렌더해도 예상 시각이 밀리지 않습니다. 이미 지난 예상에는 (지남) 이 붙습니다. 내일 이나 날짜로 붙는 표기는 페이지를 보고 있는 시점의 시각을 기준으로 정해지므로, 자정이 지나거나 창을 다시 열면 자동으로 바뀝니다.
  • 열어 둔 탭은 15초마다 새로 고쳐지고, 새로 완료된 단계에는 완료 효과가 재생됩니다. 페이지 상단의 상태 띠도 같은 주기로 갱신됩니다.
  • 페이지는 127.0.0.1 전용 정적 서버(serve.py)로 열립니다. Orca 같은 인앱 브라우저가 페이지 안 file:// 이동을 막기 때문입니다. 처음 열 때 자동으로 뜨고, /tmp 의 deadhd-*.html 만 제공합니다.
  • 허브는 이 컴퓨터의 세션 상태 파일만 읽어 만들어집니다.

입력 대기 알림과 예상 시간 보정

  • 플러그인으로 설치하면 hooks/hooks.json 의 훅이 세션 이벤트(권한 요청, 답변 종료, 도구 실행, 압축, 세션 종료)를 받아 /tmp/deadhd-state/<세션 ID>.json 에 상태를 쓰고 페이지를 다시 렌더합니다. 페이지 상단에 권한 승인 대기 · 12분째, 입력 대기 · 23분째, 작업 중 · 마지막 도구 Bash 8초 전, 신호 없음, 세션 종료 띠가 붙습니다. 모델이 갱신을 잊어도 띠는 훅이 유지합니다.
  • 압축 뒤에는 세션 시작 훅이 데이터 파일 경로를 모델에 다시 알려 줍니다.
  • 스킬 폴더나 Orca 로 설치하면 훅이 따라오지 않습니다. 쓰려면 ~/.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 완료, 지금 하는 일, 완료 예상이 함께 나옵니다.

  • 승인 대기 > 신호 없음 > 입력 대기 > 작업 중 > 훅 없음 > 완료 > 종료 > 지난 세션 순으로 정렬하고, 같은 상태에서는 오래 기다린 세션이 위로 옵니다.
  • 타일을 누르면 같은 탭에서 그 세션의 페이지로 이동하고, 세션 페이지의 허브 ↗ 버튼도 같은 탭에서 허브로 돌아옵니다.
  • 상태 파일을 쓰는 1.8.0 이후 세션만 모입니다. 24시간 넘게 갱신이 없는 세션은 「종료·지난 세션」으로 접힙니다.
  • 페이지는 127.0.0.1 전용 정적 서버로 열립니다(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 로 바꿀 수 있습니다).

요구 사항

  • Claude Code
  • python3 (표준 라이브러리만 사용)
  • Chrome (예시 그림을 다시 만들 때만 필요합니다)

표시 방식 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 에서 설치

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 -oOrca 아티팩트(공유 링크가 있는 웹 페이지)로 게시합니다. orca CLI 로그인이 필요하고, 실패하면 브라우저 탭으로 엽니다
/deadhd -cClaude 아티팩트로 게시합니다
/deadhd setup기본 열기 위치·표시 방식·테마·글꼴을 다시 고릅니다
/deadhd hub이 컴퓨터의 세션을 모은 허브 페이지를 엽니다
/deadhd --open <모드>이번 실행만 다른 위치로 엽니다
/deadhd --view <표시 방식>이번 실행만 다른 표시 방식으로 그립니다
/deadhd --theme <테마>이번 실행만 다른 테마로 렌더합니다
/deadhd --font <프리셋>이번 실행만 다른 제목 글꼴로 렌더합니다
/deadhd off페이지 갱신을 중지합니다

Claude Code 에서 밴드나 상태줄로 보고 있으면 다음 명령도 씁니다.

입력동작
/deadhd-band프롬프트 위 밴드를 접거나 펼칩니다
/deadhd-statusline프롬프트 아래 상태줄을 접거나 펼칩니다
/deadhd-open진행 페이지를 만들어 브라우저로 엽니다

명령 대신 "진행 상황 띄워줘", "체크리스트로 보여줘" 처럼 요청해도 됩니다.

설정

처음 실행할 때 열기 위치, 표시 방식, 테마, 글꼴을 한 번 묻습니다. 고른 값은 다음 실행부터 그대로 쓰입니다.

값동작
autoOrca 안에서 실행 중이면 Orca 탭으로, 아니면 시스템 브라우저로 엽니다
orcaorca 명령이 있으면 Orca 탭으로 엽니다
browserOrca 를 건너뛰고 시스템 브라우저로 엽니다
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테마가 정한 글꼴테마에 따름
pretendardPretendard Variable800 · -0.03em
noto-sansNoto Sans KR900 · -0.03em
plex-sansIBM Plex Sans KR700 · -0.02em
gothic-a1Gothic A1900 · -0.03em
nanum-gothicNanum Gothic800 · -0.02em
noto-serifNoto Serif KR900 · -0.02em
nanum-myeongjoNanum Myeongjo800 · -0.02em
hahmletHahmlet900 · -0.02em
gowun-batangGowun Batang700 · -0.01em
do-hyeonDo Hyeon400 · -0.04em
black-han-sansBlack Han Sans400 · 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_PORT47320
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

라이선스

MIT

Source 2 files
hooks/register.tsx 1324 lines
1import { 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)
1200
types/index.d.ts 93 lines
1/**
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