SLOPSHOPPER

taxi-blackbox

Claude의 도구 호출을 블랙박스처럼 녹화: 오류·거부(사고) 직전 동작을 /blackbox에서 돌려보기, 토큰·비밀번호는 가려서 기록

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taxi-blackbox
│ ┃ 블랙박스 ✕ › fix the failing auth test and add an audit log call │ ┃ ▶ PLAY 사고 1/1 · Bash 오류 01:53:20 │ ┃ ▆▆▆▆▆█▄▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Read(src/auth.ts) │ ┃ ▲ ⎿ Read 6 lines │ ┃ 직전 5개 동작 ⏺ Update(src/auth.ts) │ ┃ 01:53:20 Read /work/app/src/auth.ts ⎿ Added 2 lines, removed 1 line │ ┃ 01:53:20 Grep refresh\( ⏺ Bash(bun test) │ ┃ 01:53:20 Edit /work/app/src/auth.ts ⎿ 3 pass, 1 fail │ ┃ 01:53:20 Write /work/app/src/audit.ts │ ┃ 01:53:20 Write /work/app/src/cache.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ 사고 장면 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 01:53:20 Bash bun test │ │ ┃ │ └ src/auth.test.ts: ✓ refreshes │ › /blackbox │ ┃ │ expired token ✓ rejects a bad │ │ ┃ │ signature ✓ issues a new token ✗ │ │ ┃ │ revokes on logout error: expected 401, │ │ ┃ │ got 200 3 pass 1 fail │ │ ┃ ╰────────────────────────────────────────╯ │ ┃ [ ◀ 이전 사고 ] [ 다음 사고 ▶ ] │ ⟨Claude Code's own drawing⟩ ● REC 2025.10.09 01:53:20 00:00 · 기록 9 사고 1 /blackbox ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● REC 2025.10.09 01:53:20 00:00 · 기록 9 사고 1 /blackbox
Pane · 블랙박스
▶ PLAY 사고 1/1 · Bash 오류 01:53:20 ▆▆▆▆▆█▄▄▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▲ 직전 5개 동작 01:53:20 Read /work/app/src/auth.ts 01:53:20 Grep refresh\( 01:53:20 Edit /work/app/src/auth.ts 01:53:20 Write /work/app/src/audit.ts 01:53:20 Write /work/app/src/cache.ts ╭────────────────────────────────────────────────────────╮ │ 사고 장면 │ │ 01:53:20 Bash bun test │ │ └ src/auth.test.ts: ✓ refreshes expired token ✓ │ │ rejects a bad signature ✓ issues a new token ✗ revokes │ │ on logout error: expected 401, got 200 3 pass 1 fail │ ╰────────────────────────────────────────────────────────╯ [ ◀ 이전 사고 ] [ 다음 사고 ▶ ]
README

🚕 devbrothers-mods

클로드 코드를 택시로 개조하는 Claude Code mods

요금 미터기, 내비, 과속 단속 카메라, 블랙박스를 프롬프트 위에 달아 드려요.

Claude Code mods License: MIT

<img src="docs/images/hero.webp" alt="force push를 단속 카메라가 세우고, 미터기·내비·블랙박스가 함께 보이는 Claude Code 화면" width="900">

<sub>실제 세션 화면이에요. Claude가 <code>git push --force</code>를 하려는 순간 단속 카메라가 세웠고, 미터기는 지금까지 ₩240, 내비는 경유지 2/3, 블랙박스는 사고 1건을 기록했어요.</sub>

택시 팩

mod한 줄 요약명령
🧾taxi-meter이번 세션이 API 요금으로 얼마인지, 5시간·주간 한도를 얼마나 썼는지/meter /receipt
🧭taxi-naviClaude의 할 일 목록을 경로로 그리고 음성으로 안내/navi
📸taxi-speedcamrm -rf, force push 같은 위험한 명령을 실행 전에 세우고 물어보기/speedcam
🎥taxi-blackbox모든 도구 호출을 녹화하고, 오류·거부 직전 장면을 다시 보기/blackbox

설치

claude plugin marketplace add devbrother2024/devbrothers-mods

claude plugin install taxi-blackbox@devbrothers-mods
claude plugin install taxi-speedcam@devbrothers-mods
claude plugin install taxi-meter@devbrothers-mods
claude plugin install taxi-navi@devbrothers-mods

필요한 것만 골라 설치해도 돼요. 열려 있는 세션에서는 /reload-plugins를 치거나 Claude Code를 다시 시작하면 바로 보입니다. /plugin에서 4 mods active · taxi-blackbox, …가 보이면 성공이에요.

claude plugin marketplace update devbrothers-mods   # 새 버전 목록 받기
claude plugin update taxi-meter@devbrothers-mods    # mod별로 업데이트
claude plugin uninstall taxi-meter@devbrothers-mods # 삭제

업데이트한 뒤에는 세션을 다시 열어야 새 버전이 적용돼요.

[!TIP] 넷 다 쓴다면 블랙박스를 먼저 설치하세요. mod는 설치한 순서대로 실행돼요. 블랙박스가 단속 카메라보다 앞에 있어야 카메라가 세운 명령까지 사고로 기록됩니다.

[!IMPORTANT] 내비는 할 일 도구가 켜져 있어야 해요. Claude Code v2.1.233부터 Opus 4.8·Sonnet 5·Fable 5 이후 모델은 할 일 도구가 기본으로 꺼져 있어서 Claude가 체크리스트를 만들지 않아요. CLAUDE_CODE_ENABLE_TODO_TOOLS=1 claude로 시작하거나 ~/.claude/settings.json의 env에 넣어두세요.

git clone https://github.com/devbrother2024/devbrothers-mods
cd devbrothers-mods
claude --plugin-dir plugins/taxi-blackbox --plugin-dir plugins/taxi-speedcam \
       --plugin-dir plugins/taxi-meter --plugin-dir plugins/taxi-navi

--plugin-dir 순서가 실행 순서예요. 여기서도 블랙박스를 맨 앞에 두세요.


🧾 taxi-meter: 요금 미터기

<img src="docs/images/meter.webp" alt="택시 미터기 패널. 일반 Sonnet 5.5, 방금 요청 +₩30, 요금 ₩270, 컨텍스트 4%, 5시간 한도 13%" width="560">

세션 하나가 승차 한 번이에요. Claude가 일하는 동안 말이 달리고 요금이 딸깍딸깍 올라가요.

  • 요금은 쓴 토큰을 API 정가로 환산한 원화예요. 구독(Pro·Max) 요금제에서 실제로 청구되는 돈은 아니고, "내 구독이 API로 치면 얼마어치 일하나"를 보여줘요.
  • 큰 숫자는 세션 동안 올라가기만 해요. 새 프롬프트를 보내도 ₩0으로 돌아가지 않고, 그 요청의 요금은 위쪽 방금 요청 +₩___로 따로 보여요. /clear하면 새 승차로 0원부터 다시 세고, --continue로 이어 열면 다시 연 시점부터 새 승차예요.
  • 위쪽에는 차종(일반·모범·블랙), 실제 모델 이름(예: Sonnet 5.5), 출력 속도(tok/s)가, 오른쪽에는 컨텍스트 사용률이, 아래 줄에는 5시간 한도와 리셋 시각, 주간 한도가 나와요.
  • /model로 모델을 바꾸면 프롬프트를 보내기 전에 차종과 할증 키가 바로 바뀌어요. 이때 이전 캐시를 못 써서 다음 요청에 대화 전체를 다시 캐시에 쓰는 요금이 붙는데, 그 예상 금액을 알림으로 먼저 알려 드려요(긴 대화에서 Opus로 바꾸면 수천 원이 나오기도 해요).
키켜지는 때
빈차첫 응답 전
주행Claude가 응답하는 중
할증비싼 차종을 타는 중. Opus는 모범, Fable은 블랙 (Sonnet·Haiku는 일반)
복합컨텍스트 50% 이상, 즉 장거리
지불응답이 끝나 이번 요금이 확정됐거나 한도를 다 쓴 때

<img src="docs/images/meter-demo.webp" alt="데모 주행 끝 화면. 5시간 한도를 다 써서 빨간 숫자와 하차 안내" width="560">

5시간이나 주간 한도가 90%를 넘으면 "곧 목적지입니다", 100%면 "하차하셔야 합니다 · 재승차 시각"이 뜨고 숫자가 빨간색이 돼요. 위 화면은 /meter demo 데모 주행이에요(연출 숫자라서 미터기에 "데모 주행"이 찍혀요).

<img src="docs/images/receipt.webp" alt="/receipt 영수증 창. 승하차 시각, 새로 처리·캐시 재사용 토큰, 차종, 요금, 요청별 요금, 누적" width="900">

미터기는 토큰을 직접 세지 않고 Claude Code가 계산한 세션 비용(/cost와 같은 값)을 따라가요. 실제 세션으로 맞춰 보니 대화 기록에 남은 응답들을 정가로 계산한 값에, 기록에 안 남는 호출(세션 제목 생성, 프롬프트 추천 등)을 더하면 소수 일곱째 자리까지 같았어요.

  • 기록에 안 남는 호출 비용도 요금에 들어가요. 턴이 끝난 뒤 생기는 호출은 다음 요청의 방금 요청에 섞여 보일 수 있어요(세션 합계는 맞아요).
  • 영수증의 토큰은 새로 처리와 캐시 재사용으로 나눠 보여요. 요청할 때마다 대화 전체를 캐시에서 다시 읽어서, 긴 세션에서는 캐시 재사용이 대부분이에요. 캐시 읽기는 입력 단가의 10분의 1이라 요금 비중은 작아요.
  • 프롬프트 추천 문구가 필요 없다면 settings.json에 "promptSuggestionEnabled": false를 넣어 그 호출을 끌 수 있어요.
명령하는 일
/meter이번 승차(세션)·방금 요청·오늘·최근 7일·누적 요금
/receipt영수증 창(승하차 시각, 새로 처리·캐시 재사용 토큰, 차종 비중, 요금, 요청별 요금, 누적)
/meter reset화면 요금과 누적 장부를 모두 0원으로 (데모 중이면 데모도 끝내요)
/meter demo약 10초짜리 데모 주행. 다시 입력하거나 다음 프롬프트를 보내면 실제 미터기로 돌아와요
설정기본값설명
krw_per_usd1400원화 환산 환율
styleledled는 터미널에서 미터기 패널, compact는 한 줄
soundtrue요금이 오를 때 딸깍, 한도 경고 알림음
tick_won1000딸깍 소리를 내는 금액 단위

누적 장부는 세션을 넘어 90일치를 보관해요. 패널은 창 폭 75칸·높이 9줄 이상에서 나오고, 더 좁으면 한 줄로 바뀌어요.


🧭 taxi-navi: 내비

<img src="docs/images/navi.webp" alt="내비 패널. 위는 주행 중(경유지 1/3, 다음 안내), 아래는 도착(경유지 3곳, 소요 1분)" width="560">

Claude가 세운 할 일 목록이 경로가 되고, 🚕가 지금 하는 단계에 있어요.

  • 첫 작업이 시작되면 "경로 안내를 시작합니다", 진행 중에 단계가 늘거나 줄면 "경로를 재탐색합니다"(화살표가 5초 동안 유턴으로 바뀌어요), 다 끝나면 "목적지에 도착했습니다"라고 말해요.
  • 단계 하나를 끝낼 때마다 딩 소리가 나요. 긴 작업을 맡기고 자리를 비워도 돼요.
  • TaskCreate·TaskUpdate·TaskList·TodoWrite를 읽기만 하고, 할 일 내용은 바꾸지 않아요. 서브에이전트의 할 일은 경로에 넣지 않아요.
  • /navi를 치면 할 일 도구가 켜져 있는지 알려줘요.
설정기본값설명
voicetrue음성 안내
voice_nameYunamacOS say -v '?'에 나오는 음성 이름
soundtrue단계를 끝낼 때마다 딩

📸 taxi-speedcam: 과속 단속 카메라

<img src="docs/images/speedcam.webp" alt="force push 앞에서 뜬 질문 창(가주세요/세워주세요)과 세운 뒤의 단속 카메라 패널" width="760">

위험한 Bash 명령을 실행 직전에 "찰칵" 잡아요. 무엇이 바뀌는지 보여주고 [가주세요] / [세워주세요]로 물어봐요.

단속잡는 명령 예시
🔄 역주행 감지git push --force, git push -f, git push origin +main
🪨 낭떠러지 주의rm -rf, rm -r -f, rm --recursive --force
🏫 어린이 보호구역DROP TABLE, TRUNCATE, WHERE 없는 DELETE FROM, prisma migrate reset
⏪ 후진 주의git reset --hard, git clean -f, git checkout -- ., git stash drop
🛣️ 고속도로 진입--prod, wrangler deploy, terraform apply, npm publish
  • [가주세요]는 평소처럼 권한 확인을 거쳐 실행하고, [세워주세요]는 실행하지 않고 Claude에게 이유를 전해요. Claude는 같은 명령을 다시 시도하지 않고 다른 방법을 물어봐요.
  • 질문 창을 닫거나 claude -p처럼 물어볼 사람이 없으면 세워요.
  • 잡는 순간 화면이 하얗게 번쩍이고, 결과(통과했어요·정차했어요)가 5초 동안 남아요.

[!WARNING] 텍스트 패턴으로 잡는 안전벨트예요. alias나 스크립트 속 명령은 못 잡으니, 권한 설정의 deny 규칙을 대신하지 않아요.

설정기본값설명
modeaskask는 매번 묻고, block은 묻지 않고 세워요
soundtrue찰칵 셔터 소리
voicetrue단속 종류 음성 안내
voice_nameYunamacOS 음성 이름

🎥 taxi-blackbox: 블랙박스

<img src="docs/images/blackbox.webp" alt="/blackbox 창. 타임라인, 직전 5개 동작, 빨간 사고 장면 카드, 아래 REC 줄" width="900">

맨 아래 ● REC 줄이 날짜·시각과 함께 계속 녹화해요(응답 중에는 깜빡여요). 오류나 거부가 생기면 사고 N이 노랗게 켜져요.

  • /blackbox를 치면 사고 장면이 열려요. 타임라인(사고는 빨강, 고른 사고는 노랑 ▲), 직전 동작 목록, 사고 카드가 보여요. p·n으로 이전·다음 사고를 넘겨요.
  • 도구마다 색 배지가 붙어요. Bash 주황, Edit·Write 파랑, Read·검색 회색, 웹 청록이에요.
  • 이번 세션의 도구 호출을 최대 500개 메모리에만 기억하고 파일로 남기지 않아요. token=, password=, Bearer, sk-, ghp_, xoxb-, AKIA 형태의 값은 •••로 가려요.
설정기본값설명
before5사고 하나와 함께 보여줄 직전 동작 수

어디서 보이나요

mod의 훅은 Claude Code가 도는 모든 곳에서 실행되지만, 화면은 터미널과 Desktop 앱 Code 탭에만 그려져요.

실행 환경택시 팩 화면
터미널 claude (VS Code·JetBrains 내장 터미널 포함)패널 그대로
Claude Desktop 앱 Code 탭 (로컬 세션)한 줄 표시. Desktop에는 LED 그림(Raster)이 없어서 미터기·내비·단속 카메라가 텍스트로 바뀌어요
VS Code 확장 채팅, claude -p, Agent SDK화면 없음. 단속 카메라는 물어볼 사람이 없어서 위험 명령을 세워요

Desktop 앱 Code 탭의 로컬 세션은 터미널과 같은 ~/.claude 설정을 써서, 위 설치 명령으로 깐 mod가 그대로 로드돼요. 소리와 음성은 macOS에서만 나요.

알아두면 좋은 점

[!CAUTION] mod는 샌드박스 없이 내 권한으로 Claude Code 안에서 실행돼요. 어떤 mod든 설치 전에 코드를 읽어보세요. 이 팩은 $.fs, $.process, $.http를 쓰지 않아요. claude plugin validate plugins/<이름>의 calls: 줄로 직접 확인할 수 있어요.

  • 설정은 /config에서 바꿔요.
  • /receipt·/blackbox 창은 전체화면 모드(settings.json의 "tui": "fullscreen")에서 터미널 폭이 110칸 이상이면 오른쪽에 붙고, 그 밖에는 프롬프트 위에 열려요. 위치는 Claude Code가 정해요.
  • 넷을 다 켜면 프롬프트 위에 최대 18줄 정도가 필요해요. 전체화면 모드에서는 이 영역이 터미널 높이의 절반까지라, 창이 낮으면 아래쪽이 n more로 접히고 스크롤돼요. 창을 50줄 이상으로 키우면 다 보여요.
  • Claude Code 2.1.289에서 만들고 테스트했어요. mods API는 릴리스 사이에 바뀔 수 있어요.

내 mod 만들기

이 팩도 Claude Code에게 말로 시켜서 만들고 다듬었어요. Claude Code 세션에서 원하는 걸 말하면 내장 plugin-authoring 스킬이 mod를 써줘요.

프롬프트 위에 현재 git 브랜치를 보여주는 mod 만들어줘

직접 짜보려면 공식 튜토리얼과 화면 그리기 문서에서 시작하세요. 이 저장소의 plugins/*/hooks/register.ts와 tests/도 예제로 쓸 수 있어요.

claude plugin validate plugins/taxi-meter   # 잡는 이벤트·호출 목록, 정적 검사
claude plugin test plugins/taxi-meter       # 세션·로그인 없이 테스트
python3 scripts/make-sounds.py              # 효과음 다시 합성

효과음은 scripts/make-sounds.py가 직접 합성한 파일이라 외부 음원 라이선스가 없어요.

라이선스

MIT · 만든 사람 개발동생

Source 1 files
hooks/register.ts 336 lines
1import type { Register } from 'claude-code'
2
3type Outcome = 'ok' | 'error' | 'denied'
4type Frame = { at: number; tool: string; what: string; outcome: Outcome; reason?: string; agentId?: string }
5
6const PANE = 'taxi-blackbox'
7const CAPACITY = 500
8const RED = '#ff3b30'
9const YELLOW = '#ffcc00'
10const GREEN = '#34c759'
11const OSD = '#1c1c1e'
12const OSD_TEXT = '#e5e5e5'
13const INK = '#111111'
14
15const TRACK_RGB = 0x111111
16const RAIL_RGB = 0x3a3a3c
17const OK_RGB = 0x8e8e93
18const WINDOW_RGB = 0xf2f2f7
19const INCIDENT_RGB = 0xff3b30
20const PICK_RGB = 0xffcc00
21
22const TOOL_COLOR: Record<string, string> = {
23  Bash: '#ff9f0a',
24  Edit: '#0a84ff',
25  Write: '#0a84ff',
26  NotebookEdit: '#0a84ff',
27  Read: '#8e8e93',
28  Grep: '#8e8e93',
29  Glob: '#8e8e93',
30  WebFetch: '#30b0c7',
31  WebSearch: '#30b0c7',
32}
33const OTHER_TOOL = '#bf5af2'
34
35const SECRETS: readonly [RegExp, string][] = [
36  [/\b((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd)\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s"']+)/gi, '$1•••'],
37  [/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, '$1•••'],
38  [/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-•••'],
39  [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, 'gh•••'],
40  [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, 'xox•••'],
41  [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA•••'],
42]
43
44const mask = (text: string) => SECRETS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
45
46const oneLine = (text: string, max: number) => {
47  const flat = text.replace(/\s+/g, ' ').trim()
48  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
49}
50
51const pad = (n: number) => String(n).padStart(2, '0')
52const clockOf = (ms: number) => {
53  const d = new Date(ms)
54  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
55}
56const stampOf = (ms: number) => {
57  const d = new Date(ms)
58  return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${clockOf(ms)}`
59}
60const elapsed = (ms: number) => {
61  const total = Math.max(0, Math.floor(ms / 1000))
62  const h = Math.floor(total / 3600)
63  const body = `${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`
64  return h > 0 ? `${h}:${body}` : body
65}
66
67const describe = (e: Record<string, unknown>) => {
68  for (const field of ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'description', 'subject', 'prompt']) {
69    const value = e[field]
70    if (typeof value === 'string' && value) return value
71  }
72  const question = Array.isArray(e.questions) ? (e.questions[0] as { question?: unknown } | undefined)?.question : undefined
73  return typeof question === 'string' ? question.split('\n')[0] : ''
74}
75
76const base64 = (bytes: Uint8Array) => {
77  const native = bytes as Uint8Array & { toBase64?: () => string }
78  if (native.toBase64) return native.toBase64()
79  let binary = ''
80  for (const byte of bytes) binary += String.fromCharCode(byte)
81  return btoa(binary)
82}
83
84const columnOf = (index: number, count: number, width: number) =>
85  count <= width ? index : Math.floor((index * width) / count)
86
87const timeline = (frames: Frame[], width: number, window: [number, number] | undefined, pick: number | undefined) => {
88  const cells = Array.from({ length: width }, () => ({ glyph: '▁', rgb: RAIL_RGB, rank: 0 }))
89  frames.forEach((frame, i) => {
90    const column = columnOf(i, frames.length, width)
91    const [glyph, rgb, rank] =
92      i === pick
93        ? ['█', PICK_RGB, 4]
94        : frame.outcome !== 'ok'
95          ? ['█', INCIDENT_RGB, 3]
96          : window && i >= window[0] && i < window[1]
97            ? ['▆', WINDOW_RGB, 2]
98            : ['▄', OK_RGB, 1]
99    if (rank > cells[column].rank) cells[column] = { glyph, rgb, rank }
100  })
101  const numbers = cells.flatMap((cell) => [cell.glyph.codePointAt(0) ?? 32, cell.rgb, TRACK_RGB])
102  return { columns: width, rows: 1, cells: base64(new Uint8Array(Uint32Array.from(numbers).buffer)) }
103}
104
105const reasonOf = (out: { deny?: string; isError?: boolean; result?: unknown }) => {
106  if (out.deny) return out.deny
107  if (!out.isError) return undefined
108  return typeof out.result === 'string' ? out.result : JSON.stringify(out.result ?? '')
109}
110
111export const register: Register = (on, options) => {
112  const before = typeof options.before === 'number' && options.before >= 1 ? Math.floor(options.before) : 5
113
114  const frames: Frame[] = []
115  let dropped = 0
116  let startedAt = 0
117  let blink = true
118  let isDriving = false
119  let clock: { cancel: () => void } | undefined
120  let selected: number | undefined
121
122  const incidents = () => frames.flatMap((frame, i) => (frame.outcome === 'ok' ? [] : [i]))
123
124  on('session.start', async ($, e, next) => {
125    const result = await next(e)
126    await $.command.register({ name: 'blackbox', description: '블랙박스: 사고(오류·거부) 직전 동작 돌려보기', immediate: true })
127    startedAt = await $.clock.now()
128    clock?.cancel()
129    clock = $.clock.every(1000, () => {
130      blink = isDriving ? !blink : true
131      $.ui.invalidate('ui.render')
132    })
133    return result
134  })
135
136  on('session.end', async ($, e, next) => {
137    clock?.cancel()
138    return next(e)
139  })
140
141  on('turn.start', async ($, e, next) => {
142    isDriving = true
143    return next(e)
144  })
145
146  on('turn.complete', async ($, e, next) => {
147    const result = await next(e)
148    if (!(e as { agentId?: string }).agentId) {
149      isDriving = false
150      blink = true
151      $.ui.invalidate('ui.render')
152    }
153    return result
154  })
155
156  on('tool.call', async ($, e, next) => {
157    const at = await $.clock.now()
158    const frame: Frame = {
159      at,
160      tool: e.tool,
161      what: oneLine(mask(describe(e as unknown as Record<string, unknown>)), 90),
162      outcome: 'ok',
163      ...(e.agentId ? { agentId: e.agentId } : {}),
164    }
165    const keep = () => {
166      frames.push(frame)
167      if (frames.length > CAPACITY) {
168        frames.shift()
169        dropped += 1
170      }
171      $.ui.invalidate('ui.render')
172    }
173    try {
174      const out = await next(e)
175      const reason = reasonOf(out)
176      if (out.deny) frame.outcome = 'denied'
177      else if (out.isError) frame.outcome = 'error'
178      if (reason) frame.reason = oneLine(mask(reason), 160)
179      keep()
180      return out
181    } catch (error) {
182      frame.outcome = 'error'
183      frame.reason = oneLine(mask(String(error)), 160)
184      keep()
185      throw error
186    }
187  })
188
189  on('command.run', { command: 'blackbox' }, async ($) => {
190    selected = undefined
191    await $.ui.open({ id: PANE, title: '블랙박스', focus: true, closeOnEscape: true })
192    return {}
193  })
194
195  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
196    if (e.props.hasSurvey || startedAt === 0) return next(e)
197    const { Box, Text } = $.ui.resolve(e)
198    const now = await $.clock.now()
199    const count = incidents().length
200    const rec = Box({
201      flexDirection: 'row',
202      columnGap: 1,
203      children: [
204        Text({ bold: true, color: blink ? '#ffffff' : RED, backgroundColor: blink ? RED : OSD, children: [' ● REC '] }),
205        Text({ color: OSD_TEXT, backgroundColor: OSD, children: [` ${stampOf(now)} `] }),
206        Text({ dimColor: true, children: [`${elapsed(now - startedAt)} · 기록 ${frames.length + dropped}`] }),
207        ...(count > 0 ? [Text({ bold: true, color: INK, backgroundColor: YELLOW, children: [` 사고 ${count} `] })] : []),
208        Text({ dimColor: true, children: ['/blackbox'] }),
209      ],
210    })
211    const above = await next(e)
212    return above ? Box({ flexDirection: 'column', children: [above, rec] }) : rec
213  })
214
215  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
216    if (e.requestId !== PANE) return next(e)
217    const elements = $.ui.resolve(e)
218    const { Box, Text, Button } = elements
219    const redraw = () => $.ui.invalidate('ui.render')
220    const list = incidents()
221    const pick = list.length === 0 ? undefined : Math.min(selected ?? list.length - 1, list.length - 1)
222    const width = Math.max(20, e.props.bodyColumns - 4)
223
224    const fixed = (child: ReturnType<typeof Text>) => Box({ flexShrink: 0, children: [child] })
225    const fill = (child: ReturnType<typeof Text>) => Box({ flexGrow: 1, flexShrink: 1, minWidth: 0, children: [child] })
226    const isWide = e.props.bodyColumns >= 60
227
228    const badge = (frame: Frame, isIncident: boolean) =>
229      Text({
230        bold: true,
231        color: INK,
232        backgroundColor: isIncident ? RED : (TOOL_COLOR[frame.tool] ?? OTHER_TOOL),
233        children: [` ${frame.agentId ? '↳ ' : ''}${frame.tool} `],
234      })
235
236    const line = (frame: Frame, isIncident: boolean) =>
237      Box({
238        flexDirection: 'row',
239        columnGap: 1,
240        children: [
241          fixed(Text({ dimColor: true, children: [clockOf(frame.at)] })),
242          fixed(badge(frame, isIncident)),
243          fill(Text({ wrap: 'truncate-end', ...(isIncident ? { bold: true } : { dimColor: true }), children: [frame.what || '-'] })),
244        ],
245      })
246
247    const scrubber = (window: [number, number] | undefined, at: number | undefined) => {
248      if (!('Raster' in elements) || frames.length === 0) return []
249      const caret = at === undefined ? [] : [Text({ color: YELLOW, bold: true, children: [`${' '.repeat(columnOf(at, frames.length, width))}▲`] })]
250      return [elements.Raster({ key: 'timeline', ...timeline(frames, width, window, at) }), ...caret]
251    }
252
253    const header = (mode: 'REC' | 'PLAY', title: string, stamp: number) =>
254      Box({
255        flexDirection: 'row',
256        columnGap: 1,
257        children: [
258          fixed(
259            Text({
260              bold: true,
261              color: mode === 'REC' ? '#ffffff' : INK,
262              backgroundColor: mode === 'REC' ? RED : GREEN,
263              children: [mode === 'REC' ? ' ● REC ' : ' ▶ PLAY '],
264            }),
265          ),
266          fill(Text({ bold: true, wrap: 'truncate-end', children: [title] })),
267          fixed(Text({ color: OSD_TEXT, backgroundColor: OSD, children: [` ${isWide ? stampOf(stamp) : clockOf(stamp)} `] })),
268        ],
269      })
270
271    if (pick === undefined) {
272      return Box({
273        flexDirection: 'column',
274        paddingX: 1,
275        children: [
276          header('REC', `블랙박스 · 기록 ${frames.length + dropped} · 사고 없음`, await $.clock.now()),
277          Text({ dimColor: true, children: ['오류나 거부가 생기면 그 직전 동작을 여기서 돌려볼 수 있어요. 최근 동작:'] }),
278          ...scrubber([Math.max(0, frames.length - before), frames.length], undefined),
279          ...frames.slice(-before).map((frame) => line(frame, false)),
280        ],
281      })
282    }
283
284    const at = list[pick]
285    const incident = frames[at]
286    const label = incident.outcome === 'denied' ? '거부' : '오류'
287    return Box({
288      flexDirection: 'column',
289      paddingX: 1,
290      children: [
291        header('PLAY', `사고 ${pick + 1}/${list.length} · ${incident.tool} ${label}`, incident.at),
292        ...scrubber([Math.max(0, at - before), at], at),
293        Text({ dimColor: true, children: [`직전 ${before}개 동작`] }),
294        ...frames.slice(Math.max(0, at - before), at).map((frame) => line(frame, false)),
295        Box({
296          flexDirection: 'column',
297          borderStyle: 'round',
298          borderColor: RED,
299          paddingX: 1,
300          children: [
301            fixed(Text({ bold: true, color: '#ffffff', backgroundColor: RED, children: [' 사고 장면 '] })),
302            line(incident, true),
303            Text({ color: RED, wrap: 'wrap', children: [`└ ${incident.reason ?? label}`] }),
304          ],
305        }),
306        Box({
307          flexDirection: 'row',
308          columnGap: 2,
309          children: [
310            Button({
311              key: 'prev',
312              label: '◀ 이전 사고',
313              hotkey: 'p',
314              dimColor: pick === 0,
315              onPress: () => {
316                selected = Math.max(0, pick - 1)
317                redraw()
318              },
319            }),
320            Button({
321              key: 'next',
322              label: '다음 사고 ▶',
323              hotkey: 'n',
324              dimColor: pick === list.length - 1,
325              onPress: () => {
326                selected = Math.min(list.length - 1, pick + 1)
327                redraw()
328              },
329            }),
330          ],
331        }),
332      ],
333    })
334  })
335}
336