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…

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.

한국어 설명은 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.
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>.
| Claude Code | A build with mods. Built and checked on 2.1.291 – 2.1.293 |
| OS | Checked on macOS only. CPU / memory and the keep-awake indicator are read with macOS commands (iostat, memory_pressure, pmset) |
| Terminal | 24-bit color recommended. It works in 256 colors, with rougher sky and Clawd colors |
| Width | Everything fits at 127 columns. Narrower windows fold the arc first, then the usage pills, and keep the values as text |
| Where | What |
|---|---|
| Left picture | Clawd and the sky behind it |
| Row 1 | issue key │ progress steps ··· remote indicator · state badge (working · N subagents working · done · idle N min · waiting for you) |
| Row 2 | session / issue title │ ● repo │ ◆ model · effort |
| Row 3 | counts of subagents · monitors · workers · things left running, and caffeinate |
| Pills | ctx (context window) · 5h · 7d rate-limit usage |
| Arc | where the sun or moon is, time left until the end of the workday, weekdays |
✓ 플랜 ── ● 구현 ── ○ 검증 (plan ── implement ── verify) — which step the session is on. Implementation work goes plan → code → implement → verify → done; research goes research → organize → report.
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.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 —.
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.
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 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.

| Level | Threshold |
|---|---|
| Caution | CPU 50 % or memory 75 % |
| High | CPU 80 % for 30 s, or memory 85 %, or a memory-pressure warning |
| Critical | CPU 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.

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.

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.

caffeinate at the end of row 3 while caffeinate is keeping the display awake.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.
No text, only motion. They play in every open session at once.
| When | Effect |
|---|---|
Weekday times you list in bells | Clawd rings a bell |
| Every hour on the hour | The sun on the arc flashes and the whole path lights up |

| Place | Content |
|---|---|
| Spinner line | current step · the command being run · tool count · messages received mid-turn |
| Turn-finished line | a receipt — end time · tools · files changed · failures · context and limit growth |
| Line under the input | what this session left running |
/now | a summary of the session so far, without calling the model (zero tokens) |
/fx.| Command | Does | ||||
|---|---|---|---|---|---|
/now | progress summary (zero tokens) | ||||
/cockpit | open the side panel | ||||
| `/cockpit sky on\ | off` | sky behind Clawd (saved) | |||
| `/cockpit sky morning\ | day\ | sunset\ | night\ | auto` | preview a sky |
/cockpit poll | show polling intervals; change one with /cockpit poll sys 3 (saved) | ||||
/cockpit idle | this 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 config | show what was read from the config file; /cockpit config reload re-reads it | ||||
| `/clawd on\ | off` | show or hide Clawd | |||
/fx | start effects — preview /fx 1 3, choose /fx use 1 3, disable /fx off | ||||
/leftovers | list what this session left running |
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"]
}
| Key | Meaning | Without it |
|---|---|---|
repos[].match | a regular expression tested against the session's working folder | — |
repos[].name · color | repo label and dot color on row 2 | the folder name, dimmed |
repos[].family | red or blue — the border color | blue |
repos[].brand · word | name and big wordmark used by the start effects | the folder name |
alertFiles[] | files that turn the band into an alert while they exist. Only the session that created the file shows the alert | no alerts |
alert.title · note | wording of the alert line | "경보 파일" |
branchKey | pattern that pulls an issue key out of the branch name | upper-case keys like ABC-123 |
serviceWindow | something that only runs on weekdays between two hours; shown in the briefing start effect | the line is omitted |
location | latitude and longitude for sunrise and sunset (the time zone is the computer's) | sunrise 06:00, sunset 18:00 |
quitTime | end of the workday, HH:MM | 18:00 |
bells | weekday times at which Clawd rings a bell | none |
verifyCommand | a regular expression for extra commands that count as the verify step | the built-in list (jest, vitest, npm test, …) |
texts | your own wording for a few lines (leftClear, leftBusy, leftPane, boardHint) | the built-in wording |
issue.mcpServer · keyPattern | MCP server whose get_issue returns the issue title | linear, keys like ABC-123 |
issue.boardCommand · boardLineDir · fleetRoot | hooks into the author's own task-board and multi-session tools | those chips are absent |
One value is still a code constant:
| What | File | Name | Default |
|---|---|---|---|
| Doze / sleep / dim | hooks/model.js | IDLE_DEFAULT | 5 / 15 / 60 min |
Measured on one Mac; treat the numbers as an order of magnitude.
| One idle session | 1.8 – 1.9 % of one core (0.45 % with every customization off) — the mod is about 1.4 points at most |
| Of which Clawd | about 0.8 points. After 15 idle minutes it redraws once a second |
| Tokens | 228 per request (the phase tool definition). Pictures and effects cost none |
| Machine load and keep-awake sampling | one 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.
Captured in a 256-color test terminal, so colors are rougher than in a 24-bit terminal.


/now lists the prompts instead./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..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 testhooks/register.js 1526 lines1// 하네스 콕핏 진입점. 이벤트를 관찰만 하고(항상 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 })
1200hooks/model.js 669 lines1// 하네스 콕핏의 순수 로직. 이 파일의 함수는 `$` 를 받지 않는다 — 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}
669hooks/view.js 417 lines1// 하네스 콕핏의 화면 트리 조립. `$` 를 받지 않고, 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}
417hooks/clawd.js 582 lines1// 띠 왼쪽에 사는 작은 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)
582hooks/arc.js 189 lines1// 띠 오른쪽의 반원(해 · 달이 지나는 길)과 그 안의 퇴근 숫자, 아래 주간 띠. 순수 로직 — `$` 를 받지 않는다.
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}
189hooks/poll.js 117 lines1// 요소별 폴링 주기와, 폴링으로 읽은 글자를 값으로 바꾸는 순수 로직. `$` 를 받지 않는다.
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}
117hooks/config.js 105 lines1// 쓰는 사람의 환경에 맞추는 값. `~/.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}
105hooks/fx.js 319 lines1// 새 세션 시작 효과 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}
319hooks/mascot.js 7 lines1// 바탕화면 샘플.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