SLOPSHOPPER

harness-cockpit

A Claude Code mod that puts a live status band above the prompt: progress steps, context and rate-limit usage, subagents, a sun-arc clock with an end-of-day…

newpanebandspinnerrowsguard
v0.2.0no licenseupdated 2026-10-08KimGyuBek/harness-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness-cockpit
│ ┃ 콕핏 ✕ › fix the failing auth test and add an audit log call │ ┃ 표시할 것 없음 │ ┃ 워커 · 서브 · 감시가 ● harness-cockpit: cockpit: 최근 한도 값 읽기 실패 Error: ENOENT: no such file │ ┃ 생기면 여기에 쌓인다 ⏺ 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 · 15:54 · 도구 9 · 고친 파일 3 · 실패 1 │ │ › /leftovers │ ⎿ harness-cockpit: 띄운 것 없음 — 세션을 닫아도 된다 │ │ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ✓ 플랜 ── ✓ 코드 ── ● 구현 ● 코드 수정 3 ── ○ 검증 ── ○ 완료 ✔ 완료 42s ┃ ┃ ● app ⎇ feat/auth-refresh *2 │ ◆ Opus 5.5 목 퇴근 2h06m ctx 49% 5h 31% 7d — ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ ✓ 플랜 ── ✓ 코드 ── ● 구현 ● 코드 수정 3 ── ○ 검증 ── ○ 완료 ✔ 완료 42s ┃ ┃ ● app ⎇ feat/auth-refresh *2 │ ◆ Opus 5.5 목 퇴근 2h06m ctx 49% 5h 31% 7d — ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
Pane · 콕핏
표시할 것 없음 워커 · 서브 · 감시가 생기면 여기에 쌓인다
Turn line
✻ Worked for 42s · 15:54 · 도구 9 · 고친 파일 3 · 실패 1
README

harness-cockpit

A Claude Code mod that puts a live status band above your prompt — progress steps, context and rate-limit usage, running subagents, a sun-arc clock with an end-of-day countdown, and an animated Clawd that reacts to what the session is doing.

It only watches. It never blocks or rewrites a tool call.

The band while a turn is running

한국어 설명은 README.ko.md.

The pictures above the "Real screens" section are design renders. The band's own text is in Korean; everything else works regardless of language.

Install

At the prompt of a Claude Code terminal session:

/plugin install harness-cockpit --marketplace KimGyuBek/harness-cockpit

Answer y to Add marketplace?, then press Enter for the user scope. The band appears in that session right away.

To try it from a checkout instead: claude --plugin-dir <folder>.

Requirements

Claude CodeA build with mods. Built and checked on 2.1.291 – 2.1.293
OSChecked on macOS only. CPU / memory and the keep-awake indicator are read with macOS commands (iostat, memory_pressure, pmset)
Terminal24-bit color recommended. It works in 256 colors, with rougher sky and Clawd colors
WidthEverything fits at 127 columns. Narrower windows fold the arc first, then the usage pills, and keep the values as text

Reading the band

WhereWhat
Left pictureClawd and the sky behind it
Row 1issue key │ progress steps ··· remote indicator · state badge (working · N subagents working · done · idle N min · waiting for you)
Row 2session / issue title │ ● repo │ ◆ model · effort
Row 3counts of subagents · monitors · workers · things left running, and caffeinate
Pillsctx (context window) · 5h · 7d rate-limit usage
Arcwhere the sun or moon is, time left until the end of the workday, weekdays

Features

Progress steps

✓ 플랜 ── ● 구현 ── ○ 검증 (plan ── implement ── verify) — which step the session is on. Implementation work goes plan → code → implement → verify → done; research goes research → organize → report.

  • Steps are inferred from the tools Claude uses.
  • The mod registers one tool, phase. When Claude calls it, that value wins. One line in your CLAUDE.md ("call phase once when you move to the next step") makes the steps accurate.
  • Color means state: finished green, active blue, waiting for a decision yellow, blocked red.

Usage pills

ctx 11% 5h 32% 7d 5% — the context window and the 5-hour and 7-day limits. Each pill fills with color as the value grows, in five levels (30 / 50 / 70 / 85 %). Unknown values show —.

Sun arc, end-of-day countdown, weekdays

A dotted arc that the sun crosses by day and the moon by night. With a location in the config, sunrise and sunset are computed for the date; without one they are fixed at 06:00 and 18:00.

  • The number inside is the time left until the end of the workday (4h18m, 42m; 18:00 unless you set quitTime). It moves gray → green → lime → yellow → orange → red as the time approaches and blinks for the last ten minutes. After that it shows the overtime (+23m) in purple. Hidden on weekends.
  • The strip below is the week, today highlighted.

Night

Clawd

The mascot on the left moves with the session: pacing while thinking, reading, hammering while editing, saluting when you press Enter, cheering when a turn ends, startled on an error.

Machine load, shown on its body — it turns redder and gains sweat, steam and a shiver as CPU or memory climbs. No numbers, no text.

Load levels

LevelThreshold
CautionCPU 50 % or memory 75 %
HighCPU 80 % for 30 s, or memory 85 %, or a memory-pressure warning
CriticalCPU 95 % for 60 s, or memory 93 %, or critical memory pressure

Quitting time — on weekdays, from five minutes before the end of the workday until five minutes after, Clawd puts on sunglasses and dances (even in a sleeping session, and it keeps the sunglasses on while working). From then until an hour after, it slumps, sighs and sheds a tear. After that it is back to normal.

Sky — the cells behind Clawd change with the time of day: morning, day, sunset, night, with clouds and stars.

Four skies

Dozing and sleeping — after 5 idle minutes Clawd starts nodding off. After 15 it sleeps, and the sky turns to night with a yellow half moon whatever the hour. After 60 the whole band dims. Typing a prompt wakes it. Idle time is counted per session.

A sleeping session

Working border

The border color tells which group of repos the session is in (blue by default, red for the groups you configure). While the session works, the border blinks between that color and black every 0.6 s. A session whose main turn is idle but whose subagents are still running counts as working.

Only subagents working

Remote and keep-awake indicators

  • A phone glyph in front of the badge while Remote Control is on.
  • The word caffeinate at the end of row 3 while caffeinate is keeping the display awake.

Subagents, monitors, things left running

Row 3 shows the counts; /cockpit opens a side panel with the full lists. A subagent that has been silent for ten minutes is marked stalled. Things this session left running — docker containers, browsers, unpushed commits — are counted too.

Time effects

No text, only motion. They play in every open session at once.

WhenEffect
Weekday times you list in bellsClawd rings a bell
Every hour on the hourThe sun on the arc flashes and the whole path lights up

Outside the band

Places outside the band

PlaceContent
Spinner linecurrent step · the command being run · tool count · messages received mid-turn
Turn-finished linea receipt — end time · tools · files changed · failures · context and limit growth
Line under the inputwhat this session left running
/nowa summary of the session so far, without calling the model (zero tokens)

Also

  • Session title — set once, from the issue title, only when the session has no name. A name you typed is never touched.
  • Start effects — a 2–3 second intro when a new session opens. Pick or disable with /fx.
  • Alert line — while a file you list as an alert file exists, the border turns double red and an alert line appears on top.

Commands

CommandDoes
/nowprogress summary (zero tokens)
/cockpitopen the side panel
`/cockpit sky on\off`sky behind Clawd (saved)
`/cockpit sky morning\day\sunset\night\auto`preview a sky
/cockpit pollshow polling intervals; change one with /cockpit poll sys 3 (saved)
/cockpit idlethis session's last activity and idle stage
`/cockpit fx bell\hour`play a time effect now
`/cockpit fx party\gloom`preview the quitting-time moods for ten seconds
/cockpit configshow what was read from the config file; /cockpit config reload re-reads it
`/clawd on\off`show or hide Clawd
/fxstart effects — preview /fx 1 3, choose /fx use 1 3, disable /fx off
/leftoverslist what this session left running

Configuration

Optional. Create ~/.claude/harness-cockpit.json (see docs/config.example.json). Without it the band still works; the items below are simply absent.

{
  "repos": [
    { "match": "/work/shop(/|$)", "name": "Shop", "color": "#39C5BB", "family": "red", "brand": "Shop", "word": "SHOP" }
  ],
  "alertFiles": [{ "path": "/tmp/.allow-prod-once", "label": "prod" }],
  "alert": { "title": "Approval flag", "note": "the next production query will pass" },
  "branchKey": "(shop|web)-[0-9]+",
  "branchKeyFlags": "i",
  "serviceWindow": { "label": "dev server", "startHour": 9, "endHour": 21 },
  "location": { "lat": 51.48, "lon": 0.0 },
  "quitTime": "18:00",
  "bells": ["12:00"]
}
KeyMeaningWithout it
repos[].matcha regular expression tested against the session's working folder—
repos[].name · colorrepo label and dot color on row 2the folder name, dimmed
repos[].familyred or blue — the border colorblue
repos[].brand · wordname and big wordmark used by the start effectsthe folder name
alertFiles[]files that turn the band into an alert while they exist. Only the session that created the file shows the alertno alerts
alert.title · notewording of the alert line"경보 파일"
branchKeypattern that pulls an issue key out of the branch nameupper-case keys like ABC-123
serviceWindowsomething that only runs on weekdays between two hours; shown in the briefing start effectthe line is omitted
locationlatitude and longitude for sunrise and sunset (the time zone is the computer's)sunrise 06:00, sunset 18:00
quitTimeend of the workday, HH:MM18:00
bellsweekday times at which Clawd rings a bellnone
verifyCommanda regular expression for extra commands that count as the verify stepthe built-in list (jest, vitest, npm test, …)
textsyour own wording for a few lines (leftClear, leftBusy, leftPane, boardHint)the built-in wording
issue.mcpServer · keyPatternMCP server whose get_issue returns the issue titlelinear, keys like ABC-123
issue.boardCommand · boardLineDir · fleetRoothooks into the author's own task-board and multi-session toolsthose chips are absent

One value is still a code constant:

WhatFileNameDefault
Doze / sleep / dimhooks/model.jsIDLE_DEFAULT5 / 15 / 60 min

Cost

Measured on one Mac; treat the numbers as an order of magnitude.

One idle session1.8 – 1.9 % of one core (0.45 % with every customization off) — the mod is about 1.4 points at most
Of which Clawdabout 0.8 points. After 15 idle minutes it redraws once a second
Tokens228 per request (the phase tool definition). Pictures and effects cost none
Machine load and keep-awake samplingone open session samples; the others read its result file

Values the engine reports as events (working, step, tool count, subagents, context) are never polled. A sleeping session polls six times less often.

Real screens

Captured in a 256-color test terminal, so colors are rougher than in a 24-bit terminal.

Light and dark themes

Skies, dozing and sleeping

Known limits

  • Numbering your own prompt lines is not possible — the engine does not draw what a mod returns for those rows. /now lists the prompts instead.
  • Picture cells (Clawd, the arc) cannot follow the terminal theme, so they use fixed mid-tone colors that read on both light and dark backgrounds.
  • Installing through /plugin install from GitHub has not been run end to end yet. claude plugin validate passes and the mod loads through --plugin-dir and CLAUDE_CODE_PLUGIN_DIRS.
  • This is an unofficial community mod and is not affiliated with Anthropic.

Layout and development

.claude-plugin/plugin.json        manifest
.claude-plugin/marketplace.json   makes this repository installable as a marketplace
hooks/register.js                 event watching, state, polling, commands
hooks/model.js                    pure logic (step inference, color levels, idle, wording)
hooks/view.js                     band and panel trees
hooks/clawd.js                    Clawd scenes and picture cells
hooks/arc.js                      sun arc, countdown, sky, time effects
hooks/poll.js                     polling intervals, machine load parsing
hooks/config.js                   the optional config file
hooks/fx.js · mascot.js           start effects
tests/                            83 unit tests
claude plugin validate .
claude plugin test
Source 9 files
hooks/register.js 1526 lines
1// 하네스 콕핏 진입점. 이벤트를 관찰만 하고(항상 next 로 넘긴다) 모듈 변수에 상태를 모아
2// 프롬프트 위 띠 · 슬림 패널 · Agent 줄 · Monitor 알림 줄 · 토스트를 그린다.
3// 툴 호출을 막거나 바꾸지 않는다. 직접 답하는 툴 호출은 이 mod 가 등록한 phase 툴뿐이다.
4import {
5  alertFiles,
6  IMPL_STEPS,
7  repoFamily,
8  repoInfo,
9  branchKey,
10  rateOf,
11  modelLabel,
12  planProgress,
13  classifyTool,
14  newStepState,
15  applyActivity,
16  noteBashResult,
17  viewSteps,
18  subStatus,
19  parseTaskNotification,
20  isEndStatus,
21  monitorRemainMs,
22  monitorWarnMs,
23  dockerNameOf,
24  flagsCreatedBy,
25  fmtDur,
26  fmtClock,
27  fleetView,
28  workState,
29  idleStage,
30  isDozing,
31  restoreActive,
32  idleText,
33  titleCandidate,
34  spinnerTail,
35  receiptText,
36  hintTail,
37  nowText,
38} from './model.js'
39import { band, pane, agentStatus, monitorLine, leftoversText, arcFits } from './view.js'
40import { clawdFrame } from './clawd.js'
41import { sunTimes, quitTimer, quitMood, arcCells, weekSpans, chimeAt, skyOf, sleepSky, SKY_PHASES, SUN_FIXED } from './arc.js'
42import { normalizePoll, setPoll, pollText, due, SYS_CMD, parseSys, smoothCpu, sustain, sysLevel, parseAwake, shouldSample, SLEEP_FACTOR } from './poll.js'
43import { setConfig, getConfig, configText } from './config.js'
44import { FX, draw, bootCard, parseBoard, devWindow, dateTexts, repoOf, normalizeChoice, parseIds, introLayout } from './fx.js'
45
46const PANE_ID = 'cockpit'
47const PHASE_TOOL = 'mcp__harness-cockpit__phase'
48
49let sid = ''
50let home = ''
51let cwd = ''
52let family = 'blue'
53let repo = { name: '', color: 'inactive', worktree: false }
54let branch = ''
55let dirty = 0
56let issue = { key: '', title: '', state: '' }
57let issueFetchedKey = ''
58let issueFetchedAt = 0
59let model = ''
60let effort = ''
61let usage = { ctx: null, h5: null, d7: null }
62let busy = false
63let turnStart = 0
64let lastDur = 0
65let asking = 0
66let frame = 0
67let lastViewportCols = 0
68let lastStepKey = ''
69
70const steps = newStepState()
71const subs = new Map()
72const subByTool = new Map()
73const mons = new Map()
74const dockers = new Set()
75let shells = []
76let browserOpen = false
77// 경보 파일의 경보는 그 파일을 「만든 세션」의 띠에만 띄운다(모든 세션에 뜨면 어느 세션 일인지 헷갈린다).
78// flags = 이 세션이 만든 플래그 중 아직 살아 있는 것. flagOwned 는 파일(/tmp/.claude-cockpit/<sid>.flags)에도 남겨 mod 를 다시 읽어도 유지한다.
79// 부재 시 동작: 콕핏이 없는 세션이나 사람이 직접 만든 플래그는 주인이 없어 어느 띠에도 뜨지 않는다. 그 파일을 읽는 쪽의 동작은 그대로다.
80let flags = []
81const flagOwned = new Set()
82const flagTouched = new Map()
83
84async function saveOwned($) {
85  if (!sid) return
86  try {
87    await $.fs.write('/tmp/.claude-cockpit/' + sid + '.flags', JSON.stringify([...flagOwned]))
88  } catch (err) {
89    $.ui.log('cockpit: 플래그 주인 기록 실패 ' + String(err), { to: 'debug' })
90  }
91}
92
93async function loadOwned($) {
94  try {
95    const path = '/tmp/.claude-cockpit/' + sid + '.flags'
96    if (!sid || !(await $.fs.exists(path))) return
97    for (const label of JSON.parse(await $.fs.read(path))) flagOwned.add(String(label))
98  } catch (err) {
99    $.ui.log('cockpit: 플래그 주인 읽기 실패 ' + String(err), { to: 'debug' })
100  }
101}
102let fleet = null
103let rollupAt = 0
104
105let paneOpen = false
106let paneInline = false
107let paneUserClosed = false
108let paneAsked = false
109
110// 새 세션 시작 효과. intro 가 있으면 고른 효과들을 함께 재생한다(1번은 띠 자리에서, 나머지는 띠 위에).
111// introChoice: 효과 번호 배열, 빈 배열 = 끔. `/fx use 1 3` 이 $.store 에 남겨 다음 세션에도 이어진다.
112// 부재 시 동작: 저장된 값이 없으면 INTRO_DEFAULT(부팅 스윕)를 쓴다.
113const INTRO_DEFAULT = [1]
114let intro = null
115let introTimer = null
116let introChoice = INTRO_DEFAULT.slice()
117let interactive = false
118// 새로 연 세션인지는 세션 시작 시각으로 가린다 — 이어 연 세션은 처음 연 시각을 그대로 갖고 있어 오래된 값이 나온다.
119const FRESH_MS = 20000
120// 모듈이 올라온 직후부터 session.start 가 「새 세션인가」를 정할 때까지는 꺼진 카드를 그린다.
121// 평소 띠가 먼저 보였다가 부팅 스윕으로 바뀌는 깜빡임을 막는다. 3초가 지나면 무조건 평소 띠로 돌아간다.
122let booting = true
123const loadedAt = Date.now()
124
125// 띠 왼쪽의 Clawd. 그림은 띠가 그려질 때 한 번 올리고, 150ms 마다 $.ui.blit 으로 프레임만 갈아 끼운다.
126// clawdOn: `/clawd` 로 켜고 끄며 $.store 에 남는다. 부재 시 동작: 저장된 값이 없으면 켠다.
127let clawdOn = true
128let bandId = ''
129let doneAt = 0
130// Clawd 가 따라 하는 일들. clawdAct: 가장 최근 툴의 종류(읽기 · 고치기 · 실행) — 6초가 지나면 「생각 중」으로 돌아간다.
131// clawdHold: 아직 끝나지 않은 Bash 수(오래 도는 테스트 동안 계속 달리게 한다).
132const ACT_SCENE = { plan: 'edit', impl: 'edit', note: 'edit', report: 'edit', code: 'read', research: 'read', verify: 'run' }
133let clawdAct = 'think'
134let clawdActAt = 0
135let clawdHold = 0
136let errAt = 0
137let coinAt = 0
138let submitAt = 0
139// 스피너가 마지막으로 그려진 시각과, busy 를 스피너를 보고 추정했는지(턴 시작 이벤트를 못 본 경우).
140let spinAt = 0
141let spinFirst = 0
142let busyGuess = false
143
144// ── 띠 최종 시안(2026-10-08) ─────────────────────────────────────────────────────
145// 요소별 폴링 주기(초). `/cockpit poll` 로 바꾸면 $.store 에 남는다. 부재 시 동작: 저장된 값이 없으면 poll.js 의 기본값을 쓴다.
146let poll = normalizePoll(null)
147const lastRun = {}
148const pollBusy = {}
149// 맥 자원 · 화면 안 꺼짐은 한 세션만 재서 /tmp/.claude-cockpit/ 에 남기고 나머지 세션은 그 파일을 읽는다(세션 수만큼 재지 않는다).
150let sysBusy = false
151let sysCpu = null
152const sysHist = []
153let heat = 0
154let awake = false
155let awakeBusy = false
156// 원격(Remote Control): 켜면 엔진이 ~/.claude/sessions/<pid>.json 에 bridgeSessionId 를 적는다(2026-10-08 실측).
157// 폰이 붙기 전에도 「켜져 있음」 을 알 수 있어, 붙은 화면 목록만 볼 때 표시가 안 뜨던 것을 고쳤다.
158let sessionFile = ''
159// Clawd 뒤 하늘. `/cockpit sky on|off` 로 켜고 끄며 $.store 에 남는다. 부재 시 동작: 저장된 값이 없으면 켠다.
160let skyOn = true
161// `/cockpit sky <시간대>` 로 잠깐 다른 시간대를 본다(저장하지 않는다). 빈 값이면 지금 시각을 따른다.
162let skyForce = ''
163let remote = false
164// 오래 쉼: 본체 턴 · 하위 작업 · 프롬프트가 모두 없던 때부터 센다. 0 깨어 있음 · 1 잠(15분) · 2 흐림(60분).
165// idleCfg 는 `/cockpit idle <초> <초>` 로 시험할 때만 바꾸고 저장하지 않는다.
166let lastActiveAt = Date.now()
167// 파일에 남긴 마지막 활동 시각(같은 값을 다시 쓰지 않으려고 기억한다).
168let activeSaved = 0
169let idleNow = 0
170let idleCfg = null
171// 반원: 일출 · 일몰은 날짜가 바뀔 때만 다시 셈한다. bellAt · flashAt 은 효과가 시작된 시각.
172let sunDay = ''
173let sun = { rise: 392, set: 1086 }
174let lastMinute = -1
175let bellAt = 0
176let flashAt = 0
177let arcShown = ''
178let bodyCols = 0
179// 세션 이름: 비어 있을 때 한 번만 붙인다(손으로 붙인 이름은 건드리지 않는다).
180let sessionTitle = ''
181let titleTried = false
182let issueFullTitle = ''
183let wipLine = { name: '', auto: true }
184// 띠 밖: 이번 턴의 셈 · 보낸 프롬프트 · 줄마다의 꼬리표.
185let unpushed = 0
186let turn = newTurn()
187let toolLabel = ''
188let promptSeq = 0
189const prompts = []
190// 끝난 턴의 영수증. 「Worked for …」 줄과는 걸린 시간(ms)으로 짝을 짓는다 — 줄이 그려지는 순서에 기대면 mod 를 다시 읽은 뒤 옛 줄에 붙는다.
191const receipts = []
192
193function newTurn() {
194  return { tools: 0, files: new Set(), fails: 0, subs: 0, mid: 0, ctx0: null, h50: null }
195}
196
197function runningSubs() {
198  return [...subs.values()].filter((s) => !s.done).length
199}
200
201function activeWorkers() {
202  return fleet ? fleetView(fleet.tasks, now()).open.filter((w) => w.state === 'active').length : 0
203}
204
205// 작업 중인가 — 본체 턴 또는 하위 세션(서브에이전트 · 워커).
206function work() {
207  return workState({ busy, subs: runningSubs(), workers: activeWorkers() })
208}
209
210// 사용자 환경 설정(레포 이름표 · 경보 파일 · 작업 대장 등)을 읽는다. 부재 시 동작: 파일이 없으면 기본값이고 해당 기능만 빠진다.
211let configFound = false
212function configPath() {
213  return home + '/.claude/harness-cockpit.json'
214}
215async function loadConfig($) {
216  configFound = false
217  try {
218    if (home && (await $.fs.exists(configPath()))) {
219      setConfig(JSON.parse(await $.fs.read(configPath())), home)
220      configFound = true
221      return
222    }
223  } catch (err) {
224    $.ui.log('cockpit: 설정 파일 읽기 실패 ' + String(err), { to: 'debug' })
225  }
226  setConfig(null, home)
227}
228
229function clockNow() {
230  const d = new Date(now())
231  return { d, min: d.getHours() * 60 + d.getMinutes(), dow: d.getDay(), key: d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() }
232}
233
234// 그날의 일출 · 일몰. 위치가 설정에 있으면 계산하고(시간대는 이 컴퓨터의 것), 없으면 06:00 · 18:00 으로 고정한다.
235function sunOf(d) {
236  const loc = getConfig().location
237  if (!loc) return SUN_FIXED
238  return sunTimes(d.getFullYear(), d.getMonth() + 1, d.getDate(), loc.lat, loc.lon, -d.getTimezoneOffset() / 60)
239}
240
241function skyNow() {
242  const c = clockNow()
243  if (c.key !== sunDay) {
244    sunDay = c.key
245    sun = sunOf(c.d)
246  }
247  // 잠든 세션은 낮이어도 밤하늘에 달을 띄운다(미리 보기를 켜 둔 동안은 미리 보기가 먼저다).
248  if (idleNow >= 1 && !skyForce && moodNow() !== 'party') return sleepSky()
249  // 시간대를 미리 볼 때는 그 시간대의 한가운데 시각으로 그린다.
250  const at = { morning: 450, day: 780, sunset: sun.set - 20, night: 1290 }[skyForce]
251  return Object.assign({}, skyOf(at === undefined ? c.min : at, sun.rise, sun.set), { showOrb: !arcFits(bodyCols, clawdOn) })
252}
253
254function flashPhase(t) {
255  return flashAt && t - flashAt < 2000 ? Math.floor((t - flashAt) / 250) + 1 : 0
256}
257
258function arcNow() {
259  const c = clockNow()
260  if (c.key !== sunDay) {
261    sunDay = c.key
262    sun = sunOf(c.d)
263  }
264  const t = now()
265  // 숫자가 깜빡이는 것은 마지막 10분뿐이다. 그때가 아니면 위상을 0 으로 고정해, 분이 바뀔 때만 그림이 달라지게 한다(잠든 세션도 같다).
266  const timer = quitTimer(c.min, c.dow, getConfig().quitMin)
267  return arcCells({ min: c.min, rise: sun.rise, set: sun.set, timer, tick: timer.blink && idleNow < 1 ? Math.floor(t / 1000) : 0, flash: flashPhase(t) })
268}
269
270// 퇴근 무렵의 기분. `/cockpit fx party|gloom` 으로 10초 동안 미리 볼 수 있다(moodForce).
271let moodForce = ''
272let moodForceUntil = 0
273function moodNow() {
274  if (moodForce && now() < moodForceUntil) return moodForce
275  const c = clockNow()
276  return quitMood(c.min, c.dow, getConfig().quitMin)
277}
278
279function clawdNow() {
280  const t = now()
281  const since = (at) => (at ? t - at : -1)
282  return clawdFrame(
283    {
284      asking: asking > 0,
285      busy,
286      activity: clawdHold > 0 || t - clawdActAt < 6000 ? clawdAct : 'think',
287      sinceDone: since(doneAt),
288      sinceErr: since(errAt),
289      sinceSubmit: since(submitAt),
290      sinceCoin: since(coinAt),
291      sinceBell: since(bellAt),
292      hot: (usage.ctx !== null && usage.ctx >= 85) || !!(usage.h5 && usage.h5.pct >= 85),
293      heat,
294      asleep: idleNow >= 1,
295      dozing: isDozing(t, lastActiveAt, work().working, idleCfg),
296      mood: moodNow(),
297      sky: skyOn ? skyNow() : null,
298    },
299    t,
300  )
301}
302
303function now() {
304  return Date.now()
305}
306
307function snapshot(cols) {
308  const t = now()
309  return {
310    now: t,
311    cols,
312    frame,
313    family,
314    flags,
315    issue,
316    repo,
317    coKey: branchKey(branch),
318    branch,
319    dirty,
320    model,
321    effort,
322    usage,
323    busy,
324    turnStart,
325    lastDur,
326    asking: asking > 0,
327    work: work(),
328    idle: idleNow,
329    lastActiveAt,
330    remote,
331    awake,
332    arc: { cells: arcNow() },
333    week: weekSpans(clockNow().dow),
334    timer: quitTimer(clockNow().min, clockNow().dow, getConfig().quitMin),
335    clawd: clawdOn ? { cells: clawdNow() } : null,
336    fleet: fleet ? Object.assign({}, fleet, { rollupAt }) : null,
337    subs: [...subs.values()].map((s) => Object.assign({}, s, { status: subStatus(s, t), el: t - s.start })),
338    mons: [...mons.values()].map((m) => {
339      const remainMs = monitorRemainMs(m, t)
340      return Object.assign({}, m, { remainMs, elMs: t - m.start, warn: !m.ended && remainMs <= monitorWarnMs(m) })
341    }),
342    left: leftList(),
343  }
344}
345
346function leftList() {
347  const out = []
348  for (const s of shells) out.push('셸 ' + s)
349  for (const d of dockers) out.push('docker ' + d)
350  if (browserOpen) out.push('Playwright 브라우저')
351  return out
352}
353
354function endIntro($) {
355  intro = null
356  if (introTimer) introTimer.cancel()
357  introTimer = null
358  $.ui.invalidate('ui.render')
359}
360
361// 작업 대장 요약은 `wip ls` 출력에서 읽는다. `wip ls` 는 「N번 이어서」용 번호 파일을 세션별로 다시 쓰므로,
362// 세션 id 변수를 빼고 실행해 이 세션의 번호 파일을 건드리지 않는다.
363async function readBoard($) {
364  try {
365    // 부재 시 동작: 설정에 작업 대장 명령(issue.boardCommand)이 없으면 읽지 않는다 — 시작 효과의 브리핑 카드에서 그 칸만 빠진다.
366    const cmd = getConfig().issue.boardCommand
367    if (!cmd) return null
368    const r = await $.process.run(['/usr/bin/env', '-u', 'CLAUDE_CODE_SESSION_ID', ...cmd], { timeoutMs: 8000 })
369    return r.exitCode === 0 ? parseBoard(r.stdout) : null
370  } catch (err) {
371    $.ui.log('cockpit: 작업 대장 읽기 실패 ' + String(err), { to: 'debug' })
372    return null
373  }
374}
375
376// 새 세션 첫 몇 초는 엔진이 한도 값을 아직 모른다. statusLine 스크립트가 남긴 최근 값(계정 공통)을 먼저 써서
377// 부팅 스윕의 게이지가 빈 채로 지나가지 않게 한다. 엔진 값이 오면 그 값이 덮어쓴다.
378// 부재 시 동작: 파일이 없거나 30분보다 오래됐으면 값 없이(—) 간다.
379async function seedUsageFromCache($) {
380  try {
381    const j = JSON.parse(await $.fs.read('/tmp/.claude-usage/current.json'))
382    if (!j || now() / 1000 - Number(j.ts) > 1800) return
383    const pick = (w) => (w && typeof w.used_percentage === 'number' ? { pct: Math.round(w.used_percentage), resetsAt: w.resets_at ? Number(w.resets_at) * 1000 : null } : null)
384    usage = { ctx: usage.ctx, h5: usage.h5 || pick(j.five_hour), d7: usage.d7 || pick(j.seven_day) }
385  } catch (err) {
386    $.ui.log('cockpit: 최근 한도 값 읽기 실패 ' + String(err), { to: 'debug' })
387  }
388}
389
390function usageForFx() {
391  return {
392    ctx: usage.ctx,
393    h5: usage.h5 ? usage.h5.pct : null,
394    d7: usage.d7 ? usage.d7.pct : null,
395    h5Reset: usage.h5 && usage.h5.resetsAt ? fmtClock(usage.h5.resetsAt) : '',
396  }
397}
398
399// keep: 새 세션 시작 재생 — 브리핑(2)이 들어 있으면 첫 턴이 시작될 때까지 남긴다. 미리 보기는 정해진 길이만 재생한다.
400// 효과는 바로 시작하고, 사용량 · 작업 대장은 읽어 오는 대로 채운다(읽기를 기다리면 평소 띠가 먼저 보인다).
401function startIntro($, ids, keep) {
402  const d = new Date()
403  const ctx = Object.assign({ family, name: repo.name, word: repoOf(cwd).word, tall: clawdOn }, dateTexts(d), { usage: usageForFx(), board: null, dev: devWindow(d) })
404  if (introTimer) introTimer.cancel()
405  intro = { ids, start: now(), ctx, keep: keep && ids.includes(2) }
406  // 띠의 평소 주기(150ms)보다 촘촘히 그려야 스윕 · 빛이 끊기지 않는다. 움직임이 끝나면 이 타이머는 멈춘다.
407  introTimer = $.clock.every(80, () => {
408    if (!intro) return
409    const lay = introLayout(intro.ids, now() - intro.start, intro.keep)
410    if (lay.done) {
411      endIntro($)
412      return
413    }
414    if (!lay.moving && introTimer) {
415      introTimer.cancel()
416      introTimer = null
417    }
418    $.ui.invalidate('ui.render')
419  })
420  // 한도 값은 그릴 때마다 최신 값을 읽는다(띠 훅). 여기서는 빈 값을 채울 읽기만 걸어 둔다.
421  seedUsageFromCache($).then(() => refreshUsage($))
422  if (ids.includes(2) || ids.includes(3)) {
423    readBoard($).then((board) => {
424      ctx.board = board
425      $.ui.invalidate('ui.render')
426    })
427  }
428  $.ui.invalidate('ui.render')
429}
430
431function hasPaneData() {
432  const t = now()
433  if (flags.length) return true
434  if ([...subs.values()].some((s) => subStatus(s, t) !== 'done')) return true
435  if ([...mons.values()].some((m) => !m.ended)) return true
436  if (fleet && fleetView(fleet.tasks, t).open.length) return true
437  return leftList().length > 0
438}
439
440function animating() {
441  if (busy) return true
442  if ([...subs.values()].some((s) => !s.done)) return true
443  return [...mons.values()].some((m) => !m.ended)
444}
445
446// statusLine 스크립트가 이 표식을 보면 화면 출력만 생략한다(한도 수집은 계속한다).
447// 표식이 120초 넘게 갱신되지 않으면 statusLine 이 다시 그리므로, mod 가 멈추면 원래대로 돌아간다.
448async function touchMarker($) {
449  if (!sid) return
450  try {
451    await $.fs.write('/tmp/.claude-cockpit/' + sid, String(now()))
452    // 마지막 활동 시각도 같이 남긴다 — mod 를 다시 읽어도 이 세션의 쉼 시계가 이어지게(restoreActive).
453    if (lastActiveAt !== activeSaved) {
454      activeSaved = lastActiveAt
455      await $.fs.write('/tmp/.claude-cockpit/' + sid + '.active', String(lastActiveAt))
456    }
457  } catch (err) {
458    $.ui.log('cockpit: 표식 쓰기 실패 ' + String(err), { to: 'debug' })
459  }
460}
461
462async function refreshGit($) {
463  try {
464    const b = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
465    branch = b.exitCode === 0 ? b.stdout.trim() : ''
466    if (branch) {
467      const s = await $.process.run(['git', 'status', '--porcelain'], { timeoutMs: 5000 })
468      dirty = s.exitCode === 0 ? s.stdout.split('\n').filter((l) => l.trim()).length : 0
469      // 미push 커밋 수. 올린 적 없는 브랜치(upstream 없음)는 0 으로 둔다.
470      const u = await $.process.run(['git', 'rev-list', '--count', '@{u}..HEAD'], { timeoutMs: 5000 })
471      unpushed = u.exitCode === 0 ? Number(u.stdout.trim()) || 0 : 0
472    } else {
473      dirty = 0
474      unpushed = 0
475    }
476  } catch (err) {
477    branch = ''
478    dirty = 0
479    unpushed = 0
480  }
481}
482
483// 진행 중 이슈는 작업 대장의 이 세션 줄 파일에서 읽는다(설정의 issue.boardLineDir). 줄 파일 이름은 세션 id 앞 8자다.
484// 부재 시 동작: 폴더가 설정에 없으면 이슈 키 없이 단계만 보인다.
485async function refreshIssue($) {
486  const lineDir = getConfig().issue.boardLineDir
487  if (!lineDir || !sid) return
488  try {
489    const path = lineDir + '/' + sid.slice(0, 8) + '.json'
490    if (!(await $.fs.exists(path))) return
491    const line = JSON.parse(await $.fs.read(path))
492    const key = Array.isArray(line.issues) && line.issues.length ? String(line.issues[line.issues.length - 1]) : ''
493    wipLine = { name: String(line.name || ''), auto: line.name_auto !== false }
494    if (key !== issue.key) {
495      issue = { key, title: '', state: '' }
496      issueFullTitle = ''
497    }
498  } catch (err) {
499    $.ui.log('cockpit: 작업 대장 읽기 실패 ' + String(err), { to: 'debug' })
500  }
501}
502
503// 이슈 제목 · 상태는 이슈 트래커 MCP 서버의 get_issue 로 읽는다(설정의 issue.mcpServer · 기본 linear). 실패하면 키만 보인다.
504async function refreshLinear($, force) {
505  const ic = getConfig().issue
506  if (!ic.mcpServer || !issue.key || !ic.keyRe.test(issue.key)) return
507  if (!force && issueFetchedKey === issue.key && now() - issueFetchedAt < poll.linear * 1000) return
508  issueFetchedKey = issue.key
509  issueFetchedAt = now()
510  try {
511    const r = await $.mcp.call(ic.mcpServer, 'get_issue', { id: issue.key })
512    const block = (r.content || []).find((c) => c.type === 'text')
513    if (!block) return
514    const data = JSON.parse(block.text)
515    const title = String(data.title || '').replace(/^\[[^\]]+\]\s*/, '')
516    issueFullTitle = title
517    issue = Object.assign({}, issue, {
518      title: title.length > 26 ? title.slice(0, 25) + '…' : title,
519      state: String(data.status || ''),
520    })
521  } catch (err) {
522    $.ui.log('cockpit: 이슈 조회 실패 ' + String(err), { to: 'debug' })
523  }
524}
525
526// 워커를 관리하는 세션이면 이 세션이 소유한 열린 배치 전부와 그 태스크(워커)를 읽는다.
527async function refreshFleet($) {
528  fleet = null
529  if (!sid) return
530  try {
531    if (!(await $.fs.exists('/tmp/.claude-lane-fleet-' + sid))) return
532    // 워커 기록은 세션 위치와 무관하게 한 곳(FLEET_ROOT 환경 변수 또는 설정의 fleetRoot)에 쌓인다.
533    // 세션이 있는 폴더 기준으로 찾으면 다른 폴더에서 연 세션의 배치 보드가 비었다.
534    const root = (await $.env.get('FLEET_ROOT')) || getConfig().fleetRoot
535    if (!root) return
536    const dir = root + '/fleet'
537    const batches = await $.fs.list(dir + '/batches')
538    const recent = batches.filter((b) => b.kind === 'file' && b.name.endsWith('.json') && now() - b.mtimeMs < 3 * 24 * 3600 * 1000)
539    // 배치는 모든 태스크가 끝나도 대개 SEALED 로 남는다. 가장 새 배치 하나만 보면
540    // 앞 배치의 작업중 워커가 가려지므로, 이 세션 소유 배치를 모두 모은다.
541    const bids = new Set()
542    let firstDate = ''
543    for (const b of recent) {
544      const j = JSON.parse(await $.fs.read(dir + '/batches/' + b.name))
545      if (j.owner_sid !== sid || j.status === 'CLOSED') continue
546      const bid = b.name.replace(/\.json$/, '')
547      bids.add(bid)
548      const date = bid.split('-')[1] || ''
549      if (!firstDate || date < firstDate) firstDate = date
550    }
551    if (!bids.size) return
552    // 태스크 번호는 만든 날짜로 시작한다(T-YYYYMMDD-NNNN). 가장 오래된 배치 날짜부터만 읽어 전체를 훑지 않는다.
553    const taskDirs = (await $.fs.list(dir + '/tasks')).filter((d) => d.kind === 'dir' && d.name >= 'T-' + firstDate)
554    const tasks = []
555    for (const d of taskDirs) {
556      const metaPath = dir + '/tasks/' + d.name + '/meta.json'
557      if (!(await $.fs.exists(metaPath))) continue
558      const meta = JSON.parse(await $.fs.read(metaPath))
559      if (!bids.has(meta.batch)) continue
560      const ts = meta.ts || {}
561      tasks.push({
562        id: meta.tid || d.name,
563        status: meta.status || '',
564        issue: meta.issue_key || '',
565        acked: Date.parse(ts.acked || '') || 0,
566        dispatched: Date.parse(ts.dispatched || '') || 0,
567      })
568    }
569    fleet = { bids: [...bids], tasks }
570  } catch (err) {
571    $.ui.log('cockpit: 배치 읽기 실패 ' + String(err), { to: 'debug' })
572  }
573}
574
575async function refreshPlan($) {
576  if (!steps.planPath) return
577  try {
578    steps.plan = planProgress(await $.fs.read(steps.planPath))
579  } catch (err) {
580    steps.plan = null
581  }
582}
583
584async function refreshDockers($) {
585  if (!dockers.size) return
586  try {
587    const r = await $.process.run(['docker', 'ps', '--format', '{{.Names}}'], { timeoutMs: 10000 })
588    if (r.exitCode !== 0) return
589    const running = new Set(r.stdout.split('\n').map((s) => s.trim()).filter(Boolean))
590    for (const d of [...dockers]) if (d !== 'compose' && d !== 'docker run' && !running.has(d)) dockers.delete(d)
591  } catch (err) {
592    $.ui.log('cockpit: docker 확인 실패 ' + String(err), { to: 'debug' })
593  }
594}
595
596async function refreshFlags($) {
597  const t = now()
598  const found = []
599  let dirty = false
600  for (const f of alertFiles()) {
601    try {
602      if (!(await $.fs.exists(f.path))) {
603        if (flagOwned.delete(f.label)) dirty = true
604        continue
605      }
606      // 이 세션의 Bash 가 방금(20초 안) 그 파일을 만든 경우에만 「내 것」으로 잡는다.
607      if (!flagOwned.has(f.label) && t - (flagTouched.get(f.label) || 0) < 20000) {
608        flagOwned.add(f.label)
609        dirty = true
610      }
611      if (flagOwned.has(f.label)) found.push(f.label)
612    } catch (err) {
613      $.ui.log('cockpit: 플래그 확인 실패 ' + String(err), { to: 'debug' })
614    }
615  }
616  if (dirty) saveOwned($)
617  const added = found.filter((x) => !flags.includes(x))
618  const changed = added.length > 0 || found.length !== flags.length
619  flags = found
620  if (added.length) {
621    const al = getConfig().alert
622    $.ui.toast(al.title + ' 생성 · ' + added.join(', ') + (al.note ? ' · ' + al.note : ''), { timeoutMs: 8000 })
623  }
624  if (changed) $.ui.invalidate('ui.render')
625}
626
627async function refreshUsage($) {
628  try {
629    const u = await $.session.usage()
630    applyUsage(u.context, u.rateLimits)
631  } catch (err) {
632    $.ui.log('cockpit: 사용량 읽기 실패 ' + String(err), { to: 'debug' })
633  }
634}
635
636function applyUsage(context, rateLimits) {
637  const pct = context && typeof context.percent === 'number' ? Math.round(context.percent) : null
638  const h5 = rateOf(rateLimits, '5h')
639  const d7 = rateOf(rateLimits, '7d')
640  usage = {
641    ctx: pct === null ? usage.ctx : pct,
642    h5: h5 || usage.h5,
643    d7: d7 || usage.d7,
644  }
645}
646
647// 5시간 · 주간 한도는 계정 공통이라, 이 세션이 쉬는 동안에도 다른 세션이 쓰면 오른다.
648// statusLine 스크립트가 남기는 최근 값이 2분 안의 것이면 그 값을 쓴다(파일 읽기 한 번).
649async function refreshLimits($) {
650  await refreshUsage($)
651  try {
652    const j = JSON.parse(await $.fs.read('/tmp/.claude-usage/current.json'))
653    if (!j || now() / 1000 - Number(j.ts) > 120) return
654    const pick = (w, cur) => (w && typeof w.used_percentage === 'number' ? { pct: Math.round(w.used_percentage), resetsAt: w.resets_at ? Number(w.resets_at) * 1000 : cur ? cur.resetsAt : null } : cur)
655    usage = { ctx: usage.ctx, h5: pick(j.five_hour, usage.h5), d7: pick(j.seven_day, usage.d7) }
656  } catch (err) {
657    $.ui.log('cockpit: 최근 한도 값 읽기 실패 ' + String(err), { to: 'debug' })
658  }
659}
660
661// 맥 자원(CPU · 메모리). 캐시를 읽고, 이번에 잴 차례면 잰다. 값은 Clawd 의 색 · 땀 · 김으로만 보인다.
662// 부재 시 동작: 캐시가 1분 넘게 낡았으면(아무도 재지 않음) 평소 단계로 둔다.
663async function tickSys($) {
664  const path = '/tmp/.claude-cockpit/sys.json'
665  let cache = null
666  try {
667    if (await $.fs.exists(path)) cache = JSON.parse(await $.fs.read(path))
668  } catch (err) {
669    cache = null
670  }
671  if (!sysBusy && shouldSample(cache, now(), sid, poll.sys)) {
672    sysBusy = true
673    try {
674      const r = await $.process.run(['/bin/sh', '-c', SYS_CMD], { timeoutMs: 5000 })
675      const s = r.exitCode === 0 ? parseSys(r.stdout) : null
676      if (s) {
677        cache = Object.assign({}, s, { ts: now(), by: sid })
678        await $.fs.write(path, JSON.stringify(cache))
679      }
680    } catch (err) {
681      $.ui.log('cockpit: 맥 자원 읽기 실패 ' + String(err), { to: 'debug' })
682    } finally {
683      sysBusy = false
684    }
685  }
686  const t = now()
687  // 값을 믿는 기한은 「지금 재는 주기」의 3배(적어도 60초)다. 잠든 세션은 주기를 늘려 재므로 기한도 같이 늘린다 —
688  // 고정 60초로 두면 혼자 남은 잠든 세션에서 값이 주기 사이마다 「없음」으로 떨어진다.
689  const sysLife = Math.max(60000, poll.sys * (idleNow >= 1 ? SLEEP_FACTOR : 1) * 3000)
690  if (cache && t - Number(cache.ts) < sysLife) {
691    // 같은 표본을 두 번 넣지 않는다(읽는 세션은 재는 세션보다 자주 읽을 수 있다).
692    if (!sysHist.length || sysHist[sysHist.length - 1].ts !== cache.ts) {
693      sysHist.push({ ts: Number(cache.ts), cpu: cache.cpu })
694      sysCpu = smoothCpu(sysCpu, cache.cpu)
695    }
696    while (sysHist.length && t - sysHist[0].ts > 70000) sysHist.shift()
697    heat = sysLevel(cache, sysCpu, sustain(sysHist, t))
698  } else {
699    sysCpu = null
700    sysHist.length = 0
701    heat = 0
702  }
703}
704
705// 화면 안 꺼짐: caffeinate 가 화면 잠자기를 막는 동안만 모니터 표시를 띄운다. 맥 전체의 상태라 모든 세션 띠에 같이 보인다.
706async function tickAwake($) {
707  const path = '/tmp/.claude-cockpit/awake.json'
708  let cache = null
709  try {
710    if (await $.fs.exists(path)) cache = JSON.parse(await $.fs.read(path))
711  } catch (err) {
712    cache = null
713  }
714  if (!awakeBusy && shouldSample(cache, now(), sid, poll.awake)) {
715    awakeBusy = true
716    try {
717      const r = await $.process.run(['/usr/bin/pmset', '-g', 'assertions'], { timeoutMs: 5000 })
718      if (r.exitCode === 0) {
719        cache = { display: parseAwake(r.stdout), ts: now(), by: sid }
720        await $.fs.write(path, JSON.stringify(cache))
721      }
722    } catch (err) {
723      $.ui.log('cockpit: 화면 켜둠 읽기 실패 ' + String(err), { to: 'debug' })
724    } finally {
725      awakeBusy = false
726    }
727  }
728  // 기한은 「지금 재는 주기」의 3배. 잠든 세션은 주기가 6배라 기한도 6배다(고정하면 혼자 남은 잠든 세션에서 표시가 깜빡인다).
729  const next = !!(cache && cache.display && now() - Number(cache.ts) < poll.awake * (idleNow >= 1 ? SLEEP_FACTOR : 1) * 3000)
730  if (next !== awake) {
731    awake = next
732    $.ui.invalidate('ui.render')
733  }
734}
735
736// 이 세션의 상태 파일(~/.claude/sessions/<pid>.json)을 찾는다. pid 를 몰라 세션 id 로 한 번 찾아 두고 그 뒤로는 그 파일만 읽는다.
737async function bridgeOn($) {
738  if (!home || !sid) return false
739  const dir = home + '/.claude/sessions'
740  try {
741    if (!sessionFile) {
742      for (const f of await $.fs.list(dir)) {
743        if (f.kind !== 'file' || !f.name.endsWith('.json')) continue
744        try {
745          if (JSON.parse(await $.fs.read(dir + '/' + f.name)).sessionId === sid) {
746            sessionFile = dir + '/' + f.name
747            break
748          }
749        } catch (err) {
750          // 다른 세션이 쓰는 중인 파일은 건너뛴다.
751        }
752      }
753    }
754    if (!sessionFile) return false
755    return !!JSON.parse(await $.fs.read(sessionFile)).bridgeSessionId
756  } catch (err) {
757    sessionFile = ''
758    return false
759  }
760}
761
762// 원격이 켜져 있는가 — Remote Control 을 켰거나(폰이 아직 안 붙어도), 터미널 말고 다른 화면이 붙어 있을 때.
763// 붙고 떨어질 때 이벤트로 알고, 켜고 끄는 것은 5초마다 상태 파일로 맞춘다.
764async function refreshSurfaces($) {
765  try {
766    const next = (await bridgeOn($)) || (await $.session.surfaces()).some((s) => s !== 'terminal')
767    if (next !== remote) {
768      remote = next
769      $.ui.invalidate('ui.render')
770    }
771  } catch (err) {
772    $.ui.log('cockpit: 원격 연결 읽기 실패 ' + String(err), { to: 'debug' })
773  }
774}
775
776// 요소마다 주기가 다르다(poll.js). 1초에 한 번 불려, 때가 된 것만 돌린다. 잠든 세션은 주기를 늘린다.
777function runPolls($, t) {
778  const k = idleNow >= 1 ? SLEEP_FACTOR : 1
779  const go = (key, factor) => {
780    if (!due(t, lastRun[key], poll[key] * (factor || 1))) return false
781    lastRun[key] = t
782    return true
783  }
784  const redraw = () => $.ui.invalidate('ui.render')
785  // 앞선 읽기가 아직 안 끝났으면 이번 차례는 건너뛴다(느린 git · 파일 읽기가 겹쳐 쌓이지 않게).
786  const once = (key, fn) => {
787    if (pollBusy[key]) return
788    pollBusy[key] = true
789    Promise.resolve()
790      .then(fn)
791      .catch((err) => $.ui.log('cockpit: ' + key + ' 읽기 실패 ' + String(err), { to: 'debug' }))
792      .then(() => {
793        pollBusy[key] = false
794        redraw()
795      })
796  }
797  if (go('sys', k)) tickSys($)
798  if (go('awake', k)) tickAwake($)
799  if (go('usage', k)) once('usage', () => refreshLimits($))
800  // 경보 파일은 이 세션이 만든 것이 있거나 방금 만드는 명령을 돌렸을 때만 본다 — 평소에는 파일을 확인하지 않는다.
801  const touched = [...flagTouched.values()].some((at) => t - at < 20000)
802  if ((flagOwned.size || touched) && go('flags')) refreshFlags($)
803  if (go('git', k)) once('git', () => refreshGit($).then(() => refreshDockers($)).then(() => refreshPlan($)))
804  if (go('fleet', k)) once('fleet', () => refreshFleet($))
805  // 원격 켜짐은 작은 파일 하나를 읽는 일이라 5초마다 본다(잠든 세션은 30초).
806  if (due(t, lastRun.remote, 5 * k)) {
807    lastRun.remote = t
808    once('remote', () => refreshSurfaces($))
809  }
810  // 작업 대장 줄(이슈 키) · statusLine 표식은 30초 고정이다. 이슈 제목은 그 안에서 linear 주기로만 다시 읽는다.
811  if (due(t, lastRun.board, 30)) {
812    lastRun.board = t
813    touchMarker($)
814    once('board', () => refreshIssue($).then(() => refreshLinear($, false)))
815  }
816}
817
818function blitArc($) {
819  if (!bandId || !arcFits(bodyCols, clawdOn)) return
820  const cells = arcNow()
821  if (cells === arcShown) return
822  arcShown = cells
823  $.ui.blit({ requestId: bandId, key: 'arc', cells }).catch(() => {})
824}
825
826// 1초에 한 번: 쉼 단계 · 분이 바뀌었는지(반원 · 정각 · 정해진 시각) · 때가 된 폴링.
827function everySecond($, t) {
828  checkToasts($)
829  syncPane($)
830  if (busyGuess && t - spinAt > 3000) {
831    busy = false
832    busyGuess = false
833    $.ui.invalidate('ui.render')
834  }
835  const working = work().working
836  if (working) lastActiveAt = t
837  const stage = idleStage(t, lastActiveAt, working, idleCfg)
838  if (stage !== idleNow) {
839    idleNow = stage
840    $.ui.invalidate('ui.render')
841  }
842  const c = clockNow()
843  if (c.min !== lastMinute) {
844    const first = lastMinute < 0
845    lastMinute = c.min
846    // 모듈이 막 올라온 순간에는 울리지 않는다(다시 읽을 때마다 울리면 안 된다).
847    if (!first) {
848      const chime = chimeAt(c.min, c.dow, getConfig().bells)
849      if (chime === 'bell') bellAt = t
850      else if (chime === 'hour') flashAt = t
851    }
852    refreshSurfaces($)
853    $.ui.invalidate('ui.render')
854  }
855  runPolls($, t)
856}
857
858// 패널은 보여 줄 것이 있을 때만 연다(/cockpit 으로 직접 연 뒤에는 비어 있어도 둔다).
859// 스스로 여는 패널은 엔진이 144칸, 사용자가 한 번이라도 /cockpit 으로 연 뒤에는 110칸부터
860// 그린다(손으로 닫기 전까지 다음 세션에도 유지). 그보다 좁으면 그리지 않고 기다리므로 자동으로 숨는다.
861// 넓을 때 열린 뒤 좁아져 프롬프트 위로 내려오면 닫는다.
862async function syncPane($) {
863  const want = hasPaneData() || paneAsked
864  if (!want) {
865    paneUserClosed = false
866    if (paneOpen) {
867      paneOpen = false
868      paneInline = false
869      try {
870        await $.ui.close({ id: PANE_ID })
871      } catch (err) {
872        $.ui.log('cockpit: 패널 닫기 실패 ' + String(err), { to: 'debug' })
873      }
874    }
875    return
876  }
877  if (paneOpen && paneInline) {
878    paneOpen = false
879    paneInline = false
880    try {
881      await $.ui.close({ id: PANE_ID })
882    } catch (err) {
883      $.ui.log('cockpit: 패널 닫기 실패 ' + String(err), { to: 'debug' })
884    }
885    return
886  }
887  // 110칸 이상이면 일단 연다. 사용자가 아직 /cockpit 으로 연 적이 없으면 엔진이 144칸까지 그리지 않고 기다린다.
888  if (!paneOpen && !paneUserClosed && (lastViewportCols === 0 || lastViewportCols >= 110)) {
889    paneOpen = true
890    try {
891      await $.ui.open({ id: PANE_ID, title: '콕핏', columns: 28, rows: 2 })
892    } catch (err) {
893      paneOpen = false
894      $.ui.log('cockpit: 패널 열기 실패 ' + String(err), { to: 'debug' })
895    }
896  }
897}
898
899function checkToasts($) {
900  const t = now()
901  for (const s of subs.values()) {
902    if (!s.done && !s.stallToasted && subStatus(s, t) === 'stall') {
903      s.stallToasted = true
904      $.ui.toast('서브 정체 · ' + s.name + ' ' + Math.floor((t - s.last) / 60000) + '분째 요청 없음', { timeoutMs: 10000 })
905    }
906  }
907  for (const m of mons.values()) {
908    if (m.ended) continue
909    const remain = monitorRemainMs(m, t)
910    if (!m.warned && remain <= monitorWarnMs(m) && remain > 0) {
911      m.warned = true
912      $.ui.toast("감시 '" + m.desc + "' " + Math.ceil(remain / 60000) + '분 뒤 만료 · 다시 걸어야 함', { timeoutMs: 10000 })
913    }
914    if (remain <= 0) {
915      m.ended = true
916      if (m.events === 0) $.ui.toast("감시 '" + m.desc + "' 만료 · 이벤트 0건", { timeoutMs: 10000 })
917    }
918  }
919  const view = viewSteps(steps)
920  const cur = view.steps.findIndex((x) => x.state !== 'done' && x.state !== 'todo')
921  const key = view.mode + ':' + (cur < 0 ? 'end' : cur)
922  if (key !== lastStepKey) {
923    const prev = lastStepKey
924    lastStepKey = key
925    if (prev && view.mode === 'impl') {
926      if (cur < 0) {
927        const n = leftList().length
928        $.ui.toast('완료' + (lastDur ? ' ' + fmtDur(lastDur) : '') + (n ? ' · 띄운 것 ' + n + ' → /leftovers' : ''), { timeoutMs: 8000 })
929      } else $.ui.toast(IMPL_STEPS[cur] + ' 단계', { timeoutMs: 4000 })
930    }
931  }
932}
933
934export function register(on) {
935  on('session.start', async ($, e, next) => {
936    // 시작 효과 판정을 가장 먼저 한다 — 뒤의 준비(툴 등록 등)를 기다리면 그동안 평소 띠가 먼저 보인다.
937    try {
938      cwd = e.cwd || (await $.session.cwd())
939      home = (await $.env.get('HOME')) || ''
940      await loadConfig($)
941      family = repoFamily(cwd)
942      repo = repoInfo(cwd)
943      interactive = e.isInteractive === true && e.surface === 'terminal'
944      introChoice = normalizeChoice(await $.store.get('intro'), INTRO_DEFAULT)
945      clawdOn = (await $.store.get('clawd')) !== false
946      const u = await $.session.usage()
947      applyUsage(u.context, u.rateLimits)
948      // 새로 연 대화형 세션에서만 재생한다. 이어 열기 · mod 다시 읽기는 시작 시각이 오래돼 대상이 아니다.
949      if (interactive && introChoice.length && now() - u.startedAt < FRESH_MS) startIntro($, introChoice, true)
950    } catch (err) {
951      $.ui.log('cockpit: 시작 효과 준비 실패 ' + String(err), { to: 'debug' })
952    } finally {
953      booting = false
954      $.ui.invalidate('ui.render')
955    }
956    sid = await $.session.id()
957    // 쉼 시계는 세션마다 따로 간다. mod 를 다시 읽은 경우에만 이 세션이 남겨 둔 마지막 활동 시각을 되살린다.
958    try {
959      const base = '/tmp/.claude-cockpit/' + sid
960      if ((await $.fs.exists(base)) && (await $.fs.exists(base + '.active'))) {
961        lastActiveAt = restoreActive(now(), await $.fs.read(base), await $.fs.read(base + '.active'))
962      }
963    } catch (err) {
964      $.ui.log('cockpit: 마지막 활동 시각 읽기 실패 ' + String(err), { to: 'debug' })
965    }
966    cwd = await $.session.cwd()
967    model = modelLabel(await $.session.model())
968    await loadOwned($)
969    await $.tool.register({
970      name: 'phase',
971      description:
972        '하네스 콕핏 띠에 지금 작업 단계를 알린다. 단계를 넘길 때 한 번 부른다. step: plan(플랜) · code(코드 범위) · impl(구현) · verify(검증) · done(완료) · research(조사) · organize(정리) · report(보고). detail 은 칩에 보일 짧은 진행 표시(예: E2E 3/8). state 는 active(기본) · wait(사용자 결정 대기) · fail(막힘).',
973      inputSchema: {
974        type: 'object',
975        properties: {
976          step: { type: 'string', enum: ['plan', 'code', 'impl', 'verify', 'done', 'research', 'organize', 'report'] },
977          detail: { type: 'string' },
978          state: { type: 'string', enum: ['active', 'wait', 'fail'] },
979        },
980        required: ['step'],
981      },
982    })
983    await $.command.register({ name: 'leftovers', description: '이 세션이 띄운 것 목록 (세션을 닫기 전 확인)', immediate: true })
984    await $.command.register({ name: 'cockpit', description: '콕핏 패널 열기 (워커 · 서브 · 감시 세로 목록)', immediate: true })
985    await $.command.register({ name: 'fx', description: '새 세션 시작 효과 — 미리 보기 /fx 1 3 · 고르기 /fx use 1 3 · 끄기 /fx off', argumentHint: '[번호…|use 번호…|off]', immediate: true })
986    await $.command.register({ name: 'clawd', description: '띠의 Clawd 켜기 · 끄기 (/clawd on|off, 인자 없으면 뒤집기)', argumentHint: '[on|off]', immediate: true })
987    try {
988      poll = normalizePoll(await $.store.get('poll'))
989      skyOn = (await $.store.get('sky')) !== false
990    } catch (err) {
991      $.ui.log('cockpit: 폴링 주기 읽기 실패 ' + String(err), { to: 'debug' })
992    }
993    await $.command.register({ name: 'now', description: '지금까지 한 일 한눈에 — 모델을 부르지 않는다(토큰 0)', immediate: true })
994    $.clock.every(150, () => {
995      frame += 1
996      const t = now()
997      const fx = (bellAt && t - bellAt < 2600) || (flashAt && t - flashAt < 2250)
998      // Clawd 는 띠를 다시 그리지 않고 그림 칸만 갈아 끼운다. 띠가 접혀 있으면 엔진이 거절하고 끝난다.
999      // 잠든 세션은 1초에 한 번만 그린다(효과가 재생되는 동안은 평소 속도).
1000      if (clawdOn && bandId && (idleNow < 1 || fx || frame % 7 === 0 || moodNow() === 'party')) $.ui.blit({ requestId: bandId, key: 'clawd', cells: clawdNow() }).catch(() => {})
1001      // 반원: 정각 반짝임 동안은 매 프레임, 평소에는 1초에 한 번(가운데 점 깜빡임). 잠든 세션은 분이 바뀔 때만 달라진다.
1002      if (flashAt && t - flashAt < 2250) blitArc($)
1003      else if (frame % 7 === 0) blitArc($)
1004      if (frame % 7 === 0) everySecond($, t)
1005      // 띠 전체를 다시 그리는 것은 움직이는 것이 있을 때만: 작업 중(스피너 · 테두리 깜빡임) · 감시 중. 쉬는 동안은 5초에 한 번.
1006      if (animating() || work().working) $.ui.invalidate('ui.render')
1007      else if (frame % 35 === 0 && idleNow < 1) $.ui.invalidate('ui.render')
1008    })
1009    // 처음 한 번은 주기를 기다리지 않고 읽는다.
1010    refreshSurfaces($)
1011    if (flagOwned.size) refreshFlags($)
1012    everySecond($, now())
1013    return next(e)
1014  })
1015
1016  on('session.attach', async ($, e, next) => {
1017    const r = await next(e)
1018    refreshSurfaces($)
1019    return r
1020  })
1021
1022  on('session.detach', async ($, e, next) => {
1023    const r = await next(e)
1024    refreshSurfaces($)
1025    return r
1026  })
1027
1028  on('turn.start', async ($, e, next) => {
1029    if (intro && intro.keep) endIntro($)
1030    busy = true
1031    busyGuess = false
1032    turnStart = now()
1033    lastActiveAt = turnStart
1034    turn = newTurn()
1035    turn.ctx0 = usage.ctx
1036    turn.h50 = usage.h5 ? usage.h5.pct : null
1037    toolLabel = ''
1038    $.ui.invalidate('ui.render')
1039    return next(e)
1040  })
1041
1042  on('turn.step', async function* ($, e, next) {
1043    if (e.agentId) {
1044      const s = subs.get(e.agentId)
1045      if (s) {
1046        s.last = now()
1047        s.stallToasted = false
1048      }
1049    } else {
1050      if (e.model) model = modelLabel(e.model)
1051      if (e.effort !== undefined && e.effort !== null) effort = String(e.effort)
1052    }
1053    return yield* next(e)
1054  })
1055
1056  on('turn.complete', async ($, e, next) => {
1057    if (e.agentId) {
1058      const s = subs.get(e.agentId)
1059      if (s) s.done = true
1060      lastActiveAt = now()
1061    } else {
1062      busy = false
1063      doneAt = now()
1064      lastActiveAt = doneAt
1065      lastDur = Math.round((e.durationMs || 0) / 1000)
1066      // 턴 영수증: 이 턴의 「Worked for …」 줄이 그려질 때 걸린 시간으로 찾아 붙인다.
1067      receipts.push({
1068        ms: e.durationMs || 0,
1069        id: '',
1070        text: fmtClock(doneAt) + ' · ' + receiptText({ tools: turn.tools, files: turn.files.size, fails: turn.fails, subs: turn.subs, mid: turn.mid, ctx0: turn.ctx0, ctx1: usage.ctx, h50: turn.h50, h51: usage.h5 ? usage.h5.pct : null }),
1071      })
1072      if (receipts.length > 40) receipts.shift()
1073      toolLabel = ''
1074      refreshPlan($)
1075      refreshGit($)
1076      lastRun.git = doneAt
1077    }
1078    $.ui.invalidate('ui.render')
1079    return next(e)
1080  })
1081
1082  on('session.measure', async ($, e, next) => {
1083    applyUsage(e.context, e.rateLimits)
1084    return next(e)
1085  })
1086
1087  on('agent.spawn', async ($, e, next) => {
1088    const r = await next(e)
1089    if (r && r.agentId) {
1090      const t = now()
1091      subs.set(r.agentId, { name: e.subagentType || e.description || 'subagent', desc: e.description || '', start: t, last: t, done: false, stallToasted: false })
1092      turn.subs += 1
1093      if (e.tool_use_id) subByTool.set(e.tool_use_id, r.agentId)
1094      $.ui.invalidate('ui.render')
1095    }
1096    return r
1097  })
1098
1099  on('tool.call', async ($, e, next) => {
1100    if (e.agentId) {
1101      const s = subs.get(e.agentId)
1102      if (s) {
1103        s.last = now()
1104        s.stallToasted = false
1105      }
1106    } else {
1107      const act = classifyTool(e.tool, e)
1108      if (act) {
1109        applyActivity(steps, act)
1110        if (act.kind === 'plan') refreshPlan($)
1111      }
1112      // 띠 밖: 이번 턴의 도구 수 · 고친 파일 · 지금 하는 일(스피너 줄 · /now · 턴 영수증에 쓴다). 단계 알림 툴은 세지 않는다.
1113      if (e.tool !== PHASE_TOOL) {
1114        turn.tools += 1
1115        const path = String(e.file_path || e.notebook_path || '')
1116        if (path && /^(Edit|Write|MultiEdit|NotebookEdit)$/.test(e.tool)) turn.files.add(path)
1117        const what = String(e.command || e.description || e.pattern || e.query || (path ? path.split('/').pop() : '') || '').split('\n')[0]
1118        toolLabel = (e.tool.startsWith('mcp__') ? e.tool.split('__').pop() : e.tool) + (what ? ' ' + what : '')
1119      }
1120      const scene = act ? ACT_SCENE[act.kind] : e.tool === 'Bash' ? 'run' : ''
1121      if (scene) {
1122        clawdAct = scene
1123        clawdActAt = now()
1124      }
1125    }
1126    if (e.tool === 'Monitor' && e.tool_use_id) {
1127      mons.set(e.tool_use_id, {
1128        desc: String(e.description || 'monitor'),
1129        timeoutMs: Math.min(Number(e.timeout_ms) || 300000, 3600000),
1130        start: now(),
1131        events: 0,
1132        last: '',
1133        lastAt: 0,
1134        ended: false,
1135        warned: false,
1136      })
1137    }
1138    // 이 세션의 툴 호출이 경보 파일을 만들면, 호출이 성공으로 끝난 직후 그 시각을 적고 바로 확인한다.
1139    // 다른 훅이 막았거나 실패한 호출은 만든 것이 아니다 — 그때 다른 세션의 플래그가 있어도 내 것으로 잡지 않는다.
1140    const madeFlags = flagsCreatedBy(e.tool, e)
1141    const afterFlags = (r) => {
1142      if (!madeFlags.length || !r || r.isError || r.deny) return
1143      const t = now()
1144      for (const label of madeFlags) flagTouched.set(label, t)
1145      refreshFlags($)
1146    }
1147    if (e.tool === 'Bash') {
1148      const name = dockerNameOf(e.command)
1149      if (name) dockers.add(name)
1150    }
1151    if (e.tool === 'mcp__playwright__browser_navigate') browserOpen = true
1152    if (e.tool === 'mcp__playwright__browser_close') browserOpen = false
1153    if (e.tool === 'AskUserQuestion') {
1154      asking += 1
1155      steps.waiting = true
1156      $.ui.invalidate('ui.render')
1157      try {
1158        return await next(e)
1159      } finally {
1160        asking = Math.max(0, asking - 1)
1161        steps.waiting = asking > 0
1162        $.ui.invalidate('ui.render')
1163      }
1164    }
1165    if (e.tool === 'Bash' && !e.agentId && !e.run_in_background) {
1166      clawdHold += 1
1167      try {
1168        const r = await next(e)
1169        const failed = !!(r && (r.isError || r.deny))
1170        noteBashResult(steps, e.command, failed)
1171        // Clawd: 실패하면 깜짝 놀라고, 커밋 · 푸시가 되면 동전을 먹는다.
1172        if (failed) turn.fails += 1
1173        if (failed) errAt = now()
1174        else if (/\bgit\s+(commit|push)\b/.test(String(e.command || ''))) coinAt = now()
1175        afterFlags(r)
1176        return r
1177      } finally {
1178        clawdHold = Math.max(0, clawdHold - 1)
1179        clawdActAt = now()
1180      }
1181    }
1182    if (madeFlags.length) {
1183      const r = await next(e)
1184      afterFlags(r)
1185      return r
1186    }
1187    return next(e)
1188  })
1189
1190  // 이 mod 가 등록한 phase 툴. 띠의 단계 표시만 바꾸고 다른 동작은 하지 않는다.
1191  on('tool.call', { tool: PHASE_TOOL }, async ($, e) => {
1192    steps.override = { step: String(e.step || ''), detail: String(e.detail || ''), state: String(e.state || 'active') }
1193    $.ui.invalidate('ui.render')
1194    return { result: '띠 단계 표시를 ' + steps.override.step + (steps.override.detail ? ' · ' + steps.override.detail : '') + ' 로 바꿨다' }
1195  })
1196
1197  on('tool.describe', { tool: PHASE_TOOL }, async ($, e) => {
1198    return { description: e.description, isDeferred: false }
1199  })
1200
hooks/model.js 669 lines
1// 하네스 콕핏의 순수 로직. 이 파일의 함수는 `$` 를 받지 않는다 — mods 검증은 `$` 를
2// 같은 파일 최상위 함수에만 넘기도록 허용하므로, 상태 계산과 서식은 여기서 하고
3// 실제 호출(파일 · 프로세스 · 화면)은 register.js 가 맡는다.
4import { getConfig } from './config.js'
5
6// 게이지 · 퍼센트 색 단계. 경계는 확정 시안(5단계)의 0 · 30 · 50 · 70 · 85% 다.
7// 연두 · 주황은 Claude Code 테마 키에 없어 원색 값을 쓴다. 다크 · 라이트 둘 다에서
8// 읽히도록 중간 밝기로 골랐다.
9export const LEVEL_BOUNDS = [30, 50, 70, 85]
10export const LEVEL_COLORS = ['success', '#74AD2C', 'warning', '#E0762B', 'error']
11
12// 단계 상태 4색(B안). 아직 · 취소는 색을 쓰지 않는다.
13export const STATE_COLORS = {
14  done: 'success',
15  active: '#4D9BFF',
16  wait: 'warning',
17  fail: 'error',
18  todo: 'inactive',
19  cancel: 'inactive',
20}
21
22// 띠 카드 테두리. 설정에서 빨강 계열로 정한 레포는 빨강, 그 외는 파랑이다.
23export const FAMILY_BORDER = { red: 'error', blue: '#4D9BFF' }
24
25export const IMPL_STEPS = ['플랜', '코드', '구현', '검증', '완료']
26export const RESEARCH_STEPS = ['조사', '정리', '보고']
27export const SPINNER = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷']
28
29// 서브에이전트가 이 시간 동안 모델 요청이 없으면 정체로 본다.
30export const STALL_MS = 10 * 60 * 1000
31// Monitor 만료 이 시간 전에 한 번 알린다.
32export const MONITOR_WARN_MS = 4 * 60 * 1000
33
34// 경보 파일: 이 파일이 있는 동안 띠가 두 줄 빨간 테두리 + 경보 줄로 바뀐다(설정의 alertFiles). 예: 위험한 작업을 한 번 허용하는 승인 플래그.
35export function alertFiles() {
36  return getConfig().alertFiles
37}
38
39// 이 툴 호출이 경보 파일을 「만드는」 호출이면 그 파일의 label 들을 돌려준다.
40// Bash 는 touch · tee · 리다이렉트(> · >>)로 그 경로에 쓰는 경우만, Write 는 그 경로가 대상일 때만 본다.
41// ls · rm 처럼 경로를 언급만 하는 명령은 만든 것이 아니다. macOS 의 /private/tmp 표기도 같은 파일로 본다.
42// 경보를 만든 세션의 띠에만 띄우는 데 쓴다(플래그 파일 자체는 세션을 가리지 않는다).
43export function flagsCreatedBy(tool, input) {
44  const i = input || {}
45  const out = []
46  for (const f of alertFiles()) {
47    if (tool === 'Write') {
48      const path = String(i.file_path || '')
49      if (path === f.path || path === '/private' + f.path) out.push(f.label)
50      continue
51    }
52    if (tool !== 'Bash') continue
53    const p = f.path.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
54    const re = new RegExp('(?:\\b(?:touch|tee)\\b[^|;&\\n]*|>>?\\s*)["\']?(?:\\/private)?' + p + '(?![\\w-])')
55    if (re.test(String(i.command || ''))) out.push(f.label)
56  }
57  return out
58}
59
60export function level(p) {
61  if (p === null || p === undefined || Number.isNaN(p)) return -1
62  let i = 0
63  for (const b of LEVEL_BOUNDS) if (p >= b) i += 1
64  return i
65}
66
67export function levelColor(p) {
68  const l = level(p)
69  return l < 0 ? 'inactive' : LEVEL_COLORS[l]
70}
71
72// 칸 게이지의 칸별 색. 칸은 자기 위치(가운데 지점)의 단계 색으로 칠한다.
73export function gaugeCells(p, n) {
74  const cells = []
75  if (p === null || p === undefined) {
76    for (let i = 0; i < n; i += 1) cells.push({ filled: false, color: 'inactive' })
77    return cells
78  }
79  const k = Math.round((Math.max(0, Math.min(100, p)) / 100) * n)
80  for (let i = 0; i < n; i += 1) {
81    const at = Math.round(((i + 0.5) / n) * 100)
82    cells.push(i < k ? { filled: true, color: levelColor(at) } : { filled: false, color: 'inactive' })
83  }
84  return cells
85}
86
87// 계열(테두리 색). 설정의 repos 중 family 가 있는 첫 항목을 따르고, 없으면 파랑이다.
88export function repoFamily(cwd) {
89  const c = cwd || ''
90  for (const x of getConfig().repos) if (x.family && x.re.test(c)) return x.family
91  return 'blue'
92}
93
94// 레포 이름과 점 색. 설정의 repos 중 name 이 있는 첫 항목을 따르고, 없으면 폴더 이름을 흐린 색으로 쓴다.
95export function repoInfo(cwd) {
96  const c = cwd || ''
97  const wt = /\/worktree[^/]*\//.test(c)
98  for (const x of getConfig().repos) if (x.name && x.re.test(c)) return { name: x.name, color: x.color || 'inactive', worktree: wt }
99  const base = c.split('/').filter(Boolean).pop() || ''
100  return { name: base, color: 'inactive', worktree: wt }
101}
102
103// 브랜치 이름에서 이슈 키(feature/ABC-1234-slug 의 ABC-1234)를 뽑는다. 무늬는 설정의 branchKey 다.
104export function branchKey(branch) {
105  const m = getConfig().branchKeyRe.exec(branch || '')
106  return m ? m[0].toUpperCase() : ''
107}
108
109export function branchTail(branch) {
110  let t = branch || ''
111  const m = getConfig().branchKeyRe.exec(t)
112  if (m && t[m.index + m[0].length] === '-') t = t.slice(m.index + m[0].length + 1)
113  return t.length > 22 ? t.slice(0, 21) + '…' : t
114}
115
116export function fmtDur(sec) {
117  const s = Math.max(0, Math.floor(sec || 0))
118  if (s >= 3600) return Math.floor(s / 3600) + 'h ' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + 'm'
119  if (s >= 60) return Math.floor(s / 60) + 'm ' + String(s % 60).padStart(2, '0') + 's'
120  return s + 's'
121}
122
123export function fmtClock(ms) {
124  if (!ms) return ''
125  const d = new Date(ms)
126  return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
127}
128
129// 한도 창 하나를 고른다. 이름은 status line 의 five_hour · seven_day 를 먼저 찾고,
130// 없으면 같은 뜻의 다른 표기를 받는다. percentUsed 는 퍼센트 값(0~100)이다.
131// 주의: 1 이하를 비율(0~1)로 보고 100 을 곱하지 않는다 — 주간 한도가 초기화된 직후의 1% 가 100% 로 보였다(2026-10-08).
132export function rateOf(rateLimits, which) {
133  const arr = Array.isArray(rateLimits) ? rateLimits : []
134  const exact = which === '5h' ? 'five_hour' : 'seven_day'
135  const loose = which === '5h' ? /five|5h|5_h/i : /seven|7d|7_d|week/i
136  const r = arr.find((x) => x && x.kind === exact) || arr.find((x) => x && loose.test(String(x.kind)))
137  if (!r || typeof r.percentUsed !== 'number') return null
138  const pct = Math.max(0, r.percentUsed)
139  let resetsAt = null
140  if (r.resetsAt) {
141    const t = typeof r.resetsAt === 'number' ? r.resetsAt * (r.resetsAt < 1e12 ? 1000 : 1) : Date.parse(r.resetsAt)
142    if (!Number.isNaN(t)) resetsAt = t
143  }
144  return { pct: Math.round(pct), resetsAt }
145}
146
147export function modelLabel(model) {
148  const m = /(opus|sonnet|haiku|fable)[-_ ]?(\d+)?(?:[-_.](\d+))?/i.exec(model || '')
149  if (!m) return model || ''
150  const name = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase()
151  const ver = m[2] ? (m[3] ? m[2] + '.' + m[3] : m[2]) : ''
152  return ver ? name + ' ' + ver : name
153}
154
155// 플랜 파일의 체크박스 진행률. “검증” 이 든 항목은 따로 센다 — 검증 단계 칩에 쓴다.
156// `## Files` 아래 목록은 영역별 파일 수를 세는 데 쓴다.
157export function planProgress(text) {
158  const lines = String(text || '').split('\n')
159  let done = 0
160  let total = 0
161  let vDone = 0
162  let vTotal = 0
163  const files = []
164  let inFiles = false
165  for (const line of lines) {
166    if (/^##\s/.test(line)) inFiles = /^##\s+Files\b/.test(line)
167    const box = /^\s*-\s\[( |x|X)\]\s(.*)$/.exec(line)
168    if (box) {
169      total += 1
170      const checked = box[1] !== ' '
171      if (checked) done += 1
172      if (/검증/.test(box[2])) {
173        vTotal += 1
174        if (checked) vDone += 1
175      }
176      continue
177    }
178    if (inFiles) {
179      const f = /^\s*-\s+`?([^`\s]+)`?/.exec(line)
180      if (f) files.push(f[1])
181    }
182  }
183  return { done, total, vDone, vTotal, files }
184}
185
186// 경로로 코드 영역을 정한다. 폴더 이름의 -fe · -be · admin 꼬리와 worktree 폴더 이름을 본다.
187export function areaOf(path) {
188  const p = path || ''
189  if (/\/worktree-admin-fe\/|admin-fe/.test(p)) return '어드민 FE'
190  if (/\/worktree-admin-be\/|admin-be/.test(p)) return '어드민 BE'
191  if (/\/worktree-fe\/|-fe[-/]|web-fe/.test(p)) return 'FE'
192  if (/\/worktree\/|-be[-/]|service-be|app-be/.test(p)) return 'BE'
193  return '코드'
194}
195
196// 산출물 · 기록 경로. 여기 쓰는 것은 구현이 아니라 정리로 본다.
197const RECORD_PATH = /\/(plans|linear|flow-test|docs|report|troubleshooting|error-analysis|memory|scratchpad)\//
198const VERIFY_CMD = /\b(jest|vitest|npm (run )?(test|build|lint)|yarn (test|build|lint)|gradlew?\b.*\b(test|build)|curl|playwright|plugin (test|validate))\b/
199
200// 툴 호출 하나를 활동 종류로 분류한다. null 이면 단계에 영향을 주지 않는다.
201export function classifyTool(tool, input) {
202  const t = tool || ''
203  const i = input || {}
204  const path = i.file_path || i.notebook_path || i.path || ''
205  if (t === 'Write' || t === 'Edit' || t === 'NotebookEdit') {
206    if (/\/plans\/[^/]+\.md$/.test(path)) return { kind: 'plan', path }
207    if (/\/flow-test\//.test(path)) return { kind: 'verify', path }
208    if (RECORD_PATH.test(path)) return { kind: 'note', path }
209    return { kind: 'impl', path, area: areaOf(path) }
210  }
211  if (t === 'Read' || t === 'Grep' || t === 'Glob' || t === 'LSP') {
212    const p = path || i.pattern || ''
213    if (/\/worktree[^/]*\/|-be\/|-fe\//.test(p)) return { kind: 'code', path: p, area: areaOf(p) }
214    return { kind: 'research' }
215  }
216  if (t === 'WebFetch' || t === 'WebSearch' || /^mcp__(mysql|context7|linear|serena)/.test(t)) return { kind: 'research' }
217  if (t === 'Bash') {
218    const cmd = String(i.command || '')
219    const extra = getConfig().verifyRe
220    if (VERIFY_CMD.test(cmd) || (extra && extra.test(cmd))) return { kind: 'verify', cmd }
221    return null
222  }
223  if (/^mcp__playwright__|^mcp__mobile-mcp__/.test(t)) return { kind: 'verify' }
224  if (t === 'Artifact' || t === 'SendMessage') return { kind: 'report' }
225  return null
226}
227
228export function newStepState() {
229  return {
230    mode: 'none', // none | research | impl
231    step: -1,
232    planPath: '',
233    plan: null,
234    areas: {}, // 영역 → { edited: Set, read: Set }
235    verifyRuns: 0,
236    waiting: false,
237    failKey: '',
238    failCount: 0,
239    override: null, // phase 툴이 알린 값 { step, detail, state }
240    cancelled: [],
241  }
242}
243
244function areaSlot(s, area) {
245  if (!s.areas[area]) s.areas[area] = { edited: new Set(), read: new Set() }
246  return s.areas[area]
247}
248
249// 활동 하나를 단계 상태에 반영한다. 구현 중에 코드를 읽거나 플랜 체크박스를 고치는 것은
250// 앞 단계로 되돌리지 않는다. 검증 뒤에 코드를 다시 고치면 구현으로 돌아간다.
251export function applyActivity(s, act) {
252  if (!act) return s
253  if (act.kind === 'plan') {
254    s.planPath = act.path
255    if (s.mode !== 'impl') s.mode = 'impl'
256    if (s.step < 0) s.step = 0
257    return s
258  }
259  if (act.kind === 'impl') {
260    s.mode = 'impl'
261    areaSlot(s, act.area).edited.add(act.path)
262    s.step = 2
263    return s
264  }
265  if (act.kind === 'code') {
266    areaSlot(s, act.area).read.add(act.path)
267    if (s.mode === 'impl' && s.step < 1) s.step = 1
268    if (s.mode === 'none' || s.mode === 'research') {
269      s.mode = 'research'
270      if (s.step < 0) s.step = 0
271    }
272    return s
273  }
274  if (act.kind === 'verify') {
275    s.verifyRuns += 1
276    if (s.mode === 'impl' && s.step >= 2) s.step = 3
277    return s
278  }
279  if (act.kind === 'research') {
280    if (s.mode === 'none') {
281      s.mode = 'research'
282      s.step = 0
283    }
284    return s
285  }
286  if (act.kind === 'note') {
287    if (s.mode === 'research' && s.step < 1) s.step = 1
288    return s
289  }
290  if (act.kind === 'report') {
291    if (s.mode === 'research') s.step = 2
292    return s
293  }
294  return s
295}
296
297// 같은 Bash 명령이 연속으로 실패하면 막힘으로 본다. 성공하거나 다른 명령이 오면 다시 센다.
298export function noteBashResult(s, cmd, failed) {
299  const key = String(cmd || '').slice(0, 200)
300  if (!failed) {
301    if (s.failKey === key) {
302      s.failKey = ''
303      s.failCount = 0
304    }
305    return s
306  }
307  if (s.failKey === key) s.failCount += 1
308  else {
309    s.failKey = key
310    s.failCount = 1
311  }
312  return s
313}
314
315const PHASE_INDEX = { plan: 0, code: 1, impl: 2, verify: 3, done: 4, research: 0, organize: 1, report: 2 }
316
317// 띠 1줄에 그릴 단계 표시를 만든다. phase 툴이 알린 값이 있으면 그것이 우선한다.
318export function viewSteps(s) {
319  let mode = s.mode
320  let step = s.step
321  let state = 'active'
322  let detail = ''
323  if (s.override) {
324    const o = s.override
325    mode = ['research', 'organize', 'report'].includes(o.step) ? 'research' : 'impl'
326    step = PHASE_INDEX[o.step] ?? step
327    state = o.state || 'active'
328    detail = o.detail || ''
329  }
330  if (mode === 'none' || step < 0) return { mode: 'none', labels: [], steps: [], chips: [] }
331  const labels = mode === 'research' ? RESEARCH_STEPS : IMPL_STEPS
332  const plan = s.plan
333  if (mode === 'impl' && plan && plan.total > 0 && plan.done === plan.total && !s.override) step = 4
334  if (s.waiting) state = 'wait'
335  else if (s.failCount >= 3) state = 'fail'
336  const finished = mode === 'impl' && step === 4
337  const steps = labels.map((label, i) => {
338    let st = 'todo'
339    if (finished || i < step) st = 'done'
340    else if (i === step) st = state
341    return { label, state: st }
342  })
343  const chips = []
344  if (s.waiting) chips.push({ text: '결정 대기', state: 'wait' })
345  else if (s.failCount >= 3) chips.push({ text: '같은 실패 ' + s.failCount + '회', state: 'fail' })
346  else if (detail) chips.push({ text: detail, state: state })
347  else if (mode === 'impl') {
348    if (step === 0 && plan && plan.total) chips.push({ text: 'Tasks ' + plan.total, state: 'active' })
349    if (step === 1) {
350      for (const [area, slot] of Object.entries(s.areas)) if (slot.read.size) chips.push({ text: area + ' ' + slot.read.size + '파일', state: 'active' })
351    }
352    if (step === 2) {
353      const planned = plannedByArea(plan)
354      const names = new Set([...Object.keys(planned), ...Object.keys(s.areas).filter((a) => s.areas[a].edited.size)])
355      for (const area of names) {
356        const edited = s.areas[area] ? s.areas[area].edited.size : 0
357        const want = planned[area] || 0
358        const cancelled = s.cancelled.includes(area)
359        if (cancelled) chips.push({ text: area, state: 'cancel' })
360        else if (want) chips.push({ text: area + ' ' + Math.min(edited, want) + '/' + want, state: edited >= want ? 'done' : edited ? 'active' : 'todo' })
361        else chips.push({ text: area + ' 수정 ' + edited, state: 'active' })
362      }
363    }
364    if (step === 3) {
365      if (plan && plan.vTotal) chips.push({ text: '검증 ' + plan.vDone + '/' + plan.vTotal, state: 'active' })
366      else chips.push({ text: '검증 실행 ' + s.verifyRuns, state: 'active' })
367    }
368  }
369  return { mode, labels, steps, chips }
370}
371
372function plannedByArea(plan) {
373  const out = {}
374  if (!plan || !plan.files) return out
375  for (const f of plan.files) {
376    if (/\/$/.test(f)) continue
377    const a = areaOf(f)
378    out[a] = (out[a] || 0) + 1
379  }
380  return out
381}
382
383// 서브에이전트 레지스트리.
384export function subStatus(sub, now) {
385  if (sub.done) return 'done'
386  return now - sub.last >= STALL_MS ? 'stall' : 'run'
387}
388
389// 백그라운드 작업 알림 본문을 읽는다. 2.1.291 실측 형식:
390//   이벤트: <task-id> · <summary>Monitor event: "설명"</summary> · <event>내용</event> (tool-use-id 없음)
391//   종료:   <task-id> · <tool-use-id> · <status>completed</status> · <summary>Monitor "설명" stream ended</summary>
392// 이벤트에는 tool-use-id 가 없으므로 Monitor 는 요약 속 설명으로도 찾는다.
393export function parseTaskNotification(text) {
394  const t = String(text || '')
395  const pick = (tag) => {
396    const m = new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>').exec(t)
397    return m ? m[1].trim() : ''
398  }
399  const toolUseId = pick('tool-use-id')
400  const taskId = pick('task-id')
401  const status = pick('status')
402  const event = pick('event')
403  let summary = pick('summary') || event || pick('result')
404  if (!summary) {
405    const stripped = t.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
406    summary = stripped.split(/(?<=[.!?])\s/)[0] || stripped
407  }
408  const d = /Monitor(?: event:)?\s+"([^"]+)"/.exec(summary)
409  return { toolUseId, taskId, status, event, monitorDesc: d ? d[1] : '', summary: summary.slice(0, 200) }
410}
411
412export function isEndStatus(status) {
413  return /^(completed|failed|killed|stopped|timeout|expired|error)$/i.test(status || '')
414}
415
416// 알림 요약의 낱말로 색을 정한다. 문제 낱말이 먼저다.
417export function eventColor(summary) {
418  const s = String(summary || '')
419  if (/\b(FAIL|FAILED|ERROR|DEAD|KILLED|Traceback|OOM)\b|실패|죽음|에러/i.test(s)) return 'error'
420  if (/\b(STALL|WAIT|WAITING|TIMEOUT|EXPIRED)\b|정체|대기|만료/i.test(s)) return 'warning'
421  if (/\b(DONE|PASS|PASSED|OK|SUCCESS|COMPLETED|idle)\b|완료|통과/i.test(s)) return 'success'
422  return 'text'
423}
424
425export function monitorRemainMs(m, now) {
426  return m.start + m.timeoutMs - now
427}
428
429// 만료 알림 시점. 긴 감시는 4분 전, 짧은 감시는 제한 시간의 20% 전이다. 고정 4분이면
430// 5분짜리 기본 감시가 시작 1분 만에 “만료 임박”이 되어 신호가 소음이 된다.
431export function monitorWarnMs(m) {
432  return Math.min(MONITOR_WARN_MS, Math.round((m.timeoutMs || 0) * 0.2))
433}
434
435// 띄운 것 판정. 이 세션이 시작한 컨테이너 · 브라우저만 다룬다. 백그라운드 셸은
436// 턴 종료(Stop) 때 엔진이 주는 진행 중 작업 목록을 그대로 쓴다.
437export function dockerNameOf(cmd) {
438  const c = String(cmd || '')
439  if (!/\bdocker\s+(run|compose\b[\s\S]*\bup)\b/.test(c)) return ''
440  const n = /--name[= ]([A-Za-z0-9_.-]+)/.exec(c)
441  if (n) return n[1]
442  if (/\bdocker\s+compose\b/.test(c)) {
443    // compose 의 -p 는 프로젝트 이름이다. docker run 의 -p(포트)와 섞이지 않게 compose 일 때만 본다.
444    const p = /\s-p\s+([A-Za-z0-9_.-]+)|--project-name[= ]([A-Za-z0-9_.-]+)/.exec(c)
445    return p ? p[1] || p[2] : 'compose'
446  }
447  return 'docker run'
448}
449
450// fleet 태스크 상태를 띠 표시로 바꾼다. 상태 원어(SEALED · ACKED)는 fleet 내부 어휘라,
451// 띠에는 사용자가 다음에 무엇을 봐야 하는지(대기 · 작업중 · 검증 대기)를 적는다.
452const FLEET_STATUS = {
453  SEALED: { state: 'todo', text: '대기' },
454  DISPATCHED: { state: 'active', text: '기동' },
455  ACKED: { state: 'active', text: '작업중' },
456  DONE: { state: 'wait', text: '검증 대기' },
457  VERIFIED: { state: 'done', text: '완료' },
458  CLOSED: { state: 'done', text: '완료' },
459  CANCELLED: { state: 'cancel', text: '취소' },
460}
461
462export function fleetStatus(status) {
463  const s = String(status || '').toUpperCase()
464  if (FLEET_STATUS[s]) return FLEET_STATUS[s]
465  if (/STALL|BLOCK|FAIL/.test(s)) return { state: 'fail', text: '막힘' }
466  if (/CANCEL/.test(s)) return FLEET_STATUS.CANCELLED
467  return { state: 'active', text: s.toLowerCase() || '?' }
468}
469
470const FLEET_ORDER = { fail: 0, wait: 1, active: 2, todo: 3 }
471
472// 이 세션이 띄운 워커를 띠 한 줄로 줄인다. 끝난 워커는 개수로만 남겨서,
473// 아직 돌고 있거나 메인의 손이 필요한 워커가 앞에 보이게 한다.
474// tasks: [{ id, status, issue, acked, dispatched }] (acked · dispatched 는 epoch ms, 없으면 0)
475export function fleetView(tasks, now) {
476  const open = []
477  let done = 0
478  let cancelled = 0
479  for (const x of tasks || []) {
480    const st = fleetStatus(x.status)
481    if (st.state === 'done') {
482      done += 1
483      continue
484    }
485    if (st.state === 'cancel') {
486      cancelled += 1
487      continue
488    }
489    const tail = String(x.id || '').slice(-4)
490    const since = st.state === 'active' ? x.acked || x.dispatched || 0 : 0
491    const min = since ? Math.floor((now - since) / 60000) : 0
492    open.push({ id: x.id, state: st.state, tail, issue: x.issue || '', text: st.text, min })
493  }
494  open.sort((a, b) => FLEET_ORDER[a.state] - FLEET_ORDER[b.state] || String(a.id).localeCompare(String(b.id)))
495  return { open, done, cancelled }
496}
497
498// ── 띠 최종 시안(2026-10-08) ─────────────────────────────────────────────────────
499
500// 단계 색의 원색 값. 그림 칸(Raster)과 알약 바탕은 테마 키를 못 쓰고 숫자 색만 받는다.
501// 밝은 바탕 · 어두운 바탕 모두에서 읽히는 중간 밝기(위에 놓는 글자는 늘 어두운 색을 쓴다).
502export const LEVEL_HEX = [0x3fae58, 0x74ad2c, 0xcf9400, 0xe0762b, 0xf0506e]
503export function levelHex(p) {
504  const l = level(p)
505  return l < 0 ? 0x7b8491 : LEVEL_HEX[l]
506}
507
508// 계열 테두리의 원색 값. 작업 중에는 테두리가 이 색 ↔ 검정으로 깜빡인다(0.6초씩 · 한 바퀴 1.2초).
509// 거쳐 온 모양: 색을 서서히 어둡게 돌리기(티가 안 남) → 두 줄 테두리(어색함) → 지금의 깜빡임.
510export const FAMILY_HEX = { red: 0xff6b80, blue: 0x4d9bff }
511export const BLINK_OFF = '#000000'
512const BLINK_HALF = 4
513export function mixHex(a, b, k) {
514  const f = (s) => Math.round(((a >> s) & 255) * k + ((b >> s) & 255) * (1 - k))
515  return (f(16) << 16) | (f(8) << 8) | f(0)
516}
517export function hexText(n) {
518  return '#' + n.toString(16).padStart(6, '0')
519}
520export function blinkColor(family, frame) {
521  const base = FAMILY_HEX[family] || FAMILY_HEX.blue
522  return Math.floor((frame || 0) / BLINK_HALF) % 2 === 0 ? hexText(base) : BLINK_OFF
523}
524
525// 숫자 알약: 이름과 숫자를 한 덩어리로 적고, 값만큼의 칸을 단계 색으로 채운다.
526// 돌려주는 값은 바탕 · 글자 모양이 같은 조각들 — { text, filled, num }.
527export const PILL_COLS = 9
528export function pillParts(label, pct) {
529  const known = pct !== null && pct !== undefined && !Number.isNaN(pct)
530  const name = String(label).padEnd(3)
531  // 숫자 칸은 늘 4칸(「 5% 」 「50% 」 「100%」 「 —  」)이라 세로로 쌓아도 폭이 같다.
532  const text = ' ' + name + ' ' + (known ? (Math.round(pct) >= 100 ? '100%' : String(Math.round(pct)).padStart(2) + '% ') : ' —  ')
533  const n = text.length
534  // 값이 조금이라도 있으면 한 칸은 채운다(1~5% 에서도 단계 색이 보이게).
535  const k = known && pct > 0 ? Math.max(1, Math.min(n, Math.round((n * Math.min(100, pct)) / 100))) : 0
536  const numAt = name.length + 2
537  const out = []
538  let i = 0
539  while (i < n) {
540    const filled = i < k
541    const num = i >= numAt
542    let j = i
543    while (j < n && j < k === filled && j >= numAt === num) j += 1
544    out.push({ text: text.slice(i, j), filled, num })
545    i = j
546  }
547  return out
548}
549
550// 작업 중인가. 본체 턴이 돌거나, 본체가 쉬어도 하위 세션(서브에이전트 · 워커)이 일하면 작업 중이다.
551// 백그라운드 명령과 감시는 세지 않는다 — dev 서버처럼 계속 떠 있는 것이 있으면 영영 작업 중이 된다.
552export function workState(s) {
553  const n = (s.subs || 0) + (s.workers || 0)
554  if (s.busy) return { working: true, kind: 'main', n }
555  if (n > 0) return { working: true, kind: 'sub', n }
556  return { working: false, kind: 'idle', n: 0 }
557}
558
559// 오래 쉰 단계. 0 깨어 있음 · 1 잠(15분) · 2 흐림(60분). 작업 중이면 늘 0 이다.
560// 잠들기 전 5분부터는 존다(isDozing) — 단계 번호는 그대로 두고 따로 판정한다(잠 단계에 걸린 폴링 완화 · 밤하늘은 졸 때 적용하지 않는다).
561export const IDLE_DEFAULT = { dozeMs: 5 * 60 * 1000, sleepMs: 15 * 60 * 1000, dimMs: 60 * 60 * 1000 }
562export function isDozing(now, lastActiveAt, working, cfg) {
563  const c = cfg || IDLE_DEFAULT
564  if (working || !lastActiveAt) return false
565  const idle = now - lastActiveAt
566  const doze = c.dozeMs === undefined ? IDLE_DEFAULT.dozeMs : c.dozeMs
567  return idle >= doze && idle < c.sleepMs
568}
569export function idleStage(now, lastActiveAt, working, cfg) {
570  const c = cfg || IDLE_DEFAULT
571  if (working || !lastActiveAt) return 0
572  const idle = now - lastActiveAt
573  return idle >= c.dimMs ? 2 : idle >= c.sleepMs ? 1 : 0
574}
575// mod 를 다시 읽으면 모듈 변수가 지워져, 열려 있는 세션 전부의 쉼 시계가 같은 순간에 0 으로 돌아간다(잠든 세션이 한꺼번에 깬다).
576// 그래서 세션마다 마지막 활동 시각을 파일에 남기고, 다시 읽을 때 되살린다.
577// 표식(markerTs)이 방금까지 갱신됐으면 같은 프로세스가 mod 만 다시 읽은 것이므로 남겨 둔 시각을 쓰고,
578// 표식이 낡았으면 세션을 새로 열었거나 이어 연 것이므로 지금부터 센다.
579export function restoreActive(now, markerTs, savedTs) {
580  const m = Number(markerTs)
581  const s = Number(savedTs)
582  if (!Number.isFinite(m) || !Number.isFinite(s) || s <= 0) return now
583  if (now - m > 120000 || s > now) return now
584  return s
585}
586export function idleText(now, lastActiveAt) {
587  const min = Math.max(0, Math.floor((now - lastActiveAt) / 60000))
588  return min >= 60 ? '쉼 ' + Math.floor(min / 60) + '시간 ' + (min % 60) + '분' : '쉼 ' + min + '분'
589}
590
591// 세션 이름 후보. 이름이 이미 있으면 붙이지 않는다(손으로 붙인 이름은 건드리지 않는다).
592// 손으로 지은 작업 대장 줄 이름이 있으면 그것을, 없으면 이슈 제목의 앞부분을 쓴다. 지어내지 않는다.
593export function titleCandidate(s) {
594  if (String(s.current || '').trim()) return ''
595  const line = String(s.lineName || '').trim()
596  if (line && s.lineAuto === false) return line
597  let t = String(s.issueTitle || '')
598    .replace(/^(\s*\[[^\]]+\]\s*)+/, '')
599    .trim()
600  if (!t) return ''
601  const cut = t.search(/\s[—–-]\s|\s·\s/)
602  if (cut > 0) t = t.slice(0, cut)
603  const chars = [...t]
604  return (chars.length > 12 ? chars.slice(0, 12).join('') : t).trim()
605}
606
607// 띠 밖 ①: 스피너 줄 끝에 붙는 한 줄 — 지금 단계 · 돌리는 일 · 몇 번째 도구 · 작업 도중 받은 메시지.
608export function spinnerTail(s) {
609  const parts = []
610  if (s.step) parts.push(s.step)
611  if (s.tool) parts.push(s.tool.length > 36 ? s.tool.slice(0, 35) + '…' : s.tool)
612  if (s.tools > 0) parts.push('도구 ' + s.tools + '번째')
613  if (s.mid > 0) parts.push('도중 메시지 ' + s.mid + '건 받음')
614  return parts.length ? '· ' + parts.join(' · ') : ''
615}
616
617// 띠 밖 ③: 턴 영수증. 0 인 항목은 적지 않는다.
618export function receiptText(r) {
619  const parts = ['도구 ' + (r.tools || 0)]
620  if (r.files > 0) parts.push('고친 파일 ' + r.files)
621  if (r.fails > 0) parts.push('실패 ' + r.fails)
622  if (r.subs > 0) parts.push('서브 ' + r.subs)
623  if (r.mid > 0) parts.push('도중 메시지 ' + r.mid)
624  const delta = (label, a, b) => {
625    if (a === null || a === undefined || b === null || b === undefined) return
626    const d = Math.round(b - a)
627    if (d > 0) parts.push(label + ' +' + d + '%')
628  }
629  delta('ctx', r.ctx0, r.ctx1)
630  delta('5h', r.h50, r.h51)
631  return parts.join(' · ')
632}
633
634// 띠 밖 ④: 입력칸 아래 줄 끝에 붙는 「남은 것」. 없으면 빈 글자(아무것도 붙이지 않는다).
635export function hintTail(s) {
636  const parts = []
637  if (s.shells > 0) parts.push('셸 ' + s.shells)
638  if (s.dockers > 0) parts.push('docker ' + s.dockers)
639  if (s.browser) parts.push('브라우저 1')
640  if (s.unpushed > 0) parts.push('미push ' + s.unpushed)
641  return parts.length ? '남은 것: ' + parts.join(' · ') : ''
642}
643
644// 띠 밖 ⑤: 내 프롬프트 줄의 꼬리표.
645export function promptTag(p) {
646  const d = new Date(p.at)
647  const hm = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
648  return '#' + p.n + ' · ' + hm + (p.mid ? ' · 작업 도중' : '')
649}
650
651// 띠 밖 ②: `/now` — 모델을 부르지 않고 지금까지를 한눈에.
652export function nowText(s) {
653  const lines = []
654  const head = [s.issue || '이슈 없음', s.step ? '단계 ' + s.step : '단계 없음']
655  if (s.busy) head.push('턴 ' + fmtDur(s.turnSec) + '째')
656  else if (s.subs > 0) head.push('본체는 쉬고 하위 ' + s.subs + '개 작업 중')
657  else head.push(s.idle || '쉬는 중')
658  lines.push(head.join(' · '))
659  lines.push('이번 턴: ' + receiptText(s.turn) + (s.tool ? ' · 지금 ' + s.tool : ''))
660  const left = hintTail(s.left)
661  lines.push('서브 ' + s.subs + ' · 감시 ' + s.mons + (left ? ' · ' + left : ' · 남은 것 없음'))
662  if (s.usage) lines.push('한도: ctx ' + (s.usage.ctx ?? '—') + '% · 5h ' + (s.usage.h5 ?? '—') + '% · 7d ' + (s.usage.d7 ?? '—') + '%')
663  if (s.prompts && s.prompts.length) {
664    lines.push('보낸 프롬프트(최근 ' + s.prompts.length + '개):')
665    for (const p of s.prompts) lines.push('  ' + promptTag(p) + '  ' + (p.text.length > 60 ? p.text.slice(0, 59) + '…' : p.text))
666  }
667  return lines.join('\n')
668}
669
hooks/view.js 417 lines
1// 하네스 콕핏의 화면 트리 조립. `$` 를 받지 않고, register.js 가 넘긴 요소 생성자(E)와
2// 상태 스냅샷(snap)만으로 트리를 만든다. 같은 스냅샷이면 같은 트리가 나온다.
3import {
4  STATE_COLORS,
5  FAMILY_BORDER,
6  SPINNER,
7  levelColor,
8  levelHex,
9  mixHex,
10  hexText,
11  blinkColor,
12  pillParts,
13  PILL_COLS,
14  idleText,
15  fmtDur,
16  fmtClock,
17  branchTail,
18  eventColor,
19  fleetView,
20} from './model.js'
21import { getConfig } from './config.js'
22import { CLAWD_COLS, CLAWD_ROWS } from './clawd.js'
23import { ARC_COLS, ARC_ROWS } from './arc.js'
24
25const DIM = 'inactive'
26const RUN = STATE_COLORS.active
27const TEAL = '#1FA89E'
28
29// 60분 넘게 쉰 띠는 글자를 전부 흐리게 그린다. band() 가 그리는 동안에만 켠다.
30let dimAll = false
31
32function t(E, text, props) {
33  const p = Object.assign({ children: [text] }, props || {})
34  if (dimAll) p.dimColor = true
35  return E.Text(p)
36}
37
38function sep(E) {
39  return t(E, '  │  ', { color: DIM })
40}
41
42// 숨쉬기 점. 회전은 “작업중” 배지 한 곳만 쓰고(확정 사항), 나머지 진행 중 표시는
43// 밝기만 바뀌는 점으로 둬서 띠가 산만해지지 않게 한다.
44function breathe(E, frame, color) {
45  const on = Math.floor(frame / 4) % 2 === 0
46  return t(E, '●', { color: on ? color : DIM })
47}
48
49function ago(ms) {
50  const s = Math.max(0, Math.floor(ms / 1000))
51  if (s < 60) return s + '초 전'
52  if (s < 3600) return Math.floor(s / 60) + '분 전'
53  return Math.floor(s / 3600) + '시간 전'
54}
55
56function stateColor(st) {
57  return STATE_COLORS[st] || DIM
58}
59
60function currentColor(view) {
61  const cur = view.steps.find((x) => x.state === 'active' || x.state === 'wait' || x.state === 'fail')
62  if (cur) return stateColor(cur.state)
63  if (view.steps.length && view.steps.every((x) => x.state === 'done')) return STATE_COLORS.done
64  return RUN
65}
66
67function chip(E, c, frame) {
68  const color = stateColor(c.state)
69  if (c.state === 'cancel') return [t(E, ' '), t(E, c.text + ' 취소', { color: DIM, strikethrough: true })]
70  const mark = c.state === 'done' ? '✓ ' : c.state === 'todo' ? '○ ' : null
71  return [t(E, '  '), mark ? t(E, mark, { color }) : breathe(E, frame, color), mark ? null : t(E, ' '), t(E, c.text, { color, bold: c.state !== 'todo' })]
72}
73
74function stepperWide(E, view, frame) {
75  const out = []
76  view.steps.forEach((s, i) => {
77    const color = stateColor(s.state)
78    let icon
79    if (s.state === 'done') icon = t(E, '✓', { color })
80    else if (s.state === 'active') icon = breathe(E, frame, color)
81    else if (s.state === 'wait') icon = t(E, '!', { color, bold: true })
82    else if (s.state === 'fail') icon = t(E, '✕', { color, bold: true })
83    else icon = t(E, '○', { color: DIM })
84    out.push(icon, t(E, ' ' + s.label, { color, bold: s.state === 'active' || s.state === 'wait' || s.state === 'fail' }))
85    const isCurrent = s.state === 'active' || s.state === 'wait' || s.state === 'fail'
86    if (isCurrent) for (const c of view.chips) out.push(...chip(E, c, frame))
87    if (i < view.steps.length - 1) out.push(t(E, ' ── ', { color: s.state === 'done' ? STATE_COLORS.done : DIM }))
88  })
89  return out
90}
91
92function stepperNarrow(E, view, frame) {
93  const out = []
94  view.steps.forEach((s) => {
95    if (s.state === 'active') out.push(breathe(E, frame, stateColor(s.state)))
96    else out.push(t(E, s.state === 'todo' ? '○' : '●', { color: stateColor(s.state) }))
97  })
98  const cur = view.steps.find((x) => x.state === 'active' || x.state === 'wait' || x.state === 'fail')
99  const label = cur ? cur.label : view.steps.length ? '완료' : ''
100  out.push(t(E, ' ' + label, { bold: true, color: currentColor(view) }))
101  const c = view.chips.find((x) => x.state !== 'todo' && x.state !== 'done' && x.state !== 'cancel')
102  if (c) out.push(...chip(E, c, frame))
103  return out
104}
105
106// 숫자 알약: 이름과 숫자를 적고, 값만큼의 칸 바탕을 단계 색으로 채운다(칸 게이지를 대신한다).
107// 터미널 바탕색(밝은 테마 · 어두운 테마)에 상관없이 읽히게: 채운 칸은 단계 색 바탕에 어두운 글자,
108// 빈 칸은 테마의 흐린 색 바탕에 테마의 글자색. 바탕과 글자를 늘 같은 쪽에서 고르므로 어느 테마에서도 묻히지 않는다.
109function pillSegs(E, label, pct) {
110  const fill = hexText(levelHex(pct))
111  return pillParts(label, pct).map((p) => t(E, p.text, p.filled ? { backgroundColor: fill, color: '#15171c', bold: p.num } : { backgroundColor: 'subtle', color: 'text', bold: p.num }))
112}
113
114function usageValues(snap) {
115  const u = snap.usage
116  return [
117    ['ctx', u.ctx],
118    ['5h', u.h5 ? u.h5.pct : null],
119    ['7d', u.d7 ? u.d7.pct : null],
120  ]
121}
122
123// 세로로 쌓은 알약 3개(9칸 × 3줄). 띠가 3줄일 때 오른쪽에 둔다.
124function pillStack(E, snap) {
125  return E.Box({ flexDirection: 'column', flexShrink: 0, paddingLeft: 1, children: usageValues(snap).map((v) => E.Text({ children: pillSegs(E, v[0], v[1]) })) })
126}
127
128// 한 줄로 놓은 사용량. 넓으면 알약 셋, 좁으면 숫자만.
129function usageRow(E, snap, compact) {
130  const out = []
131  usageValues(snap).forEach((v, i) => {
132    if (compact) {
133      if (i) out.push(t(E, '  '))
134      out.push(t(E, v[0] + ' ', { color: DIM }), t(E, v[1] === null || v[1] === undefined ? '—' : v[1] + '%', { color: levelColor(v[1]), bold: true }))
135    } else {
136      if (i) out.push(t(E, ' '))
137      out.push(...pillSegs(E, v[0], v[1]))
138    }
139  })
140  return out
141}
142
143// 반원(해 · 달의 길 + 안쪽 퇴근 숫자)과 그 아래 주간 띠. 그림은 key 로 잡아 두고 register.js 가 갈아 끼운다.
144// 오늘은 테마의 글자색을 바탕으로 뒤집어 그린다(밝은 테마에서는 어두운 칸, 어두운 테마에서는 밝은 칸).
145const WEEK_STYLE = {
146  today: { backgroundColor: 'text', color: 'inverseText', bold: true },
147  past: { color: DIM },
148  future: { color: 'subtle' },
149  weekend: { backgroundColor: '#9b72e8', color: '#15171c', bold: true },
150}
151function arcBlock(E, snap) {
152  return E.Box({
153    flexDirection: 'column',
154    flexShrink: 0,
155    alignItems: 'center',
156    paddingLeft: 1,
157    children: [E.Raster({ key: 'arc', columns: ARC_COLS, rows: ARC_ROWS, cells: snap.arc.cells }), E.Text({ children: (snap.week || []).map((d) => t(E, d.text, WEEK_STYLE[d.kind] || {})) })],
158  })
159}
160
161// 반원을 그릴 자리가 없을 때: 요일 한 글자와 퇴근 숫자를 글자로.
162function timeSegs(E, snap) {
163  const out = []
164  const today = (snap.week || []).find((d) => d.kind === 'today' || d.kind === 'weekend')
165  if (today) out.push(t(E, today.text, { color: DIM }))
166  const tm = snap.timer
167  // 글자로 적을 때는 「퇴근」 을 붙여 무엇까지 남은 시간인지 밝힌다.
168  if (tm && !tm.off) out.push(t(E, ' 퇴근 ', { color: DIM }), t(E, tm.text, { color: hexText(tm.color), bold: true }))
169  return out
170}
171
172function identSegs(E, snap, narrow) {
173  const out = []
174  const dirty = snap.dirty > 0 ? t(E, ' *' + snap.dirty, { color: 'warning' }) : snap.branch ? t(E, ' ✓', { color: DIM }) : null
175  const glyph = t(E, snap.repo.worktree ? '○ ' : '● ', { color: snap.repo.color })
176  if (narrow) {
177    out.push(glyph, t(E, snap.coKey || snap.repo.name, { color: DIM }))
178    if (dirty) out.push(dirty)
179    return out
180  }
181  if (snap.issue.title) {
182    out.push(t(E, snap.issue.title, { bold: true }))
183    if (snap.issue.state) out.push(t(E, '  ' + snap.issue.state, { color: '#C79BFF' }))
184    out.push(sep(E))
185  }
186  out.push(glyph, t(E, snap.repo.name, { bold: true }))
187  if (snap.coKey) out.push(t(E, ' ' + snap.coKey, { color: DIM }))
188  if (snap.branch) out.push(t(E, '  ⎇ ' + branchTail(snap.branch), { color: 'success' }))
189  if (dirty) out.push(dirty)
190  if (snap.model) {
191    out.push(sep(E), t(E, '◆ ' + snap.model, { color: RUN }))
192    if (snap.effort) out.push(t(E, '·' + snap.effort, { color: DIM }))
193  }
194  return out
195}
196
197// 하위 작업 · 감시 · 띄운 것 칩.
198function chipSegs(E, snap, narrow) {
199  const out = []
200  // 워커 목록은 슬림 패널에 세로로 쌓고(띠 가로 나열은 읽기 어렵다),
201  // 띠에는 개수와 메인의 손이 필요한 수만 남긴다.
202  const workers = snap.fleet ? fleetView(snap.fleet.tasks, snap.now) : null
203  if (workers && workers.open.length) {
204    const fail = workers.open.filter((x) => x.state === 'fail').length
205    const wait = workers.open.filter((x) => x.state === 'wait').length
206    const tail = fail ? ' · 막힘 ' + fail : wait ? ' · 검증 대기 ' + wait : ''
207    out.push(t(E, '● 워커 ' + workers.open.length + (narrow ? '' : tail), { color: fail ? 'error' : wait ? 'warning' : RUN }), t(E, '   '))
208  }
209  const runningSubs = snap.subs.filter((s) => s.status !== 'done')
210  if (runningSubs.length) {
211    const stalls = runningSubs.filter((s) => s.status === 'stall').length
212    out.push(t(E, '● 서브 ' + runningSubs.length + (stalls ? ' · 정체 ' + stalls : ''), { color: stalls ? 'warning' : RUN }), t(E, '   '))
213  }
214  const liveMons = snap.mons.filter((m) => !m.ended)
215  if (liveMons.length) {
216    const warn = liveMons.filter((m) => m.warn).length
217    out.push(t(E, '● 감시 ' + liveMons.length + (warn ? ' · 만료 임박 ' + warn : ''), { color: warn ? '#E8833A' : DIM }), t(E, '   '))
218  }
219  if (snap.left.length) out.push(t(E, '● 띄운 것 ' + snap.left.length, { color: 'warning' }), t(E, '   '))
220  // 화면 안 꺼짐: caffeinate 가 화면 잠자기를 막는 동안 그 이름을 그대로 적는다(네모 · 커피잔 · 「화면 켜둠」 을 거쳐 이 글자로 정했다).
221  // 자리는 상태 칩 줄의 끝이다. 작업 배지 옆은 폰 표시만 둔다.
222  if (snap.awake) out.push(t(E, 'caffeinate', { color: '#E0A100' }), t(E, '   '))
223  return out
224}
225
226// 작업 배지와 그 왼쪽의 작은 표시(원격 = 폰).
227function badgeSegs(E, snap, narrow, view, work) {
228  const out = []
229  if (snap.remote) out.push(t(E, '▯ ', { color: '#2FA8E0' }))
230  if (snap.busy) {
231    out.push(t(E, SPINNER[snap.frame % SPINNER.length], { color: currentColor(view), bold: true }))
232    if (!narrow) out.push(t(E, ' 작업중', { color: currentColor(view), bold: true }))
233    out.push(t(E, ' ' + fmtDur((snap.now - snap.turnStart) / 1000), { color: DIM }))
234  } else if (snap.asking) {
235    out.push(t(E, '… 입력 대기', { color: 'warning', bold: true }))
236  } else if (work.kind === 'sub') {
237    // 본체는 쉬지만 하위 세션이 일하는 중 — 작업 중으로 본다.
238    out.push(t(E, SPINNER[snap.frame % SPINNER.length], { color: TEAL, bold: true }), t(E, ' 하위 ' + work.n + (narrow ? '' : ' 작업중'), { color: TEAL, bold: true }))
239  } else if ((snap.idle || 0) >= 1) {
240    out.push(t(E, idleText(snap.now, snap.lastActiveAt), { color: DIM }))
241  } else if (snap.lastDur) {
242    out.push(t(E, '✔ 완료', { color: STATE_COLORS.done, bold: true }), t(E, ' ' + fmtDur(snap.lastDur), { color: DIM }))
243  }
244  return out
245}
246
247function row(E, left, right) {
248  return E.Box({
249    flexDirection: 'row',
250    justifyContent: 'space-between',
251    columnGap: 2,
252    children: [
253      E.Box({ flexGrow: 1, flexShrink: 1, children: [E.Text({ wrap: 'truncate-end', children: left && left.length ? left : [t(E, ' ')] })] }),
254      right && right.length ? E.Box({ flexShrink: 0, children: [E.Text({ wrap: 'truncate-end', children: right })] }) : null,
255    ],
256  })
257}
258
259// 띠 카드: [Clawd] [일 3줄] [알약 세로 3개] [반원 + 주간 띠].
260// 테두리는 굵은 선이고 색은 계열을 말한다. 일하는 동안(본체 또는 하위)에는 그 색 ↔ 검정으로 깜빡인다.
261// 경보 파일이 살아 있으면 레포 색과 상관없이 두 줄 빨간 테두리와 경보 줄로 바꿔, 빨강 계열 레포에서도 평소 카드와 구분되게 한다.
262export function band(E, snap, view) {
263  dimAll = (snap.idle || 0) >= 2
264  try {
265    return bandTree(E, snap, view)
266  } finally {
267    dimAll = false
268  }
269}
270
271// 반원을 그릴 폭인가. register.js 도 같은 기준으로 「반원 그림을 갈아 끼울지」를 정한다.
272export function arcFits(total, hasClawd) {
273  return !!hasClawd && total >= 70 && total - (CLAWD_COLS + 1) >= 96
274}
275
276function bandTree(E, snap, view) {
277  const total = snap.cols || 120
278  // Clawd 는 폭이 70칸 이상일 때만 띠 왼쪽에 둔다. 그림이 있어야 띠가 3줄이 되고, 그때만 오른쪽 칸(알약 · 반원)을 세운다.
279  const clawd = snap.clawd && total >= 70 ? snap.clawd : null
280  const inner = total - (clawd ? CLAWD_COLS + 1 : 0)
281  const showStack = !!clawd && inner >= 76
282  const showArc = !!snap.arc && arcFits(total, !!clawd)
283  const cols = inner - (showStack ? PILL_COLS + 2 : 0) - (showArc ? ARC_COLS + 2 : 0)
284  const narrow = showStack ? cols < 62 : cols < 100
285  const alert = snap.flags.length > 0
286  const work = snap.work || { working: !!snap.busy, kind: snap.busy ? 'main' : 'idle', n: 0 }
287  const rows = []
288  if (alert) {
289    const al = getConfig().alert
290    const msg = narrow ? '⚠ ' + al.title + ' ' + snap.flags.join(', ') : '⚠ ' + al.title + ' 활성  ' + snap.flags.join(', ') + (al.note ? ' — ' + al.note : '')
291    rows.push(E.Text({ wrap: 'truncate-end', children: [t(E, msg, { color: 'error', bold: true })] }))
292  }
293  const left1 = []
294  if (snap.issue.key) left1.push(t(E, snap.issue.key, { bold: true, color: currentColor(view) }), sep(E))
295  if (view.mode === 'none') left1.push(t(E, '진행 단계 없음', { color: DIM }))
296  else left1.push(...(narrow ? stepperNarrow(E, view, snap.frame) : stepperWide(E, view, snap.frame)))
297  const chips = chipSegs(E, snap, narrow)
298  const badge = badgeSegs(E, snap, narrow, view, work)
299  if (clawd) {
300    // 3줄: 첫 줄 일 + 배지, 둘째 줄 이름, 셋째 줄 하위 작업 칩.
301    rows.push(row(E, left1, badge))
302    rows.push(row(E, identSegs(E, snap, narrow), showStack ? [] : usageRow(E, snap, cols < 100)))
303    rows.push(row(E, chips, showArc ? [] : timeSegs(E, snap)))
304  } else {
305    // 그림이 없으면 2줄: 칩과 배지는 첫 줄 오른쪽, 요일 · 퇴근 숫자와 사용량은 둘째 줄 오른쪽.
306    const time = timeSegs(E, snap)
307    rows.push(row(E, left1, [...chips, ...badge]))
308    rows.push(row(E, identSegs(E, snap, narrow), [...time, ...(time.length ? [t(E, '   ')] : []), ...usageRow(E, snap, cols < 100)]))
309  }
310  const frame = {
311    // 테두리는 늘 굵은 한 줄이고, 작업 중에는 계열 색 ↔ 검정으로 깜빡인다(두 줄 테두리는 어색해서 되돌렸다).
312    // 두 줄은 경보 파일이 살아 있을 때만 쓴다(빨강 고정 + 맨 위 경보 줄).
313    borderStyle: alert ? 'double' : 'bold',
314    borderColor: alert ? 'error' : dimAll ? DIM : work.working ? blinkColor(snap.family, snap.frame) : FAMILY_BORDER[snap.family] || FAMILY_BORDER.blue,
315    paddingX: 1,
316  }
317  if (!clawd) return E.Box(Object.assign({ flexDirection: 'column', children: rows }, frame))
318  // 그림은 key 로 잡아 두고 register.js 가 $.ui.blit 으로 프레임만 갈아 끼운다(띠 전체를 다시 그리지 않는다).
319  const children = [
320    E.Box({ flexShrink: 0, children: [E.Raster({ key: 'clawd', columns: CLAWD_COLS, rows: CLAWD_ROWS, cells: clawd.cells })] }),
321    E.Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: rows }),
322  ]
323  if (showStack) children.push(pillStack(E, snap))
324  if (showArc) children.push(arcBlock(E, snap))
325  return E.Box(Object.assign({ flexDirection: 'row', columnGap: 1, children }, frame))
326}
327
328// 슬림 패널. 경보 · 워커 · 서브 · 감시 · 띄운 것 중 있는 것만 위에서부터 쌓는다.
329export function pane(E, snap) {
330  const lines = []
331  const head = (text) => lines.push(t(E, text, { color: DIM, bold: true }))
332  const line = (children) => lines.push(E.Text({ wrap: 'truncate-end', children }))
333  if (snap.flags.length) {
334    const al = getConfig().alert
335    head(al.title)
336    for (const f of snap.flags) line([t(E, '⚠ ' + f, { color: 'error', bold: true })])
337    if (al.note) line([t(E, '  ' + al.note, { color: 'error' })])
338    lines.push(t(E, ' '))
339  }
340  // 이 세션이 fleet 으로 띄운 워커. 배치를 가리지 않고 모아 한 줄에 하나씩 쌓는다.
341  const workers = snap.fleet ? fleetView(snap.fleet.tasks, snap.now) : null
342  if (workers && workers.open.length) {
343    head('워커 ' + workers.open.length)
344    for (const w of workers.open) {
345      const color = stateColor(w.state)
346      const mark = w.state === 'todo' ? t(E, '○', { color: DIM }) : w.state === 'active' ? breathe(E, snap.frame, color) : t(E, '●', { color })
347      const urgent = w.state === 'wait' || w.state === 'fail'
348      line([mark, t(E, ' ' + w.tail + (w.issue ? ' ' + w.issue : ''))])
349      line([t(E, '  ' + w.text + (w.min > 0 ? ' · ' + w.min + '분' : ''), { color: urgent ? color : DIM, bold: urgent })])
350    }
351    if (workers.done) line([t(E, '✓ 완료 ' + workers.done, { color: STATE_COLORS.done })])
352    if (snap.fleet.rollupAt) line([t(E, 'ROLLUP ' + ago(snap.now - snap.fleet.rollupAt), { color: DIM })])
353    lines.push(t(E, ' '))
354  }
355  const subs = snap.subs.filter((s) => s.status !== 'done')
356  if (subs.length) {
357    head('서브 ' + subs.length)
358    for (const s of subs) {
359      const stall = s.status === 'stall'
360      line([stall ? t(E, '●', { color: 'warning' }) : breathe(E, snap.frame, RUN), t(E, ' ' + s.name)])
361      line([t(E, '  ' + fmtDur(s.el / 1000) + ' · ' + ago(snap.now - s.last) + (stall ? ' · 정체' : ''), { color: stall ? 'warning' : DIM, bold: stall })])
362    }
363    lines.push(t(E, ' '))
364  }
365  const mons = snap.mons.filter((m) => !m.ended)
366  if (mons.length) {
367    head('감시 ' + mons.length)
368    for (const m of mons) {
369      const color = m.events === 0 ? DIM : eventColor(m.last) === 'text' ? STATE_COLORS.done : eventColor(m.last)
370      line([t(E, '◉ ', { color }), t(E, m.desc)])
371      const tail = m.warn ? [t(E, ' · 만료 ' + Math.max(0, Math.ceil(m.remainMs / 60000)) + '분 전', { color: '#E8833A', bold: true })] : []
372      line([t(E, '  ' + Math.floor(m.elMs / 60000) + '/' + Math.round(m.timeoutMs / 60000) + '분 · ' + m.events + '건' + (m.last ? ' · ' + m.last : ''), { color: DIM }), ...tail])
373    }
374    lines.push(t(E, ' '))
375  }
376  if (snap.left.length) {
377    head('띄운 것 ' + snap.left.length)
378    for (const x of snap.left) line([t(E, '● ', { color: 'warning' }), t(E, x)])
379    line([t(E, getConfig().texts.leftPane + ' — ' + snap.left.length + '개 남음', { color: 'warning', bold: true })])
380  }
381  // /cockpit 으로 직접 연 패널은 비어 있어도 남는다. 빈 칸 대신 무엇이 쌓이는 자리인지 적는다.
382  if (!lines.length) {
383    head('표시할 것 없음')
384    line([t(E, '워커 · 서브 · 감시가', { color: DIM })])
385    line([t(E, '생기면 여기에 쌓인다', { color: DIM })])
386  }
387  return E.Box({ flexDirection: 'column', children: lines })
388}
389
390// 대화 안 Agent 줄 아래에 붙이는 상태 한 줄.
391export function agentStatus(E, sub, now, frame) {
392  const stall = sub.status === 'stall'
393  return E.Text({
394    wrap: 'truncate-end',
395    children: [
396      t(E, '  '),
397      stall ? t(E, '●', { color: 'warning' }) : breathe(E, frame, RUN),
398      t(E, ' ' + fmtDur((now - sub.start) / 1000) + ' · 마지막 요청 ' + ago(now - sub.last) + (stall ? ' · 정체 의심' : ''), { color: stall ? 'warning' : DIM, bold: stall }),
399    ],
400  })
401}
402
403// 접힌 Monitor 알림 줄. 펼친 보기(ctrl+o)에서는 엔진 그림을 그대로 둔다.
404export function monitorLine(E, desc, summary, at) {
405  const color = eventColor(summary) === 'text' ? STATE_COLORS.done : eventColor(summary)
406  return E.Text({
407    wrap: 'truncate-end',
408    children: [t(E, '◉ ', { color }), t(E, desc, { color: DIM }), t(E, ' · '), t(E, summary, { color, bold: true }), t(E, ' ' + fmtClock(at), { color: DIM })],
409  })
410}
411
412export function leftoversText(left) {
413  const tx = getConfig().texts
414  if (!left.length) return tx.leftClear
415  return '띄운 것 ' + left.length + '개 — ' + left.join(' · ') + '\n' + tx.leftBusy
416}
417
hooks/clawd.js 582 lines
1// 띠 왼쪽에 사는 작은 Clawd(Claude Code 마스코트). `$` 를 받지 않는다 — 상태와 시각(ms)만으로 한 프레임을 만든다.
2// 모양은 Claude Code 시작 화면의 Clawd 를 반 칸 단위 점(가로 18 × 세로 6)으로 옮긴 것이다. 점 그림을 다시
3// 사분면 블록 글자(▛ ▜ ▝ …)로 묶어 그리므로 반 칸씩 옮겨도 모양이 깨지지 않는다(종종걸음 · 반 줄 깡충 · 화면 밖으로 나가기).
4// 가만히 선 자세(x 짝수 · y 0)는 시작 화면의 글자와 똑같이 나온다. 돌아서는 모습은 시작 화면 애니메이션의 글자를 그대로 쓴다.
5
6export const CLAWD_COLS = 15
7export const CLAWD_ROWS = 3
8export const CLAWD_TICK_MS = 150
9
10const W = CLAWD_COLS * 2
11const H = CLAWD_ROWS * 2
12const SW = 18
13const SH = 6
14// 다 보이는 채로 설 수 있는 x 범위(반 칸 단위).
15const X_MAX = W - SW
16const X_MID = X_MAX / 2
17// Claude Code 테마의 clawd_body · clawd_background 값.
18const BODY = 0xd77757
19const EYE = 0x000000
20const DEF = 0x01000000
21// 주변 효과의 색. 그림 칸은 테마를 따라가지 못하므로 밝은 바탕 · 어두운 바탕 어디서나 읽히는 중간 밝기만 쓴다(2026-10-08).
22const YELLOW = 0xe0a100
23const RED = 0xf0506e
24const CYAN = 0x2fa8e0
25const GRAY = 0x8a8a8a
26const STEAM = 0x9aa3b2
27// 맥 자원 단계별 몸 색: 평소 → 주의 → 높음 → 위험. 잠들면 조금 가라앉은 색.
28export const HEAT_COLORS = [BODY, 0xe2643c, 0xee4b2b, 0xc22a1e]
29const SLEEP_BODY = 0xa85f48
30// 우울할 때의 몸 색(자원 단계가 평소일 때만).
31const GLOOM_BODY = 0x8c98b3
32// 한 칸의 네 점(왼위 8 · 오른위 4 · 왼아래 2 · 오른아래 1) → 사분면 블록 글자.
33const QUAD = [' ', '▗', '▖', '▄', '▝', '▐', '▞', '▟', '▘', '▚', '▌', '▙', '▀', '▜', '▛', '█']
34const UNQUAD = new Map(QUAD.map((ch, bits) => [ch, bits]))
35
36// ── 모양 ────────────────────────────────────────────────────────────────────────
37// 머리 두 줄에서 양옆이 몸으로 막힌 빈 점을 눈으로 본다(검은 바탕으로 칠할 자리).
38function withEyes(on) {
39  const eye = new Uint8Array(SW * SH)
40  for (const y of [0, 1]) {
41    let first = -1
42    let last = -1
43    for (let x = 0; x < SW; x += 1) {
44      if (on[y * SW + x]) {
45        if (first < 0) first = x
46        last = x
47      }
48    }
49    for (let x = first + 1; x < last; x += 1) if (!on[y * SW + x]) eye[y * SW + x] = 1
50  }
51  return { s: on, e: eye }
52}
53
54const poseCache = new Map()
55
56// eyes: -1 왼쪽 0 정면 1 오른쪽 · eyeUp: 위를 봄 · blink: 두 눈 감음 · wink: 'L' | 'R' 한쪽만 감음 · shades: 선글라스(눈 줄에 검은 알 두 개)
57// armL / armR: 'down' | 'up' · legs: 0 | 1 (번갈아 쓰면 종종걸음)
58function pose(over) {
59  const p = Object.assign({ eyes: 0, eyeUp: false, blink: false, wink: '', shades: false, armL: 'down', armR: 'down', legs: 0 }, over || {})
60  const key = JSON.stringify(p)
61  if (poseCache.has(key)) return poseCache.get(key)
62  const on = new Uint8Array(SW * SH)
63  const put = (x, y) => {
64    on[y * SW + x] = 1
65  }
66  for (let x = 3; x <= 15; x += 1) for (const y of [0, 1, 2, 3]) put(x, y)
67  const ey = p.eyeUp ? 0 : 1
68  if (!p.blink && p.wink !== 'L') on[ey * SW + 5 + p.eyes] = 0
69  if (!p.blink && p.wink !== 'R') on[ey * SW + 13 + p.eyes] = 0
70  // 선글라스: 눈 줄을 가로지르는 검은 알 두 개(가운데 한 점은 코걸이로 남긴다). 눈 위치 · 깜빡임과 상관없이 덮는다.
71  if (p.shades) for (let x = 4; x <= 14; x += 1) if (x !== 9) on[1 * SW + x] = 0
72  // 팔: 내리면 몸통 줄에서 옆으로, 올리면 한 줄 위로 꺾인다.
73  for (const [x, y] of p.armL === 'up' ? [[1, 1], [2, 1], [2, 2]] : [[1, 2], [2, 2]]) put(x, y)
74  for (const [x, y] of p.armR === 'up' ? [[16, 1], [17, 1], [16, 2]] : [[16, 2], [17, 2]]) put(x, y)
75  for (const x of p.legs ? [4, 6, 12, 14] : [3, 5, 13, 15]) put(x, 4)
76  const sp = withEyes(on)
77  poseCache.set(key, sp)
78  return sp
79}
80
81// 시작 화면 애니메이션의 「돌아서는」 글자들(Claude Code 2.1.293 에서 옮김).
82const TURN_GLYPHS = {
83  r12: [' ▐█▜██▛█ ', '▝▜██████▀', ' ▝▝   ▝▝ '],
84  r30: ['  █▛██▛▌ ', ' ▝█████▛ ', '  ▘▘  ▘▘ '],
85  r55: ['  ▐█▛█▜  ', '  ▐████  ', '  ▝▝ ▝▝  '],
86  r75: ['   ██▛▌  ', '   ███▌  ', '   ▘  ▘  '],
87  edge: ['   ▐██   ', '   ▐██   ', '   ▝ ▝   '],
88  b105: ['   ███▌  ', '   ███▌  ', '   ▘  ▘  '],
89  b125: ['  ▐████  ', '  ▐████  ', '  ▝▝ ▝▝  '],
90  b150: ['  █████▌ ', ' ▝█████▛ ', '  ▘▘  ▘▘ '],
91  back: [' ▐██████ ', '▝▜██████▀', ' ▝▝   ▝▝ '],
92  l75: ['   ▛██▌  ', '   ███▌  ', '   ▘  ▘  '],
93  l55: ['  ▐▜▛██  ', '  ▐████  ', '  ▝▝ ▝▝  '],
94  l30: ['  ▛██▛█▌ ', ' ▝█████▛ ', '  ▘▘  ▘▘ '],
95  l12: [' ▐▛███▜█ ', '▝▜██████▀', ' ▝▝   ▝▝ '],
96}
97// 한 바퀴. 뒤돌아선 뒤 반대쪽으로 도는 구간은 같은 그림을 좌우로 뒤집어 쓴다.
98const SPIN = [['r12'], ['r30'], ['r55'], ['r75'], ['edge'], ['b105'], ['b125'], ['b150'], ['back'], ['b150', true], ['b125', true], ['b105', true], ['edge', true], ['l75'], ['l55'], ['l30'], ['l12']]
99
100const turnCache = new Map()
101
102function turn(name, flip) {
103  const key = name + (flip ? '~' : '')
104  if (turnCache.has(key)) return turnCache.get(key)
105  const on = new Uint8Array(SW * SH)
106  TURN_GLYPHS[name].forEach((row, cy) => {
107    ;[...row].forEach((ch, cx) => {
108      const bits = UNQUAD.get(ch) || 0
109      const set = (dx, dy) => {
110        const x = cx * 2 + dx
111        on[(cy * 2 + dy) * SW + (flip ? SW - 1 - x : x)] = 1
112      }
113      if (bits & 8) set(0, 0)
114      if (bits & 4) set(1, 0)
115      if (bits & 2) set(0, 1)
116      if (bits & 1) set(1, 1)
117    })
118  })
119  const sp = withEyes(on)
120  turnCache.set(key, sp)
121  return sp
122}
123
124// 한 프레임 = 모양(s · e) + 자리(x: 반 칸 좌우, y: 반 줄 아래로) + 주변 효과(fx: 빈 칸에만 그리는 글자).
125function fr(x, y, over, fx) {
126  const sp = pose(over)
127  return { s: sp.s, e: sp.e, x, y, fx: fx || [], over: over || {} }
128}
129
130// 같은 자세에 선글라스만 씌운 프레임. 돌아서는 그림(tf)은 얼굴이 정면이 아니라 그대로 둔다.
131const shadedCache = new WeakMap()
132function shaded(f) {
133  if (!f.over) return f
134  if (!shadedCache.has(f)) {
135    const sp = pose(Object.assign({}, f.over, { shades: true, eyeUp: false }))
136    shadedCache.set(f, Object.assign({}, f, { s: sp.s, e: sp.e }))
137  }
138  return shadedCache.get(f)
139}
140
141function tf(name, flip, x) {
142  const sp = turn(name, flip)
143  return { s: sp.s, e: sp.e, x, y: 0, fx: [] }
144}
145
146// 몸 바로 오른쪽 · 왼쪽 빈 칸의 열 번호.
147function right(x) {
148  return Math.floor((x + SW) / 2)
149}
150function left(x) {
151  return Math.floor((x + 1) / 2) - 1
152}
153function mark(cx, cy, ch, color) {
154  return { cx, cy, ch, color }
155}
156
157// ── 동작 조각 ────────────────────────────────────────────────────────────────────
158function repeat(n, frames) {
159  const out = []
160  for (let i = 0; i < n; i += 1) out.push(...frames)
161  return out
162}
163const clampX = (x) => Math.max(0, Math.min(X_MAX, x))
164
165const look = (x) => [fr(x, 0, { eyes: -1 }), fr(x, 0, { eyes: -1 }), fr(x, 0, { eyes: 1 }), fr(x, 0, { eyes: 1 }), fr(x, 0, { eyeUp: true }), fr(x, 0)]
166const hop = (x, n) => repeat(n, [fr(x, 1), fr(x, 0, { armL: 'up', armR: 'up' })])
167const wave = (x) => repeat(3, [fr(x, 0, { armR: 'up' }), fr(x, 0, { armL: 'up' })])
168const stomp = (x) => repeat(3, [fr(x, 0, { legs: 1 }), fr(x, 0)])
169const blink = (x) => [fr(x, 0, { blink: true }), fr(x, 0), fr(x, 0, { blink: true }), fr(x, 0)]
170const wink = (x) => [fr(x, 0, { wink: 'R' }), fr(x, 0, { wink: 'R', armR: 'up' }), fr(x, 0)]
171const spin = (x) => SPIN.map(([name, flip]) => tf(name, flip, x))
172const shiver = (x) => repeat(3, [fr(clampX(x - 1), 0, { blink: true }), fr(clampX(x + 1), 0, { blink: true })])
173const dance = (x) =>
174  repeat(2, [
175    fr(clampX(x - 1), 0, { armL: 'up', legs: 1, eyes: -1 }),
176    fr(clampX(x - 1), 1, { armL: 'up' }),
177    fr(clampX(x + 1), 0, { armR: 'up', legs: 1, eyes: 1 }),
178    fr(clampX(x + 1), 1, { armR: 'up' }),
179  ])
180
181// 한 발짝씩 종종걸음.
182function walk(from, to) {
183  const out = []
184  const dir = to > from ? 1 : -1
185  for (let x = from + dir, i = 0; dir > 0 ? x <= to : x >= to; x += dir, i += 1) out.push(fr(x, 0, { eyes: dir, legs: i % 2 }))
186  return out
187}
188
189// 먼지를 날리며 내달린다. 화면 밖 좌표까지 갈 수 있다(잘려 보인다).
190function dash(from, to) {
191  const out = []
192  const dir = to > from ? 1 : -1
193  for (let x = from + dir * 3, i = 0; dir > 0 ? x <= to : x >= to; x += dir * 3, i += 1) {
194    const behind = dir > 0 ? left(x) : right(x)
195    out.push(fr(x, i % 2, { eyes: dir, legs: i % 2, armL: i % 2 ? 'up' : 'down', armR: i % 2 ? 'down' : 'up' }, [mark(behind, 2, i % 2 ? '⠐' : '⠂', GRAY)]))
196  }
197  out.push(fr(to, 0, { eyes: dir }))
198  return out
199}
200
201// 아래로 쏙 숨었다가 까꿍.
202const peekaboo = (x) => [
203  fr(x, 1),
204  fr(x, 3),
205  fr(x, 5),
206  fr(x, 7),
207  fr(x, 7),
208  fr(x, 7),
209  fr(x, 4, { armL: 'up', armR: 'up' }),
210  fr(x, 0, { armL: 'up', armR: 'up' }, [mark(right(x), 0, '*', YELLOW)]),
211  fr(x, 1, { armL: 'up', armR: 'up' }),
212  fr(x, 0),
213]
214
215// 오른쪽 밖으로 뛰쳐나갔다가 왼쪽에서 다시 들어온다.
216const lap = (x) => [...dash(x, W + 2), ...dash(-SW - 2, X_MID)]
217
218// 반짝이를 뿌리며 만세.
219function sparkle(x, n) {
220  const out = []
221  for (let i = 0; i < n; i += 1) {
222    const fx = [mark(i % 2 ? right(x) : left(x), i % 3, i % 2 ? '*' : '+', YELLOW), mark(i % 2 ? left(x) : right(x), (i + 1) % 3, '.', YELLOW)]
223    out.push(fr(x, i % 2, { armL: 'up', armR: 'up', legs: i % 2 }, fx))
224  }
225  return out
226}
227
228// 동전을 보고 달려가 폴짝 뛰어 먹는다.
229function coin(x) {
230  const stop = X_MAX - 4
231  const cx = right(stop)
232  const c = [mark(cx, 1, '▄', YELLOW)]
233  const out = [fr(x, 0, { eyes: 1 }, c), fr(x, 0, { eyes: 1, eyeUp: false, armL: 'up' }, c)]
234  for (const f of walk(x, stop)) out.push(Object.assign({}, f, { fx: c }))
235  out.push(fr(stop, 1, { eyes: 1 }, c), fr(stop, 0, { eyes: 1, armR: 'up' }, [mark(cx, 0, '+', YELLOW)]), fr(stop, 0, { armL: 'up', armR: 'up' }, [mark(cx, 0, '*', YELLOW)]), fr(stop, 1), fr(stop, 0))
236  return out
237}
238
239// ── 안무 ────────────────────────────────────────────────────────────────────────
240// 쉬는 동안: 동작 조각을 정해진 씨앗으로 섞어 길게 잇는다(같은 순서가 금방 되풀이되지 않게). 조각 사이는 걸어서 옮긴다.
241function idlePlaylist() {
242  let seed = 20261008
243  const rnd = (n) => {
244    seed = (seed * 1664525 + 1013904223) >>> 0
245    return seed % n
246  }
247  const bits = [look, (x) => hop(x, 2), wave, stomp, blink, wink, spin, shiver, dance, peekaboo, (x) => sparkle(x, 4), coin, lap, (x) => hop(x, 1)]
248  const out = []
249  let x = X_MID
250  for (let round = 0; round < 3; round += 1) {
251    const order = bits.map((b, i) => i)
252    for (let i = order.length - 1; i > 0; i -= 1) {
253      const j = rnd(i + 1)
254      ;[order[i], order[j]] = [order[j], order[i]]
255    }
256    for (const i of order) {
257      const frames = bits[i](x)
258      out.push(...frames)
259      x = clampX(frames[frames.length - 1].x)
260      // 다음 조각을 할 자리로 걸어간다.
261      const next = rnd(X_MAX + 1)
262      out.push(...walk(x, next))
263      x = next
264    }
265  }
266  out.push(...walk(x, X_MID))
267  return out
268}
269
270const HOME = X_MID
271
272const IDLE = idlePlaylist()
273
274// 턴이 도는 동안 — 지금 하는 일에 따라 다르게 논다.
275const LOOPS = {
276  // 생각 중(툴 없음): 왔다 갔다 서성인다.
277  think: [...walk(HOME, X_MAX), ...look(X_MAX), ...walk(X_MAX, 0), ...look(0), ...walk(0, HOME), ...hop(HOME, 1)],
278  // 읽는 중: 눈으로 줄을 훑고, 가끔 갸웃(?).
279  read: [
280    fr(HOME, 0, { eyes: -1 }),
281    fr(HOME, 0, { eyes: -1 }),
282    fr(HOME, 0),
283    fr(HOME, 0, { eyes: 1 }),
284    fr(HOME, 1, { eyes: 1 }),
285    fr(HOME, 0, { eyes: -1 }),
286    fr(HOME, 0),
287    fr(HOME, 0, { eyes: 1 }),
288    fr(HOME, 0, { eyeUp: true }, [mark(right(HOME), 0, '?', CYAN)]),
289    fr(HOME, 0, { eyeUp: true }, [mark(right(HOME), 0, '?', CYAN)]),
290    fr(HOME, 1, { blink: true }),
291    fr(HOME, 0),
292  ],
293  // 고치는 중: 오른팔로 뚝딱뚝딱, 내려칠 때 불꽃.
294  edit: [
295    fr(HOME, 0, { armR: 'up', eyes: 1 }),
296    fr(HOME, 1, { eyes: 1 }, [mark(right(HOME), 2, '*', YELLOW)]),
297    fr(HOME, 0, { armR: 'up', eyes: 1 }),
298    fr(HOME, 1, { eyes: 1 }, [mark(right(HOME), 1, '.', YELLOW)]),
299    fr(HOME, 0, { armR: 'up', eyes: 1, legs: 1 }),
300    fr(HOME, 1, { eyes: 1 }, [mark(right(HOME), 2, '+', YELLOW)]),
301    fr(HOME, 0, { armR: 'up', blink: true }),
302    fr(HOME, 1, { eyes: 1 }, [mark(right(HOME), 2, '*', YELLOW)]),
303  ],
304  // 명령 · 테스트 실행 중: 제자리 전력 질주.
305  run: [
306    fr(HOME, 0, { legs: 0, armL: 'up', eyes: 1 }, [mark(left(HOME), 2, '⠐', GRAY)]),
307    fr(HOME + 1, 1, { legs: 1, armR: 'up', eyes: 1 }, [mark(left(HOME + 1), 2, '⠂', GRAY)]),
308    fr(HOME, 0, { legs: 0, armL: 'up', eyes: 1 }, [mark(left(HOME) - 1, 2, '⠐', GRAY)]),
309    fr(HOME - 1, 1, { legs: 1, armR: 'up', eyes: 1 }, [mark(left(HOME - 1), 2, '⠂', GRAY)]),
310  ],
311  // 사용자 답을 기다리는 중: 두 팔 들고 뛰며 빨간 느낌표를 깜빡인다.
312  wait: [
313    fr(HOME, 0, { armL: 'up', armR: 'up' }, [mark(right(HOME), 0, '!', RED)]),
314    fr(HOME, 1, {}, [mark(right(HOME), 0, '!', RED)]),
315    fr(HOME, 0, { armL: 'up', armR: 'up' }),
316    fr(HOME, 1, { blink: true }),
317  ],
318}
319
320// 한 번만 하는 동작. since(그 일이 일어난 뒤 지난 시간)로 프레임을 고른다.
321const ONCE = {
322  // 턴이 막 끝남: 반짝이 만세 → 한 바퀴.
323  cheer: [...sparkle(HOME, 8), ...spin(HOME), ...hop(HOME, 1)],
324  // 명령이 실패함: 깜짝 놀라 부르르.
325  startle: [
326    fr(HOME, 0, { armL: 'up', armR: 'up', eyeUp: true }, [mark(right(HOME), 0, '!', RED)]),
327    fr(HOME, 0, { armL: 'up', armR: 'up', eyeUp: true }, [mark(right(HOME), 0, '!', RED)]),
328    ...shiver(HOME).map((f) => Object.assign({}, f, { fx: [mark(right(f.x), 0, '!', RED)] })),
329    fr(HOME, 1, { blink: true }),
330    fr(HOME, 0),
331  ],
332  // 프롬프트를 받음: 「넵!」 하고 폴짝.
333  // 1.35초. 처음에는 0.75초였는데 짧아서 눈에 안 띄었다.
334  salute: [
335    fr(HOME, 1),
336    fr(HOME, 0, { armR: 'up' }),
337    fr(HOME, 0, { armR: 'up' }),
338    fr(HOME, 1, { armR: 'up' }),
339    fr(HOME, 0, { armR: 'up' }),
340    fr(HOME, 0, { armR: 'up' }),
341    fr(HOME, 1, { armR: 'up', blink: true }),
342    fr(HOME, 0, { armR: 'up' }),
343    fr(HOME, 0),
344  ],
345  // 커밋 · 푸시 성공: 동전 먹기.
346  coin: coin(2),
347  // 정해진 시각(설정의 bells · 평일): 두 팔을 번갈아 들며 좌우로 흔들고 음표를 날린다. 글자는 띄우지 않는다.
348  bell: repeat(4, [
349    fr(HOME - 1, 0, { armL: 'up', eyes: -1 }, [mark(left(HOME - 1), 0, '♪', YELLOW)]),
350    fr(HOME, 0, { armL: 'up', armR: 'up' }),
351    fr(HOME + 1, 0, { armR: 'up', eyes: 1 }, [mark(right(HOME + 1), 0, '♪', YELLOW)]),
352    fr(HOME, 1, { armL: 'up', armR: 'up', blink: true }),
353  ]),
354}
355
356// 오래 쉰 세션: 눈을 감고 반 줄 내려앉아 잔다. 1초에 한 장씩만 넘긴다(잠든 세션은 그림도 1초에 한 번 그린다).
357const SLEEP = [
358  fr(HOME, 1, { blink: true }),
359  fr(HOME, 1, { blink: true }, [mark(right(HOME), 1, 'z', GRAY)]),
360  fr(HOME, 1, { blink: true }, [mark(right(HOME), 1, 'z', GRAY), mark(right(HOME) + 1, 0, 'z', GRAY)]),
361  fr(HOME, 1, { blink: true }, [mark(right(HOME) + 1, 0, 'z', GRAY)]),
362]
363
364// 조는 중(5분 넘게 쉼): 눈이 감기며 고개가 떨어졌다가 화들짝 깬다. 한 장에 0.45초씩 천천히 넘긴다.
365export const DOZE_TICK_MS = 450
366const DOZE = [
367  fr(HOME, 0),
368  fr(HOME, 0),
369  fr(HOME, 0, { blink: true }),
370  fr(HOME, 1, { blink: true }),
371  fr(HOME, 1, { blink: true }),
372  fr(HOME, 1, { blink: true }),
373  fr(HOME, 0, { eyeUp: true }),
374  fr(HOME, 0),
375  fr(HOME, 0, { blink: true }),
376  fr(HOME, 1, { blink: true }),
377  fr(HOME, 1, { blink: true }),
378  fr(HOME, 1, { blink: true }),
379  fr(HOME, 1, { blink: true }),
380  fr(HOME, 0, { eyes: -1 }),
381  fr(HOME, 0, { eyes: 1 }),
382]
383
384// 퇴근 5분 전 ~ 5분 뒤: 선글라스를 쓰고 춤춘다. 음표가 날린다.
385const SHADES = { shades: true }
386const PARTY = repeat(2, [
387  fr(HOME - 1, 0, Object.assign({ armL: 'up', legs: 1 }, SHADES), [mark(left(HOME - 1), 0, '♪', YELLOW)]),
388  fr(HOME - 1, 1, Object.assign({ armL: 'up' }, SHADES), [mark(left(HOME - 1), 0, '♪', YELLOW)]),
389  fr(HOME + 1, 0, Object.assign({ armR: 'up', legs: 1 }, SHADES), [mark(right(HOME + 1), 0, '♪', YELLOW)]),
390  fr(HOME + 1, 1, Object.assign({ armR: 'up' }, SHADES), [mark(right(HOME + 1), 0, '♪', YELLOW)]),
391  fr(HOME, 0, Object.assign({ armL: 'up', armR: 'up' }, SHADES)),
392  fr(HOME, 1, Object.assign({}, SHADES)),
393  fr(HOME, 0, Object.assign({ armL: 'up', armR: 'up', legs: 1 }, SHADES)),
394  fr(HOME, 1, Object.assign({}, SHADES)),
395])
396
397// 퇴근 5분 뒤 ~ 1시간 뒤: 주저앉아 한숨 쉬고 눈물 한 방울. 한 장에 0.45초씩 천천히 넘긴다.
398const GLOOM = [
399  fr(HOME, 1, { eyes: -1 }),
400  fr(HOME, 1, { eyes: -1 }),
401  fr(HOME, 1, { blink: true }),
402  fr(HOME, 1, { blink: true }, [mark(right(HOME), 1, '.', GRAY)]),
403  fr(HOME, 1, { blink: true }, [mark(right(HOME), 1, '.', GRAY), mark(right(HOME) + 1, 0, '.', GRAY)]),
404  fr(HOME, 1, { eyes: 1 }),
405  fr(HOME, 1, { eyes: 1 }),
406  fr(HOME, 1, { blink: true }, [mark(left(HOME), 1, "'", CYAN)]),
407  fr(HOME, 1, { blink: true }, [mark(left(HOME), 2, "'", CYAN)]),
408  fr(HOME, 1),
409]
410
411const within = (since, frames) => since >= 0 && since < frames.length * CLAWD_TICK_MS
412const at = (frames, since) => frames[Math.min(frames.length - 1, Math.floor(since / CLAWD_TICK_MS))]
413
414// st: { asking, busy, activity: 'think' | 'read' | 'edit' | 'run', sinceDone, sinceErr, sinceSubmit, sinceCoin, sinceBell, hot, heat, asleep }
415// since* 는 그 일이 일어난 뒤 지난 ms(없었으면 -1). hot 은 컨텍스트나 5시간 한도가 85% 이상일 때 — 땀을 흘린다.
416// heat 는 맥 자원 단계(0 평소 · 1 주의 · 2 높음 · 3 위험) — 몸 색과 땀 · 김 · 떨림으로 보인다. asleep 은 오래 쉰 세션.
417export function clawdScene(st) {
418  if (st.asking) return 'wait'
419  if (within(st.sinceErr, ONCE.startle)) return 'startle'
420  if (within(st.sinceCoin, ONCE.coin)) return 'coin'
421  if (within(st.sinceBell, ONCE.bell)) return 'bell'
422  // 엔터 「넵」 은 턴이 시작됐는지와 상관없이 재생한다 — 프롬프트 훅이 오래 걸리면 턴 시작이 1초 넘게 늦어,
423  // 「작업 중일 때만」 으로 걸어 두면 재생 창이 지난 뒤라 한 번도 보이지 않았다.
424  if (within(st.sinceSubmit, ONCE.salute)) return 'salute'
425  if (st.busy) return LOOPS[st.activity] ? st.activity : 'think'
426  if (within(st.sinceDone, ONCE.cheer)) return 'cheer'
427  // 퇴근 무렵의 기분(st.mood): 신날 때는 자던 세션도 일어나 춤추고, 우울은 잠든 세션에는 걸지 않는다.
428  if (st.mood === 'party') return 'party'
429  if (st.asleep) return 'sleep'
430  if (st.mood === 'gloom') return 'gloom'
431  if (st.dozing) return 'doze'
432  return 'idle'
433}
434
435const SINCE = { startle: 'sinceErr', coin: 'sinceCoin', salute: 'sinceSubmit', cheer: 'sinceDone', bell: 'sinceBell' }
436
437export function clawdFrameOf(st, ms) {
438  const scene = clawdScene(st)
439  const tick = Math.floor(Math.max(0, ms) / CLAWD_TICK_MS)
440  let f
441  if (scene === 'idle') f = IDLE[tick % IDLE.length]
442  else if (scene === 'sleep') f = SLEEP[Math.floor(Math.max(0, ms) / 1000) % SLEEP.length]
443  else if (scene === 'doze') f = DOZE[Math.floor(Math.max(0, ms) / DOZE_TICK_MS) % DOZE.length]
444  else if (scene === 'gloom') f = GLOOM[Math.floor(Math.max(0, ms) / DOZE_TICK_MS) % GLOOM.length]
445  else if (scene === 'party') f = PARTY[tick % PARTY.length]
446  else if (ONCE[scene]) f = at(ONCE[scene], st[SINCE[scene]])
447  else f = LOOPS[scene][tick % LOOPS[scene].length]
448  // 신나는 시간에는 일하는 중에도 선글라스를 쓴다.
449  if (st.mood === 'party' && scene !== 'party') f = shaded(f)
450  const heat = Math.max(0, Math.min(3, st.heat || 0))
451  // 위험: 좌우로 떤다(자는 동안은 떨지 않는다).
452  if (heat >= 3 && scene !== 'sleep') f = Object.assign({}, f, { x: clampX(f.x + (tick % 2 ? 1 : -1)) })
453  const fx = [...f.fx]
454  const free = (cx, cy) => !fx.some((m) => m.cx === cx && m.cy === cy)
455  // 땀: 한도 85% 이상이거나 자원이 주의 · 높음일 때 머리 옆에 한 방울.
456  if ((st.hot || heat === 1 || heat === 2) && tick % 8 < 4 && f.y === 0 && free(right(f.x), 0)) fx.push(mark(right(f.x), 0, "'", CYAN))
457  // 김: 높음이면 한 줄기, 위험이면 양쪽 두 줄기. 위아래로 번갈아 놓아 피어오르는 것처럼 보인다.
458  if (heat >= 2 && scene !== 'sleep') {
459    const up = tick % 6 < 3 ? 0 : 1
460    if (free(left(f.x), up)) fx.push(mark(left(f.x), up, '~', STEAM))
461    if (heat >= 3 && free(right(f.x) + 1, 1 - up)) fx.push(mark(right(f.x) + 1, 1 - up, '~', STEAM))
462  }
463  const body = heat !== 0 ? HEAT_COLORS[heat] : scene === 'sleep' ? SLEEP_BODY : scene === 'gloom' ? GLOOM_BODY : HEAT_COLORS[0]
464  return Object.assign({}, f, { fx, body })
465}
466
467// ── 그리기 ──────────────────────────────────────────────────────────────────────
468// 프레임 → 화면 점 그림. 화면 밖으로 나간 점은 잘린다.
469function compose(f) {
470  const on = new Uint8Array(W * H)
471  const eye = new Uint8Array(W * H)
472  for (let sy = 0; sy < SH; sy += 1) {
473    const y = sy + f.y
474    if (y < 0 || y >= H) continue
475    for (let sx = 0; sx < SW; sx += 1) {
476      const x = sx + f.x
477      if (x < 0 || x >= W) continue
478      if (f.s[sy * SW + sx]) on[y * W + x] = 1
479      if (f.e[sy * SW + sx]) eye[y * W + x] = 1
480    }
481  }
482  return { on, eye }
483}
484
485// 하늘 장식: 빈 칸에만 놓는다. 밤에는 별, 아침 · 낮에는 작은 구름.
486const STARS = [
487  [1, 0, '.'],
488  [6, 0, '·'],
489  [14, 1, '*'],
490  [10, 1, '.'],
491  [3, 2, '·'],
492  [13, 2, '.'],
493]
494const CLOUD = [
495  [1, 1, '▂'],
496  [2, 1, '▃'],
497  [3, 1, '▂'],
498]
499
500// 하늘 바탕 위에서 주변 효과 글자가 묻히지 않게 한다 — 대비가 2 에 못 미치면 밝은 바탕에서는 남색, 어두운 바탕에서는 흰색으로 바꾼다.
501function lum(c) {
502  const ch = (v) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : Math.pow((v / 255 + 0.055) / 1.055, 2.4))
503  return 0.2126 * ch((c >> 16) & 255) + 0.7152 * ch((c >> 8) & 255) + 0.0722 * ch(c & 255)
504}
505export function onSky(color, bg) {
506  const a = lum(color)
507  const b = lum(bg)
508  const ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
509  if (ratio >= 2) return color
510  return b > 0.25 ? 0x1b2452 : 0xffffff
511}
512
513// 프레임 → 칸마다 { ch, fg, bg }. sky: skyOf 의 결과에 showOrb(해 · 달도 그릴지)를 얹은 것 — 없으면 터미널 바탕 그대로.
514function cellsOf(f, sky) {
515  const { on, eye } = compose(f)
516  const cells = []
517  const back = (cy) => (sky ? sky.rows[cy] : DEF)
518  for (let cy = 0; cy < CLAWD_ROWS; cy += 1) {
519    for (let cx = 0; cx < CLAWD_COLS; cx += 1) {
520      const i = (dx, dy) => (cy * 2 + dy) * W + cx * 2 + dx
521      const bits = on[i(0, 0)] * 8 + on[i(1, 0)] * 4 + on[i(0, 1)] * 2 + on[i(1, 1)]
522      const hasEye = eye[i(0, 0)] || eye[i(1, 0)] || eye[i(0, 1)] || eye[i(1, 1)]
523      cells.push({ ch: QUAD[bits], fg: bits ? f.body || BODY : DEF, bg: hasEye && bits ? EYE : back(cy), empty: bits === 0 && !hasEye })
524    }
525  }
526  if (sky) {
527    // 노을: 맨 아랫줄 빈 칸에 주황 빛줄기.
528    if (sky.glow) {
529      for (let cx = 0; cx < CLAWD_COLS; cx += 1) {
530        const c = cells[(CLAWD_ROWS - 1) * CLAWD_COLS + cx]
531        if (c.empty) Object.assign(c, { ch: '▁', fg: 0xf0a35a })
532      }
533    }
534    for (const [cx, cy, ch] of sky.stars ? STARS : sky.cloud ? CLOUD : []) {
535      const c = cells[cy * CLAWD_COLS + cx]
536      if (c.empty) Object.assign(c, { ch, fg: 0xffffff })
537    }
538    // 해 · 달은 반원이 같은 것을 보여 주므로, 반원이 안 보이는 좁은 폭에서만 그린다.
539    if (sky.showOrb && sky.orb) {
540      const c = cells[sky.orb.cy * CLAWD_COLS + Math.max(0, Math.min(CLAWD_COLS - 1, sky.orb.cx))]
541      if (c.empty) Object.assign(c, { ch: sky.orb.ch, fg: sky.orb.color })
542    }
543  }
544  // 주변 효과는 몸이 없는 칸에만 그린다(하늘 장식 위에 덮어쓴다).
545  for (const m of f.fx) {
546    if (m.cx < 0 || m.cx >= CLAWD_COLS || m.cy < 0 || m.cy >= CLAWD_ROWS) continue
547    const c = cells[m.cy * CLAWD_COLS + m.cx]
548    if (c.empty) Object.assign(c, { ch: m.ch, fg: sky ? onSky(m.color, back(m.cy)) : m.color, bg: back(m.cy) })
549  }
550  return cells
551}
552
553// 줄마다의 글자(시험 · 눈으로 확인용).
554export function clawdRows(f) {
555  const cells = cellsOf(f)
556  const rows = []
557  for (let cy = 0; cy < CLAWD_ROWS; cy += 1) rows.push(cells.slice(cy * CLAWD_COLS, (cy + 1) * CLAWD_COLS).map((c) => c.ch).join(''))
558  return rows
559}
560
561// Raster cells 에 넣을 [글자, 글자색, 바탕색] u32 배열.
562export function clawdWords(f, sky) {
563  const cells = cellsOf(f, sky)
564  const words = new Uint32Array(cells.length * 3)
565  cells.forEach((c, i) => {
566    words[i * 3] = c.ch.codePointAt(0)
567    words[i * 3 + 1] = c.fg
568    words[i * 3 + 2] = c.bg
569  })
570  return words
571}
572
573export function clawdFrame(st, ms) {
574  return new Uint8Array(clawdWords(clawdFrameOf(st, ms), st.sky || null).buffer).toBase64()
575}
576
577// 시험용: 가만히 선 자세와 장면별 프레임 묶음.
578export function clawdRest(x) {
579  return fr(x, 0)
580}
581export const CLAWD_SCENES = Object.assign({ idle: IDLE, sleep: SLEEP, doze: DOZE, party: PARTY, gloom: GLOOM }, LOOPS, ONCE)
582
hooks/arc.js 189 lines
1// 띠 오른쪽의 반원(해 · 달이 지나는 길)과 그 안의 퇴근 숫자, 아래 주간 띠. 순수 로직 — `$` 를 받지 않는다.
2// 반원은 점자 글자(한 칸에 점 2 × 4)로 찍고, 숫자는 그림 칸에 한글이 못 들어가서 ASCII 만 쓴다.
3
4export const ARC_COLS = 14
5export const ARC_ROWS = 2
6// 퇴근 시각(자정부터의 분)의 기본값. 평일만 센다. 설정의 quitTime 으로 바꾼다.
7export const QUIT_MIN = 18 * 60
8// 정해진 시각 효과(Clawd 종)의 기본값 — 없음. 설정의 bells 로 넣는다(평일에만 울린다).
9export const BELLS_DEFAULT = []
10// 위치가 설정에 없을 때 쓰는 일출 · 일몰.
11export const SUN_FIXED = { rise: 6 * 60, set: 18 * 60 }
12export const DAY_KO = ['일', '월', '화', '수', '목', '금', '토']
13
14const DEF = 0x01000000
15const BRL = [
16  [0x01, 0x08],
17  [0x02, 0x10],
18  [0x04, 0x20],
19  [0x40, 0x80],
20]
21// 그림 칸의 색은 테마를 따라가지 못한다(숫자 색만 받는다). 그래서 밝은 바탕 · 어두운 바탕 어디서나 읽히는 중간 밝기만 쓴다
22// (흰 바탕 · 어두운 바탕 모두에서 대비 2.4 이상).
23const TC = { dim: 0x7b8491, green: 0x3fae58, lime: 0x74ad2c, yellow: 0xcf9400, orange: 0xe0762b, red: 0xf0506e, purple: 0x9b72e8 }
24const MOON = 0x8f9bd9
25const REST = 0x7b8491
26const FLASH = 0xff6b00
27
28// 그날의 일출 · 일몰(자정부터의 분). 위도 · 경도 · 시간대(UTC 와의 차 · 시간)를 받는다. NOAA 간이식이라 2~3분쯤 어긋날 수 있다.
29export function sunTimes(year, month, day, lat, lon, tzHours) {
30  const n = Math.floor((Date.UTC(year, month - 1, day) - Date.UTC(year, 0, 0)) / 86400000)
31  const g = ((2 * Math.PI) / 365) * (n - 1)
32  const eq = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g) - 0.014615 * Math.cos(2 * g) - 0.040849 * Math.sin(2 * g))
33  const decl =
34    0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g) - 0.006758 * Math.cos(2 * g) + 0.000907 * Math.sin(2 * g) - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g)
35  const rad = Math.PI / 180
36  const cosH = Math.cos(90.833 * rad) / (Math.cos(lat * rad) * Math.cos(decl)) - Math.tan(lat * rad) * Math.tan(decl)
37  const ha = Math.acos(Math.max(-1, Math.min(1, cosH))) / rad
38  const noon = 720 - 4 * lon - eq + tzHours * 60
39  return { rise: Math.round(noon - 4 * ha), set: Math.round(noon + 4 * ha) }
40}
41
42// 퇴근까지 남은 시간. 주말과 퇴근 5시간 뒤부터는 표시하지 않는다(off).
43// 색은 가까울수록 회색 → 초록 → 연두 → 노랑 → 주황 → 빨강. 마지막 10분과 퇴근 직후 10분은 깜빡인다.
44// 남은 시간은 「4h20m」 · 「42m」 처럼 길이로 적는다 — 「4:20」 으로 적으면 지금 시각처럼 읽힌다.
45// 그림 칸에 한글이 못 들어가서 퇴근 시각에는 「0m」 을 초록으로 깜빡이고, 그 뒤에는 넘긴 시간을 보라색으로 보인다.
46export function quitTimer(min, dow, end = QUIT_MIN) {
47  if (dow === 0 || dow === 6) return { off: true }
48  const fmt = (m) => (m >= 60 ? Math.floor(m / 60) + 'h' + String(Math.floor(m % 60)).padStart(2, '0') + 'm' : Math.floor(m) + 'm')
49  const rem = end - min
50  if (rem <= 0) {
51    const over = -rem
52    if (over > 300) return { off: true }
53    return over < 10 ? { text: '0m', color: TC.green, blink: true, done: true } : { text: '+' + fmt(over), color: TC.purple, blink: false, done: true }
54  }
55  const color = rem > 240 ? TC.dim : rem > 120 ? TC.green : rem > 60 ? TC.lime : rem > 30 ? TC.yellow : rem > 10 ? TC.orange : TC.red
56  return { text: fmt(rem), color, blink: rem <= 10, done: false }
57}
58
59// 해 색: 뜰 때 주홍 → 오전 귤빛 → 한낮 호박빛 → 오후 주황 → 질 때 붉은 주황. 노랑 · 흰빛은 밝은 바탕에서 안 보여 쓰지 않는다.
60export function sunColor(f) {
61  return f < 0.07 ? 0xff7d52 : f < 0.22 ? 0xf08a24 : f < 0.62 ? 0xe08a00 : f < 0.8 ? 0xe8801a : f < 0.93 ? 0xf07030 : 0xff6336
62}
63
64// 반원 위에서 해(낮) · 달(밤)이 지금 어디쯤인지(0 왼쪽 끝 ~ 1 오른쪽 끝).
65export function orbit(min, rise, set) {
66  const day = min >= rise && min < set
67  const span = day ? set - rise : 1440 - (set - rise)
68  const f = day ? (min - rise) / span : ((min - set + 1440) % 1440) / span
69  return { day, f: Math.max(0, Math.min(1, f)) }
70}
71
72// o: { min, rise, set, timer(quitTimer 결과 또는 없음), tick(초 단위 위상 — 홀수일 때 깜빡임이 꺼진 쪽), flash(정각 반짝임 위상 — 홀수일 때 밝음) }
73// 돌려주는 값은 Raster cells 에 넣을 [글자, 글자색, 바탕색] u32 배열(14 × 2칸).
74export function arcWords(o) {
75  const cols = ARC_COLS
76  const rows = ARC_ROWS
77  const W = cols * 2
78  const H = rows * 4
79  const { day, f } = orbit(o.min, o.rise, o.set)
80  const bright = (o.flash || 0) % 2 === 1
81  const orb = day ? sunColor(f) : MOON
82  const bits = new Array(cols * rows).fill(0)
83  const lit = new Array(cols * rows).fill(false)
84  const at = (g) => [Math.round(1 + g * (W - 3)), Math.round(H - 1 - Math.sin(Math.PI * g) * (H - 1))]
85  for (let i = 0; i <= 80; i += 1) {
86    const g = i / 80
87    const [x, y] = at(g)
88    const k = (y >> 2) * cols + (x >> 1)
89    bits[k] |= BRL[y & 3][x & 1]
90    if (g <= f) lit[k] = true
91  }
92  const words = new Uint32Array(cols * rows * 3)
93  const put = (c, r, cp, fg) => {
94    const k = (r * cols + c) * 3
95    words[k] = cp
96    words[k + 1] = fg
97    words[k + 2] = DEF
98  }
99  // 지나온 길은 해 · 달 색, 남은 길은 회색. 정각 반짝임의 밝은 위상에서는 길 전체가 켜지고 해가 겹고리(◉)로 커진다
100  // (흰색으로 번쩍이면 밝은 바탕에서 안 보인다).
101  for (let k = 0; k < cols * rows; k += 1) put(k % cols, Math.floor(k / cols), bits[k] ? 0x2800 + bits[k] : 0x20, lit[k] || bright ? orb : REST)
102  const [sx, sy] = at(f)
103  put(Math.max(0, Math.min(cols - 1, sx >> 1)), sy >> 2, bright ? 0x25c9 : day ? 0x25cf : 0x25d7, bright ? FLASH : orb)
104  const tm = o.timer
105  if (tm && !tm.off && tm.text) {
106    const odd = (o.tick || 0) % 2 === 1
107    const start = Math.floor((cols - tm.text.length) / 2)
108    for (let i = 0; i < tm.text.length; i += 1) {
109      const ch = tm.text[i]
110      // 깜빡임은 마지막 10분(과 퇴근 직후 10분)에만 — 숫자 전체가 꺼졌다 켜진다.
111      // 평소에 가운데 점을 매초 깜빡이면 쉬는 세션마다 1초에 한 번씩 그림을 갈아 끼우게 되어 뺐다(부하 실측 2026-10-08).
112      const off = odd && tm.blink
113      put(start + i, 1, off ? 0x20 : ch.charCodeAt(0), tm.color)
114    }
115  }
116  return words
117}
118
119export function arcCells(o) {
120  return new Uint8Array(arcWords(o).buffer).toBase64()
121}
122
123// Clawd 뒤 하늘. 그림 칸 세 줄의 바탕색 — 위에서 아래로.
124// 아침은 아래가 복숭앗빛, 낮은 파랑, 밤은 남색에 달과 별.
125// 노을은 처음 그린 안과 다르다 — 그 안의 아래 두 줄(자주 · 주황)은 주황색 Clawd 와 밝기가 같아 몸이 묻혀서, 남보라로 어둡게 깔고 주황은 땅끝 빛줄기(glow)로 옮겼다.
126export const SKY = {
127  morning: [0x7fb2e8, 0xa9cdf0, 0xffd9a8],
128  day: [0x4a9be8, 0x6fb4f0, 0x9ccbf5],
129  sunset: [0x2f2f6b, 0x5a3f80, 0x8a4577],
130  night: [0x0d1433, 0x141c44, 0x1b2452],
131}
132export const SKY_PHASES = ['morning', 'day', 'sunset', 'night']
133export function skyPhase(min, rise, set) {
134  if (min < rise - 30 || min >= set + 55) return 'night'
135  if (min < 600) return 'morning'
136  return min < set - 80 ? 'day' : 'sunset'
137}
138// 하늘 위의 해 색. 반원의 해와 달리 바탕이 늘 하늘색이라 밝은 노랑 · 흰빛을 쓸 수 있다.
139export function skySunColor(f) {
140  return f < 0.07 ? 0xff7d52 : f < 0.22 ? 0xffb84d : f < 0.38 ? 0xffe27a : f < 0.62 ? 0xfff6c2 : f < 0.8 ? 0xffd75f : f < 0.93 ? 0xffa53d : 0xff6336
141}
142// 돌려주는 값: { phase, rows(줄마다의 바탕색), stars, cloud, glow(노을의 땅끝 빛줄기), orb(해 · 달의 칸 — 반원이 안 보일 때만 그린다) }
143export function skyOf(min, rise, set, cols = 15) {
144  const phase = skyPhase(min, rise, set)
145  let orb
146  if (phase === 'night') orb = { cx: cols - 3, cy: 0, ch: '◗', color: 0xffd23f }
147  else {
148    const f = Math.max(0, Math.min(1, (min - rise) / (set - rise)))
149    const cy = phase === 'day' ? 0 : phase === 'morning' ? (min < 480 ? 2 : 1) : min < set - 30 ? 1 : 2
150    orb = { cx: Math.round(f * (cols - 1)), cy, ch: '●', color: skySunColor(f) }
151  }
152  return { phase, rows: SKY[phase], stars: phase === 'night', cloud: phase === 'day' || phase === 'morning', glow: phase === 'sunset', orb }
153}
154
155// 잠든 세션(오래 쉰 세션)의 하늘: 시각과 상관없이 밤이고, 반원이 보여도 달을 그린다.
156// 달은 왼쪽 위에 둔다 — 오른쪽 위는 잠든 Clawd 의 z 가 올라가는 자리다.
157// 달 글자는 노란 반달(◗)이다 — 처음 쓴 반쯤 찬 원 글자는 눈알처럼 보였다. 반원의 달도 같은 글자로 맞췄다(색은 밝은 바탕에서도 읽히는 연보라 그대로).
158export function sleepSky() {
159  return { phase: 'night', rows: SKY.night, stars: true, cloud: false, glow: false, showOrb: true, orb: { cx: 1, cy: 0, ch: '◗', color: 0xffd23f } }
160}
161
162// 퇴근 무렵 Clawd 의 기분. 평일에만 본다.
163// 퇴근 5분 전부터 퇴근 5분 뒤까지는 신남(party — 선글라스), 그 뒤 퇴근 1시간 뒤까지는 우울(gloom), 그 밖에는 없음.
164// 기본 퇴근 18:00 이면 17:55~18:05 신남 · 18:05~19:00 우울 · 19:00 부터는 평소대로다.
165export const MOOD_PARTY_MIN = 5
166export const MOOD_END_MIN = 60
167export function quitMood(min, dow, end = QUIT_MIN) {
168  if (dow === 0 || dow === 6) return ''
169  const d = min - end
170  if (d >= -MOOD_PARTY_MIN && d < MOOD_PARTY_MIN) return 'party'
171  if (d >= MOOD_PARTY_MIN && d < MOOD_END_MIN) return 'gloom'
172  return ''
173}
174
175// 주간 띠: 평일 다섯 글자. 오늘은 밝게, 지난 날은 흐리게, 남은 날은 더 흐리게. 주말에는 그 요일을 끝에 붙인다.
176export function weekSpans(dow) {
177  const weekend = dow === 0 || dow === 6
178  const out = []
179  for (let i = 1; i <= 5; i += 1) out.push({ text: DAY_KO[i], kind: i === dow ? 'today' : i < dow || weekend ? 'past' : 'future' })
180  if (weekend) out.push({ text: DAY_KO[dow], kind: 'weekend' })
181  return out
182}
183
184// 분이 바뀐 순간에 울릴 효과. 정해진 시각(평일)이 정각과 겹치면 종만 친다.
185export function chimeAt(min, dow, bells = BELLS_DEFAULT) {
186  if (dow >= 1 && dow <= 5 && bells.includes(min)) return 'bell'
187  return min % 60 === 0 ? 'hour' : ''
188}
189
hooks/poll.js 117 lines
1// 요소별 폴링 주기와, 폴링으로 읽은 글자를 값으로 바꾸는 순수 로직. `$` 를 받지 않는다.
2// 이벤트로 알 수 있는 것(턴 · 툴 · 서브에이전트 · 프롬프트 · 원격 연결)은 폴링하지 않는다. 여기 있는 것은 밖에서 바뀌어 물어봐야만 아는 값이다.
3
4// 초 단위. 자주 바뀌고 싸게 읽히는 것은 촘촘히, 느리게 바뀌거나 비싼 것은 듬성듬성.
5export const POLL_DEFAULT = { sys: 5, awake: 30, usage: 60, flags: 5, git: 60, fleet: 30, linear: 600 }
6// 이보다 촘촘하게는 받지 않는다(맥 자원 한 번 읽기에 1초가 걸린다).
7export const POLL_MIN = { sys: 2, awake: 10, usage: 15, flags: 2, git: 15, fleet: 10, linear: 60 }
8export const POLL_LABEL = {
9  sys: '맥 자원(CPU · 메모리) — 한 세션만 재고 나머지는 그 값을 읽는다',
10  awake: '화면 안 꺼짐(caffeinate) — 한 세션만 잰다',
11  usage: '토큰 한도(5h · 7d) — 컨텍스트는 턴마다 이벤트로 받는다',
12  flags: '경보 파일 — 이 세션이 만든 것이 있을 때만',
13  git: '브랜치 · 변경 수 · 미push — 턴이 끝날 때도 읽는다',
14  fleet: '워커 — 워커 기록이 있는 세션일 때만',
15  linear: '이슈 제목 — 이슈가 바뀌면 바로 읽는다',
16}
17// 잠든 세션(오래 쉰 세션)은 주기를 이만큼 늘린다. 경보 파일 확인은 늘리지 않는다.
18export const SLEEP_FACTOR = 6
19
20// 저장된 값(없거나 깨진 값 포함)을 기본값 위에 얹고 하한으로 자른다.
21export function normalizePoll(saved) {
22  const out = Object.assign({}, POLL_DEFAULT)
23  if (saved && typeof saved === 'object') {
24    for (const k of Object.keys(POLL_DEFAULT)) {
25      const v = Number(saved[k])
26      if (Number.isFinite(v) && v > 0) out[k] = Math.max(POLL_MIN[k], Math.round(v))
27    }
28  }
29  return out
30}
31
32// `/cockpit poll <요소> <초>` 의 한 건. 모르는 요소 · 숫자가 아닌 값은 바꾸지 않고 이유를 돌려준다.
33export function setPoll(cur, key, value) {
34  if (!(key in POLL_DEFAULT)) return { ok: false, poll: cur, msg: '모르는 요소: ' + key + ' — ' + Object.keys(POLL_DEFAULT).join(' · ') + ' 중에서 고른다' }
35  if (value === 'reset' || value === '기본') return { ok: true, poll: Object.assign({}, cur, { [key]: POLL_DEFAULT[key] }), msg: key + ' 를 기본값 ' + POLL_DEFAULT[key] + '초로 되돌렸다' }
36  const v = Number(value)
37  if (!Number.isFinite(v) || v <= 0) return { ok: false, poll: cur, msg: '초는 0보다 큰 숫자로 적는다 — 예: /cockpit poll sys 3' }
38  const sec = Math.max(POLL_MIN[key], Math.round(v))
39  const note = sec !== Math.round(v) ? ' (하한 ' + POLL_MIN[key] + '초로 맞춤)' : ''
40  return { ok: true, poll: Object.assign({}, cur, { [key]: sec }), msg: key + ' 주기를 ' + sec + '초로 바꿨다' + note }
41}
42
43export function pollText(poll) {
44  const lines = ['요소별 폴링 주기 — 바꾸기: /cockpit poll <요소> <초> · 되돌리기: /cockpit poll <요소> reset']
45  for (const k of Object.keys(POLL_DEFAULT)) lines.push('  ' + k.padEnd(7) + String(poll[k]).padStart(4) + '초' + (poll[k] !== POLL_DEFAULT[k] ? ' (기본 ' + POLL_DEFAULT[k] + ')' : '') + '  ' + POLL_LABEL[k])
46  lines.push('폴링하지 않는 것: 작업 중 · 단계 · 도구 수 · 서브에이전트 · 컨텍스트 · 원격 연결 · 세션 이름(이벤트) · 반원 · 퇴근 숫자 · 정각 효과(분이 바뀔 때)')
47  return lines.join('\n')
48}
49
50export function due(now, last, sec) {
51  return !last || now - last >= sec * 1000
52}
53
54// 맥 자원을 읽는 명령. iostat 는 1초 동안의 실제 CPU 사용률을, memory_pressure 는 여유 메모리 비율을, sysctl 은 커널의 메모리 압력 단계를 준다.
55// ps · top 보다 싸다(한 번에 CPU 13ms쯤 · 실측 2026-10-08).
56export const SYS_CMD = '/usr/sbin/iostat -c 2 -w 1 | /usr/bin/tail -1; /usr/bin/memory_pressure -Q | /usr/bin/tail -1; /usr/sbin/sysctl -n kern.memorystatus_vm_pressure_level'
57
58// SYS_CMD 출력 → { cpu(%), memUsed(%), pressure(1 정상 · 2 경고 · 4 위험) }. 읽지 못하면 null.
59export function parseSys(text) {
60  const lines = String(text || '')
61    .split('\n')
62    .map((l) => l.trim())
63    .filter(Boolean)
64  if (lines.length < 2) return null
65  // iostat 마지막 줄: … us sy id 1m 5m 15m — 디스크 수에 따라 앞 열 개수가 달라 뒤에서 센다.
66  const io = lines[0].split(/\s+/).map(Number)
67  if (io.length < 6 || io.some((n) => !Number.isFinite(n))) return null
68  const idle = io[io.length - 4]
69  const m = /free percentage:\s*(\d+)%/.exec(lines[1])
70  if (!m || idle < 0 || idle > 100) return null
71  const pressure = lines[2] && /^\d+$/.test(lines[2]) ? Number(lines[2]) : 1
72  return { cpu: Math.round(100 - idle), memUsed: 100 - Number(m[1]), pressure }
73}
74
75// CPU 는 순간값이 튀므로 직전 값과 반씩 섞어 쓴다(두세 번 연속으로 높아야 단계가 오른다).
76export function smoothCpu(prev, cpu) {
77  return prev === null || prev === undefined ? cpu : Math.round(prev * 0.5 + cpu * 0.5)
78}
79
80// CPU 는 세션을 여는 순간처럼 몇 초씩 튄다. 높음은 80% 이상이 30초, 위험은 95% 이상이 60초 이어질 때만 인정한다.
81// hist: 최근 표본 [{ ts, cpu }] (오래된 것부터). 창의 8할 이상을 덮는 표본이 모두 기준을 넘어야 한다.
82export function sustain(hist, now) {
83  const over = (ms, th) => {
84    const w = (hist || []).filter((h) => now - h.ts <= ms)
85    return w.length >= 2 && now - w[0].ts >= ms * 0.8 && w.every((h) => h.cpu >= th)
86  }
87  return { hi: over(30000, 80), crit: over(60000, 95) }
88}
89
90// 0 평소 · 1 주의 · 2 높음 · 3 위험. 기준은 확정 시안의 안(案)이다.
91// 주의: CPU 50%(직전 값과 섞은 값) 또는 메모리 75% · 높음: CPU 80% 가 30초 또는 메모리 85% 또는 압력 경고 · 위험: CPU 95% 가 60초 또는 메모리 93% 또는 압력 위험.
92export function sysLevel(s, cpu, held) {
93  if (!s) return 0
94  const c = cpu === null || cpu === undefined ? s.cpu : cpu
95  const h = held || { hi: false, crit: false }
96  if (h.crit || s.memUsed >= 93 || s.pressure >= 4) return 3
97  if (h.hi || s.memUsed >= 85 || s.pressure >= 2) return 2
98  if (c >= 50 || s.memUsed >= 75) return 1
99  return 0
100}
101
102// `pmset -g assertions` 출력에서 caffeinate 가 화면 잠자기를 막고 있는지 본다.
103// 턴이 도는 동안 Claude Code 가 거는 caffeinate 는 시스템 잠자기만 막으므로(화면은 꺼진다) 여기에 잡히지 않는다.
104export function parseAwake(text) {
105  return /pid \d+\(caffeinate\):.*PreventUserIdleDisplaySleep/.test(String(text || ''))
106}
107
108// 여러 세션이 같은 값을 따로 재지 않게, 캐시 파일을 보고 「이번에는 내가 잰다」를 정한다.
109// 마지막으로 잰 세션이 계속 재고, 그 세션이 사라져 값이 주기의 2.2배보다 낡으면 먼저 본 세션이 이어받는다.
110export function shouldSample(cache, now, sid, sec) {
111  if (!cache || !Number.isFinite(Number(cache.ts))) return true
112  const age = now - Number(cache.ts)
113  // 재는 데 1초쯤 걸리고 값은 다 잰 뒤에 적히므로, 재던 세션은 주기의 6할만 지나도 다시 잰다(주기를 꽉 채우길 기다리면 간격이 두 배가 된다).
114  if (cache.by === sid) return age >= sec * 600
115  return age >= sec * 2200
116}
117
hooks/config.js 105 lines
1// 쓰는 사람의 환경에 맞추는 값. `~/.claude/harness-cockpit.json` 을 register.js 가 읽어 setConfig 로 넘긴다.
2// 순수 로직 — `$` 를 받지 않는다. 부재 시 동작: 파일이 없으면 아래 기본값으로 돌고, 해당 기능(레포 이름표 · 경보 · 작업 대장 · 워커)만 빠진다.
3
4const DEFAULT_BRANCH_KEY = '[A-Z][A-Z0-9]+-[0-9]+'
5const DEFAULT_ISSUE_KEY = '^[A-Z][A-Z0-9]*-[0-9]+$'
6
7function compile(src, flags) {
8  if (typeof src !== 'string' || !src) return null
9  try {
10    return new RegExp(src, flags || '')
11  } catch {
12    return null
13  }
14}
15
16function expand(path, home) {
17  const p = String(path || '')
18  return p.startsWith('~/') ? String(home || '') + p.slice(1) : p
19}
20
21const str = (v) => (typeof v === 'string' ? v : '')
22
23// 깨진 항목(정규식 오류 · 빠진 값)은 그 항목만 버린다 — 설정 파일 한 줄 때문에 띠 전체가 멈추면 안 된다.
24export function normalizeConfig(raw, home) {
25  const r = raw && typeof raw === 'object' ? raw : {}
26  const repos = []
27  for (const x of Array.isArray(r.repos) ? r.repos : []) {
28    const re = compile(x && x.match)
29    if (!re) continue
30    const family = x.family === 'red' || x.family === 'blue' ? x.family : ''
31    repos.push({ re, name: str(x.name), color: str(x.color), family, brand: str(x.brand), word: str(x.word) })
32  }
33  const alertFiles = []
34  for (const x of Array.isArray(r.alertFiles) ? r.alertFiles : []) {
35    if (x && typeof x.path === 'string' && x.path.startsWith('/') && str(x.label)) alertFiles.push({ path: x.path, label: x.label })
36  }
37  const a = r.alert && typeof r.alert === 'object' ? r.alert : {}
38  const i = r.issue && typeof r.issue === 'object' ? r.issue : {}
39  const cmd = Array.isArray(i.boardCommand) && i.boardCommand.length && i.boardCommand.every((s) => typeof s === 'string' && s) ? i.boardCommand.map((s) => expand(s, home)) : null
40  const w = r.serviceWindow && typeof r.serviceWindow === 'object' ? r.serviceWindow : null
41  const hour = (v) => (Number.isInteger(v) && v >= 0 && v <= 24 ? v : null)
42  const serviceWindow = w && str(w.label) && hour(w.startHour) !== null && hour(w.endHour) !== null && w.startHour < w.endHour ? { label: w.label, start: w.startHour, end: w.endHour } : null
43  // "HH:MM" → 자정부터의 분. 틀린 값은 null.
44  const hm = (v) => {
45    const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(str(v))
46    return m ? Number(m[1]) * 60 + Number(m[2]) : null
47  }
48  const loc = r.location && typeof r.location === 'object' ? r.location : null
49  const num = (v, lo, hi) => (typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null)
50  const location = loc && num(loc.lat, -66, 66) !== null && num(loc.lon, -180, 180) !== null ? { lat: loc.lat, lon: loc.lon } : null
51  const tx = r.texts && typeof r.texts === 'object' ? r.texts : {}
52  return {
53    repos,
54    alertFiles,
55    // 글귀 몇 개는 쓰는 사람의 말버릇에 맞출 수 있다. 없으면 아래 기본 글귀다.
56    texts: {
57      leftClear: str(tx.leftClear) || '띄운 것 없음 — 세션을 닫아도 된다',
58      leftBusy: str(tx.leftBusy) || '닫기 전에 정리한다',
59      leftPane: str(tx.leftPane) || '닫기 전 정리 필요',
60      boardHint: str(tx.boardHint),
61    },
62    // 일출 · 일몰을 계산할 위치(위도 · 경도). 없으면 06:00 · 18:00 으로 고정한다.
63    location,
64    // 퇴근 시각(자정부터의 분)과 종을 칠 시각들(평일). 종은 기본으로 없다.
65    quitMin: hm(r.quitTime) === null ? 18 * 60 : hm(r.quitTime),
66    bells: (Array.isArray(r.bells) ? r.bells : []).map(hm).filter((v) => v !== null),
67    // 「검증」 단계로 볼 명령을 더한다(정규식). 기본 목록(jest · vitest · npm test 등)에 얹는다.
68    verifyRe: compile(str(r.verifyCommand)),
69    // 평일 정해진 시간대에만 켜지는 것(예: 개발 서버)의 이름과 시간. 시작 효과의 브리핑 카드에 한 줄로 나온다. 없으면 그 줄이 빠진다.
70    serviceWindow,
71    alert: { title: str(a.title) || '경보 파일', note: str(a.note) },
72    branchKeyRe: compile(str(r.branchKey) || DEFAULT_BRANCH_KEY, str(r.branchKey) ? str(r.branchKeyFlags) : '') || compile(DEFAULT_BRANCH_KEY),
73    issue: {
74      boardCommand: cmd,
75      boardLineDir: expand(str(i.boardLineDir), home),
76      mcpServer: i.mcpServer === undefined ? 'linear' : str(i.mcpServer),
77      keyRe: compile(str(i.keyPattern) || DEFAULT_ISSUE_KEY) || compile(DEFAULT_ISSUE_KEY),
78    },
79    fleetRoot: expand(str(r.fleetRoot), home),
80  }
81}
82
83let cfg = normalizeConfig(null, '')
84
85export function setConfig(raw, home) {
86  cfg = normalizeConfig(raw, home)
87  return cfg
88}
89
90export function getConfig() {
91  return cfg
92}
93
94// `/cockpit config` 가 찍는 한 줄 요약.
95export function configText(c, path, found) {
96  const n = (list, pick) => list.filter(pick).length
97  return [
98    '설정 파일 ' + path + (found ? '' : ' — 없음(기본값으로 동작)'),
99    '  레포 이름표 ' + n(c.repos, (x) => x.name) + ' · 계열 색 ' + n(c.repos, (x) => x.family) + ' · 시작 효과 이름 ' + n(c.repos, (x) => x.brand || x.word) + ' · 경보 파일 ' + c.alertFiles.length + ' · 가동 시간대 ' + (c.serviceWindow ? c.serviceWindow.label : '없음'),
100    '  위치 ' + (c.location ? '설정됨' : '없음(일출 06:00 · 일몰 18:00 고정)') + ' · 퇴근 ' + String(Math.floor(c.quitMin / 60)).padStart(2, '0') + ':' + String(c.quitMin % 60).padStart(2, '0') + ' · 종 ' + (c.bells.length ? c.bells.length + '개' : '없음'),
101    '  브랜치 키 /' + c.branchKeyRe.source + '/' + c.branchKeyRe.flags + ' · 작업 대장 명령 ' + (c.issue.boardCommand ? '있음' : '없음') + ' · 이슈 조회 MCP ' + (c.issue.mcpServer || '없음') + ' · 워커 기록 ' + (c.fleetRoot ? '있음' : '없음'),
102    '고친 뒤 다시 읽기: /cockpit config reload',
103  ].join('\n')
104}
105
hooks/fx.js 319 lines
1// 새 세션 시작 효과 4종의 프레임 그리기. `$` 를 받지 않는다 — 요소 생성자(E) · 재료(ctx) ·
2// 경과 시간(ms)만으로 트리를 만든다. 같은 입력이면 같은 프레임이 나온다.
3// register.js 가 그린다 — 1번(부팅 스윕)은 띠 자리에서, 나머지는 띠 위에 얹어서. 여러 개를 함께 재생할 수 있다.
4// 고르는 법: /fx use 1 4 (기본 1 · /fx off 는 끔).
5import { MASCOT } from './mascot.js'
6import { getConfig } from './config.js'
7
8// ms 는 미리 보기(/fx N) 재생 길이다. 2번(브리핑)은 새 세션 시작 때는 첫 턴이 시작될 때까지 남는다.
9export const FX = {
10  1: { name: '부팅 스윕', ms: 2600, note: '띠가 왼쪽부터 켜지고 한도 게이지가 0 에서 차오른다 · 2줄' },
11  2: { name: '브리핑 카드', ms: 9000, note: '작업 대장 · 최근 작업 · 가동 시간대(설정했을 때) · 한도를 한 장으로 · 첫 프롬프트에 접힘' },
12  3: { name: '캐릭터 인사', ms: 3600, note: '샘플 캐릭터가 통통 튀며 인사 · 3초 뒤 사라짐' },
13  4: { name: '워드마크', ms: 2800, note: '레포 이름을 큰 글자로 · 빛이 한 번 지나감 · 레포 색(파랑 / 빨강)' },
14}
15
16const DEF = 0x01000000
17const DIM = 'inactive'
18const ACCENT = { blue: 0x4d9bff, red: 0xff6b80 }
19const LEVEL_COLORS = ['success', '#8CC63F', 'warning', '#E8833A', 'error']
20
21function t(E, text, props) {
22  return E.Text(Object.assign({ children: [text] }, props || {}))
23}
24
25function clamp(x) {
26  return Math.max(0, Math.min(1, x))
27}
28
29// 빠르게 시작해 천천히 멈추는 곡선.
30function ease(x) {
31  return 1 - Math.pow(1 - clamp(x), 3)
32}
33
34export function mix(a, b, k) {
35  const c = (shift) => Math.round(((a >> shift) & 255) + (((b >> shift) & 255) - ((a >> shift) & 255)) * clamp(k))
36  return (c(16) << 16) | (c(8) << 8) | c(0)
37}
38
39export function hex(n) {
40  return '#' + n.toString(16).padStart(6, '0')
41}
42
43function toBase64(words) {
44  return new Uint8Array(words.buffer).toBase64()
45}
46
47function levelColor(p) {
48  if (p === null || p === undefined) return DIM
49  return LEVEL_COLORS[p >= 85 ? 4 : p >= 70 ? 3 : p >= 50 ? 2 : p >= 30 ? 1 : 0]
50}
51
52function gauge(E, label, p, n, k) {
53  const shown = p === null || p === undefined ? null : Math.round(p * k)
54  const filled = shown === null ? 0 : Math.round((shown / 100) * n)
55  const out = [t(E, label + ' ', { color: DIM })]
56  for (let i = 0; i < n; i += 1) {
57    const at = Math.round(((i + 0.5) / n) * 100)
58    out.push(t(E, '▊', { color: i < filled ? levelColor(at) : 'subtle' }))
59  }
60  out.push(t(E, shown === null ? ' —' : ' ' + shown + '%', { color: levelColor(shown), bold: true }))
61  return out
62}
63
64// 한글 · 한자 등 넓은 글자는 2칸으로 센다(줄 맞춤용).
65export function cellWidth(s) {
66  let n = 0
67  for (const ch of String(s)) n += ch.codePointAt(0) >= 0x1100 ? 2 : 1
68  return n
69}
70
71function card(E, color, children) {
72  return E.Box({ flexDirection: 'column', borderStyle: 'round', borderColor: color, paddingX: 1, children })
73}
74
75function line(E, children) {
76  return E.Text({ wrap: 'truncate-end', children })
77}
78
79// ── 1. 부팅 스윕 ────────────────────────────────────────────────────────────────
80// 앞 0.9초: 빛나는 머리가 왼쪽에서 오른쪽으로 지나가며 선을 켠다. 뒤: 글자가 찍히고 게이지가 차오른다.
81export function sweepWords(width, pos, accent) {
82  const words = new Uint32Array(width * 3)
83  for (let i = 0; i < width; i += 1) {
84    const lit = i < pos
85    const head = lit && i >= pos - 4
86    words[i * 3] = lit ? 0x2501 : 0x2500
87    words[i * 3 + 1] = head ? mix(accent, 0xffffff, 0.3 + 0.7 * ((i - (pos - 4)) / 4)) : lit ? accent : 0x3a3a3a
88    words[i * 3 + 2] = DEF
89  }
90  return words
91}
92
93function sweep(E, ctx, ms, cols) {
94  const accent = ACCENT[ctx.family]
95  const width = Math.max(10, Math.min(cols - 4, 110))
96  const border = hex(mix(0x3a3a3a, accent, ease(ms / 700)))
97  if (ms < 900) {
98    const pos = Math.round(ease(ms / 900) * width)
99    return card(E, border, [E.Raster({ key: 'fx-sweep', columns: width, rows: 1, cells: toBase64(sweepWords(width, pos, accent)) }), t(E, ' '), ...(ctx.tall ? [t(E, ' ')] : [])])
100  }
101  const full = '● ' + ctx.name + ' 세션 시작 · ' + ctx.dateText
102  const typed = full.slice(0, Math.floor((ms - 900) / 35))
103  const k = ease((ms - 900) / 1100)
104  const u = ctx.usage
105  return card(E, border, [
106    line(E, [t(E, typed.slice(0, 1), { color: hex(accent) }), t(E, typed.slice(1), { bold: true })]),
107    line(E, [...gauge(E, 'ctx', u.ctx, 8, k), t(E, '   '), ...gauge(E, '5h', u.h5, 6, k), t(E, '   '), ...gauge(E, '7d', u.d7, 6, k)]),
108    ...(ctx.tall ? [t(E, ' ')] : []),
109  ])
110}
111
112// ── 2. 브리핑 카드 ──────────────────────────────────────────────────────────────
113// 줄이 0.13초 간격으로 하나씩 펼쳐진다.
114function briefing(E, ctx, ms) {
115  const accent = hex(ACCENT[ctx.family])
116  const b = ctx.board
117  const label = (s) => t(E, s + ' '.repeat(Math.max(0, 10 - cellWidth(s))) + ' ', { color: DIM })
118  const rows = [
119    line(E, [t(E, '세션 브리핑', { bold: true, color: accent }), t(E, '  ' + ctx.dateLong + ' · ' + ctx.name, { color: DIM })]),
120    line(E, [
121      label('작업 대장'),
122      ...(b
123        ? [t(E, '이어받을 것 '), t(E, String(b.resume), { bold: true, color: accent }), t(E, ' · 대기 '), t(E, String(b.wait), { bold: true }), ...(getConfig().texts.boardHint ? [t(E, '   ' + getConfig().texts.boardHint, { color: DIM })] : [])]
124        : [t(E, '읽지 못함', { color: DIM })]),
125    ]),
126    line(E, [label('최근 작업'), t(E, b && b.recent.length ? b.recent.map((r) => r.task + (r.issue ? ' (' + r.issue + ')' : '')).join(' · ') : '없음')]),
127    ...(ctx.dev ? [line(E, [label(ctx.dev.label), t(E, ctx.dev.open ? '가동 시간대' : '정지 시간대', { color: ctx.dev.open ? 'success' : 'warning', bold: true }), t(E, ' · ' + ctx.dev.text, { color: DIM })])] : []),
128    line(E, [label('한도'), ...gauge(E, '5h', ctx.usage.h5, 8, 1), t(E, ctx.usage.h5Reset ? ' ↻' + ctx.usage.h5Reset : '', { color: DIM }), t(E, '   '), ...gauge(E, '7d', ctx.usage.d7, 8, 1)]),
129  ]
130  return card(E, accent, rows.slice(0, Math.min(rows.length, Math.floor(ms / 130) + 1)))
131}
132
133// ── 3. 캐릭터 인사 ──────────────────────────────────────────────────────────────
134// 그림 높이 + 1줄을 고정해 두고 빈 줄을 위 · 아래로 옮겨 튀는 모양을 낸다(띠 높이가 흔들리지 않는다).
135function mascot(E, ctx, ms, cols, maxRows) {
136  const m = maxRows >= 24 ? MASCOT[36] : MASCOT[30]
137  const accent = hex(ACCENT[ctx.family])
138  const up = Math.floor(ms / 320) % 2 === 0
139  const slide = Math.max(0, 14 - Math.floor(ms / 30))
140  const pic = E.Raster({ key: 'fx-mascot', columns: m.cols, rows: m.rows, cells: m.cells })
141  const hello = '안녕하세요!'.slice(0, Math.floor(ms / 70))
142  const words = [t(E, hello, { bold: true, color: accent })]
143  if (ms > 1000) words.push(t(E, ctx.dateLong + ' · ' + ctx.name))
144  if (ms > 1500 && ctx.board) words.push(t(E, '이어받을 것 ' + ctx.board.resume + ' · 대기 ' + ctx.board.wait, { color: DIM }))
145  const pad = []
146  for (let i = 0; i < Math.floor(m.rows / 2) - 1; i += 1) pad.push(t(E, ' '))
147  return E.Box({
148    flexDirection: 'row',
149    columnGap: 3,
150    paddingLeft: slide,
151    children: [
152      E.Box({ flexDirection: 'column', flexShrink: 0, children: up ? [pic, t(E, ' ')] : [t(E, ' '), pic] }),
153      cols - slide - m.cols >= 24 ? E.Box({ flexDirection: 'column', children: [...pad, ...words] }) : null,
154    ],
155  })
156}
157
158// ── 4. 워드마크 ────────────────────────────────────────────────────────────────
159// 3×5 점 글꼴. 점 하나를 2칸(██)으로 그려 5줄짜리 큰 글자를 만든다.
160const FONT = {
161  A: '010101111101101', B: '110101110101110', C: '011100100100011', D: '110101101101110', E: '111100110100111',
162  F: '111100110100100', G: '011100101101011', H: '101101111101101', I: '111010010010111', J: '001001001101010',
163  K: '101101110101101', L: '100100100100111', M: '101111111101101', N: '110101101101101', O: '111101101101111',
164  P: '110101110100100', Q: '010101101111011', R: '110101110101101', S: '111100111001111', T: '111010010010010',
165  U: '101101101101111', V: '101101101101010', W: '101101111111101', X: '101101010101101', Y: '101101010010010',
166  Z: '111001010100111', '-': '000000111000000',
167}
168
169// 반환: { cols, rows: 5, on(x, y) } — 글자 사이 한 점 띄움, 점 하나는 scale 칸.
170export function wordGrid(text, scale) {
171  const letters = [...text.toUpperCase()].filter((c) => FONT[c])
172  const dots = Math.max(0, letters.length * 4 - 1)
173  return {
174    cols: dots * scale,
175    rows: 5,
176    on(x, y) {
177      const dx = Math.floor(x / scale)
178      const li = Math.floor(dx / 4)
179      const cx = dx % 4
180      if (cx === 3 || li >= letters.length) return false
181      return FONT[letters[li]][y * 3 + cx] === '1'
182    },
183  }
184}
185
186export function wordmarkWords(text, scale, ms, accent) {
187  const g = wordGrid(text, scale)
188  const words = new Uint32Array(g.cols * g.rows * 3)
189  const shown = ease(ms / 700) * g.cols
190  const span = g.cols + 24
191  const center = -12 + span * clamp((ms - 500) / 1500)
192  for (let y = 0; y < g.rows; y += 1) {
193    for (let x = 0; x < g.cols; x += 1) {
194      const w = (y * g.cols + x) * 3
195      const lit = g.on(x, y) && x < shown
196      // 빛은 오른쪽 위에서 왼쪽 아래로 기울어 지나간다.
197      const glow = clamp(1 - Math.abs(x + y * 1.5 - center) / 9)
198      words[w] = lit ? 0x2588 : 0x20
199      words[w + 1] = lit ? mix(accent, 0xffffff, glow * 0.9) : DEF
200      words[w + 2] = DEF
201    }
202  }
203  return { cols: g.cols, rows: g.rows, words }
204}
205
206function wordmark(E, ctx, ms, cols) {
207  const accent = ACCENT[ctx.family]
208  const scale = wordGrid(ctx.word, 2).cols <= cols - 2 ? 2 : 1
209  const r = wordmarkWords(ctx.word, scale, ms, accent)
210  const sub = ms > 800 ? '세션 시작 · ' + ctx.dateText : ' '
211  return E.Box({
212    flexDirection: 'column',
213    paddingLeft: 1,
214    children: [E.Raster({ key: 'fx-word', columns: r.cols, rows: r.rows, cells: toBase64(r.words) }), t(E, sub, { color: DIM })],
215  })
216}
217
218// 저장된 값(숫자 하나 · 배열 · 없음)을 효과 번호 배열로 고른다. 0 과 빈 배열은 「끔」이다.
219// 부재 시 동작: 값이 없거나 알 수 없는 모양이면 fallback 을 쓴다.
220export function normalizeChoice(value, fallback) {
221  if (value === 0) return []
222  const list = Array.isArray(value) ? value : typeof value === 'number' ? [value] : null
223  if (!list) return fallback.slice()
224  const out = []
225  for (const v of list) if (FX[v] && !out.includes(v)) out.push(v)
226  return out.length || list.length === 0 ? out : fallback.slice()
227}
228
229// 명령 인자에서 효과 번호를 뽑는다. "1 3" · "1,3" · "13" 모두 [1, 3].
230export function parseIds(text) {
231  const out = []
232  for (const ch of String(text || '')) {
233    const n = Number(ch)
234    if (FX[n] && !out.includes(n)) out.push(n)
235  }
236  return out
237}
238
239// 여러 효과를 함께 재생할 때 지금 무엇을 그릴지 정한다.
240// keep: 새 세션 시작 재생 — 브리핑(2)은 첫 턴이 시작될 때까지 남는다. 미리 보기는 각자 정해진 길이만 재생한다.
241// 반환: above = 띠 위에 얹을 효과(위에서 아래 순서) · sweep = 띠 자리에서 부팅 스윕을 그릴지 ·
242//       moving = 아직 움직이는 중인지 · done = 더 그릴 것이 없는지
243export function introLayout(ids, ms, keep) {
244  const on = (id) => ids.includes(id) && (ms < FX[id].ms || (id === 2 && keep))
245  const above = [3, 4, 2].filter(on)
246  const sweep = on(1)
247  const moving = ids.some((id) => ms < (id === 2 && keep ? 1200 : FX[id].ms))
248  return { above, sweep, moving, done: !sweep && above.length === 0 }
249}
250
251// 아직 켜지지 않은 띠. 새 세션인지 정해지기 전 잠깐 그린다(부팅 스윕의 0초 모습과 같은 크기).
252// tall: 띠에 Clawd 가 있어 3줄일 때.
253export function bootCard(E, tall) {
254  return card(E, 'subtle', tall ? [t(E, ' '), t(E, ' '), t(E, ' ')] : [t(E, ' '), t(E, ' ')])
255}
256
257export function draw(E, id, ctx, ms, cols, maxRows) {
258  if (id === 1) return sweep(E, ctx, ms, cols)
259  if (id === 2) return briefing(E, ctx, ms)
260  if (id === 3) return mascot(E, ctx, ms, cols, maxRows)
261  return wordmark(E, ctx, ms, cols)
262}
263
264// ── 재료 만들기(순수) ───────────────────────────────────────────────────────────
265// `wip` 명령의 출력에서 요약 숫자와 위쪽 작업 3개를 뽑는다. 형식이 다르면 null.
266export function parseBoard(text) {
267  const m = /이어받을 것\s*\**(\d+)\**\s*·\s*대기\s*\**(\d+)/.exec(text || '')
268  if (!m) return null
269  const recent = []
270  for (const row of String(text).split('\n')) {
271    if (!/^\|\s*\d+\s*\|/.test(row)) continue
272    const c = row.split('|').map((s) => s.trim())
273    const task = (c[3] || '').replace(/\*\*/g, '')
274    const issue = (c[4] || '').replace(/^-$/, '')
275    if (task) recent.push({ task: task.length > 22 ? task.slice(0, 21) + '…' : task, issue: issue.split(',')[0].trim() })
276    if (recent.length === 3) break
277  }
278  return { resume: Number(m[1]), wait: Number(m[2]), recent }
279}
280
281// 평일 정해진 시간대에만 켜져 있는 것(설정의 serviceWindow — 예: 개발 서버)이 지금 가동 시간대인지. 실제 응답 여부가 아니라 시간대만 말한다.
282// 부재 시 동작: 설정이 없으면 null 이고 브리핑 카드에서 그 줄이 빠진다.
283export function devWindow(d, win) {
284  const w = win === undefined ? getConfig().serviceWindow : win
285  if (!w) return null
286  const p = (n) => String(n).padStart(2, '0')
287  const span = '평일 ' + p(w.start) + '–' + w.end + '시'
288  const day = d.getDay()
289  const h = d.getHours()
290  const weekday = day >= 1 && day <= 5
291  if (weekday && h >= w.start && h < w.end) {
292    const left = (w.end - h) * 60 - d.getMinutes()
293    return { label: w.label, open: true, text: span + ' · 정지까지 ' + Math.floor(left / 60) + '시간 ' + (left % 60) + '분' }
294  }
295  const next = weekday && h < w.start ? '오늘' : day >= 1 && day <= 4 ? '내일' : '월요일'
296  return { label: w.label, open: false, text: span + ' · 다음 가동 ' + next + ' ' + p(w.start) + ':00' }
297}
298
299const DAYS = ['일', '월', '화', '수', '목', '금', '토']
300
301export function dateTexts(d) {
302  const p = (n) => String(n).padStart(2, '0')
303  const hm = p(d.getHours()) + ':' + p(d.getMinutes())
304  return {
305    dateText: p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' (' + DAYS[d.getDay()] + ') ' + hm,
306    dateLong: d.getMonth() + 1 + '월 ' + d.getDate() + '일 (' + DAYS[d.getDay()] + ') ' + hm,
307  }
308}
309
310// 시작 효과(워드마크 · 인사)에 쓰는 이름. 설정의 repos 중 brand · word 가 있는 첫 항목을 따르고, 없으면 폴더 이름이다.
311export function repoOf(cwd) {
312  const c = String(cwd || '')
313  for (const x of getConfig().repos) {
314    if ((x.brand || x.word) && x.re.test(c)) return { family: x.family || 'blue', name: x.brand || x.word, word: x.word || x.brand.toUpperCase() }
315  }
316  const base = c.split('/').filter(Boolean).pop() || 'CLAUDE'
317  return { family: 'blue', name: base, word: base.toUpperCase().replace(/[^A-Z-]/g, '').slice(0, 12) || 'CLAUDE' }
318}
319
hooks/mascot.js 7 lines
1// 바탕화면 샘플.jpeg 를 미리 칸 그림으로 구워 둔 값(Raster cells). 실행 중에는 그림 파일을 읽지 않는다.
2// 배경은 터미널 기본색으로 비우고 캐릭터 둘레 1픽셀만 흰색으로 남겼다. 크기별 { cols, rows, cells }.
3export const MASCOT = {
4  30: { cols: 30, rows: 15, cells: 'IAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP39/QAAAAABgCUAAP///wD19fYAgCUAAPT09ABBQUMAgCUAAHx9gABlVzwAgCUAAJyeoQBXTTYAgCUAAP///wBQUVYAgCUAAP///wD7+/sAhCUAAPz8/AAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP7+/gAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAPz8/AD8/PwAgCUAAP///wDNztEAgCUAAHFydgAjIBwAgCUAAEk/KwC4o3cAgCUAAM+4hwC1oHQAgCUAAMuzgwC3oXQAgCUAAEc9JwDAqXkAgCUAAF9gZAA0LiMAgCUAAP///wBYVlQAgCUAAP///wBqaWkAgCUAAP///wC1t7kAgCUAAP///wCvsbQAgCUAAO3u8AA9OjYAgCUAAIiJjABrXEAAgCUAAFlXUACsl2wAgCUAAGBdVwCnkWcAgCUAALS0tgAtKCEAgCUAAP///wDX2NoAgCUAAP39/QD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP39/QAAAAABhCUAAP///wAAAAABgCUAAP7+/gD5+fkAgCUAAP///wCUk5QAgCUAALOzswCfk3oAgCUAAHtqSwDAqnkAgCUAAMSufQCynnIAgCUAALWgdAC1oHQAgCUAALehdQC0oHMAgCUAALeidQC8p3kAgCUAALSfdAC9p3sAgCUAAKKOYwC0oHUAgCUAAIt5VADGsH8AgCUAAFxSPQDAq3sAgCUAAFpPPADCrH0AgCUAAKmUawC7pngAgCUAAMiwgAC1n3MAgCUAALeidgC2oXQAgCUAAMu1hQC/qXsAgCUAAGpcPwCSgFsAgCUAAHZ3ewBEQ0YAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QD+/v4AgCUAAP///wDb29sAgCUAANrZ2gA1NTYAgCUAACssLgC3uLgAgCUAACggEgCIiIgAgCUAAKuVZwBLSEEAgCUAAMKrfABPRTQAgCUAAMWufwBuYUYAgCUAAMWtfgBuYUcAgCUAAMKrfQBWTDwAgCUAAK+ZbQBPTUgAgCUAAEpCMACbnKAAgCUAABsYFACqq64AgCUAALCccQAsJyAAgCUAAMOsfgCKdlIAgCUAALOecwDAqXkAgCUAALSfcwDEr38AgCUAALahdQC+qXsAgCUAALahdQC5pHcAgCUAALqleAC5pXgAgCUAAKiUaQC2oG8AgCUAAERAPQDUy7IAgCUAAPj5+gD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QD///8AgCUAAP7+/gCtrq4AgCUAAEZGRgBTVFUAgCUAAK2vrwD///8AgCUAAP///wD5+fkAgCUAAP///wD8/PwAgCUAAPb2+AD///8AgCUAAMPExwD///8AgCUAAJWWmAD///8AgCUAAIeIjAD19fQAgCUAAM3O0QD///8AgCUAAP3+/wD///8AgCUAAP///wD8/PwAgCUAAP///wD8/PwAgCUAAM3O0AD///8AgCUAAG9vcADIyMgAgCUAAExGNwDExcgAgCUAAHRnSACTlJYAgCUAAJeFXABramcAgCUAAK2YbABbV1AAgCUAALeidABOSkIAgCUAAJB9VgBZVlMAgCUAAEVAOgBlZWgAgCUAAOzs7ABBQEIAgCUAAP///wD4+PkAhCUAAP39/QAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP///wAAAAABgCUAAP///wD39/cAgCUAAFZWVgBHR0cAgCUAAL29vQDx8fEAgCUAAP39/gD///8AgCUAAPz8/AD+/v4AgCUAAP///wD4+PkAgCUAAP7+/gD///0AgCUAAP///wCurq8AgCUAAODg4AArKy0AgCUAAO3s7ABUVVUAgCUAAP///wDHyMgAgCUAAP7+/gD///8AgCUAAP///wD9/f0AgCUAAP///wD///8AgCUAAPz8/AD///8AgCUAAOjo6ADW1tcAgCUAAP///wC5uboAgCUAAP///wD+/v4AgCUAAP///wD8/PwAgCUAAPz9/wD///8AgCUAAPT19wD///8AgCUAAP///wD7+/sAgCUAAP///wD8/PwAgCUAAEFAQQC6uroAgCUAAI6OjgAwMDEAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAOHh4QDg4OAAgCUAAEtLTABQUFAAgCUAAP7+/gD///8AgCUAAO7t/QCyrdUAgCUAAMvF8QBQTWUAgCUAANDK/ACBe50AgCUAAPn1/wCpoMwAgCUAAEtLTgC1s8AAgCUAACkpLQA3NzYAgCUAAC8wMQA5ODoAgCUAAIaGhwDm5uYAgCUAAP7+/gD///8AgCUAAPX19QD///8AgCUAAP///wD///8AgCUAAJubmwCMjY8AgCUAACoqKwAPDxEAgCUAAGxtbQAeHx8AgCUAAIKDgwBoamwAgCUAAP///QD//f8AgCUAAPz8+gD6+P8AgCUAAP79/gD///8AgCUAAP7+/gD//v8AgCUAAP///wD///8AgCUAAPHw8QD+/v4AgCUAADIxNAA1NTYAgCUAAOzs7ADW1tYAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wAAAAABgCUAAPj4+AD///8AgCUAAEhISQBqaWsAgCUAAPDx7gCoqKgAgCUAANTO7gD///8AgCUAAJaPvwDk4P8AgCUAAHp1mgDj3v8AgCUAAIB6nwD08v8AgCUAAP///wD9/f0AgCUAAP///wD9/f0AgCUAAP///wD+/v4AgCUAAPX19QDg4OAAgCUAAODg4AA+Pj8AgCUAAMHBwQA1NjYAgCUAAP///wBpaWkAgCUAAPT19QDu7u4AgCUAAJGRkQD///8AgCUAAICBgAD///8AgCUAALayzwCOhrQAgCUAAJuUwQBpZoIAgCUAAKSdzABpZYcAgCUAANnX6QCQiaoAgCUAAP39/AD///8AgCUAAP///wD9/f0AgCUAAPz8/ADV1dUAgCUAADQzNAA2NjgAgCUAANzc3QD6+foAgCUAAP///wD+/v4AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD8/PwAgCUAAMvLywD///8AgCUAAD49PgB/f38AgCUAAP///wBnZ2cAgCUAAP//+wD8/P0AgCUAAP///AD8/PwAgCUAAP///gD+/v8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAPv7+wD+/v4AgCUAAP///wD+/v4AgCUAAKOjpADy8vIAgCUAADw9PQCenp4AgCUAAJ6engD9/f0AgCUAAP///wD///8AgCUAAPn5+QD+/v4AgCUAAPb1+gD///8AgCUAANfR/AD8/P0AgCUAAMrD/ADx7/kAgCUAAMvF+gD5+P0AgCUAAOzp/wCFhoQAgCUAANbW1QBGRkcAgCUAAP///wCkpKQAgCUAAI6NjgBISEkAgCUAAGRjZQDBwcEAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP7+/gD///8AgCUAAObm5gDT09IAgCUAAHV1dQA7OzsAgCUAAGFhYgDY2NgAgCUAAPz7/AD19fUAgCUAAP///wD///8AgCUAAP7+/gD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAPf39wD///8AgCUAALa3twD///8AgCUAAPn5+QD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP/+/gD///8AgCUAAP///wD+/v8AgCUAAOfn5gD5+fkAgCUAAHBxcQD///8AgCUAAOnq6gB0dHUAgCUAAAAAAQAsLS0AgCUAAGdoaQD19vYAgCUAAP///wD9/f0AgCUAAP39/QAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QAAAAABgCUAAP///wD+/v4AgCUAAMfHxwD///8AgCUAAHNzcwD///8AgCUAAMXFxQChoaEAgCUAAPr6+gCtra0AgCUAAP39/QD+/v4AgCUAAP///wD8/PwAgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAPz8/AD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD8/PwAgCUAAP///wD+/v4AgCUAAP39/QCurq4AgCUAALCwsACsrKwAgCUAAOnq6gD///8AgCUAAP///wD///8AhCUAAP7+/gAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAPz8/AD+/v4AgCUAAKysrADs7OwAgCUAAIiIiABPT08AgCUAAP///wD29vYAgCUAAPr6+gD///8AgCUAAP///wD+/v4AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD+/v4AgCUAAPv7+wD8/PwAgCUAAP///wD4+PgAgCUAAHl4eQAqKiwAgCUAAJycngBXU00AgCUAAMTGyQBsYUkAgCUAANzd3wBHQTcAgCUAAP///wDW19gAgCUAAP7+/gD+/v4AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD+/v4AgCUAAJiYmQDq6usAgCUAAGtqawAnJygAgCUAAODg4ACxsbEAgCUAAPr6+gD///8AgCUAAPv7+wD///8AgCUAAP39/QD///8AgCUAAP7+/gD///8AgCUAAP///wD///8AgCUAAP7+/gD///8AgCUAAP///wD+/v4AgCUAAP///wD///8AgCUAAP39/QD///8AgCUAAPn5+QD///8AgCUAAP///wCMjIwAgCUAAG1tcQA4ODkAgCUAAE1BKwB5cWIAgCUAAMKregBnXUsAgCUAAKWSZgBbWVgAgCUAADItJQDGyMoAgCUAANDR0wD///8AgCUAAP///wD+/v4AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD+/v4AgCUAAJ6dngDz8/MAgCUAAFJSUgCUlJMAgCUAAOnp6QBubm4AgCUAAGFiYgCio6MAgCUAAG1tbQCpqakAgCUAAL6+vgBhYWEAgCUAAO/v7wBbW1sAgCUAAP7+/gBmZmYAgCUAAPLy8gBbW1sAgCUAAP///wB5eXkAgCUAAPv7+wD///8AgCUAAMzMzQA3NjcAgCUAAG1tbQCenp8AgCUAAFNTUwD///8AgCUAAPf39wD///8AgCUAAPHz9gD///8AgCUAANve4AD///8AgCUAAP///wD+/v0AgCUAAP///wAAAAABgCUAAP7+/gAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAPz8/AAAAAABgCUAAO/v7wAAAAABgCUAAPn5+QD///8AgCUAAFpaWgDc3NwAgCUAAJOTkwBubm4AgCUAAGhoaADW1tYAgCUAAP///wD9/f0AgCUAAP39/QAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAAB' },
5  36: { cols: 36, rows: 18, cells: 'IAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAPz8/AAAAAABgCUAAP39/QD///8AgCUAAP///wB6e34AgCUAALGytAAjHRUAgCUAAJGTlwBcUToAgCUAAPHy8QAoKCgAgCUAAP///wDBwsQAgCUAAPz8/AD///8AhCUAAPv7+wAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP///wAAAAABhCUAAP39/QAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAP7+/gAAAAABgCUAAP///wDw8fEAgCUAAKqqrQAaHB4AgCUAAAoHBQCCc1UAgCUAALCedADDrH0AgCUAANK5hwCvmnAAgCUAAHpsTQDMtIMAgCUAAAkJCwBlWD4AgCUAANDQ0AAhIicAgCUAAP///wDY2NoAgCUAAP///wDn6OkAgCUAAP7+/gD///8AhCUAAP///wAAAAABgCUAAP7+/gD///8AgCUAAP///wCXmJwAgCUAAPn6+gA4NjYAgCUAAK2usQBJPy4AgCUAAJGTlgBtYUQAgCUAANXW1wAxKyIAgCUAAP///wCLjI8AgCUAAP39/QD///8AhCUAAPz8/AAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAPz8/AAAAAABhCUAAP///wAAAAABgCUAAPz7+wD///8AgCUAAHl7gACIhH8AgCUAAC8nGwCSgFoAgCUAAMixggC/q3oAgCUAALOfcwC2oXQAgCUAALeidQC3onUAgCUAALegcwC3onUAgCUAAL6oeQC2oXUAgCUAAEM7LAC9qHoAgCUAADYxKAC5pHYAgCUAADo2LwC0n3IAgCUAAEZGRwCEdFIAgCUAAK2usQBTSjYAgCUAAE9QUgB3Z0sAgCUAAD01JgDFr4EAgCUAAJ2KYwC+p3gAgCUAAMStfwC1n3MAgCUAAM21hQCwnXIAgCUAAKmUbADIsoIAgCUAABkXFQBCOScAgCUAAOLi5ACEhYoAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAPz8/AAAAAABgCUAAPz8/AD///8AgCUAAP///wCJiYsAgCUAAOHh4QAMCwoAgCUAAHh4ewCBcFAAgCUAALOkgQDErHsAgCUAAL2mdgC8pnkAgCUAALWhdAC4onUAgCUAALeidQC4onYAgCUAALahdQC8pnkAgCUAALWgdADCrH4AgCUAALWgdADNtYQAgCUAAL2oewBoXUIAgCUAALymegAgHBQAgCUAALijdQC/qX0AgCUAAMCrfAC5pHYAgCUAAMWvfwCxnXEAgCUAAMGsfQC1oHQAgCUAALagdAC3onUAgCUAALehdQC3onUAgCUAALiidQC3onUAgCUAALWgcwC1oHQAgCUAAMWvfwDBq3wAgCUAAGxeQwCHd1UAgCUAAEpLUQAmJioAgCUAAP///wD+/v4AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABhCUAAPz8/AAAAAABgCUAAPz8/AD///8AgCUAAP///wCNjY0AgCUAAG5tbwArLC0AgCUAADo6PAD8/PwAgCUAAFFSUwD///8AgCUAAD02KAC0trkAgCUAAGpdQQBrbXAAgCUAAJiEYAA8OzoAgCUAALaidgA7ODAAgCUAALSecQBFQDoAgCUAAKSPZwBDQkAAgCUAAH1tTgBjY2UAgCUAAEE5KACrrbEAgCUAAEVESAD9/f0AgCUAAFVWWQD///8AgCUAADEqHQBvcXQAgCUAALehdgApIx0AgCUAAMmwgABvX0IAgCUAALmjdgCynnEAgCUAALSfcwDFsIAAgCUAALWgdADFrn8AgCUAALahdAC+qXsAgCUAALahdAC7pngAgCUAALmkdwDAq30AgCUAAKqWaQCvm2sAgCUAAJKNgQDY0b4AgCUAAPv8/AD///8AhCUAAPz8+wAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP7+/gD///8AgCUAAODg4ABhYmMAgCUAABUVFQBtbm8AgCUAANrc2wD///8AgCUAAP///wD7+/sAgCUAAPz7/AD///8AgCUAAP///wD8/PwAgCUAAP///wD7+/sAgCUAAP///wD9/f0AgCUAAOTk5gD///8AgCUAAKamqQDr6+oAgCUAAPX19wD6+vkAgCUAAP///wD8/PwAgCUAAP///wD8/PwAgCUAAP///wD///8AgCUAAPz8/AD9/f0AgCUAAP///wD8/PwAgCUAANjZ2wDZ2NgAgCUAAHN0dwC/v78AgCUAADUyKwDm5ugAgCUAAFJJMwCjpKcAgCUAAHZoRwBwcXIAgCUAAJiFXQBVVFMAgCUAAKOQaABAPj0AgCUAAKiUaQA7OjcAgCUAAFpNNgBkY2YAgCUAAAcFBwCMi40AgCUAAMbGxgASEhMAgCUAAP///wDg4OEAgCUAAP39/QD9/f0AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAO7u7gC7u7sAgCUAACcnKAA7OzsAgCUAAN/f4AD///8AgCUAAP7+/gD+/f4AgCUAAP38/QD///0AgCUAAP7+/wD///4AgCUAAP/+/wD///0AgCUAAP///wD+//4AgCUAAP///wDQ0NAAgCUAAP///wA0NDQAgCUAAP///wBAQEEAgCUAAP///wC1tbUAgCUAAP///wD///8AgCUAAP///wD9/f0AgCUAAP///wD///8AgCUAAP7+/gD///8AgCUAAP///wD///8AgCUAAOnp6QD///8AgCUAAN3d3QD39/gAgCUAAP///wD6+voAgCUAAP///wD///8AgCUAAP///wD7+/sAgCUAAP///wD+/f0AgCUAAPv8/QD///8AgCUAAP7+/gD///8AgCUAAP///wD5+fkAgCUAAP///wD8/PwAgCUAAEZFRgDMzMwAgCUAAFNTUwAHBwcAgCUAAP///wDh4eEAgCUAAPz8/AD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAJWVlQCBgYIAgCUAAFVVVQBqamsAgCUAAP7+/gD///8AgCUAAPn7+gDf3PkAgCUAAPLv/wCYkL8AgCUAAN/c+wC1r+IAgCUAAOTh9QDIv/sAgCUAAP7+/QDu5f8AgCUAAENCRQBMTE8AgCUAACMjKAADAwUAgCUAAJaYmAAQEBEAgCUAABkaGwAzNDUAgCUAAPz8/AD+/v4AgCUAAP///wD8/PwAgCUAAP7+/gD5+fkAgCUAAP7+/gD///8AgCUAAPz8/ADKy8sAgCUAAHR0dAAAAAAAgCUAAF9fYACHiIkAgCUAAGFgYQBISUkAgCUAANzc3ABgYWAAgCUAAP79/gD///0AgCUAAP39/QD9/fkAgCUAAP7+/gD//v0AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP7+/gD8+/wAgCUAAP///wD+/v4AgCUAACIhJABIR0kAgCUAAKioqACBgYIAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAI6OjgC8vLwAgCUAAGNjYwA7Oz0AgCUAAP7+/gD///8AgCUAALqz3gDY0u8AgCUAAEZDWAC0quMAgCUAAFlVaACZlMEAgCUAAG9phwB1cJYAgCUAAJuTuwCgmsAAgCUAAN7d4QD///8AgCUAAHR0cgD///8AgCUAAF1dXgD///8AgCUAAM3NzgD///8AgCUAAP///wDe3t4AgCUAAP///wDg4OAAgCUAAP///wCurq4AgCUAAPz8/AD///8AgCUAAOrr6wD9/f0AgCUAACMkJwDW1tYAgCUAABscHQCRkY8AgCUAAAAAAACysbQAgCUAAKKjqgC2sNYAgCUAAPHu/wCVjbsAgCUAAOvm/wCXj8EAgCUAAP7+/wC6s9sAgCUAAP///wDy8vYAgCUAAP///wD//v8AgCUAAPv7+wD7+/sAgCUAAP///wD///8AgCUAAFBPUABJSEkAgCUAAHBvcQB8e30AgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP7+/gAAAAABgCUAAPX29QD///8AgCUAAC0sLQB1dHUAgCUAANnZ2QBfXmAAgCUAAPv8/gD///8AgCUAAODc+QD9/vkAgCUAANvV/wD///wAgCUAAOHc/wD///0AgCUAAPn3/wD///4AgCUAAP39/QD///8AgCUAAPv7/AD///8AgCUAAPn5+QD///8AgCUAAP39/QD+/v4AgCUAAI2NjQD///8AgCUAACoqKgBLTE0AgCUAADExMgBSU1QAgCUAAGFhYQBaWloAgCUAAMvLywD+/v4AgCUAAP///wD5+fkAgCUAAP///wD7+/sAgCUAAPn0/wDp5/cAgCUAAG5okADIwPoAgCUAAF9ccQDBuvQAgCUAAGhmfwC+uPIAgCUAAFRObgDAuesAgCUAANHM4gD//f8AgCUAAP39/QD///8AgCUAAP39/QD///8AgCUAAPv7+wDKysoAgCUAACIhIwAYGBoAgCUAALW1tgD29fYAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP7+/gD8/PwAgCUAAOXl5QD///8AgCUAACgoKACbm5sAgCUAAMbGxgAAAAAAgCUAAP///wDl5OUAgCUAAPz8/QD+/v4AgCUAAP7+/wD+/v4AgCUAAP7+/wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP39/QD+/v4AgCUAAP///wD///8AgCUAALu7uwDo6OgAgCUAAGxsbACnp6cAgCUAAOfn5wD///8AgCUAAP///wD///8AgCUAAP39/QD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAOzr+gD///8AgCUAAN7a+QD///wAgCUAAODb+QD///8AgCUAAPn4/wCKiocAgCUAAIWFhABVVVcAgCUAAFJSUgCIiIkAgCUAAPf39wBXV1cAgCUAAHV0dQAqKiwAgCUAAFNSUwDGxsYAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAKurqwCtra0AgCUAAE9PTwA1NTUAgCUAAJmZmQDb29sAgCUAAO7u7gDv7+8AgCUAAP///wD9/f0AgCUAAP7+/gD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP7+/gD///8AgCUAAP///wD///8AgCUAAPDw8AD///8AgCUAAMLCwgD///8AgCUAAPv7+wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP7+/wD///8AgCUAAPz8/QD///8AgCUAAP7+/wD+/v4AgCUAAIeHiAD///8AgCUAAOLj4wD09PQAgCUAALy8vQAdHh8AgCUAAAAAAABKTEwAgCUAAHl7fAD///8AgCUAAP///wD8/PwAgCUAAPz8/AAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QAAAAABgCUAAP///wD+/v4AgCUAAK6urgD///8AgCUAAFVVVQD///8AgCUAAJiYmADi4uIAgCUAAP///wB6enoAgCUAAP39/QD///8AgCUAAP///wD+/v4AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP39/QD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP7+/gD///8AgCUAAP39/QD+/v4AgCUAAOzs6wCTk5MAgCUAAJiYmQD19fUAgCUAAPf4+AD9/f0AgCUAAP///wAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QD9/f0AgCUAAObm5gD///8AgCUAAERERABLS0sAgCUAAP39/QDZ2dkAgCUAAP///wD9/f0AgCUAAP///wD8/PwAgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD8/PwAgCUAAP7+/gD+/v4AgCUAAPz8/ADDw8MAgCUAACssKwA5ODoAgCUAAN7d3QC9vsEAgCUAAP///wBjY2QAgCUAAP///wCFhYYAgCUAAP///wD+/v4AhCUAAP7+/gAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD9/f0AgCUAAJWVlQD6+voAgCUAAFVUVQBFRUYAgCUAAP///wCXl5cAgCUAAP///wDl5eUAgCUAAP///wD7+/sAgCUAAP///wD7+/sAgCUAAP///wD+/v4AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD///8AgCUAAP///wD5+fkAgCUAAPr6+gD///8AgCUAAP///wCxsrMAgCUAAFJTVgATDgkAgCUAACklHwC9qHwAgCUAAFRMOQDRuocAgCUAAJuKYwClk2oAgCUAADctHAAkHxcAgCUAAIuMjwCwsbQAgCUAAP///wD///8AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD///8AgCUAAKuqrAAvLjAAgCUAAAgICACvr7AAgCUAAK6urgD///8AgCUAAP///wCjo6MAgCUAAP///wCTk5MAgCUAAP///wDt7e0AgCUAAPz8/AD///8AgCUAAPv7+wD///8AgCUAAPz8/AD///8AgCUAAPv7+wD///8AgCUAAPz8/AD///8AgCUAAP///wD+/v4AgCUAAP///wD+/v4AgCUAAP7+/gD//v8AgCUAAP///wCenp4AgCUAAMDAwAAXFxcAgCUAABgYGwC3t7YAgCUAAFNORgD4+PoAgCUAAGpdRgC3urwAgCUAAE9ENQC+wMIAgCUAAERDQwD///8AgCUAALCyswD///8AgCUAAP///wD9/f0AgCUAAP7+/gAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP///wD+/v4AgCUAAIKCggD///8AgCUAAEtLSwDa2toAgCUAAGtrawC6uroAgCUAAD0/PwD7/PwAgCUAAFRUVAD///8AgCUAADAwMADn5+cAgCUAAFNTUwCoqKgAgCUAAIWFhQCJiYkAgCUAAJmZmQB5eXkAgCUAAH5+fgCUlJQAgCUAAKCgoAAnJycAgCUAAP///wDMzMwAgCUAAMHBwQDV1dUAgCUAADo6OwA6OjoAgCUAAFRTVAD///8AgCUAANfX1wD///8AgCUAAP///wD8/PwAgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABgCUAAP39/QAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAPz8/AAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wAAAAABgCUAAP///wD7+/sAgCUAALKysgD///8AgCUAAEVFRQCTk5MAgCUAAGdnZwCXl5cAgCUAAI2NjQD///8AgCUAAP39/QD+/v4AIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAABIAAAAAAAAAEAAAAB' },
6}
7