여러 Claude 세션의 상태(내 차례, 진행 중)와 저장소 worktree 별 커밋, MR, Linear 상태를 한 패널과 입력창 위 한 줄로 보이고, 세션마다 MR 이 머지되면 깨지는 포켓몬 알을 키운다

English | 한국어
"Your very own POKéMON legend is about to unfold! A world of dreams and adventures with POKéMON awaits! Let's go!" — Professor Oak, Pokémon Red and Blue
Every Claude Code session you open takes a Pokémon egg from the DAY CARE. The egg rocks while the session works, cracks when your MR opens, and hatches the moment it is merged. Leave a Pokémon at the DAY CARE and it grows while you work, until one day it evolves right in front of you. And who knows, keep at it long enough and you might just run into a legendary Pokémon.
Gotta catch 'em all!

/mod-hud panel show which sessions are waiting on you, which are working, your worktrees, MRs that need you, and Linear mentions. Sections you have no setup for simply stay hidden.It runs inside the Claude Code engine, so there is no Node, npm or React to install, and it works whatever your project's language.
user scope, so it runs in every project. claude plugin marketplace add reeny404/mod-hud
claude plugin install mod-hud@hyoyyoi
/reload-plugins in an open one./mod-hud to open the panel. The eggs, DAY CARE and PC BOX appear at the bottom of the panel when it is docked beside the conversation.read_api scope: // ~/.claude/settings.json
{ "env": { "GITLAB_TOKEN": "<token>" } }
That's it. For every option and what each one does, run:
/mod-hud --help
Paste this into Claude Code:
Install the mod-hud Claude Code plugin for me: run
`claude plugin marketplace add reeny404/mod-hud` and
`claude plugin install mod-hud@hyoyyoi` with the user scope.
Then ask me whether I use GitLab. If I do, ask for a GitLab token with
read_api scope and add it as GITLAB_TOKEN under "env" in
~/.claude/settings.json. Finally tell me to run /reload-plugins and
/mod-hud, and to run /mod-hud --help to see the other options.
claude plugin marketplace update hyoyyoi
claude plugin update mod-hud
feat/#ABC-12-x or fix/ABC-12-x.claude plugin validate .
claude plugin test .hooks/register.tsx 2160 lines1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolResults, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type {
5 Attention,
6 BackgroundTask,
7 BoardEntry,
8 BoxEntry,
9 InboxCache,
10 InboxItem,
11 IssuesCache,
12 Mr,
13 Pokedex,
14 Row,
15 SessionStatus,
16 TodoItem,
17} from '../types'
18import {
19 applyFetch,
20 categoryLabel,
21 emptyCache,
22 formatAgoShort,
23 INBOX_TTL_MS,
24 onlyForMe,
25 parseCache,
26 parseNotifications,
27 shouldFetch,
28 sortInbox,
29 toastText,
30} from './inbox-lib'
31import {
32 assignSessions,
33 cleanupBlocker,
34 emptyIssuesCache,
35 findCheck,
36 gitlabProjectApi,
37 isGithubRemote,
38 isLongLivedBranch,
39 issuesFileOf,
40 ISSUES_TTL_MS,
41 mrAction,
42 parseDefaultBranch,
43 parseGithubPr,
44 parseIssue,
45 parseIssuesCache,
46 parseWorktrees,
47 readEnvValue,
48} from './issues-lib'
49import { canEvolve, rarityOf, typesOf } from './dex-data'
50import { advanceEvolution, TAKE_BACK, xpBar, xpLeft } from './evolve-lib'
51import { helpText, isHelp } from './help-lib'
52import type { EncounterAction } from './evolve-lib'
53import {
54 advancePokedex,
55 ARRIVAL_FRAMES,
56 ARRIVAL_ROWS,
57 boxOnPage,
58 boxPageOf,
59 boxPageOfKey,
60 boxPages,
61 daycareGroundRows,
62 drawArrival,
63 drawDaycareGround,
64 drawNest,
65 drawPortrait,
66 eggsOf,
67 EVOLVE_MS,
68 evolvePhase,
69 eggsOnScreen,
70 emptyPokedex,
71 favoritesFirst,
72 keeperOf,
73 localDay,
74 nestRows,
75 parseKeys,
76 parsePokedex,
77 speciesToLoad,
78 updatePending,
79 WALKER_SIZE,
80} from './nest-lib'
81import type { NestInput, SpriteLookup } from './nest-lib'
82import { crop, decodePng, fit } from './png-lib'
83import type { Pixels } from './png-lib'
84import {
85 applyTaskUpdate,
86 attentionAfterTurn,
87 finishedTaskIds,
88 firstLine,
89 formatFor,
90 freshness,
91 isCodeReviewPrompt,
92 launchedTaskId,
93 pendingTasks,
94 projectName,
95 pickTitle,
96 PRUNE_MS,
97 summarizeTodos,
98 transcriptPathOf,
99 triage,
100} from './sessions-lib'
101
102const PANE = 'mod-hud'
103
104// 섹션마다 아이콘과 색을 달리해 제목이 항목과 섞이지 않게 한다
105const ICON = { mine: '⚑', running: '▶', mr: '⇄', inbox: '@', issues: '⎇' } as const
106const COLOR = { mine: 'red', running: 'yellow', mr: 'blue', inbox: 'magenta', issues: 'cyan' } as const
107
108const ATTENTION = {
109 permission: { mark: '!', label: 'permission', color: 'red' },
110 question: { mark: '?', label: 'question', color: 'yellow' },
111 answered: { mark: '✓', label: 'done', color: 'green' },
112} as const
113const HEARTBEAT_MS = 60 * 1000
114// 알과 박스 그림을 다시 칠하는 간격. 패널 전체가 아니라 그림만 칠하므로 짧아도 된다
115const NEST_TICK_MS = 250
116const SESSIONS_REFRESH_MS = 30 * 1000
117
118// 세션 상태
119
120// 모든 세션이 자기 상태를 여기 쓰고, 패널과 입력창 위 한 줄이 모아 읽는다
121const STATUS_DIR = '.claude/mod-sessions'
122
123const board = atom({ plugin: 'mod-hud', key: 'board' } as const, {
124 mine: [],
125 running: [],
126 idle: [],
127 hidden: { stale: 0, ended: 0 },
128})
129const sessionsMeta = atom({ plugin: 'mod-hud', key: 'sessionsMeta' } as const, {
130 refreshedAt: null,
131 ownId: null,
132})
133
134let dir: string | null = null
135let mine: SessionStatus | null = null
136// /clear 나 /resume 로 끝난 세션. 같은 창에서 이어지는 새 세션이 알과 대시보드 표시를 물려받는다
137let handoff: { id: string; isDashboard: boolean } | null = null
138let todos: TodoItem[] = []
139let isRefreshing = false
140
141async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | null> {
142 try {
143 const ran = await $.process.run(['git', ...args], { cwd })
144 return ran.exitCode === 0 ? ran.stdout : null
145 } catch {
146 return null
147 }
148}
149
150async function statusDir($: EngineInterface): Promise<string | null> {
151 if (dir === null) {
152 const home = await $.env.get('HOME')
153 dir = home ? `${home}/${STATUS_DIR}` : null
154 }
155 return dir
156}
157
158async function readOwnStatus($: EngineInterface, path: string): Promise<SessionStatus | null> {
159 try {
160 return JSON.parse(await $.fs.read(path)) as SessionStatus
161 } catch {
162 return null
163 }
164}
165
166async function writeStatus($: EngineInterface, patch: Partial<SessionStatus>) {
167 try {
168 const base = await statusDir($)
169 if (!base) {
170 return
171 }
172 const id = await $.session.id()
173 // /clear 나 /resume 뒤 classic SessionStart 가 mod 에 오지 않을 때가 있어, 새 id 로 처음 쓸 때 끝난 세션을 이어 받는다
174 const inherited = handoff && handoff.id !== id ? { predecessor: handoff.id, isDashboard: handoff.isDashboard } : {}
175 if (handoff && handoff.id !== id) {
176 // 끝난 세션의 요청, 상태는 새 세션 것이 아니다
177 mine = null
178 handoff = null
179 }
180 const cwd = await $.session.cwd()
181 const repo = await $.session.repo()
182 const root = repo?.root ?? cwd
183 const updatedAt = await $.clock.now()
184 // mod 가 다시 로드되면 모듈 변수가 비므로, 대기 시작 시각과 브랜치는 이전 상태 파일에서 이어 받는다
185 const prev = mine ?? (await readOwnStatus($, `${base}/${id}.json`))
186 // 세션들이 checkout 하나를 같이 쓰면 다른 세션이 브랜치를 바꾼다. 이 세션이 일을 시작하거나 끝낼 때 본 브랜치만 남긴다
187 const isWorkEdge = patch.state === 'working' || (patch.state === 'waiting' && prev?.state === 'working')
188 const branch =
189 isWorkEdge || !prev ? (await git($, cwd, ['branch', '--show-current']))?.trim() || null : prev.branch
190 const state = patch.state ?? prev?.state ?? 'waiting'
191 const waitingSince =
192 state !== 'waiting' ? undefined : prev?.state === 'waiting' ? (prev.waitingSince ?? updatedAt) : updatedAt
193 mine = {
194 state: 'waiting',
195 prompt: null,
196 todos: null,
197 // 대시보드 표시, 리뷰 세션 표시와 MR 상태는 mod 를 다시 로드해도 이어 간다
198 isDashboard: prev?.isDashboard,
199 isCodeReview: prev?.isCodeReview,
200 mrState: prev?.mrState,
201 mrBranch: prev?.mrBranch,
202 ...mine,
203 ...inherited,
204 ...patch,
205 id,
206 cwd,
207 root,
208 project: projectName(root),
209 branch,
210 updatedAt,
211 waitingSince,
212 // 브랜치가 없으면 MR 도 없다. 저장소 밖 세션은 MR 을 조회하지 않으므로 여기서 적어 둔다
213 ...(branch === null ? { mrState: null, mrBranch: null } : {}),
214 }
215 await $.fs.write(`${base}/${id}.json`, JSON.stringify(mine))
216 } catch {
217 // 상태 기록이 실패해도 세션 동작은 막지 않는다
218 }
219}
220
221// 이만큼 지나도 그대로면 OS 알림을 보낸다. 보고 있는 세션에서 바로 답할 때는 알림이 쌓이지 않는다
222const NOTIFY_DELAY_MS = 60 * 1000
223// 입력창 위에 풀어 보일 내 차례 줄 수
224const BAND_SHOWN = 3
225
226// 나를 기다리기 시작한 세션이 스스로 알린다. 다른 세션은 알리지 않으므로 같은 알림이 겹치지 않는다
227async function setAttention($: EngineInterface, attention: Attention | null) {
228 await writeStatus($, { attention })
229 if (!attention) {
230 return
231 }
232 void (async () => {
233 await $.clock.sleep(NOTIFY_DELAY_MS)
234 const now = mine?.attention
235 if (!mine || now?.kind !== attention.kind || now.since !== attention.since) {
236 return
237 }
238 const name = mine.title ?? (mine.branch && parseIssue(mine.branch)) ?? mine.branch ?? mine.project
239 const body = attention.text ?? mine.prompt
240 await $.ui.notify(`${name} ${ATTENTION[attention.kind].label}${body ? `: ${body}` : ''}`, { title: mine.project })
241 })().catch(() => undefined)
242}
243
244// 아직 끝나지 않은 백그라운드 작업. mod 가 다시 로드돼 mine 이 비었으면 상태 파일에서 읽는다
245async function backgroundTasks($: EngineInterface): Promise<BackgroundTask[]> {
246 if (mine) {
247 return mine.background ?? []
248 }
249 const base = await statusDir($)
250 const own = base ? await readOwnStatus($, `${base}/${await $.session.id()}.json`) : null
251 return own?.background ?? []
252}
253
254// 답은 끝났지만 백그라운드 작업을 기다리는 세션. 확인 대기 대신 진행 중에 두고, heartbeat 가 작업이 끝났는지 다시 본다
255async function keepWorking($: EngineInterface) {
256 await writeStatus($, { state: 'working', attention: null, waitingOnTasks: true })
257}
258
259// classic hook 이 알려 준 transcript 경로. mod 가 다시 로드된 직후에는 비어 있어 규칙으로 만든다
260let transcriptPath: string | null = null
261
262async function ownTranscript($: EngineInterface): Promise<string | null> {
263 const home = await $.env.get('HOME')
264 return transcriptPath ?? (home ? transcriptPathOf(home, await $.session.root(), await $.session.id()) : null)
265}
266
267// 끝났다는 알림이 prompt.submit 을 거치지 않고 올 때가 있다. 턴 도중에 와서 queued_command 로 붙거나, 결과가 <agent-message> 로 먼저 온 경우다
268// 그래서 transcript 에서 그 작업 id 가 나온 줄만 grep 으로 뽑아 끝난 작업을 찾는다
269async function finishedInTranscript($: EngineInterface, tasks: readonly BackgroundTask[]): Promise<string[]> {
270 try {
271 const path = await ownTranscript($)
272 if (!path || tasks.length === 0) {
273 return []
274 }
275 const found = await $.process.run(['grep', '-F', ...tasks.flatMap(task => ['-e', task.id]), path])
276 return finishedTaskIds(found.stdout)
277 } catch {
278 return []
279 }
280}
281
282// 끝났거나 너무 오래된 작업을 지우고 남은 작업을 돌려준다. transcript 까지 보는 것은 턴이 끝났을 때만이다
283async function settleBackground($: EngineInterface, now: number, withTranscript: boolean): Promise<BackgroundTask[]> {
284 const tasks = await backgroundTasks($)
285 const finished = withTranscript ? await finishedInTranscript($, pendingTasks(tasks, now)) : []
286 const pending = pendingTasks(tasks, now, finished)
287 if (pending.length !== tasks.length) {
288 await writeStatus($, { background: pending })
289 }
290 return pending
291}
292
293// 1분마다 살아 있다고 남긴다. 백그라운드 작업만 기다리던 세션은 작업이 다 끝났으면 내 차례로 넘긴다
294async function heartbeat($: EngineInterface) {
295 const now = await $.clock.now()
296 const isWaitingOnTasks = mine?.state === 'working' && mine.waitingOnTasks === true
297 const pending = await settleBackground($, now, isWaitingOnTasks)
298 if (isWaitingOnTasks && pending.length === 0) {
299 await writeStatus($, { state: 'waiting', waitingOnTasks: false })
300 await setAttention($, { kind: 'answered', since: now, text: null })
301 return
302 }
303 await writeStatus($, {})
304}
305
306// /resume 목록에 보이는 이름을 transcript 에서 읽는다. 파일이 커서 이름 줄만 grep 으로 뽑는다
307async function readTitle($: EngineInterface): Promise<string | null> {
308 try {
309 const path = await ownTranscript($)
310 if (!path) {
311 return null
312 }
313 const found = await $.process.run(['grep', '-E', '"type":"(custom-title|ai-title)"', path])
314 return pickTitle(found.stdout)
315 } catch {
316 return null
317 }
318}
319
320// 클릭한 MR 이나 Linear 알림을 기본 브라우저로 연다
321function openUrl($: EngineInterface, url: string | null | undefined) {
322 if (url) {
323 void $.process.run(['open', url]).catch(() => undefined)
324 }
325}
326
327const currentTodos = (): TodoItem[] => todos
328
329async function setTodos($: EngineInterface, next: TodoItem[]) {
330 todos = next
331 await writeStatus($, { todos: summarizeTodos(todos) })
332}
333
334async function readSessions($: EngineInterface): Promise<SessionStatus[]> {
335 const base = await statusDir($)
336 if (!base || !(await $.fs.exists(base))) {
337 return []
338 }
339 const now = await $.clock.now()
340 const sessions: SessionStatus[] = []
341 for (const entry of await $.fs.list(base)) {
342 if (!entry.name.endsWith('.json')) {
343 continue
344 }
345 const path = `${base}/${entry.name}`
346 if (now - entry.mtimeMs > PRUNE_MS) {
347 await $.process.run(['rm', '-f', path]).catch(() => undefined)
348 continue
349 }
350 try {
351 sessions.push(JSON.parse(await $.fs.read(path)) as SessionStatus)
352 } catch {
353 // 쓰는 도중이면 다음 갱신 때 읽는다
354 }
355 }
356 return sessions
357}
358
359async function refreshSessions($: EngineInterface) {
360 if (isRefreshing) {
361 return
362 }
363 isRefreshing = true
364 try {
365 const now = await $.clock.now()
366 const merged = Object.entries(await read($, branchMrs)).flatMap(([branch, mr]) => (mr.state === 'merged' ? [branch] : []))
367 const sessions = await readSessions($)
368 const next = triage(sessions, mine?.id ?? null, now, new Set(merged))
369 await update($, board, () => next)
370 await keepPokedex($, sessions, now).catch(() => undefined)
371 await update($, sessionsMeta, () => ({ refreshedAt: now, ownId: mine?.id ?? null }))
372 } finally {
373 isRefreshing = false
374 }
375}
376
377// 저장소의 worktree 와 MR
378
379// 포켓몬 이름을 쓸 언어. PokeAPI 의 언어 이름이다
380type PokemonLanguage = 'en' | 'ko'
381
382// plugin.json 의 userConfig. register 가 받은 값으로 채운다
383const config = { gitlabUrl: '', baseBranch: '', autoCleanup: false, pokemonLanguage: 'en' as PokemonLanguage }
384
385const LINEAR_TTL_MS = 5 * 60 * 1000
386// 파일을 읽어 조회 주기가 됐는지 보는 간격. 파일만 읽으므로 짧아도 된다
387const ISSUES_CHECK_MS = 30 * 1000
388
389const rows = atom({ plugin: 'mod-hud', key: 'rows' } as const, [])
390const branchMrs = atom({ plugin: 'mod-hud', key: 'branchMrs' } as const, {})
391const issuesMeta = atom({ plugin: 'mod-hud', key: 'issuesMeta' } as const, {
392 refreshedAt: null,
393 isRefreshing: false,
394 log: [],
395})
396
397// 세션이 열린 git 저장소. api 는 GitLab 프로젝트 API 주소이고 GitLab 이 아니면 null, base 는 커밋 수를 셀 기준 브랜치
398// host 는 MR 을 찾는 곳(GitHub 는 gh CLI), name 은 패널에 보일 저장소 이름, problem 은 MR 을 못 찾는 까닭
399type RepoInfo = {
400 root: string
401 api: string | null
402 base: string
403 host: 'gitlab' | 'github' | null
404 name: string | null
405 problem: string | null
406}
407
408// undefined 는 아직 안 봄, null 은 git 저장소가 아님
409let repoInfo: RepoInfo | null | undefined
410const linearCache = new Map<string, { status: string | null; at: number }>()
411
412async function ensureRepo($: EngineInterface): Promise<RepoInfo | null> {
413 if (repoInfo === undefined) {
414 const repo = await $.session.repo()
415 if (!repo) {
416 repoInfo = null
417 } else {
418 const head = config.baseBranch ? null : await git($, repo.root, ['symbolic-ref', 'refs/remotes/origin/HEAD'])
419 const api = repo.remote ? gitlabProjectApi(repo.remote, config.gitlabUrl) : null
420 const host = api ? 'gitlab' : repo.remote && isGithubRemote(repo.remote) ? 'github' : null
421 repoInfo = {
422 root: repo.root,
423 api,
424 base: config.baseBranch || (head && parseDefaultBranch(head)) || 'main',
425 host,
426 name: repo.remote?.trim().match(/([^/:]+\/[^/:]+?)(?:\.git)?\/?$/)?.[1] ?? null,
427 problem: !repo.remote
428 ? 'no remote'
429 : host === 'gitlab'
430 ? (await readToken($, repo.root))
431 ? null
432 : 'no GITLAB_TOKEN'
433 : host === 'github'
434 ? (await isGhReady($, repo.root))
435 ? null
436 : 'gh not signed in'
437 : 'not GitLab or GitHub',
438 }
439 }
440 }
441 return repoInfo
442}
443
444// 환경 변수 GITLAB_TOKEN 을 먼저 보고, 없으면 저장소의 .env.local 에서 읽는다
445async function readToken($: EngineInterface, root: string): Promise<string | null> {
446 const fromEnv = await $.env.get('GITLAB_TOKEN').catch(() => undefined)
447 if (fromEnv) {
448 return fromEnv
449 }
450 try {
451 return readEnvValue(await $.fs.read(`${root}/.env.local`), 'GITLAB_TOKEN')
452 } catch {
453 return null
454 }
455}
456
457async function fetchMr($: EngineInterface, api: string, token: string, branch: string): Promise<Mr | null> {
458 const query = `source_branch=${encodeURIComponent(branch)}&order_by=updated_at&per_page=1`
459 const res = await $.http.fetch(`${api}/merge_requests?${query}`, {
460 headers: { 'PRIVATE-TOKEN': token },
461 })
462 if (!res.ok) {
463 return null
464 }
465 const [mr] = JSON.parse(res.text) as Array<Parameters<typeof mrAction>[0] & { iid: number; sha: string; web_url: string }>
466 return mr ? { iid: mr.iid, state: mr.state, sha: mr.sha, url: mr.web_url, action: mrAction(mr) } : null
467}
468
469async function isGhReady($: EngineInterface, cwd: string): Promise<boolean> {
470 try {
471 return (await $.process.run(['gh', 'auth', 'status'], { cwd })).exitCode === 0
472 } catch {
473 return false
474 }
475}
476
477// GitHub 는 gh CLI 로 브랜치의 가장 최근 PR 을 찾는다. gh 가 없거나 로그인하지 않았으면 null
478async function fetchPr($: EngineInterface, cwd: string, branch: string): Promise<Mr | null> {
479 try {
480 const fields = 'number,state,headRefOid,url,mergeable'
481 const ran = await $.process.run(['gh', 'pr', 'list', '--head', branch, '--state', 'all', '--limit', '1', '--json', fields], { cwd })
482 return ran.exitCode === 0 ? parseGithubPr(ran.stdout) : null
483 } catch {
484 return null
485 }
486}
487
488async function fetchLinear($: EngineInterface, issue: string, now: number): Promise<string | null> {
489 const cached = linearCache.get(issue)
490 if (cached && now - cached.at < LINEAR_TTL_MS) {
491 return cached.status
492 }
493 let status: string | null = null
494 try {
495 const result = await $.mcp.call('claude.ai Linear', 'get_issue', { id: issue, fields: ['status'] })
496 const text = result.content.find(block => block.type === 'text')?.text
497 const parsed = text ? (JSON.parse(text) as { status?: string | { name?: string } }) : null
498 status = typeof parsed?.status === 'string' ? parsed.status : (parsed?.status?.name ?? null)
499 } catch {
500 // Linear 연결이 없으면 상태 없이 보인다
501 }
502 linearCache.set(issue, { status, at: now })
503 return status
504}
505
506// MR 이 머지됐고 남은 작업이 없는 worktree 를 지운다. 지웠으면 기록할 한 줄을 돌려준다
507async function cleanup($: EngineInterface, root: string, row: Row): Promise<string | null> {
508 const removed = await $.process.run(['git', 'worktree', 'remove', row.path], { cwd: root })
509 if (removed.exitCode !== 0) {
510 row.note = `cleanup failed: ${removed.stderr.trim().split('\n')[0] ?? ''}`
511 return null
512 }
513 // head 가 MR 의 sha 와 같다는 걸 확인했으므로 squash 머지여도 -D 로 지운다
514 if (row.branch) {
515 await $.process.run(['git', 'branch', '-D', row.branch], { cwd: root })
516 }
517 const name = row.issue ? `#${row.issue}` : (row.branch ?? row.path)
518 // 하루 넘게 대기하던 세션은 지운 폴더에 남으므로 닫으라고 알린다
519 const left = row.session ? ', close its idle session' : ''
520 $.ui.toast(`${name} MR merged, worktree cleaned up${left}`)
521 return `${name}: removed worktree and branch${left}`
522}
523
524// git, GitLab, Linear 를 불러 worktree 목록을 새로 만든다. 조회를 맡은 세션만 부른다
525async function collectIssues($: EngineInterface, { root, api, base, host }: RepoInfo, now: number) {
526 const listed = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root })
527 const trees = parseWorktrees(listed.stdout)
528 const allSessions = await readSessions($)
529 const sessions = assignSessions(trees, allSessions, now)
530 const token = api ? await readToken($, root) : null
531 const findMr = (branch: string) =>
532 api && token ? fetchMr($, api, token, branch) : host === 'github' ? fetchPr($, root, branch) : null
533 const ownCwd = await $.session.cwd()
534
535 const next: Row[] = []
536 const log: string[] = []
537 for (const tree of trees) {
538 const [ahead, dirty] = await Promise.all([
539 $.process.run(['git', 'rev-list', '--count', `origin/${base}..HEAD`], { cwd: tree.path }),
540 $.process.run(['git', 'status', '--porcelain'], { cwd: tree.path }),
541 ])
542 const issue = tree.branch ? parseIssue(tree.branch) : null
543 const row: Row = {
544 ...tree,
545 issue,
546 ahead: Number(ahead.stdout.trim()) || 0,
547 dirty: dirty.stdout.split('\n').filter(Boolean).length,
548 session: sessions.get(tree.path) ?? null,
549 mr: tree.branch ? await findMr(tree.branch) : null,
550 linear: issue && issue.includes('-') ? await fetchLinear($, issue, now) : null,
551 note: null,
552 }
553
554 // 자동 정리는 worktree 와 브랜치를 지우므로 autoCleanup 을 켠 사람만 한다
555 if (config.autoCleanup) {
556 const blocker = cleanupBlocker(row, allSessions, ownCwd, now)
557 const removed = blocker === null ? await cleanup($, root, row) : null
558 if (removed !== null) {
559 log.push(removed)
560 continue
561 }
562 if (blocker !== null && blocker !== 'not-merged') {
563 row.note = `merged, cleanup on hold: ${blocker}`
564 }
565 }
566 next.push(row)
567 }
568
569 // worktree 가 아닌 브랜치에서 일한 세션도 MR 을 찾는다. 세션 줄의 MR 링크와 머지 여부에 쓴다
570 const known = new Map(next.map(row => [row.branch, row.mr]))
571 const found: Record<string, Mr> = {}
572 // MR 을 찾아본 브랜치. 세션은 여기 있는 브랜치의 MR 상태만 상태 파일에 적는다
573 const checked = new Set<string>()
574 for (const branch of new Set(allSessions.filter(s => s.state !== 'ended').map(s => s.branch))) {
575 if (!branch || isLongLivedBranch(branch, base)) {
576 continue
577 }
578 const mr = known.has(branch) ? known.get(branch) : await findMr(branch)
579 checked.add(branch)
580 if (mr) {
581 found[branch] = mr
582 }
583 }
584 return { rows: next, branchMrs: found, checked: [...checked], log }
585}
586
587async function readIssuesCache($: EngineInterface, path: string): Promise<IssuesCache> {
588 try {
589 return parseIssuesCache(await $.fs.read(path))
590 } catch {
591 return emptyIssuesCache()
592 }
593}
594
595let isIssuesRefreshing = false
596
597// 파일이 2분 안이면 파일만 읽고, 아니면 한 세션만 조회해 파일에 쓴다. isForced 는 새로고침 버튼
598async function refreshIssues($: EngineInterface, isForced: boolean) {
599 const repo = await ensureRepo($)
600 const home = await $.env.get('HOME')
601 if (!repo || !home || isIssuesRefreshing) {
602 return
603 }
604 isIssuesRefreshing = true
605 try {
606 // 다른 저장소의 세션과 섞이지 않게 저장소마다 파일을 따로 둔다
607 const path = `${home}/.claude/${issuesFileOf(repo.root)}`
608 const me = await $.session.id()
609 let cache = await readIssuesCache($, path)
610 // 이 세션 브랜치의 MR 을 아직 찾아보지 않았으면 주기를 기다리지 않고 조회한다. 그 전까지 이 세션은 알을 받지 않는다
611 const isUnchecked = (branch: string | null | undefined) =>
612 !!branch && !isLongLivedBranch(branch, repo.base) && !cache.checked.includes(branch)
613
614 if (shouldFetch(cache, await $.clock.now(), me, isForced || isUnchecked(mine?.branch), ISSUES_TTL_MS)) {
615 // 파일에 이 세션이 맡는다고 써 두고, 잠깐 뒤 다시 읽어 그대로일 때만 부른다. 나중에 쓴 세션이 맡는다
616 await $.fs.write(path, JSON.stringify({ ...cache, lease: { by: me, at: await $.clock.now() } }))
617 await $.clock.sleep(LEASE_SETTLE_MS)
618 const claimed = await readIssuesCache($, path)
619 if (claimed.lease?.by === me) {
620 await update($, issuesMeta, prev => ({ ...prev, isRefreshing: true }))
621 const now = await $.clock.now()
622 try {
623 const collected = await collectIssues($, repo, now)
624 cache = { fetchedAt: now, ...collected, log: [...collected.log, ...claimed.log].slice(0, 5), lease: null }
625 } catch {
626 // 실패해도 fetchedAt 을 남겨 모든 세션이 주기 동안 다시 부르지 않게 한다. 본 목록은 그대로 둔다
627 cache = { ...claimed, fetchedAt: now, lease: null }
628 }
629 await $.fs.write(path, JSON.stringify(cache))
630 } else {
631 cache = claimed
632 }
633 }
634
635 await update($, rows, () => cache.rows)
636 await update($, branchMrs, () => cache.branchMrs)
637 await update($, issuesMeta, () => ({ refreshedAt: cache.fetchedAt, isRefreshing: false, log: cache.log }))
638 // 포켓몬 알이 금 가고 부화하는 기준이라, 이 세션 브랜치의 MR 상태를 조회한 브랜치와 함께 상태 파일에 적어 다른 세션도 보게 한다
639 // 아직 찾아보지 않은 브랜치는 적지 않는다. 다른 브랜치를 보고 적은 상태는 모르는 것으로 친다
640 const branch = mine?.branch ?? null
641 if (mine && !isUnchecked(branch)) {
642 const mrState = branch ? (cache.branchMrs[branch]?.state ?? null) : null
643 if ((mine.mrState ?? null) !== mrState || mine.mrBranch !== branch) {
644 await writeStatus($, { mrState, mrBranch: branch })
645 }
646 }
647 } catch {
648 // 이슈를 못 읽어도 다른 패널 동작은 막지 않는다
649 } finally {
650 isIssuesRefreshing = false
651 await update($, issuesMeta, prev => ({ ...prev, isRefreshing: false }))
652 }
653}
654
655// 포켓몬 알과 박스
656
657// 부화, 키우미집, 박스 기록. 열린 세션 중 id 가 가장 작은 세션만 쓰고, 모든 세션이 읽는다
658const POKEDEX_FILE = '.claude/mod-hud-pokedex.json'
659// 박스로 보내기 요청. 어느 세션에서 눌러도 알 key 이름의 빈 파일을 두고, 기록을 쓰는 세션이 읽어 박스에 넣은 뒤 지운다
660const SENDBOX_DIR = '.claude/mod-hud-sendbox'
661// 앞장세우기 요청. 어느 세션에서 눌러도 박스 key 를 이 파일에 쓰고, 기록을 쓰는 세션이 읽어 반영한 뒤 지운다
662const LEAD_FILE = '.claude/mod-hud-lead'
663// 야생 전설에게 누른 버튼(throw, run, dismiss). 어느 세션에서 눌러도 이 파일에 쓰고, 기록을 쓰는 세션이 읽어 반영한 뒤 지운다
664const ENCOUNTER_FILE = '.claude/mod-hud-encounter'
665// 즐겨찾는 박스 key. 기록과 따로 두어 어느 세션에서 눌러도 바로 쓰고, 다른 세션은 다음 조회 때 읽는다
666const FAVORITES_FILE = '.claude/mod-hud-favorites.json'
667// 한 번 펼쳐 본 박스 key. 펼쳐 본 포켓몬은 NEW 가 사라진다. 다른 세션도 다음 조회 때 읽는다
668const SEEN_FILE = '.claude/mod-hud-seen.json'
669// 박스에 들어온 지 이만큼 안 된 포켓몬은 펼쳐 보기 전까지 이름 뒤에 NEW 가 붙는다. 길면 박스가 온통 NEW 라 한 시간으로 둔다
670const NEW_MS = 60 * 60 * 1000
671// 세션이 닫힌 뒤 머지를 기다리는 알의 MR 을 다시 조회하는 간격
672const PENDING_CHECK_MS = 2 * 60 * 1000
673
674const pokedex = atom({ plugin: 'mod-hud', key: 'pokedex' } as const, emptyPokedex())
675const eggs = atom({ plugin: 'mod-hud', key: 'eggs' } as const, [])
676
677let pendingCheckedAt = 0
678// 즐겨찾는 포켓몬은 PC BOX 맨 앞에 모이고 이름 앞에 ♥ 가 붙는다
679let favorites = new Set<string>()
680let seen = new Set<string>()
681// 이 패널에서 박스로 보낸 알. 기록을 쓰는 세션이 반영할 때까지 기록을 다시 읽어도 패널에 되살리지 않는다
682const sending = new Set<string>()
683
684async function readPokedex($: EngineInterface, home: string): Promise<Pokedex> {
685 try {
686 return parsePokedex(await $.fs.read(`${home}/${POKEDEX_FILE}`))
687 } catch {
688 return emptyPokedex()
689 }
690}
691
692// 닫힌 세션의 브랜치는 그 세션의 저장소 remote 와 토큰으로 MR 을 다시 조회한다
693async function refreshPending($: EngineInterface, dex: Pokedex): Promise<Pokedex> {
694 let next = dex
695 for (const egg of dex.pending) {
696 if (egg.mrState !== 'opened' || !egg.branch) {
697 continue
698 }
699 try {
700 const remote = (await git($, egg.root, ['remote', 'get-url', 'origin']))?.trim()
701 const api = remote ? gitlabProjectApi(remote, config.gitlabUrl) : null
702 const token = api ? await readToken($, egg.root) : null
703 const mr =
704 api && token
705 ? await fetchMr($, api, token, egg.branch)
706 : remote && isGithubRemote(remote)
707 ? await fetchPr($, egg.root, egg.branch)
708 : null
709 if (mr && mr.state !== egg.mrState) {
710 next = updatePending(next, egg.key, mr.state)
711 }
712 } catch {
713 // 조회하지 못하면 다음 주기에 다시 본다
714 }
715 }
716 return next
717}
718
719async function sendToBox($: EngineInterface, key: string) {
720 const home = await $.env.get('HOME')
721 if (!home) {
722 return
723 }
724 const dir = `${home}/${SENDBOX_DIR}`
725 await $.process.run(['mkdir', '-p', dir])
726 await $.fs.write(`${dir}/${key}`, '')
727 // 기록을 쓰는 세션이 다음 조회 때 넣으므로, 그동안 이 패널에서는 바로 빼 두고 토스트도 여기서 띄운다
728 sending.add(key)
729 const egg = (await read($, eggs)).find(egg => egg.key === key)
730 await update($, eggs, prev => prev.filter(egg => egg.key !== key))
731 if (egg) {
732 $.ui.toast(`${await pokemonName($, home, egg.species)} was sent to the PC BOX (${egg.label})`)
733 }
734 void refreshSessions($)
735}
736
737async function readKeys($: EngineInterface, home: string, file: string): Promise<Set<string>> {
738 try {
739 const path = `${home}/${file}`
740 return new Set((await $.fs.exists(path)) ? parseKeys(await $.fs.read(path)) : [])
741 } catch {
742 return new Set()
743 }
744}
745
746async function toggleFavorite($: EngineInterface, key: string) {
747 const home = await $.env.get('HOME')
748 if (!home) {
749 return
750 }
751 // 다른 세션에서 바꾼 것을 덮지 않게 파일을 다시 읽고 고친다
752 const next = await readKeys($, home, FAVORITES_FILE)
753 if (next.has(key)) {
754 next.delete(key)
755 } else {
756 next.add(key)
757 }
758 await $.fs.write(`${home}/${FAVORITES_FILE}`, JSON.stringify([...next]))
759 favorites = next
760 $.ui.invalidate('ui.render')
761}
762
763// 펼쳐 본 포켓몬은 NEW 를 지운다. 박스에 없는 key 는 버려 파일이 자라지 않게 한다
764async function markSeen($: EngineInterface, key: string, box: readonly BoxEntry[]) {
765 const home = await $.env.get('HOME')
766 if (!home) {
767 return
768 }
769 const inBox = new Set(box.map(entry => entry.key))
770 const next = new Set([...(await readKeys($, home, SEEN_FILE)), key].filter(one => inBox.has(one)))
771 await $.fs.write(`${home}/${SEEN_FILE}`, JSON.stringify([...next]))
772 seen = next
773 $.ui.invalidate('ui.render')
774}
775
776async function pickLead($: EngineInterface, key: string) {
777 const home = await $.env.get('HOME')
778 if (!home) {
779 return
780 }
781 await $.fs.write(`${home}/${LEAD_FILE}`, key)
782 void refreshSessions($)
783}
784
785async function answerEncounter($: EngineInterface, action: EncounterAction) {
786 const home = await $.env.get('HOME')
787 if (!home) {
788 return
789 }
790 if (action === 'throw' && wild && thrown !== wild.species) {
791 thrown = wild.species
792 arrivalShown = { kind: 'catch', species: wild.species, at: await $.clock.now(), name: wild.name }
793 wild = null
794 $.ui.invalidate('ui.render')
795 }
796 await $.fs.write(`${home}/${ENCOUNTER_FILE}`, action)
797 void refreshSessions($)
798}
799
800async function readEncounterRequest($: EngineInterface, home: string): Promise<EncounterAction | null> {
801 try {
802 const path = `${home}/${ENCOUNTER_FILE}`
803 const text = (await $.fs.exists(path)) ? (await $.fs.read(path)).trim() : ''
804 return text === 'throw' || text === 'run' || text === 'dismiss' ? text : null
805 } catch {
806 return null
807 }
808}
809
810async function readLeadRequest($: EngineInterface, home: string): Promise<string | null> {
811 try {
812 const path = `${home}/${LEAD_FILE}`
813 return (await $.fs.exists(path)) ? (await $.fs.read(path)).trim() || null : null
814 } catch {
815 return null
816 }
817}
818
819async function readSendRequests($: EngineInterface, home: string): Promise<string[]> {
820 const dir = `${home}/${SENDBOX_DIR}`
821 try {
822 return (await $.fs.exists(dir)) ? (await $.fs.list(dir)).map(entry => entry.name) : []
823 } catch {
824 return []
825 }
826}
827
828// 세션 목록을 읽을 때마다 기록을 맞춘다. 기록을 쓰는 세션만 파일을 바꾸고 토스트를 띄운다
829async function keepPokedex($: EngineInterface, sessions: SessionStatus[], now: number) {
830 const home = await $.env.get('HOME')
831 if (!home) {
832 return
833 }
834 let dex = await readPokedex($, home)
835 if (keeperOf(sessions, now) === (await $.session.id())) {
836 const before = JSON.stringify(dex)
837 if (now - pendingCheckedAt >= PENDING_CHECK_MS) {
838 pendingCheckedAt = now
839 dex = await refreshPending($, dex)
840 }
841 const requests = await readSendRequests($, home)
842 const advanced = advancePokedex(dex, sessions, now, requests)
843 const pick = await readLeadRequest($, home)
844 const answer = await readEncounterRequest($, home)
845 const grown = advanceEvolution(advanced.dex, sessions, now, pick, answer)
846 dex = grown.dex
847 if (JSON.stringify(dex) !== before) {
848 await $.fs.write(`${home}/${POKEDEX_FILE}`, JSON.stringify(dex))
849 }
850 // 부화하지 않은 알의 요청은 버튼이 없어 생기지 않으니, 읽은 요청은 모두 지운다
851 for (const name of requests) {
852 await $.process.run(['rm', '-f', `${home}/${SENDBOX_DIR}/${name}`])
853 }
854 if (pick !== null) {
855 await $.process.run(['rm', '-f', `${home}/${LEAD_FILE}`])
856 }
857 if (answer !== null) {
858 await $.process.run(['rm', '-f', `${home}/${ENCOUNTER_FILE}`])
859 }
860 for (const egg of advanced.born) {
861 $.ui.toast(`Your ${egg.label} egg hatched into ${await pokemonName($, home, egg.species)}!`)
862 }
863 // 이 패널에서 보낸 알은 누를 때 이미 토스트를 띄웠다
864 for (const egg of advanced.boxed.filter(egg => !sending.has(egg.key))) {
865 $.ui.toast(`${await pokemonName($, home, egg.species)} was sent to the PC BOX (${egg.label})`)
866 }
867 for (const egg of advanced.left) {
868 $.ui.toast(`Left the ${egg.label} egg at the DAY CARE`)
869 }
870 for (const egg of advanced.given) {
871 $.ui.toast(`Got an egg from the DAY CARE for ${egg.label}`)
872 }
873 for (const { entry, from } of grown.evolved) {
874 // 진화했다는 말은 패널의 진화 장면이 끝날 때 한다
875 $.ui.toast(`What? ${(await pokemonName($, home, from)).toUpperCase()} is evolving!`)
876 }
877 if (grown.dex.encounter && grown.dex.encounter.at === now) {
878 $.ui.toast(`A wild ${(await pokemonName($, home, grown.dex.encounter.species)).toUpperCase()} appeared!`)
879 }
880 for (const entry of grown.legends) {
881 $.ui.toast(`Gotcha! ${(await pokemonName($, home, entry.species)).toUpperCase()} was caught!`)
882 }
883 for (const species of grown.fled) {
884 $.ui.toast(`Oh no! ${(await pokemonName($, home, species)).toUpperCase()} fled...`)
885 }
886 }
887 favorites = await readKeys($, home, FAVORITES_FILE)
888 seen = await readKeys($, home, SEEN_FILE)
889 await update($, pokedex, () => dex)
890 const all = eggsOf(sessions, dex, now)
891 // 기록에서 더 이상 부화한 알이 아니면 박스에 들어간 것이라 잊는다
892 for (const key of sending) {
893 if (!all.some(egg => egg.key === key && egg.stage === 'hatched')) {
894 sending.delete(key)
895 }
896 }
897 const next = all.filter(egg => !sending.has(egg.key))
898 // 30초마다 부르므로 그대로면 쓰지 않아 패널을 괜히 다시 그리지 않는다
899 await update($, eggs, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
900}
901
902// 포켓몬 그림과 이름은 PokeAPI 에서 처음 쓸 때 한 번 받아 이 폴더에 두고 다시 쓴다
903const SPRITE_DIR = '.claude/mod-hud-sprites'
904// 7세대 박스 아이콘. 처음부터 작게 그린 도트라 줄이지 않아도 돼 뭉개지지 않는다
905const spriteUrl = (species: number) =>
906 `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/versions/generation-vii/icons/${species}.png`
907const speciesUrl = (species: number) => `https://pokeapi.co/api/v2/pokemon-species/${species}`
908
909// 읽은 그림은 투명한 테두리를 잘라 둔다. null 은 받은 파일이 깨져 이 세션에서는 다시 읽지 않는 것
910const sprites = new Map<number, Pixels | null>()
911const fitted = new Map<string, Pixels>()
912const spritesLoading = new Set<number>()
913
914const lookupSprite: SpriteLookup = (species, size) => {
915 const key = `${species}:${size}`
916 const cached = fitted.get(key)
917 if (cached) {
918 return cached
919 }
920 const source = sprites.get(species)
921 if (!source) {
922 return undefined
923 }
924 const made = fit(source, size)
925 fitted.set(key, made)
926 return made
927}
928
929// 금 간 알과 박스에 보이는 포켓몬만 부른다. 받지 못하면 그동안 몬스터볼을 그리고 다음 그릴 때 다시 받는다
930async function loadSprite($: EngineInterface, species: number) {
931 if (sprites.has(species) || spritesLoading.has(species)) {
932 return
933 }
934 spritesLoading.add(species)
935 try {
936 const home = await $.env.get('HOME')
937 if (!home) {
938 return
939 }
940 const path = `${home}/${SPRITE_DIR}/icons/${species}.png`
941 if (!(await $.fs.exists(path))) {
942 const got = await $.process.run(['curl', '-fsSL', '--create-dirs', '-o', path, spriteUrl(species)])
943 if (got.exitCode !== 0) {
944 return
945 }
946 }
947 const { base64 } = await $.fs.read(path, { as: 'bytes' })
948 // 엔진의 Uint8Array 는 fromBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
949 const bytes = (Uint8Array as unknown as { fromBase64(text: string): Uint8Array }).fromBase64(base64)
950 try {
951 sprites.set(species, crop(decodePng(bytes)))
952 } catch {
953 // 받다 끊긴 파일은 지워 다음 세션에서 다시 받게 한다
954 sprites.set(species, null)
955 await $.process.run(['rm', '-f', path])
956 }
957 } catch {
958 // 네트워크나 파일을 못 쓰면 몬스터볼로 둔다
959 } finally {
960 spritesLoading.delete(species)
961 }
962}
963
964// 펼친 박스 줄에 그릴 도트. 아직 없으면 받아 온 뒤 다시 그린다
965function loadOpened($: EngineInterface, species: number) {
966 if (sprites.has(species)) {
967 return
968 }
969 void loadSprite($, species).then(() => {
970 if (sprites.get(species)) {
971 $.ui.invalidate('ui.render')
972 }
973 })
974}
975
976// 설정한 언어의 이름. 파일에서 한 번 읽어 메모리에 두고, 모르는 이름은 PokeAPI 에서 받아 파일에 더한다. 언어마다 파일을 따로 둔다
977const namesPath = (home: string) => `${home}/${SPRITE_DIR}/names-${config.pokemonLanguage}.json`
978const names = new Map<number, string>()
979// 이 세션에서 받으려다 실패한 종. 패널을 그릴 때마다 네트워크를 다시 부르지 않는다
980const namesFailed = new Set<number>()
981const namesLoading = new Map<number, Promise<string | null>>()
982let namesRead: Promise<void> | null = null
983
984function readNames($: EngineInterface, home: string): Promise<void> {
985 namesRead ??= (async () => {
986 try {
987 const saved = JSON.parse(await $.fs.read(namesPath(home))) as Record<string, string>
988 for (const [species, name] of Object.entries(saved)) {
989 names.set(Number(species), name)
990 }
991 } catch {
992 // 처음이면 파일이 없다
993 }
994 })()
995 return namesRead
996}
997
998function fetchName($: EngineInterface, home: string, species: number): Promise<string | null> {
999 const loading = namesLoading.get(species)
1000 if (loading) {
1001 return loading
1002 }
1003 const made = (async () => {
1004 try {
1005 const res = await $.http.fetch(speciesUrl(species))
1006 const parsed = res.ok ? (JSON.parse(res.text) as { names?: { name: string; language: { name: string } }[] }) : null
1007 const name = parsed?.names?.find(one => one.language.name === config.pokemonLanguage)?.name
1008 if (!name) {
1009 namesFailed.add(species)
1010 return null
1011 }
1012 names.set(species, name)
1013 await $.fs.write(namesPath(home), JSON.stringify(Object.fromEntries(names)))
1014 return name
1015 } catch {
1016 namesFailed.add(species)
1017 return null
1018 } finally {
1019 namesLoading.delete(species)
1020 }
1021 })()
1022 namesLoading.set(species, made)
1023 return made
1024}
1025
1026// 토스트에 쓰는 이름. 받을 때까지 기다리고, 못 받으면 도감 번호로 부른다
1027async function pokemonName($: EngineInterface, home: string, species: number): Promise<string> {
1028 await readNames($, home)
1029 const known = names.get(species) ?? (namesFailed.has(species) ? null : await fetchName($, home, species))
1030 return known ?? `No.${species}`
1031}
1032
1033// 패널에 쓰는 이름. 기다리지 않고 지금 아는 이름을 쓰며, 모르면 도감 번호로 그리고 받은 뒤 다시 그린다
1034async function shownName($: EngineInterface, home: string, species: number): Promise<string> {
1035 await readNames($, home)
1036 const known = names.get(species)
1037 if (known) {
1038 return known
1039 }
1040 if (!namesFailed.has(species)) {
1041 void fetchName($, home, species).then(name => {
1042 if (name) {
1043 $.ui.invalidate('ui.render')
1044 }
1045 })
1046 }
1047 return `No.${species}`
1048}
1049
1050// 패널이 그린 알과 박스의 크기와 내용. 패널이 그릴 때마다 바꾸고, 칠할 수 없으면 비운다
1051let nestScene: { columns: number; rows: number; input: NestInput } | null = null
1052// 보고 있는 박스 페이지. 0부터 센다
1053let boxPage = 0
1054// 이 패널이 마지막으로 본 진화의 시각(기록에 적힌 것)과 그 진화를 처음 본 이 세션의 시각
1055// 진화는 기록을 쓰는 세션이 적고 다른 세션은 30초마다 읽으므로, 깜빡임은 이 세션이 처음 본 때부터 센다
1056let evolvingSeen = 0
1057let evolvingSeenAt = 0
1058// 이 진화를 처음 본 이 세션의 시각. 그림을 기다리느라 evolvingSeenAt 이 밀려도 그대로다
1059let evolvingFirstAt = 0
1060// 이보다 오래된 진화는 처음 봐도 깜빡이지 않는다. 다른 세션이 기록을 읽는 간격보다 넉넉하다
1061const EVOLVING_FRESH_MS = 60 * 1000
1062// 진화 전과 뒤 그림을 받을 때까지 진화 장면을 미룬다. 이만큼 기다려도 못 받으면 그림 없이 지나간다
1063const EVOLVE_WAIT_MS = 30 * 1000
1064// 진화 장면 아래 글 줄에 쓸 진화 전과 뒤 이름
1065let evolvingNames: { from: string; to: string } | null = null
1066// 마지막으로 그린 진화 장면 글. 바뀌면 다시 그린다
1067let evolveCaptionDrawn: string | null = null
1068// 이 패널이 마지막으로 본 전설 등장(기록에 적힌 시각)과, 그 등장 장면을 처음 보인 이 세션의 시각
1069let arrivalSeen = 0
1070// 보일 전설 장면. appear 는 줄무늬와 함께 나타나는 장면, catch 는 몬스터볼에 잡히는 장면. at 은 이 세션에서 장면을 시작할 시각
1071let arrivalShown: { kind: 'appear' | 'catch'; species: number; at: number; name: string } | null = null
1072// 장면을 겹쳐 띄운 Raster 가 지금 그려져 있는지. 시작할 때와 끝날 때 다시 그린다
1073let arrivalMounted = false
1074// 장면마다 drawArrival 의 걸음 범위
1075const ARRIVAL_PART = { appear: { from: 0, length: 16 }, catch: { from: 16, length: ARRIVAL_FRAMES - 16 } } as const
1076const ARRIVAL_COLUMNS = 60
1077// 지금 나타나 있는 야생 전설과 놓친 전설의 이름
1078let wild: { species: number; name: string } | null = null
1079// 이 패널에서 몬스터볼을 던진 전설. 기록을 쓰는 세션은 30초마다 요청을 읽으므로, 그 전에 잡는 장면을 먼저 보이고 버튼을 걷는다
1080// 기록에 그 전설의 잡는 장면이 적히면 다시 보이지 않는다
1081let thrown: number | null = null
1082let gone: string | null = null
1083
1084// 지금 보일 등장 장면의 걸음. 끝났으면 null
1085function arrivalStep(now: number): number | null {
1086 if (!arrivalShown || now < arrivalShown.at) {
1087 return null
1088 }
1089 const part = ARRIVAL_PART[arrivalShown.kind]
1090 const step = Math.floor((now - arrivalShown.at) / NEST_TICK_MS)
1091 return step < part.length ? part.from + step : null
1092}
1093
1094function arrivalCells(columns: number, now: number): string {
1095 const shown = arrivalShown!
1096 return base64Of(drawArrival(shown.species, shown.name, Math.min(columns, ARRIVAL_COLUMNS), arrivalStep(now) ?? 0, lookupSprite))
1097}
1098// PC BOX 와 DAY CARE 머리줄의 글자. 박스 수와, 키우미집에 맡겨 키우는 포켓몬의 경험치. ★ 와 막대는 Raster 가 못 그려 Text 로 그린다
1099let boxHeader: { count: number; lead: string | null } = { count: 0, lead: null }
1100// PC BOX 를 접었는지. 접으면 머리줄만 보인다
1101let boxCollapsed = false
1102// 마지막으로 그린 키우미집 풀밭 줄 수. 칠할 때 줄 수가 바뀌었으면 다시 그린다
1103let daycareRowsDrawn = 0
1104// 키우미집을 접었는지. 접으면 머리줄의 맡긴 포켓몬 경험치와 남은 알 수만 보인다
1105let daycareCollapsed = false
1106// 이번 박스 페이지의 줄마다 보일 이름과 속성. 박스 key 로 찾는다
1107let boxRows = new Map<
1108 string,
1109 { name: string; types: string; caught: string; canLead: boolean; rarity: ReturnType<typeof rarityOf>; isFavorite: boolean; isNew: boolean }
1110>()
1111// 눌러 펼친 박스 줄의 key. 펼친 줄 아래에 잡은 날짜와 어디서 왔는지, 키우미집 버튼이 보인다
1112let boxOpen: string | null = null
1113let nestFrame = 0
1114// 그림이 보일 때만 돈다. 패널이 닫히거나 입력창 위로 옮겨 칠할 곳이 없으면 멈춘다
1115let nestTimer: ReturnType<EngineInterface['clock']['every']> | null = null
1116// 그린 직후에는 Raster 가 아직 화면에 붙지 않아 칠하기를 거절할 수 있어, 몇 번 거절이 이어질 때만 멈춘다
1117let nestDenied = 0
1118const NEST_DENY_LIMIT = 8
1119
1120function base64Of(cells: Uint32Array): string {
1121 // 엔진의 Uint8Array 는 toBase64 를 갖지만 TypeScript 의 lib 에는 아직 없다
1122 const bytes = new Uint8Array(cells.buffer) as Uint8Array & { toBase64(): string }
1123 return bytes.toBase64()
1124}
1125
1126function nestCells(scene: { columns: number; input: NestInput }, now: number): string {
1127 return base64Of(drawNest(scene.input, scene.columns, nestFrame, now, lookupSprite))
1128}
1129
1130// 막 진화해 진화 장면을 보이는 중인 포켓몬. 최종 진화형이 되어 맡긴 것이 풀렸어도 장면이 끝날 때까지 풀밭에 선다
1131function evolvingOf(scene: { input: NestInput }, now: number): BoxEntry | undefined {
1132 const evolving = scene.input.evolving
1133 return evolving && now - evolving.at < EVOLVE_MS ? scene.input.box.find(entry => entry.key === evolving.key) : undefined
1134}
1135
1136// 키우미집에 맡긴 포켓몬. 풀밭을 걸어다닌다
1137function walkerOf(scene: { input: NestInput }, now: number): BoxEntry | undefined {
1138 return evolvingOf(scene, now) ?? scene.input.box.find(entry => entry.key === scene.input.lead)
1139}
1140
1141function daycareCells(scene: { columns: number; input: NestInput }, now: number): string {
1142 const lead = walkerOf(scene, now)
1143 // 맡긴 포켓몬이 있으면 그림을 아직 못 불러왔어도 풀밭 높이는 미리 맞춘다
1144 const walker = lead ? (lookupSprite(lead.species, WALKER_SIZE) ?? null) : undefined
1145 const evolving = evolvingOf(scene, now) && scene.input.evolving
1146 const before = evolving ? (lookupSprite(evolving.from, WALKER_SIZE) ?? null) : null
1147 const elapsed = evolving ? now - evolving.at : 0
1148 return base64Of(drawDaycareGround(scene.input.daycare, scene.columns, nestFrame, walker, before, elapsed))
1149}
1150
1151// 진화 장면 아래 글 줄. 원작처럼 진화하는 동안과 끝난 뒤 글이 다르다
1152function evolveCaption(scene: { input: NestInput }, now: number): string | null {
1153 const evolving = evolvingOf(scene, now) && scene.input.evolving
1154 if (!evolving || !evolvingNames) {
1155 return null
1156 }
1157 const { from, to } = evolvingNames
1158 return evolvePhase(now - evolving.at) === 'done'
1159 ? `Congratulations! Your ${from} evolved into ${to}!`
1160 : `What? ${from} is evolving!`
1161}
1162
1163// 진화 전과 뒤 그림을 아직 못 받았으면 장면의 시작을 지금으로 미룬다. 처음 본 뒤 EVOLVE_WAIT_MS 가 지나면 그만 미룬다
1164function holdEvolution(scene: { input: NestInput }, now: number) {
1165 const evolving = scene.input.evolving
1166 const entry = evolvingOf(scene, now)
1167 if (!evolving || !entry || now - evolvingFirstAt >= EVOLVE_WAIT_MS) {
1168 return
1169 }
1170 if (!lookupSprite(evolving.from, WALKER_SIZE) || !lookupSprite(entry.species, WALKER_SIZE)) {
1171 evolving.at = now
1172 evolvingSeenAt = now
1173 }
1174}
1175
1176function startNest($: EngineInterface) {
1177 nestDenied = 0
1178 nestTimer ??= $.clock.every(NEST_TICK_MS, () => void tickNest($))
1179}
1180
1181function stopNest() {
1182 nestScene = null
1183 nestTimer?.cancel()
1184 nestTimer = null
1185}
1186
1187// 박스 페이지를 앞뒤로 넘긴다. 끝에서 넘기면 처음으로 돌아간다
1188function turnBox($: EngineInterface, delta: number) {
1189 if (!nestScene) {
1190 return
1191 }
1192 const pages = boxPages(nestScene.input)
1193 boxPage = (boxPageOf(nestScene.input) + delta + pages) % pages
1194 $.ui.invalidate('ui.render')
1195}
1196
1197async function tickNest($: EngineInterface) {
1198 if (!nestScene) {
1199 stopNest()
1200 returnhooks/inbox-lib.ts 243 lines1import type { InboxCache, InboxItem } from '../types'
2
3// 이만큼 지나지 않은 조회 결과는 어느 세션이든 파일에서 그대로 쓴다
4export const INBOX_TTL_MS = 15 * 60 * 1000
5// 패널을 열거나 새로고침을 눌러도 이보다 짧은 간격으로는 다시 부르지 않는다
6export const FORCE_MIN_MS = 30 * 1000
7// 조회를 맡은 세션이 이 안에 끝내지 못하면(꺼졌거나 멈췄으면) 다른 세션이 넘겨받는다
8export const LEASE_MS = 2 * 60 * 1000
9
10export const emptyCache = (): InboxCache => ({
11 fetchedAt: null,
12 okAt: null,
13 notifications: [],
14 seenIds: [],
15 lease: null,
16})
17
18// 공유 캐시 파일이 깨졌거나 예전 모양이어도 빈 값으로 읽는다
19export const parseCache = (text: string): InboxCache => {
20 try {
21 const raw = JSON.parse(text) as Partial<InboxCache> | null
22 if (!raw || typeof raw !== 'object') {
23 return emptyCache()
24 }
25 return {
26 fetchedAt: typeof raw.fetchedAt === 'number' ? raw.fetchedAt : null,
27 okAt: typeof raw.okAt === 'number' ? raw.okAt : null,
28 notifications: Array.isArray(raw.notifications) ? raw.notifications : [],
29 seenIds: Array.isArray(raw.seenIds) ? raw.seenIds.filter(id => typeof id === 'string') : [],
30 lease:
31 raw.lease && typeof raw.lease.by === 'string' && typeof raw.lease.at === 'number'
32 ? { by: raw.lease.by, at: raw.lease.at }
33 : null,
34 }
35 } catch {
36 return emptyCache()
37 }
38}
39
40// https://linear.app/<workspace>/issue/ABC-615/... 에서 이슈 번호를 뽑는다
41export const issueFromUrl = (url: string | null): string | null =>
42 url?.match(/\/issue\/([A-Za-z]+-\d+)/)?.[1]?.toUpperCase() ?? null
43
44type RawNotification = {
45 id?: unknown
46 type?: unknown
47 category?: unknown
48 title?: unknown
49 subtitle?: unknown
50 url?: unknown
51 createdAt?: unknown
52 archivedAt?: unknown
53 snoozedUntilAt?: unknown
54}
55
56const str = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null)
57
58// 패널에 한 줄로 보이게 비어 있지 않은 첫 줄만 남긴다
59const oneLine = (value: unknown): string | null =>
60 str(value)
61 ?.split('\n')
62 .map(line => line.trim())
63 .find(Boolean) ?? null
64
65// get_notifications 의 text 블록을 읽는다. 모양이 다르면 null
66export const parseNotifications = (text: string, now: number): InboxItem[] | null => {
67 let parsed: { notifications?: unknown }
68 try {
69 parsed = JSON.parse(text) as { notifications?: unknown }
70 } catch {
71 return null
72 }
73 if (!parsed || !Array.isArray(parsed.notifications)) {
74 return null
75 }
76 return (parsed.notifications as RawNotification[]).flatMap(raw => {
77 const id = str(raw.id)
78 if (!id || raw.archivedAt) {
79 return []
80 }
81 // 미뤄 둔 알림은 그 시각이 지나야 보인다
82 const snoozedUntil = str(raw.snoozedUntilAt)
83 if (snoozedUntil && Date.parse(snoozedUntil) > now) {
84 return []
85 }
86 const url = str(raw.url)
87 const createdAt = Date.parse(str(raw.createdAt) ?? '')
88 return [
89 {
90 id,
91 type: str(raw.type) ?? 'unknown',
92 category: str(raw.category),
93 title: oneLine(raw.title) ?? '',
94 subtitle: oneLine(raw.subtitle),
95 url,
96 issue: issueFromUrl(url),
97 createdAt: Number.isNaN(createdAt) ? 0 : createdAt,
98 },
99 ]
100 })
101}
102
103// 사람이 답해야 할 것이 위로 오게 둔 순서. 여기 없는 카테고리는 그 아래에 둔다
104const CATEGORY_ORDER = [
105 'mentions',
106 'commentsAndReplies',
107 'assignments',
108 'reviews',
109 'statusChanges',
110 'reminders',
111 'triage',
112 'subscriptions',
113 'reactions',
114 'documentChanges',
115]
116
117const CATEGORY_LABEL: Record<string, string> = {
118 mentions: 'mention',
119 commentsAndReplies: 'comment',
120 assignments: 'assigned',
121 reviews: 'review',
122 statusChanges: 'status',
123 reminders: 'reminder',
124 triage: 'triage',
125 subscriptions: 'subscribed',
126 reactions: 'reaction',
127 documentChanges: 'document',
128}
129
130// category 가 비어 오면 type 이름으로 짐작한다
131export const categoryOf = (item: Pick<InboxItem, 'type' | 'category'>): string => {
132 if (item.category) {
133 return item.category
134 }
135 const type = item.type
136 if (/Mention/i.test(type)) {
137 return 'mentions'
138 }
139 if (/Comment|Reply/i.test(type)) {
140 return 'commentsAndReplies'
141 }
142 if (/Assigned/i.test(type)) {
143 return 'assignments'
144 }
145 if (/Status|Blocked|Unblocked/i.test(type)) {
146 return 'statusChanges'
147 }
148 if (/Reaction/i.test(type)) {
149 return 'reactions'
150 }
151 if (/Document|Description/i.test(type)) {
152 return 'documentChanges'
153 }
154 return type
155}
156
157// 나를 태그했거나 내 댓글에 답한 알림만 남긴다. GitLab 연동이 바꾼 상태나 배정 같은 알림은 보이지 않는다
158const FOR_ME = new Set(['mentions', 'commentsAndReplies'])
159
160export const onlyForMe = (items: readonly InboxItem[]): InboxItem[] =>
161 items.filter(item => FOR_ME.has(categoryOf(item)))
162
163// 모르는 카테고리는 이름 그대로 보인다
164export const categoryLabel = (item: Pick<InboxItem, 'type' | 'category'>): string => {
165 const category = categoryOf(item)
166 return CATEGORY_LABEL[category] ?? category
167}
168
169const rank = (item: InboxItem): number => {
170 const index = CATEGORY_ORDER.indexOf(categoryOf(item))
171 return index === -1 ? CATEGORY_ORDER.length : index
172}
173
174// 카테고리 순서대로, 같은 카테고리 안에서는 최신을 위로
175export const sortInbox = (items: readonly InboxItem[]): InboxItem[] =>
176 [...items].sort((a, b) => rank(a) - rank(b) || b.createdAt - a.createdAt)
177
178// 지금 조회해야 하는지. 다른 세션이 조회 중이면 그 결과를 기다린다. 이슈 조회도 같은 규칙을 주기만 바꿔 쓴다
179export const shouldFetch = (
180 cache: Pick<InboxCache, 'fetchedAt' | 'lease'>,
181 now: number,
182 me: string,
183 isForced: boolean,
184 ttlMs: number = INBOX_TTL_MS,
185): boolean => {
186 if (cache.lease && cache.lease.by !== me && now - cache.lease.at < LEASE_MS) {
187 return false
188 }
189 const age = cache.fetchedAt === null ? Infinity : now - cache.fetchedAt
190 return age >= (isForced ? FORCE_MIN_MS : ttlMs)
191}
192
193// 조회 결과를 캐시에 반영하고, 토스트할 새 알림을 돌려준다. fetched 가 null 이면 조회 실패
194export const applyFetch = (
195 prev: InboxCache,
196 fetched: InboxItem[] | null,
197 now: number,
198): { cache: InboxCache; fresh: InboxItem[] } => {
199 if (fetched === null) {
200 // 실패해도 fetchedAt 을 남겨 모든 세션이 15분 동안 다시 부르지 않게 한다. 본 목록은 그대로 둔다
201 return { cache: { ...prev, fetchedAt: now, notifications: [], lease: null }, fresh: [] }
202 }
203 const seen = new Set(prev.seenIds)
204 // 처음 성공한 조회에서는 이미 쌓여 있던 알림을 새 알림으로 치지 않는다
205 const fresh = prev.okAt === null ? [] : fetched.filter(item => !seen.has(item.id))
206 return {
207 cache: {
208 fetchedAt: now,
209 okAt: now,
210 notifications: fetched,
211 // 지금 목록에 있는 것만 남겨 파일이 끝없이 자라지 않게 한다
212 seenIds: fetched.map(item => item.id),
213 lease: null,
214 },
215 fresh,
216 }
217}
218
219// 알림 줄 끝에 붙는 시각. 제목 옆 좁은 칸에서 꺾이지 않게 3h 처럼 짧게 쓴다
220export const formatAgoShort = (ms: number): string => {
221 const minutes = Math.floor(ms / 60_000)
222 if (minutes < 1) {
223 return 'now'
224 }
225 if (minutes < 60) {
226 return `${minutes}m`
227 }
228 const hours = Math.floor(minutes / 60)
229 return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`
230}
231
232const TOAST_TITLE_MAX = 40
233
234export const toastText = (fresh: readonly InboxItem[]): string => {
235 const [top] = sortInbox(fresh)
236 if (!top) {
237 return ''
238 }
239 const title = top.title.length > TOAST_TITLE_MAX ? `${top.title.slice(0, TOAST_TITLE_MAX)}…` : top.title
240 const head = `Linear ${top.issue ? `${top.issue} ` : ''}${categoryLabel(top)}: ${title}`
241 return fresh.length > 1 ? `${head} and ${fresh.length - 1} more` : head
242}
243hooks/issues-lib.ts 219 lines1import type { IssuesCache, Mr, Row, TreeSession, Tree } from '../types'
2
3// 이 시간 안에 heartbeat 가 없으면 세션이 닫힌 것으로 본다
4export const LIVE_MS = 3 * 60 * 1000
5
6// feat/#ABC-611-xxx, bugfix/#RF-92-xxx, feat/#867-xxx, chore/ABC-523-xxx 에서 이슈 번호를 뽑는다
7// # 이 없으면 대문자 키만 이슈로 본다. feat/add-login-2 같은 브랜치를 이슈로 잡지 않게 한다
8export const parseIssue = (branch: string): string | null =>
9 (branch.match(/#([A-Za-z]+-\d+|\d+)/)?.[1] ?? branch.match(/(?:^|[/_])([A-Z][A-Z0-9]*-\d+)(?![A-Za-z0-9])/)?.[1])?.toUpperCase() ??
10 null
11
12// origin remote 주소로 GitLab API 의 프로젝트 주소를 만든다. override 는 API 호스트가 remote 와 다를 때(ssh remote 등) 쓴다
13// GitHub 은 MR 이 없으므로 null
14export const gitlabProjectApi = (remote: string, override = ''): string | null => {
15 const url = remote.trim()
16 const parsed =
17 url.match(/^https?:\/\/(?:[^@/]+@)?([^/]+)\/(.+?)(?:\.git)?\/?$/) ??
18 url.match(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/) ??
19 url.match(/^(?:[^@/]+@)?([^/:]+):(.+?)(?:\.git)?\/?$/)
20 if (!parsed) {
21 return null
22 }
23 const [, host = '', path = ''] = parsed
24 if (/(^|\.)github\.com(:\d+)?$/.test(host)) {
25 return null
26 }
27 const isHttp = /^https?:/.test(url)
28 const base = override.trim().replace(/\/+$/, '') || `${isHttp ? url.slice(0, url.indexOf(':')) : 'https'}://${host}`
29 return `${base}/api/v4/projects/${encodeURIComponent(path)}`
30}
31
32// remote 가 GitHub 저장소인지. GitHub 는 API 대신 gh CLI 로 PR 을 찾는다
33export const isGithubRemote = (remote: string): boolean =>
34 /^(?:https?:\/\/(?:[^@/]+@)?|ssh:\/\/(?:[^@/]+@)?|(?:[^@/]+@)?)github\.com[:/]/.test(remote.trim())
35
36// `gh pr list --json number,state,headRefOid,url,mergeable` 출력의 첫 PR 을 GitLab MR 과 같은 모양으로 바꾼다
37export const parseGithubPr = (stdout: string): Mr | null => {
38 try {
39 const [pr] = JSON.parse(stdout) as Array<{ number: number; state: string; headRefOid: string; url: string; mergeable?: string }>
40 if (!pr) {
41 return null
42 }
43 const state = pr.state === 'OPEN' ? 'opened' : pr.state === 'MERGED' ? 'merged' : 'closed'
44 const action = state !== 'opened' ? null : pr.mergeable === 'CONFLICTING' ? 'conflict' : pr.mergeable === 'MERGEABLE' ? 'mergeable' : null
45 return { iid: pr.number, state, sha: pr.headRefOid, url: pr.url, action }
46 } catch {
47 return null
48 }
49}
50
51// `git symbolic-ref refs/remotes/origin/HEAD` 출력에서 기본 브랜치 이름을 뽑는다
52export const parseDefaultBranch = (symbolicRef: string): string | null =>
53 symbolicRef.trim().match(/^refs\/remotes\/origin\/(.+)$/)?.[1] ?? null
54
55// 세션 줄에 MR 을 찾지 않는 브랜치. 기본 브랜치와 배포 브랜치는 MR 의 소스가 아니다
56export const isLongLivedBranch = (branch: string, base: string): boolean =>
57 branch === base || ['main', 'master', 'develop'].includes(branch) || branch.startsWith('release/')
58
59// 이슈 조회 결과를 저장소마다 따로 둘 파일 이름
60export const issuesFileOf = (root: string): string => `mod-issues-${root.replace(/[^a-zA-Z0-9]/g, '-')}.json`
61
62// `git worktree list --porcelain` 출력을 worktree 목록으로 바꾼다. 첫 블록이 메인 worktree 다
63export const parseWorktrees = (porcelain: string): Tree[] =>
64 porcelain
65 .split('\n\n')
66 .map(block => block.trim())
67 .filter(Boolean)
68 .map((block, index) => {
69 const lines = block.split('\n')
70 const field = (name: string) =>
71 lines.find(line => line.startsWith(`${name} `))?.slice(name.length + 1) ?? null
72
73 return {
74 path: field('worktree') ?? '',
75 head: field('HEAD') ?? '',
76 branch: field('branch')?.replace(/^refs\/heads\//, '') ?? null,
77 isMain: index === 0,
78 }
79 })
80 .filter(tree => tree.path !== '')
81
82export const isLive = (session: TreeSession, now: number): boolean =>
83 session.state !== 'ended' && now - session.updatedAt < LIVE_MS
84
85// 메인 worktree 경로가 다른 worktree 경로의 접두어이므로 가장 긴 경로에 붙인다
86export const findTreeFor = (trees: Tree[], cwd: string): Tree | null =>
87 trees
88 .filter(tree => cwd === tree.path || cwd.startsWith(`${tree.path}/`))
89 .sort((a, b) => b.path.length - a.path.length)[0] ?? null
90
91export const assignSessions = (
92 trees: Tree[],
93 sessions: TreeSession[],
94 now: number,
95): Map<string, TreeSession> => {
96 const byTree = new Map<string, TreeSession>()
97 for (const session of sessions) {
98 if (!isLive(session, now)) {
99 continue
100 }
101 const tree = findTreeFor(trees, session.cwd)
102 const prev = tree ? byTree.get(tree.path) : undefined
103 if (tree && (!prev || prev.updatedAt < session.updatedAt)) {
104 byTree.set(tree.path, session)
105 }
106 }
107 return byTree
108}
109
110// 머지된 worktree 에서 이만큼 넘게 입력을 기다린 세션은 정리를 막지 않는다
111export const IDLE_CLEANUP_MS = 24 * 60 * 60 * 1000
112
113export const isIdle = (session: TreeSession, now: number): boolean =>
114 session.state === 'waiting' && session.waitingSince !== undefined && now - session.waitingSince >= IDLE_CLEANUP_MS
115
116// 머지된 MR 의 worktree 를 지워도 되는지 본다. 못 지우면 그 이유를 돌려준다
117// sessions 는 모든 세션의 상태 파일. 한 worktree 에 세션이 여럿이면 모두 오래 대기 중이어야 지운다
118export const cleanupBlocker = (row: Row, sessions: readonly TreeSession[], ownCwd: string, now: number): string | null => {
119 if (row.isMain || row.mr?.state !== 'merged') {
120 return 'not-merged'
121 }
122 const here = sessions.filter(session => isLive(session, now) && findTreeFor([row], session.cwd))
123 if (here.some(session => !isIdle(session, now))) {
124 return 'a session is open here'
125 }
126 // 이 세션의 상태 파일이 없어 위에서 못 걸렀을 때
127 if (here.length === 0 && findTreeFor([row], ownCwd)) {
128 return 'this session runs here'
129 }
130 if (row.dirty > 0) {
131 return `${row.dirty} uncommitted changes`
132 }
133 if (row.head !== row.mr.sha) {
134 return 'has commits not in the MR'
135 }
136 return null
137}
138
139type RawMr = { state: string; detailed_merge_status?: string; has_conflicts?: boolean }
140
141// 열린 MR 중 내가 움직여야 하는 것만 고른다. 충돌은 rebase 를, 머지 가능은 머지 버튼을 누를 차례다
142export const mrAction = (raw: RawMr): Mr['action'] => {
143 if (raw.state !== 'opened') {
144 return null
145 }
146 if (raw.has_conflicts || raw.detailed_merge_status === 'conflict' || raw.detailed_merge_status === 'need_rebase') {
147 return 'conflict'
148 }
149 return raw.detailed_merge_status === 'mergeable' ? 'mergeable' : null
150}
151
152const SCRIPTS = ['build', 'build-lib', 'typecheck', 'test', 'test:e2e', 'lint']
153// 패키지 스크립트가 아니라 도구 이름 그대로 남기는 검사
154const TOOLS = ['vitest', 'jest', 'pytest', 'ruff', 'mypy', 'pyright', 'tsc']
155
156// 상태로 남길 검사를 찾는다. pnpm·npm·yarn 스크립트나 테스트·린트 도구. 없으면 null
157export const findCheck = (command: string): string | null => {
158 const script = command.match(/\b(?:pnpm|npm|yarn)\s+(?:run\s+)?([\w:-]+)/)?.[1]
159 if (script && SCRIPTS.includes(script)) {
160 return script
161 }
162 // 명령을 ;·&&·| 로 나눈 각 조각의 첫 단어를 본다. pnpm exec·uv run·python -m 같은 실행기는 벗긴다
163 const runner = /^(?:(?:pnpm|npx|uv|poetry|pipenv|hatch)\s+(?:exec|run)\s+|npx\s+|python3?\s+-m\s+)/
164 for (const part of command.split(/[;&|]+/)) {
165 const word = part.trim().replace(/^cd\s+\S+\s*/, '').replace(runner, '').split(/\s+/)[0] ?? ''
166 if (TOOLS.includes(word)) {
167 return word
168 }
169 }
170 return null
171}
172
173export const formatAgo = (ms: number): string => {
174 const minutes = Math.floor(ms / 60_000)
175 if (minutes < 1) {
176 return 'just now'
177 }
178 if (minutes < 60) {
179 return `${minutes}m ago`
180 }
181 const hours = Math.floor(minutes / 60)
182 return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
183}
184
185// .env.local 에서 GITLAB_TOKEN 값만 꺼낸다
186export const readEnvValue = (text: string, name: string): string | null => {
187 const line = text.split('\n').find(one => one.trim().startsWith(`${name}=`))
188 const value = line?.slice(line.indexOf('=') + 1).trim().replace(/^['"]|['"]$/g, '')
189 return value ? value : null
190}
191
192// 이슈 조회 주기. 세션이 몇 개든 이 주기에 한 번만 git 과 GitLab 을 부른다
193export const ISSUES_TTL_MS = 2 * 60 * 1000
194
195export const emptyIssuesCache = (): IssuesCache => ({ fetchedAt: null, rows: [], branchMrs: {}, checked: [], log: [], lease: null })
196
197// 깨졌거나 예전 모양인 파일은 빈 값으로 읽는다
198export const parseIssuesCache = (text: string): IssuesCache => {
199 try {
200 const raw = JSON.parse(text) as Partial<IssuesCache> | null
201 if (!raw || typeof raw !== 'object') {
202 return emptyIssuesCache()
203 }
204 return {
205 fetchedAt: typeof raw.fetchedAt === 'number' ? raw.fetchedAt : null,
206 rows: Array.isArray(raw.rows) ? raw.rows : [],
207 branchMrs: raw.branchMrs && typeof raw.branchMrs === 'object' && !Array.isArray(raw.branchMrs) ? raw.branchMrs : {},
208 checked: Array.isArray(raw.checked) ? raw.checked.filter(branch => typeof branch === 'string') : [],
209 log: Array.isArray(raw.log) ? raw.log.filter(line => typeof line === 'string') : [],
210 lease:
211 raw.lease && typeof raw.lease.by === 'string' && typeof raw.lease.at === 'number'
212 ? { by: raw.lease.by, at: raw.lease.at }
213 : null,
214 }
215 } catch {
216 return emptyIssuesCache()
217 }
218}
219hooks/dex-data.ts 77 lines1// 1세대 도감 데이터. PokeAPI 의 pokemon-species 에서 뽑았고 바뀌지 않으니 박아 둔다
2// 진화도 1세대 안에서만 한다. 2세대 이후에 생긴 진화 전 모습(피츄, 먹고자 등)과 진화형(크로뱃 등)은 없는 것으로 친다
3
4// 알에서 나오는 종. 진화 전 모습이고 전설이 아닌 74종
5export const EGG_SPECIES: readonly number[] = [
6 1, 4, 7, 10, 13, 16, 19, 21, 23, 25, 27, 29, 32, 35, 37, 39, 41, 43, 46, 48, 50, 52, 54, 56, 58, 60, 63, 66, 69, 72, 74,
7 77, 79, 81, 83, 84, 86, 88, 90, 92, 95, 96, 98, 100, 102, 104, 106, 107, 108, 109, 111, 113, 114, 115, 116, 118, 120,
8 122, 123, 124, 125, 126, 127, 128, 129, 131, 132, 133, 137, 138, 140, 142, 143, 147,
9]
10
11// 종마다 진화하는 다음 종. 이브이만 셋으로 갈라진다
12export const EVOLUTIONS: Readonly<Record<number, readonly number[]>> = {
13 1: [2], 2: [3], 4: [5], 5: [6], 7: [8], 8: [9], 10: [11], 11: [12], 13: [14], 14: [15], 16: [17], 17: [18], 19: [20],
14 21: [22], 23: [24], 25: [26], 27: [28], 29: [30], 30: [31], 32: [33], 33: [34], 35: [36], 37: [38], 39: [40], 41: [42],
15 43: [44], 44: [45], 46: [47], 48: [49], 50: [51], 52: [53], 54: [55], 56: [57], 58: [59], 60: [61], 61: [62], 63: [64],
16 64: [65], 66: [67], 67: [68], 69: [70], 70: [71], 72: [73], 74: [75], 75: [76], 77: [78], 79: [80], 81: [82], 84: [85],
17 86: [87], 88: [89], 90: [91], 92: [93], 93: [94], 96: [97], 98: [99], 100: [101], 102: [103], 104: [105], 109: [110],
18 111: [112], 116: [117], 118: [119], 120: [121], 129: [130], 133: [134, 135, 136], 138: [139], 140: [141], 147: [148],
19 148: [149],
20}
21
22export const LEGENDS = { articuno: 144, zapdos: 145, moltres: 146, mewtwo: 150, mew: 151 } as const
23
24// 진화로 처음 만들면 그 새가 오는 최종 진화형. 얼음은 프리저, 전기는 썬더, 불꽃은 파이어
25export const LEGEND_FINALS: Readonly<Record<144 | 145 | 146, readonly number[]>> = {
26 144: [87, 91],
27 145: [26, 82, 101, 135],
28 146: [6, 38, 59, 78, 136],
29}
30
31// 원작에서 귀한 포켓몬. 한 번만 받거나, 화석에서 되살리거나, 사파리존이나 게임코너에서만 만나거나, 드물게 나오는 종이다
32const RARE_SPECIES: ReadonlySet<number> = new Set([
33 83, 106, 107, 108, 113, 115, 122, 123, 124, 125, 126, 127, 128, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141,
34 142, 143, 147, 148, 149,
35])
36
37// PC BOX 줄의 이름 뒤에 붙는 표시. 전설은 ✦, 귀한 포켓몬은 ✧ 이다
38export function rarityOf(species: number): 'legend' | 'rare' | null {
39 if ((Object.values(LEGENDS) as number[]).includes(species)) {
40 return 'legend'
41 }
42 return RARE_SPECIES.has(species) ? 'rare' : null
43}
44
45// 도감이 이만큼 차면 뮤츠가 온다
46export const MEWTWO_DEX = 100
47
48export function canEvolve(species: number): boolean {
49 return EVOLUTIONS[species] !== undefined
50}
51
52// 종마다 속성. 도감 번호 순서다. 원작 골드 버전(2세대) 기준이라 코일은 강철이 붙고 삐삐는 페어리가 아닌 노말이다
53const TYPES: readonly string[] = [
54 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'FIRE', 'FIRE', 'FIRE/FLYING', 'WATER', 'WATER', 'WATER', 'BUG',
55 'BUG', 'BUG/FLYING', 'BUG/POISON', 'BUG/POISON', 'BUG/POISON', 'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING',
56 'NORMAL', 'NORMAL', 'NORMAL/FLYING', 'NORMAL/FLYING', 'POISON', 'POISON', 'ELECTRIC', 'ELECTRIC', 'GROUND', 'GROUND',
57 'POISON', 'POISON', 'POISON/GROUND', 'POISON', 'POISON', 'POISON/GROUND', 'NORMAL', 'NORMAL', 'FIRE', 'FIRE',
58 'NORMAL', 'NORMAL', 'POISON/FLYING', 'POISON/FLYING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'BUG/GRASS',
59 'BUG/GRASS', 'BUG/POISON', 'BUG/POISON', 'GROUND', 'GROUND', 'NORMAL', 'NORMAL', 'WATER', 'WATER', 'FIGHTING',
60 'FIGHTING', 'FIRE', 'FIRE', 'WATER', 'WATER', 'WATER/FIGHTING', 'PSYCHIC', 'PSYCHIC', 'PSYCHIC', 'FIGHTING',
61 'FIGHTING', 'FIGHTING', 'GRASS/POISON', 'GRASS/POISON', 'GRASS/POISON', 'WATER/POISON', 'WATER/POISON', 'ROCK/GROUND',
62 'ROCK/GROUND', 'ROCK/GROUND', 'FIRE', 'FIRE', 'WATER/PSYCHIC', 'WATER/PSYCHIC', 'ELECTRIC/STEEL', 'ELECTRIC/STEEL',
63 'NORMAL/FLYING', 'NORMAL/FLYING', 'NORMAL/FLYING', 'WATER', 'WATER/ICE', 'POISON', 'POISON', 'WATER', 'WATER/ICE',
64 'GHOST/POISON', 'GHOST/POISON', 'GHOST/POISON', 'ROCK/GROUND', 'PSYCHIC', 'PSYCHIC', 'WATER', 'WATER', 'ELECTRIC',
65 'ELECTRIC', 'GRASS/PSYCHIC', 'GRASS/PSYCHIC', 'GROUND', 'GROUND', 'FIGHTING', 'FIGHTING', 'NORMAL', 'POISON',
66 'POISON', 'GROUND/ROCK', 'GROUND/ROCK', 'NORMAL', 'GRASS', 'NORMAL', 'WATER', 'WATER', 'WATER', 'WATER', 'WATER',
67 'WATER/PSYCHIC', 'PSYCHIC', 'BUG/FLYING', 'ICE/PSYCHIC', 'ELECTRIC', 'FIRE', 'BUG', 'NORMAL', 'WATER', 'WATER/FLYING',
68 'WATER/ICE', 'NORMAL', 'NORMAL', 'WATER', 'ELECTRIC', 'FIRE', 'NORMAL', 'ROCK/WATER', 'ROCK/WATER', 'ROCK/WATER',
69 'ROCK/WATER', 'ROCK/FLYING', 'NORMAL', 'ICE/FLYING', 'ELECTRIC/FLYING', 'FIRE/FLYING', 'DRAGON', 'DRAGON',
70 'DRAGON/FLYING', 'PSYCHIC', 'PSYCHIC',
71]
72
73// 1세대가 아닌 종은 속성을 모르는 것으로 빈 글자를 돌려준다
74export function typesOf(species: number): string {
75 return TYPES[species - 1] ?? ''
76}
77hooks/evolve-lib.ts 216 lines1// 박스 포켓몬의 진화. 세션이 일한 시간이 경험치가 되고, 앞장선 포켓몬 한 마리만 먹는다
2// 경험치가 차면 앞장선 포켓몬이 한 단계 진화하고, 진화로 정해진 최종 진화형을 처음 만들거나 도감을 채우면 전설이 박스에 온다
3// 기록을 쓰는 세션이 advancePokedex 바로 뒤에 부른다
4
5import type { BoxEntry, Pokedex, SessionStatus } from '../types'
6import { canEvolve, EVOLUTIONS, LEGEND_FINALS, LEGENDS, MEWTWO_DEX } from './dex-data'
7import { hash, hasEgg } from './nest-lib'
8import { livenessOf } from './sessions-lib'
9
10// 진화 한 번에 드는 일한 시간
11export const XP_PER_EVOLUTION_MS = 3 * 60 * 60 * 1000
12// 지난번 맞춘 뒤 이보다 오래 지났으면 잠자기나 모든 세션이 꺼진 시간으로 보고 세지 않는다
13export const XP_GAP_MS = 5 * 60 * 1000
14// 같은 종이 이만큼 모이면 자동으로 고를 때 그 종이 먼저 앞장선다
15const CROWD = 3
16// 키우미집에서 데려오기 요청. 맡길 포켓몬 key 대신 이 값을 보낸다
17export const TAKE_BACK = '-'
18// 야생 전설이 기다리는 시간. 지나면 달아난다
19export const ENCOUNTER_MS = 60 * 60 * 1000
20// 앞의 전설 장면이 끝나길 기다리는 시간. 차례를 기다리던 전설은 이만큼 지난 뒤 나타난다
21export const SCENE_GAP_MS = 10 * 1000
22// 야생 전설에게 하는 일. throw 는 몬스터볼 던지기, run 은 무시하기, dismiss 는 달아난 안내 닫기
23export type EncounterAction = 'throw' | 'run' | 'dismiss'
24const LEGEND_SPECIES: readonly number[] = Object.values(LEGENDS)
25
26// 진화하면 될 종. 이브이처럼 갈라지면 칸 key 로 골라 다시 계산해도 같다
27export function evolutionOf(entry: BoxEntry): number | null {
28 const next = EVOLUTIONS[entry.species]
29 if (!next) {
30 return null
31 }
32 return next[hash(`evolve:${entry.key}`) % next.length]!
33}
34
35const firstCaught = (entries: readonly BoxEntry[]): BoxEntry | undefined =>
36 entries.reduce<BoxEntry | undefined>((best, one) => (!best || one.at < best.at ? one : best), undefined)
37
38// 아무도 고르지 않았을 때 앞장설 포켓몬. 같은 종이 3마리 이상이면 그 종이 먼저고, 그중 가장 먼저 잡은 포켓몬이다
39export function autoLead(box: readonly BoxEntry[]): string | null {
40 const able = box.filter(one => canEvolve(one.species))
41 const counts = new Map<number, number>()
42 for (const one of able) {
43 counts.set(one.species, (counts.get(one.species) ?? 0) + 1)
44 }
45 const crowded = able.filter(one => (counts.get(one.species) ?? 0) >= CROWD)
46 return firstCaught(crowded.length > 0 ? crowded : able)?.key ?? null
47}
48
49// pick 은 사용자가 Leave at DAY CARE 를 누른 박스 key, 또는 Take back 을 누른 TAKE_BACK
50// evolved 는 이번에 진화한 칸과 진화 전 종, legends 는 이번에 박스에 들어온 전설
51// action 은 야생 전설에게 누른 버튼
52// legends 는 이번에 잡은 전설, fled 는 이번에 달아난 전설
53export function advanceEvolution(
54 dex: Pokedex,
55 sessions: readonly SessionStatus[],
56 now: number,
57 pick: string | null = null,
58 action: EncounterAction | null = null,
59) {
60 let box = [...dex.box]
61 const seen = new Set([...dex.seen, ...box.map(one => one.species)])
62 const legends = new Set([...dex.legends, ...box.map(one => one.species).filter(species => LEGEND_SPECIES.includes(species))])
63 const evolved: Array<{ entry: BoxEntry; from: number }> = []
64 const arrived: BoxEntry[] = []
65
66 const able = (key: string | null) => box.find(one => one.key === key && canEvolve(one.species))
67 let lead = dex.lead
68 let chosen = dex.chosen
69 if (pick === TAKE_BACK) {
70 lead = null
71 chosen = false
72 } else if (pick !== null && box.some(one => one.key === pick)) {
73 // 진화하지 않는 포켓몬(메타몽 등)과 최종 진화형도 맡길 수 있다. 맡긴 동안은 MAX 라 경험치가 쌓이지 않는다
74 lead = pick
75 chosen = true
76 } else if (!box.some(one => one.key === lead)) {
77 // 맡긴 포켓몬은 최종 진화형이 되어도 데려올 때까지 남는다. 박스에서 사라졌을 때만 다시 고른다
78 lead = null
79 chosen = false
80 }
81 lead ??= autoLead(box)
82
83 // 최종 진화형이 맡겨져 있으면 더 진화할 수 없어 경험치를 쌓지 않는다
84 let xp = dex.xp
85 const isMaxed = lead !== null && !able(lead)
86 if (!isMaxed && dex.xpAt > 0 && now - dex.xpAt <= XP_GAP_MS) {
87 // 권한 창이나 질문으로 나를 기다리는 세션은 상태가 working 이어도 일하지 않는 것으로 친다
88 const working = sessions.filter(session => hasEgg(session) && livenessOf(session, now) === 'working' && !session.attention).length
89 // 기록을 쓰는 세션이 바뀌며 시계가 조금 어긋나도 경험치가 줄지 않게 한다
90 xp += Math.max(0, now - dex.xpAt) * working
91 }
92
93 let evolving = dex.evolving
94 const leader = able(lead)
95 if (leader && xp >= XP_PER_EVOLUTION_MS) {
96 const species = evolutionOf(leader)!
97 const grown = { ...leader, species }
98 box = box.map(one => (one.key === leader.key ? grown : one))
99 seen.add(species)
100 evolved.push({ entry: grown, from: leader.species })
101 evolving = { key: leader.key, from: leader.species, at: now }
102 xp = 0
103 }
104
105 let encounter = dex.encounter
106 let scene = dex.scene
107 let fled = dex.fled
108 const escaped = new Set(dex.escaped)
109 const waiting = [...dex.waiting]
110 const gone: number[] = []
111 const runAway = () => {
112 if (encounter) {
113 fled = { species: encounter.species }
114 escaped.add(encounter.species)
115 gone.push(encounter.species)
116 encounter = null
117 }
118 }
119 if (action === 'throw' && encounter) {
120 const entry = { key: `legend:${encounter.species}`, species: encounter.species, root: encounter.root, branch: encounter.branch, label: encounter.label, at: now }
121 box = [entry, ...box]
122 legends.add(entry.species)
123 seen.add(entry.species)
124 escaped.delete(entry.species)
125 arrived.push(entry)
126 scene = { kind: 'catch', species: entry.species, at: now }
127 encounter = null
128 } else if (action === 'run' || (encounter && now - encounter.at >= ENCOUNTER_MS)) {
129 runAway()
130 }
131 if (action === 'dismiss') {
132 fled = null
133 }
134
135 // 전설은 박스에 바로 오지 않고 야생으로 나타난다. 잡은 전설은 다시 나타나지 않는다
136 // 다른 전설이 나타나 있으면 차례를 기다린다. 저장소와 라벨은 진화한 칸, 없으면 박스 맨 앞 칸의 것을 쓴다
137 const meet = (species: number) => {
138 const from = evolved[0]?.entry ?? box[0]
139 if (legends.has(species) || encounter?.species === species || !from) {
140 return
141 }
142 // 다른 전설이 나타나 있거나 전설 장면이 아직 끝나지 않았으면 차례를 기다린다
143 if (encounter || (scene && now - scene.at < SCENE_GAP_MS)) {
144 if (!waiting.includes(species)) {
145 waiting.push(species)
146 }
147 return
148 }
149 encounter = { species, at: now, root: from.root, branch: from.branch, label: from.label }
150 scene = { kind: 'appear', species, at: now }
151 escaped.delete(species)
152 if (waiting.includes(species)) {
153 waiting.splice(waiting.indexOf(species), 1)
154 }
155 }
156 for (const [legend, finals] of Object.entries(LEGEND_FINALS)) {
157 if (evolved.some(one => finals.includes(one.entry.species))) {
158 meet(Number(legend))
159 }
160 }
161 // 도감 조건은 한 번 채우면 계속 채워져 있어, 달아난 뒤에는 새 포켓몬이 박스에 들어올 때만 다시 나타난다
162 // 박스에 막 들어온 칸은 들어온 시각이 지금이다(advancePokedex 가 같은 now 로 넣는다). 진화한 칸과 전설 칸은 시각이 그대로거나 전설이다
163 const newcomer = box.some(one => one.at === now && !one.key.startsWith('legend:'))
164 const dexReady = (species: number) => !escaped.has(species) || newcomer
165 if (seen.size >= MEWTWO_DEX && dexReady(LEGENDS.mewtwo)) {
166 meet(LEGENDS.mewtwo)
167 }
168 if (Array.from({ length: LEGENDS.mew - 1 }, (_, i) => i + 1).every(species => seen.has(species)) && dexReady(LEGENDS.mew)) {
169 meet(LEGENDS.mew)
170 }
171
172 // 차례를 기다리던 전설은 앞의 전설이 끝나고 그 장면도 끝난 뒤 나타난다
173 if (!encounter && waiting.length > 0 && (!scene || now - scene.at >= SCENE_GAP_MS)) {
174 meet(waiting.shift()!)
175 }
176 // 차례를 기다리는 동안 잡은 전설은 빼 둔다
177 for (const species of [...waiting]) {
178 if (legends.has(species)) {
179 waiting.splice(waiting.indexOf(species), 1)
180 }
181 }
182
183 const next: Pokedex = {
184 ...dex,
185 box,
186 xp,
187 xpAt: now,
188 lead,
189 chosen,
190 seen: [...seen],
191 legends: [...legends],
192 evolving,
193 encounter,
194 scene,
195 fled,
196 escaped: [...escaped],
197 waiting,
198 }
199 return { dex: next, evolved, legends: arrived, fled: gone }
200}
201
202const BAR = 5
203
204// PC BOX 머리줄의 경험치 막대
205export function xpBar(xp: number): string {
206 const filled = Math.max(0, Math.min(BAR, Math.floor((xp / XP_PER_EVOLUTION_MS) * BAR)))
207 return '▓'.repeat(filled) + '░'.repeat(BAR - filled)
208}
209
210// 진화까지 남은 일한 시간. 분은 올림한다
211export function xpLeft(xp: number): string {
212 const minutes = Math.max(0, Math.ceil((XP_PER_EVOLUTION_MS - xp) / 60_000))
213 const hours = Math.floor(minutes / 60)
214 return hours > 0 ? `${hours}h${minutes % 60}m` : `${minutes}m`
215}
216hooks/help-lib.ts 44 lines1// /mod-hud --help 가 보이는 설정 안내. README 는 짧게 두고 상세한 설정은 여기서 알려 준다
2
3export function isHelp(args: string): boolean {
4 return ['--help', '-h', 'help'].includes(args.trim())
5}
6
7export function helpText(): string {
8 return [
9 'mod-hud: see which session is waiting on you, and raise a Pokémon egg in every session.',
10 '',
11 'Commands',
12 ' /mod-hud open or close the panel',
13 ' /mod-hud dashboard take this session out of the Pokémon eggs (run again to put it back)',
14 ' /mod-hud --help show this help',
15 '',
16 'GitLab MR status (Worktree, MR sections, eggs cracking and hatching)',
17 ' Needs a token with read_api scope, found in this order:',
18 ' 1. the GITLAB_TOKEN environment variable',
19 ' e.g. ~/.claude/settings.json: { "env": { "GITLAB_TOKEN": "<token>" } }',
20 ' 2. GITLAB_TOKEN=... in .env.local at the repository root',
21 '',
22 'Linear Inbox',
23 ' Shows up when the Linear connector is enabled on claude.ai.',
24 '',
25 'Options (/config > mod-hud, or claude plugin install mod-hud@hyoyyoi --config key=value)',
26 ' gitlabUrl GitLab API address. Empty: worked out from the origin remote.',
27 ' Set it only when the remote is ssh and the web port differs.',
28 ' baseBranch Branch commits are counted against. Empty: origin/HEAD, then main.',
29 ' autoCleanup false by default. When true, removes a worktree and its local branch',
30 ' once its MR is merged, nothing is left uncommitted, local HEAD equals',
31 ' the MR head, and no session is open there. Each removal shows a toast;',
32 ' a kept worktree shows why as "merged, cleanup on hold: ...".',
33 ' With --plugin-dir, set options in ~/.claude/settings.json:',
34 ' { "pluginConfigs": { "mod-hud": { "options": { "autoCleanup": true } } } }',
35 ' pokemonLanguage',
36 ' Language of Pokémon names: en (default) or ko.',
37 '',
38 'Files',
39 ' ~/.claude/mod-sessions/ one status file per session (removed after a day)',
40 ' ~/.claude/mod-hud-pokedex.json eggs, DAY CARE and PC BOX, shared by every session',
41 ' ~/.claude/mod-hud-sprites/ Pokémon icons and names cached from PokeAPI',
42 ].join('\n')
43}
44hooks/nest-lib.ts 1085 lines1// 패널 아래 빈 자리에 그리는 포켓몬 알, 키우미집, PC 박스
2// 키우미집에는 매일 알 7개가 들어오고, 알 없는 세션은 진행 중이 되면 하나를 받는다
3// 세션이 일하면 알이 흔들리고, 그 브랜치의 MR 이 열리면 금이 가고, 머지되면 포켓몬이 나온다
4// 세션이 닫히면 부화한 포켓몬은 박스로, MR 없이 닫힌 알은 키우미집으로 돌아간다. 금 간 알은 머지될 때까지 남아 기다린다
5// 기록은 모든 저장소와 세션이 같이 쓰는 파일에 남는다
6// 포켓몬은 1세대 도감 번호로만 정해 두고, 그림은 MR 이 열리거나 박스를 열 때 불러온 스프라이트로 그린다
7
8import type { BoxEntry, DexEgg, HeldEgg, NestEgg, Pokedex, SessionStatus } from '../types'
9import { EGG_SPECIES } from './dex-data'
10import { isLongLivedBranch, parseIssue } from './issues-lib'
11import type { Pixels } from './png-lib'
12import { CLEAR } from './png-lib'
13import { isCodeReviewSession, KEEP_MS, livenessOf } from './sessions-lib'
14
15export type Egg = NestEgg
16export type Stage = NestEgg['stage']
17
18// boxPage 는 0부터 센 박스 페이지. 넘치면 마지막 페이지로 당긴다
19// lead 는 키우미집에 맡긴 박스 포켓몬의 key, evolving 은 가장 최근 진화. 키우미집 풀밭이 걷는 포켓몬과 진화 장면을 고를 때 쓴다
20export type NestInput = {
21 eggs: Egg[]
22 daycare: HeldEgg[]
23 box: BoxEntry[]
24 boxPage: number
25 lead?: string | null
26 evolving?: Pokedex['evolving']
27}
28
29// 불러온 스프라이트를 size 안에 들어가게 줄여 준다. 아직 없으면 undefined
30export type SpriteLookup = (species: number, size: number) => Pixels | undefined
31
32// 박스 아이콘 도트는 대부분 이보다 작아 줄이지 않고 그대로 그린다. 정수 배가 아니게 줄이면 도트가 뭉개져 더 줄이지 않는다
33export const HATCHED_SIZE = 24
34// 키우미집을 걷는 포켓몬은 이 안에 들어가게 줄인다. 큰 포켓몬(원본 32점 남짓)을 이보다 작게 줄이면 눈과 팔다리가 사라진다
35export const ICON_SIZE = 20
36// 막 진화한 포켓몬의 진화 장면. 원작처럼 진화 전 모습으로 잠깐 서 있다가(start), 하얗게 빛나며 진화 전과 뒤 모습을
37// 번갈아 보이고 점점 빨라진 뒤(morph), 화면이 번쩍이고(flash), 새 모습으로 선다(done). 값은 장면이 시작한 뒤 ms 다
38export const EVOLVE_START_MS = 2000
39export const EVOLVE_MORPH_MS = 9000
40export const EVOLVE_FLASH_MS = 9750
41export const EVOLVE_MS = 13000
42export type EvolvePhase = 'start' | 'morph' | 'flash' | 'done'
43
44export function evolvePhase(elapsed: number): EvolvePhase {
45 if (elapsed < EVOLVE_START_MS) {
46 return 'start'
47 }
48 if (elapsed < EVOLVE_MORPH_MS) {
49 return 'morph'
50 }
51 return elapsed < EVOLVE_FLASH_MS ? 'flash' : 'done'
52}
53
54// morph 동안 이 순간 진화 뒤 모습이 보이는지. 처음엔 1초에 한 번 바뀌고 끝에는 1초에 네 번 바뀐다
55export function isMorphNew(elapsed: number): boolean {
56 const t = Math.max(0, elapsed - EVOLVE_START_MS) / 1000
57 const span = (EVOLVE_MORPH_MS - EVOLVE_START_MS) / 1000
58 const flips = t + (3 / (2 * span)) * t * t
59 return Math.floor(flips) % 2 === 1
60}
61// 부화한 뒤 높이 뛰며 반짝이는 시간
62export const CELEBRATE_MS = 90 * 1000
63// 세션이 닫힌 뒤 부화한 알은 아무도 못 봤을 수 있어, 박스로 보내기를 누를 때까지 둥지에 남는다. 이만큼 지나면 저절로 박스로 간다
64export const UNSEEN_HATCH_MS = 24 * 60 * 60 * 1000
65// /clear 나 /resume 로 끝난 세션의 알을 이어 받을 세션이 나타나길 기다리는 시간
66export const HANDOFF_MS = 2 * 60 * 1000
67// 키우미집에 남겨 두는 알 수. 넘치면 오래된 알부터 내보낸다
68const DAYCARE_MAX = 30
69// 이미 정리한 세션 id 를 남겨 두는 수
70const DONE_MAX = 500
71// 알 줄 수. 넘치는 알은 보이지 않는다
72const EGG_ROWS_MAX = 2
73
74// 알 칸과 부화한 포켓몬 칸의 폭, 그림 줄 수. 알만 있는 줄은 낮게, 포켓몬이 있는 줄만 높게 그린다
75// 알 칸은 가장 크게 기운 알(16칸)이 딱 들어가는 폭이다. 좁은 패널에도 한 줄에 알이 많이 들어가게 한다
76const EGG_SLOT = 16
77const EGG_ROWS = 8
78const HATCHED_SLOT = 24
79const HATCHED_ROWS = 14
80// 박스 한 페이지의 줄 수. 원작 골드 버전의 PC BOX 처럼 한 줄에 한 마리씩 글자로 보이고 넘치면 페이지를 넘겨 본다
81export const BOX_PER_PAGE = 6
82
83const UPPER_HALF = 0x2580
84const LOWER_HALF = 0x2584
85const SPACE = 0x20
86
87const C = {
88 // 4세대 알 스프라이트의 색
89 eggLine: 0x3a3428,
90 eggShine: 0xffffff,
91 egg: 0xfff6de,
92 eggLight: 0xeee6bd,
93 eggShade: 0xd5cda4,
94 spot: 0x9ccd83,
95 spotLight: 0xcde6b4,
96 spotShade: 0x83b46a,
97 crack: 0x5a4030,
98 nest: 0x9a6a3a,
99 nestDark: 0x7a4f2a,
100 sparkle: 0xfff6a0,
101 sparkleHot: 0xffffff,
102 ballRed: 0xe8384f,
103 ballWhite: 0xf4f4f4,
104 ballLine: 0x2a2a2a,
105 labelEgg: 0x9a9a9a,
106 labelCracked: 0xf0b030,
107 labelHatched: 0x78c850,
108 // 패널 제목의 내 차례(red), 진행 중(yellow)과 맞춘 색
109 labelMine: 0xe8505b,
110 labelWorking: 0xf2d24b,
111 daycareBar: 0xc8782a,
112 daycareGround: 0x8cc860,
113 daycareGroundAlt: 0x7cb850,
114 boxBar: 0x3a6ea5,
115 boxBarText: 0xffffff,
116 boxCount: 0xffe080,
117} as const
118
119// 알 몸통 14x13. 4세대 알 스프라이트를 둥글게 줄여 다시 찍은 것. 외곽선, 위쪽 하이라이트, 오른쪽 아래 그림자
120const EGG_BODY = [
121 '.....kkkk.....',
122 '...kkweeekk...',
123 '..kewweeeehk..',
124 '.keewweeeeehk.',
125 '.keeeeeeeeehk.',
126 'keeeeeeeeeehhk',
127 'keeeeeeeeeehhk',
128 'keeeeeeeeehhhk',
129 'keeeeeeeehhhhk',
130 'keeeeeeehhhhhk',
131 '.kssssssssssk.',
132 '..kksssssskk..',
133 '....kkkkkk....',
134]
135// 연두 반점 무늬. 알마다 하나를 골라 몸통에 칠한다. (x, y) 는 몸통 폭과 키에 대한 비율, r 은 폭에 대한 비율인 원이다
136const SPOTS: ReadonlyArray<ReadonlyArray<readonly [number, number, number]>> = [
137 // 4세대 알의 무늬
138 [[0.12, 0.5, 0.2], [0.6, 0.5, 0.09], [0.8, 0.66, 0.16], [0.3, 0.9, 0.12], [0.84, 0.9, 0.05]],
139 // 점 셋
140 [[0.25, 0.4, 0.13], [0.7, 0.45, 0.13], [0.48, 0.78, 0.13]],
141 // 왼쪽에 크게
142 [[0.1, 0.55, 0.28], [0.75, 0.75, 0.08]],
143 // 사선으로 셋
144 [[0.25, 0.3, 0.1], [0.5, 0.55, 0.12], [0.78, 0.8, 0.14]],
145 // 오른쪽 위에 둘
146 [[0.7, 0.3, 0.16], [0.35, 0.72, 0.1]],
147 // 기본 알. 위 오른쪽, 왼쪽 큰 점, 오른쪽 아래, 아래
148 [[0.6, 0.27, 0.13], [0.2, 0.52, 0.17], [0.85, 0.6, 0.15], [0.42, 0.88, 0.15]],
149]
150const EGG_PALETTE = {
151 k: C.eggLine,
152 w: C.eggShine,
153 e: C.egg,
154 h: C.eggLight,
155 s: C.eggShade,
156 G: C.spot,
157 g: C.spotLight,
158 D: C.spotShade,
159 c: C.crack,
160}
161// 금이 간 알의 금 모양들. 알마다 하나를 골라 그린다
162const CRACKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
163 // 위에서 내려오다 아래에서 두 갈래로 갈라진다
164 [[5, 2], [6, 3], [7, 4], [7, 5], [6, 6], [8, 6], [5, 7], [9, 7], [6, 8]],
165 // 가운데 짧은 금
166 [[7, 3], [6, 4], [7, 5]],
167 // 지그재그로 내려오다 옆으로 한 칸 갈라진다
168 [[7, 2], [6, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
169 // 껍질 꼭대기가 깨져 두 갈래로 내려온다
170 [[7, 0], [7, 1], [6, 2], [8, 2], [6, 3], [7, 4]],
171 // 오른쪽 위에서 내려오다 옆으로 한 칸 갈라진다
172 [[8, 2], [7, 3], [7, 4], [8, 4], [7, 5], [6, 6]],
173]
174// 키우미집에 쌓이는 작은 알
175const MINI_EGG = ['..kk..', '.kwwk.', 'keeeek', 'kGGeek', 'kGeeGk', '.kssk.', '..kk..']
176const MINI_SLOT = 7
177// 스프라이트를 아직 못 불러왔을 때 대신 그리는 몬스터볼
178const BALL = ['..rrrr..', '.rrrrrr.', 'rrrrrrrr', 'kkkwwkkk', 'wwwkkwww', 'wwwwwwww', '.wwwwww.', '..wwww..']
179const BALL_PALETTE = { r: C.ballRed, w: C.ballWhite, k: C.ballLine }
180// 일하는 세션의 알이 기우는 각도(도). 오뚝이처럼 크게 한 번, 작게 한 번 흔들리고 쉰다. 음수는 왼쪽이다
181const ROCK = [0, -15, -25, -15, 0, 15, 25, 15, 0, -10, 0, 10, 0, 0, 0, 0, 0, 0]
182// 기운 알을 담는 칸. 바닥 줄이 알의 바닥이고, 가장 크게 기울어도 잘리지 않는다
183const ROCK_WIDTH = 20
184const ROCK_HEIGHT = 15
185// 부화한 포켓몬이 통통 튀는 높이. 막 나왔을 때는 더 높이 뛴다
186const HOP = [0, 1, 2, 1, 0, 0, 0, 0]
187const HOP_HIGH = [0, 2, 4, 2, 0, 0]
188
189export function hash(text: string): number {
190 let n = 2166136261
191 for (let i = 0; i < text.length; i++) {
192 n = Math.imul(n ^ text.charCodeAt(i), 16777619)
193 }
194 return n >>> 0
195}
196
197// 알 key 마다 정해지는 도감 번호. 알에서는 진화 전 모습만 나온다
198export function speciesOf(key: string): number {
199 return EGG_SPECIES[hash(key) % EGG_SPECIES.length]!
200}
201
202// 키우미집에 매일 새로 채우는 알 수
203export const DAILY_EGGS = 7
204
205// 로컬 시간대의 날짜 YYYY-MM-DD. 이 날짜가 바뀌면 키우미집을 새로 채운다
206export function localDay(now: number): string {
207 const date = new Date(now)
208 const pad = (n: number) => String(n).padStart(2, '0')
209 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
210}
211
212// 그날 키우미집에 들어오는 알. 날짜와 순번으로 key 를 지어 종도 날마다 정해진다
213export function freshEggs(day: string): HeldEgg[] {
214 return Array.from({ length: DAILY_EGGS }, (_, i) => {
215 const key = `day:${day}:${i + 1}`
216 return { key, species: speciesOf(key) }
217 })
218}
219
220export const emptyPokedex = (): Pokedex => ({
221 day: '',
222 hatched: {},
223 pending: [],
224 daycare: [],
225 box: [],
226 holds: {},
227 done: [],
228 sent: {},
229 xp: 0,
230 xpAt: 0,
231 lead: null,
232 chosen: false,
233 seen: [],
234 legends: [],
235 evolving: null,
236 encounter: null,
237 scene: null,
238 fled: null,
239 escaped: [],
240 waiting: [],
241})
242
243export function parsePokedex(text: string): Pokedex {
244 try {
245 const parsed = JSON.parse(text) as Partial<Pokedex>
246 const eggs = <T extends { species?: unknown }>(list: unknown): T[] =>
247 Array.isArray(list) ? (list as T[]).filter(entry => typeof entry?.species === 'number') : []
248 const number = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
249 const numbers = (value: unknown): number[] => (Array.isArray(value) ? value.filter(n => typeof n === 'number') : [])
250 const isEvolving = (value: unknown): value is Pokedex['evolving'] => {
251 const one = value as { key?: unknown; from?: unknown; at?: unknown } | null
252 return typeof one?.key === 'string' && typeof one.from === 'number' && typeof one.at === 'number'
253 }
254 const isEncounter = (value: unknown): value is Pokedex['encounter'] => {
255 const one = value as { species?: unknown; at?: unknown; root?: unknown; label?: unknown } | null
256 return typeof one?.species === 'number' && typeof one.at === 'number' && typeof one.root === 'string' && typeof one.label === 'string'
257 }
258 const isScene = (value: unknown): value is Pokedex['scene'] => {
259 const one = value as { kind?: unknown; species?: unknown; at?: unknown } | null
260 return (one?.kind === 'appear' || one?.kind === 'catch') && typeof one.species === 'number' && typeof one.at === 'number'
261 }
262 const record = <T>(value: unknown): Record<string, T> => (value && typeof value === 'object' ? (value as Record<string, T>) : {})
263 // 옛 기록의 sent 는 { count, branch } 라 버린다
264 const sent = Object.entries(record<unknown>(Array.isArray(parsed.sent) ? {} : parsed.sent)).filter(
265 (entry): entry is [string, string | null] => typeof entry[1] === 'string' || entry[1] === null,
266 )
267 return {
268 day: typeof parsed.day === 'string' ? parsed.day : '',
269 hatched: record(parsed.hatched),
270 pending: eggs(parsed.pending),
271 daycare: eggs(parsed.daycare),
272 box: eggs(parsed.box),
273 holds: record(parsed.holds),
274 done: Array.isArray(parsed.done) ? parsed.done : [],
275 sent: Object.fromEntries(sent),
276 xp: Math.max(0, number(parsed.xp)),
277 xpAt: number(parsed.xpAt),
278 lead: typeof parsed.lead === 'string' ? parsed.lead : null,
279 chosen: parsed.chosen === true,
280 seen: numbers(parsed.seen),
281 legends: numbers(parsed.legends),
282 evolving: isEvolving(parsed.evolving) ? parsed.evolving : null,
283 encounter: isEncounter(parsed.encounter) ? parsed.encounter : null,
284 scene: isScene(parsed.scene) ? parsed.scene : null,
285 fled: typeof parsed.fled?.species === 'number' ? { species: parsed.fled.species } : null,
286 escaped: numbers(parsed.escaped),
287 waiting: numbers(parsed.waiting),
288 }
289 } catch {
290 return emptyPokedex()
291 }
292}
293
294// 알을 쥐고 있는 세션. 끊긴 지 한 시간이 안 된 세션은 잠자기에서 돌아올 수 있어 열린 것으로 본다
295export function isOpenSession(session: SessionStatus, now: number): boolean {
296 const liveness = livenessOf(session, now)
297 return liveness === 'working' || liveness === 'waiting' || (liveness === 'stale' && now - session.updatedAt <= KEEP_MS)
298}
299
300// 알을 갖는 세션. /mod-hud dashboard 로 대시보드라고 표시한 세션과 iTerm2 리뷰 세션은 알이 없다
301export function hasEgg(session: SessionStatus): boolean {
302 return !session.isDashboard && !isCodeReviewSession(session)
303}
304
305// 기록을 쓰는 세션. 열린 세션 중 id 가 가장 작은 세션 하나만 써서 여러 세션이 서로 덮어쓰지 않게 한다
306export function keeperOf(sessions: readonly SessionStatus[], now: number): string | null {
307 const open = sessions.filter(session => isOpenSession(session, now)).map(session => session.id)
308 return open.length > 0 ? open.reduce((a, b) => (a < b ? a : b)) : null
309}
310
311export function labelOf(session: Pick<SessionStatus, 'branch' | 'project'>): string {
312 if (!session.branch || isLongLivedBranch(session.branch, 'main')) {
313 return session.project
314 }
315 const issue = parseIssue(session.branch)
316 return issue ? `#${issue}` : session.branch
317}
318
319type Sent = Pokedex['sent']
320
321// mrState 를 지금 브랜치에서 조회했는지. mrBranch 가 없는 옛 상태 파일은 아직 모르는 것으로 친다
322function knowsMr(session: SessionStatus): boolean {
323 return session.mrBranch !== undefined && session.mrBranch === session.branch
324}
325
326// 지금 알에 쓰는 MR 상태. 박스로 보냈거나 알을 받을 때 이미 머지된 브랜치에 머무는 동안은 머지되지 않은 것으로 친다
327// 다른 브랜치를 보고 적은 MR 상태는 모르는 것으로 친다. mrBranch 가 없는 옛 상태 파일은 예전처럼 믿는다
328function mrStateOf(sent: Sent, session: SessionStatus): string | null {
329 if (sent[session.id] !== undefined && sent[session.id] === session.branch) {
330 return null
331 }
332 if (session.mrBranch !== undefined && session.mrBranch !== session.branch) {
333 return null
334 }
335 return session.mrState ?? null
336}
337
338// 세션 상태와 기록을 맞춘다. 기록을 쓰는 세션이 세션 목록을 읽을 때마다 부른다
339// requests 는 박스로 보내기를 누른 알의 key. 부화한 알만 보낸다
340// born 은 이번에 부화한 알, boxed 는 박스에 넣은 포켓몬, left 는 키우미집에 맡긴 알, given 은 키우미집에서 받은 알
341export function advancePokedex(dex: Pokedex, sessions: readonly SessionStatus[], now: number, requests: readonly string[] = []) {
342 const today = localDay(now)
343 const hatched = { ...dex.hatched }
344 const holds = { ...dex.holds }
345 let pending = [...dex.pending]
346 // 날짜가 바뀌면 키우미집에 남은 알은 버리고 그날 알로 새로 채운다
347 let daycare = today === dex.day ? [...dex.daycare] : freshEggs(today)
348 const box = [...dex.box]
349 const done = new Set(dex.done)
350 const sent: Sent = { ...dex.sent }
351 const asked = new Set(requests)
352 const born: DexEgg[] = []
353 const boxed: DexEgg[] = []
354 const left: DexEgg[] = []
355 const given: DexEgg[] = []
356 const eggOf = (session: SessionStatus, egg: HeldEgg): DexEgg => ({
357 key: egg.key,
358 species: egg.species,
359 root: session.root,
360 branch: session.branch,
361 label: labelOf(session),
362 at: now,
363 })
364 const hatch = (egg: DexEgg) => {
365 if (hatched[egg.key] === undefined) {
366 hatched[egg.key] = now
367 born.push(egg)
368 }
369 }
370 const toBox = (egg: DexEgg) => {
371 delete hatched[egg.key]
372 box.unshift({ ...egg, at: now })
373 boxed.push(egg)
374 }
375 // 키우미집으로 돌아간 알은 다음에 받은 세션에서 새로 부화한다
376 const toDaycare = (egg: DexEgg) => {
377 delete hatched[egg.key]
378 daycare.unshift({ key: egg.key, species: egg.species })
379 left.push(egg)
380 }
381 // 세션이 놓은 알. 머지됐거나 MR 이 열려 있으면 머지를 기다리고, 아니면 키우미집으로 간다
382 const release = (session: SessionStatus, held: HeldEgg) => {
383 const egg = eggOf(session, held)
384 const mrState = mrStateOf(sent, session)
385 // 세션이 열려 있을 때 이미 부화해 보인 포켓몬은 바로 박스로 간다
386 if (mrState === 'merged' && hatched[egg.key] !== undefined) {
387 toBox(egg)
388 } else if (mrState === 'merged' || mrState === 'opened') {
389 pending.push({ ...egg, mrState })
390 } else {
391 toDaycare(egg)
392 }
393 }
394
395 // /clear 나 /resume 는 같은 창에서 새 id 로 이어지므로, 끝난 세션의 알은 이어진 세션이 물려받는다
396 const heirs = new Map(
397 sessions.flatMap(session => (session.predecessor && isOpenSession(session, now) ? [[session.predecessor, session] as const] : [])),
398 )
399 for (const session of sessions) {
400 const heir = heirs.get(session.id)
401 if (!heir || isOpenSession(session, now) || done.has(session.id)) {
402 continue
403 }
404 done.add(session.id)
405 const egg = holds[session.id]
406 // 이어 받을 세션이 이미 알을 들고 있으면 끝난 세션의 알은 닫힌 세션의 알처럼 놓는다
407 if (egg && holds[heir.id]) {
408 release(session, egg)
409 } else if (egg) {
410 holds[heir.id] = egg
411 }
412 if (sent[session.id] !== undefined && sent[heir.id] === undefined) {
413 sent[heir.id] = sent[session.id]!
414 }
415 delete holds[session.id]
416 }
417
418 // 알이 없는 진행 중인 세션. 아래에서 키우미집의 알을 나눠 준다
419 const empty: SessionStatus[] = []
420 for (const session of sessions) {
421 if (!hasEgg(session)) {
422 // 알을 받은 뒤 대시보드로 표시한 세션은 든 알을 놓는다
423 const mine = holds[session.id]
424 if (mine) {
425 release(session, mine)
426 delete holds[session.id]
427 }
428 if (!isOpenSession(session, now)) {
429 done.add(session.id)
430 }
431 continue
432 }
433 if (isOpenSession(session, now)) {
434 // 잠자기에서 한 시간 넘게 지나 닫힌 것으로 보낸 세션이 돌아오면, 아직 아무도 안 받아 간 자기 알만 되찾는다
435 if (done.has(session.id)) {
436 done.delete(session.id)
437 const key = holds[session.id]?.key
438 if (daycare.some(egg => egg.key === key) || pending.some(egg => egg.key === key)) {
439 daycare = daycare.filter(egg => egg.key !== key)
440 pending = pending.filter(egg => egg.key !== key)
441 } else {
442 delete holds[session.id]
443 }
444 }
445 const mine = holds[session.id]
446 if (mine && mrStateOf(sent, session) === 'merged') {
447 const egg = eggOf(session, mine)
448 hatch(egg)
449 // 박스로 보내면 빈 세션이 된다. 같은 브랜치의 머지로 다음 알이 깨지지 않게 브랜치를 기억한다
450 if (asked.has(egg.key)) {
451 toBox(egg)
452 delete holds[session.id]
453 sent[session.id] = session.branch
454 }
455 }
456 if (!holds[session.id] && livenessOf(session, now) === 'working') {
457 empty.push(session)
458 }
459 continue
460 }
461 if (done.has(session.id)) {
462 continue
463 }
464 // 이어 받을 세션이 아직 상태를 쓰지 않았을 수 있어 잠깐 기다린다
465 const isHandingOff = session.endReason === 'clear' || session.endReason === 'resume'
466 if (isHandingOff && now - session.updatedAt < HANDOFF_MS) {
467 continue
468 }
469 done.add(session.id)
470 // 든 알은 holds 에 남겨 둬 잠자기에서 돌아오면 되찾게 한다
471 const mine = holds[session.id]
472 if (!mine) {
473 continue
474 }
475 release(session, mine)
476 }
477
478 // 먼저 일을 시작한 세션부터 키우미집 앞의 알을 하나씩 받는다
479 // 지금 브랜치의 MR 상태를 아직 모르는 세션은 이번에는 건너뛴다. 이미 머지된 브랜치에서 받은 알이 그 머지로 깨지지 않게 한다
480 empty.sort((a, b) => (a.workingSince ?? a.updatedAt) - (b.workingSince ?? b.updatedAt) || (a.id < b.id ? -1 : 1))
481 for (const session of empty.filter(knowsMr)) {
482 const egg = daycare.shift()
483 if (!egg) {
484 break
485 }
486 // 이 알을 전에 들었던 세션은 잠자기에서 돌아와도 되찾지 못하게 기록에서 지운다
487 for (const [id, other] of Object.entries(holds)) {
488 if (other.key === egg.key) {
489 delete holds[id]
490 }
491 }
492 holds[session.id] = egg
493 // 이미 머지된 브랜치에서 받은 알은 그 머지로 깨지지 않는다
494 if (session.mrState === 'merged') {
495 sent[session.id] = session.branch
496 }
497 given.push(eggOf(session, egg))
498 }
499
500 // 세션이 닫힌 뒤 머지된 알은 부화해 박스로 보내기를 누를 때까지 둥지에 남는다. 머지 없이 닫힌 MR 의 알은 키우미집으로 간다
501 pending = pending.filter(egg => {
502 if (egg.mrState === 'merged') {
503 hatch(egg)
504 if (now - (hatched[egg.key] ?? now) < UNSEEN_HATCH_MS && !asked.has(egg.key)) {
505 return true
506 }
507 const { mrState: _, ...entry } = egg
508 toBox(entry)
509 return false
510 }
511 if (egg.mrState !== 'opened') {
512 const { mrState: _, ...entry } = egg
513 toDaycare(entry)
514 return false
515 }
516 return true
517 })
518
519 // 상태 파일이 지워진 세션은 다시 볼 일이 없으니 정리한 기록에서도 뺀다
520 const known = new Set(sessions.map(session => session.id))
521 const ofKnown = <T>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => known.has(id)))
522 const nextDaycare = daycare.slice(0, DAYCARE_MAX)
523 const nextHolds = ofKnown(holds)
524 // 들거나 맡기거나 기다리는 알이 아닌 알의 부화 기록은 지운다
525 const kept = new Set([...Object.values(nextHolds), ...nextDaycare, ...pending].map(egg => egg.key))
526 // 진화 기록은 advanceEvolution 이 맞추므로 그대로 넘긴다
527 const next: Pokedex = {
528 ...dex,
529 day: today,
530 hatched: Object.fromEntries(Object.entries(hatched).filter(([key]) => kept.has(key))),
531 pending,
532 daycare: nextDaycare,
533 box,
534 holds: nextHolds,
535 done: [...done].filter(id => known.has(id)).slice(-DONE_MAX),
536 sent: ofKnown(sent),
537 }
538 return { dex: next, born, boxed, left, given }
539}
540
541// 기다리는 알의 MR 상태를 바꾼다. 기록을 쓰는 세션이 MR 을 다시 조회한 뒤 부른다
542export function updatePending(dex: Pokedex, key: string, mrState: string): Pokedex {
543 return { ...dex, pending: dex.pending.map(egg => (egg.key === key ? { ...egg, mrState } : egg)) }
544}
545
546// 패널에 그릴 알. 열린 세션의 알을 먼저, 머지를 기다리는 알을 그 뒤에 둔다. 자리가 바뀌지 않게 세션 id 순으로 둔다
547export function eggsOf(sessions: readonly SessionStatus[], dex: Pokedex, now: number): Egg[] {
548 const stageOf = (mrState: string | null | undefined): Stage =>
549 mrState === 'merged' ? 'hatched' : mrState === 'opened' ? 'cracked' : 'egg'
550 const open = sessions
551 .filter(session => isOpenSession(session, now) && hasEgg(session))
552 .sort((a, b) => (a.id < b.id ? -1 : 1))
553 .map(session => {
554 // 알을 아직 안 받은 세션은 세션 id 로 된 빈 둥지로 둔다
555 const mine = dex.holds[session.id]
556 const key = mine?.key ?? session.id
557 const stage = mine ? stageOf(mrStateOf(dex.sent, session)) : 'empty'
558 const isWorking = livenessOf(session, now) === 'working'
559 return {
560 key,
561 label: labelOf(session),
562 species: mine?.species ?? 0,
563 stage,
564 isShaking: (stage === 'egg' || stage === 'cracked') && isWorking,
565 activity: session.attention ? ('mine' as const) : isWorking ? ('working' as const) : null,
566 hatchedAt: dex.hatched[key] ?? null,
567 }
568 })
569 const waiting = dex.pending.map(egg => ({
570 key: egg.key,
571 label: egg.label,
572 species: egg.species,
573 stage: stageOf(egg.mrState),
574 isShaking: false,
575 activity: null,
576 hatchedAt: dex.hatched[egg.key] ?? null,
577 }))
578 return [...open, ...waiting]
579}
580
581// 그림에 쓸 스프라이트 번호. 금 간 알은 곧 나올 것이라 미리 불러 둔다
582export function speciesToLoad(input: NestInput, width: number): number[] {
583 const eggs = eggsOnScreen(input, width).filter(egg => egg.stage !== 'egg' && egg.stage !== 'empty')
584 return [...new Set(eggs.map(egg => egg.species))]
585}
586
587// 픽셀 그림판. 비운 픽셀은 터미널 배경이 비친다
588type Canvas = { width: number; height: number; px: Uint32Array }
589
590function canvasOf(width: number, height: number): Canvas {
591 return { width, height, px: new Uint32Array(width * height).fill(CLEAR) }
592}
593
594function put(canvas: Canvas, x: number, y: number, color: number) {
595 if (x >= 0 && x < canvas.width && y >= 0 && y < canvas.height) {
596 canvas.px[y * canvas.width + x] = color
597 }
598}
599
600function sprite(canvas: Canvas, x: number, y: number, rows: string[], palette: Record<string, number>) {
601 rows.forEach((row, dy) => {
602 for (let dx = 0; dx < row.length; dx++) {
603 const color = palette[row[dx] ?? '.']
604 if (color !== undefined) {
605 put(canvas, x + dx, y + dy, color)
606 }
607 }
608 })
609}
610
611function blit(canvas: Canvas, x: number, y: number, image: Pixels) {
612 image.px.forEach((color, i) => {
613 if (color !== CLEAR) {
614 put(canvas, x + (i % image.width), y + Math.floor(i / image.width), color)
615 }
616 })
617}
618
619// 몸통에 알 key 로 고른 반점과, 금 간 알이면 금을 칠한다
620function paintedEgg(key: string, stage: 'egg' | 'cracked'): string[][] {
621 const width = EGG_BODY[0]!.length
622 const height = EGG_BODY.length
623 const spots = SPOTS[hash(`spots:${key}`) % SPOTS.length]!
624 const rows = EGG_BODY.map((row, y) =>
625 [...row].map((c, x) => {
626 if (c !== 'e' && c !== 'h' && c !== 's') {
627 return c
628 }
629 const hit = spots.some(([bx, by, r]) => {
630 const dx = (x + 0.5) / width - bx
631 const dy = ((y + 0.5) / height - by) * (height / width)
632 return dx * dx + dy * dy <= r * r
633 })
634 return hit ? (c === 's' ? 'D' : 'G') : c
635 }),
636 )
637 if (stage === 'cracked') {
638 // 프레임마다 바뀌지 않게 알 key 로 고른다
639 for (const [x, y] of CRACKS[hash(`crack:${key}`) % CRACKS.length]!) {
640 rows[y]![x] = 'c'
641 }
642 }
643 return rows
644}
645
646const eggSprites = new Map<string, string[]>()
647
648// 알 key 의 무늬와 금으로 칠한 알을 angle 도만큼 기울인 그림. ROCK_WIDTH x ROCK_HEIGHT 이고 바닥 줄이 알의 바닥이다
649export function eggSprite(key: string, stage: 'egg' | 'cracked', angle: number): string[] {
650 const id = `${key}:${stage}:${angle}`
651 const cached = eggSprites.get(id)
652 if (cached) {
653 return cached
654 }
655 const source = paintedEgg(key, stage)
656 const width = source[0]!.length
657 const height = source.length
658 // 아랫면 곡률 중심을 축으로 돌리고, 기운 쪽으로 조금 굴려 둥근 바닥이 둥지에 닿은 채 기울게 한다
659 const radius = width / 2 - 0.5
660 const a = (angle * Math.PI) / 180
661 const pivotX = width / 2
662 const pivotY = height - radius
663 const outX = ROCK_WIDTH / 2 + radius * a * 0.4
664 const outY = ROCK_HEIGHT - radius
665 const sample = (x: number, y: number) => {
666 const dx = x - outX
667 const dy = y - outY
668 const sx = Math.floor(dx * Math.cos(-a) - dy * Math.sin(-a) + pivotX)
669 const sy = Math.floor(dx * Math.sin(-a) + dy * Math.cos(-a) + pivotY)
670 return source[sy]?.[sx] ?? '.'
671 }
672 // 칸을 3x3 으로 나눠 반 넘게 알 안이면 칠한다. 한 점만 읽으면 테두리가 들쭉날쭉하다. 색은 칸 중심에서 읽는다
673 const grid = Array.from({ length: ROCK_HEIGHT }, (_, y) =>
674 Array.from({ length: ROCK_WIDTH }, (_, x) => {
675 let inside = 0
676 for (const oy of [1 / 6, 1 / 2, 5 / 6]) {
677 for (const ox of [1 / 6, 1 / 2, 5 / 6]) {
678 inside += sample(x + ox, y + oy) === '.' ? 0 : 1
679 }
680 }
681 if (inside < 5) {
682 return '.'
683 }
684 const center = sample(x + 0.5, y + 0.5)
685 return center === '.' ? 'e' : center
686 }),
687 )
688 // 이웃이 하나뿐인 칸은 돌리다 생긴 돌기라 지운다. 지우면 또 생길 수 있어 없을 때까지 되풀이한다
689 const neighbours = (x: number, y: number) =>
690 [grid[y]?.[x - 1], grid[y]?.[x + 1], grid[y - 1]?.[x], grid[y + 1]?.[x]].filter(c => c !== undefined && c !== '.').length
691 for (let changed = true; changed; ) {
692 changed = false
693 grid.forEach((row, y) =>
694 row.forEach((c, x) => {
695 if (c !== '.' && neighbours(x, y) < 2) {
696 row[x] = '.'
697 changed = true
698 }
699 }),
700 )
701 }
702 // 1칸 외곽선은 돌리면 구멍이 나서, 모양만 돌리고 바깥 둘레를 외곽선으로 다시 칠한다
703 const filled = (x: number, y: number) => (grid[y]?.[x] ?? '.') !== '.'
704 const rows = grid.map((row, y) =>
705 row
706 .map((c, x) => {
707 if (c === '.') {
708 return '.'
709 }
710 if (!filled(x - 1, y) || !filled(x + 1, y) || !filled(x, y - 1) || !filled(x, y + 1)) {
711 return 'k'
712 }
713 return c === 'k' ? 'e' : c
714 })
715 .join(''),
716 )
717 eggSprites.set(id, rows)
718 return rows
719}
720
721// 반짝이는 별을 width x height 칸 안에 흩뿌린다
722// 몸 모양 그대로 하얗게 칠한 그림
723function silhouette(image: Pixels): Pixels {
724 return { ...image, px: image.px.map(color => (color === CLEAR ? CLEAR : C.sparkleHot)) }
725}
726
727function drawSparkles(canvas: Canvas, x: number, y: number, width: number, height: number, frame: number) {
728 const spots = [
729 [0.05, 0.25],
730 [0.9, 0.1],
731 [0.1, 0.7],
732 [0.92, 0.55],
733 [0.45, 0.02],
734 [0.7, 0.35],
735 ] as const
736 spots.forEach(([fx, fy], i) => {
737 const dx = Math.round(fx * (width - 1))
738 const dy = y + Math.round(fy * (height - 1))
739 if ((frame + i) % 3 === 0) {
740 return
741 }
742 const color = (frame + i) % 2 === 0 ? C.sparkle : C.sparkleHot
743 put(canvas, x + dx, dy, color)
744 if ((frame + i) % 3 === 1) {
745 put(canvas, x + dx - 1, dy, color)
746 put(canvas, x + dx + 1, dy, color)
747 put(canvas, x + dx, dy - 1, color)
748 put(canvas, x + dx, dy + 1, color)
749 }
750 })
751}
752
753function drawSlot(canvas: Canvas, slotX: number, slot: number, egg: Egg, frame: number, now: number, lookup: SpriteLookup) {
754 const floor = canvas.height - 1
755 const eggX = slotX + Math.floor((slot - ROCK_WIDTH) / 2)
756 const eggTop = floor - ROCK_HEIGHT
757 for (let dx = 1; dx < slot - 1; dx++) {
758 put(canvas, slotX + dx, floor, dx % 2 === 0 ? C.nest : C.nestDark)
759 }
760 if (egg.stage === 'empty') {
761 return
762 }
763 if (egg.stage === 'hatched') {
764 // 껍질 없이 포켓몬만 둥지 위에서 통통 튄다. 막 나왔을 때는 더 높이 뛰며 반짝인다
765 const isCelebrating = egg.hatchedAt !== null && now - egg.hatchedAt < CELEBRATE_MS
766 const hops = isCelebrating ? HOP_HIGH : HOP
767 const bottom = floor - 1 - (hops[(frame + egg.key.length) % hops.length] ?? 0)
768 const image = lookup(egg.species, HATCHED_SIZE)
769 if (image) {
770 blit(canvas, slotX + Math.floor((slot - image.width) / 2), bottom - image.height + 1, image)
771 } else {
772 sprite(canvas, slotX + Math.floor((slot - 8) / 2), bottom - 7, BALL, BALL_PALETTE)
773 }
774 if (isCelebrating) {
775 drawSparkles(canvas, slotX, 0, slot, floor, frame)
776 }
777 return
778 }
779 const angle = egg.isShaking ? (ROCK[(frame + egg.key.length) % ROCK.length] ?? 0) : 0
780 sprite(canvas, eggX, eggTop, eggSprite(egg.key, egg.stage, angle), EGG_PALETTE)
781}
782
783// 위아래 두 픽셀을 반 블록 한 칸에 담는다. 둘 다 비면 빈칸으로 둔다
784function pixelCells(canvas: Canvas, out: number[]) {
785 for (let row = 0; row < canvas.height / 2; row++) {
786 for (let x = 0; x < canvas.width; x++) {
787 const top = canvas.px[row * 2 * canvas.width + x] ?? CLEAR
788 const bottom = canvas.px[(row * 2 + 1) * canvas.width + x] ?? CLEAR
789 if (top === CLEAR && bottom === CLEAR) {
790 out.push(SPACE, CLEAR, CLEAR)
791 } else if (top === CLEAR) {
792 out.push(LOWER_HALF, bottom, CLEAR)
793 } else {
794 out.push(UPPER_HALF, top, bottom)
795 }
796 }
797 }
798}
799
800// 글자 한 줄. Raster 는 한 칸짜리 글자만 받으므로 ASCII 밖의 글자는 ? 로 바꾼다
801function textCells(width: number, parts: { at: number; text: string; color: number }[], background: number, out: number[]) {
802 const line = Array.from({ length: width }, () => [SPACE, CLEAR, background])
803 for (const part of parts) {
804 for (let i = 0; i < part.text.length; i++) {
805 const at = part.at + i
806 const code = part.text.charCodeAt(i)
807 if (at >= 0 && at < width) {
808 line[at] = [code >= 0x20 && code < 0x7f ? code : 0x3f, part.color, background]
809 }
810 }
811 }
812 for (const cell of line) {
813 out.push(...cell)
814 }
815}
816
817type EggRow = { eggs: Egg[]; slots: number[]; rows: number }
818
819// 알을 줄마다 채운다. 알은 좁은 칸, 부화한 포켓몬은 넓은 칸을 쓴다. 두 줄을 넘는 알은 보이지 않는다
820function eggLayout(eggs: Egg[], width: number): EggRow[] {
821 const rows: EggRow[] = []
822 let current: Egg[] = []
823 let used = 0
824 const close = () => {
825 const slots = current.map(egg => (egg.stage === 'hatched' ? HATCHED_SLOT : EGG_SLOT))
826 rows.push({ eggs: current, slots, rows: current.some(egg => egg.stage === 'hatched') ? HATCHED_ROWS : EGG_ROWS })
827 current = []
828 used = 0
829 }
830 for (const egg of eggs) {
831 const slot = egg.stage === 'hatched' ? HATCHED_SLOT : EGG_SLOT
832 if (current.length > 0 && used + slot > width) {
833 close()
834 if (rows.length === EGG_ROWS_MAX) {
835 return rows
836 }
837 }
838 current.push(egg)
839 used += slot
840 }
841 if (current.length > 0 && rows.length < EGG_ROWS_MAX) {
842 close()
843 }
844 return rows
845}
846
847// 패널에 보이는 알. 박스로 보내기 버튼과 스프라이트를 불러올 대상이 이것이다
848export function eggsOnScreen(input: NestInput, width: number): Egg[] {
849 return eggLayout(input.eggs, width).flatMap(row => row.eggs)
850}
851
852export function boxPages(input: NestInput): number {
853 return Math.max(1, Math.ceil(input.box.length / BOX_PER_PAGE))
854}
855
856export function boxPageOf(input: NestInput): number {
857 return Math.min(Math.max(0, input.boxPage), boxPages(input) - 1)
858}
859
860export function boxOnPage(input: NestInput): BoxEntry[] {
861 const page = boxPageOf(input)
862 return input.box.slice(page * BOX_PER_PAGE, (page + 1) * BOX_PER_PAGE)
863}
864
865// 전설 등장 장면. 원작의 야생 포켓몬 배틀처럼 줄무늬로 화면이 덮이고, 전설이 나타나고, 몬스터볼에 잡힌다
866// 그림 12줄과 글 한 줄. 프레임(250ms)마다 한 걸음이고 ARRIVAL_FRAMES 걸음이면 끝난다
867export const ARRIVAL_FRAMES = 36
868export const ARRIVAL_ROWS = 13
869const ARRIVAL_HEIGHT = (ARRIVAL_ROWS - 1) * 2
870// 전설 그림 크기. 아래 글 줄에 닿지 않게 그림 칸보다 작게 그리고 한 줄을 띄운다
871const ARRIVAL_SIZE = 20
872const ARRIVAL_FLOOR = ARRIVAL_HEIGHT - 2
873const ARRIVAL_PAPER = 0xf8f8f0
874const ARRIVAL_INK = 0x202020
875
876export function drawArrival(species: number, name: string, width: number, step: number, lookup: SpriteLookup): Uint32Array {
877 const canvas = canvasOf(width, ARRIVAL_HEIGHT)
878 canvas.px.fill(ARRIVAL_PAPER)
879 const shout = name.toUpperCase()
880 const image = lookup(species, ARRIVAL_SIZE)
881 const legendX = Math.floor(width / 2) - 14
882 const ballX = Math.floor(width / 2) - 4
883 const ballY = ARRIVAL_HEIGHT - 10
884 const legend = (x: number) => {
885 if (image) {
886 blit(canvas, x, ARRIVAL_FLOOR - image.height, image)
887 } else {
888 sprite(canvas, x, ARRIVAL_FLOOR - 8, BALL, BALL_PALETTE)
889 }
890 }
891 let caption = ''
892 if (step < 6) {
893 // 두 줄씩 번갈아 왼쪽과 오른쪽에서 검은 띠가 들어온다
894 const reach = Math.min(width, Math.ceil(((step + 1) * width) / 5))
895 for (let y = 0; y < ARRIVAL_HEIGHT; y++) {
896 for (let i = 0; i < reach; i++) {
897 put(canvas, Math.floor(y / 2) % 2 === 0 ? i : width - 1 - i, y, ARRIVAL_INK)
898 }
899 }
900 } else if (step < 16) {
901 // 오른쪽에서 미끄러져 들어온다
902 legend(Math.max(legendX, width - (step - 6) * 10))
903 caption = `A wild ${shout} appeared!`
904 } else if (step < 20) {
905 // 몬스터볼이 날아가는 동안 전설이 깜빡인다
906 if (step % 2 === 0) {
907 legend(legendX)
908 }
909 sprite(canvas, 2 + (step - 16) * Math.floor((ballX - 2) / 3), ballY - (step - 16) * 2, BALL, BALL_PALETTE)
910 caption = 'Go! POKe BALL!'
911 } else if (step < 32) {
912 // 세 번 흔들리고 잡힌다
913 const isCaught = step >= 30
914 const shake = isCaught ? 0 : ([0, -1, 0, 1][(step - 20) % 4] ?? 0)
915 sprite(canvas, ballX + shake, ballY, BALL, BALL_PALETTE)
916 if (isCaught) {
917 drawSparkles(canvas, ballX - 6, ballY - 6, 20, 14, step)
918 }
919 caption = isCaught ? `Gotcha! ${shout} was caught!` : '...'
920 } else {
921 sprite(canvas, ballX, ballY, BALL, BALL_PALETTE)
922 caption = `${shout} was sent to the PC BOX`
923 }
924 const out: number[] = []
925 pixelCells(canvas, out)
926 textCells(width, [{ at: Math.max(0, Math.floor((width - caption.length) / 2)), text: caption, color: C.boxBarText }], ARRIVAL_INK, out)
927 return Uint32Array.from(out)
928}
929
930// 아이콘의 원본 크기(7세대 아이콘은 40x30). 야생 전설을 줄이지 않고 그린다
931const PORTRAIT_SIZE = 40
932
933export function drawPortrait(species: number, lookup: SpriteLookup): { columns: number; rows: number; cells: Uint32Array } | null {
934 const image = lookup(species, PORTRAIT_SIZE)
935 if (!image) {
936 return null
937 }
938 const canvas = canvasOf(image.width, image.height + (image.height % 2))
939 blit(canvas, 0, 0, image)
940 const out: number[] = []
941 pixelCells(canvas, out)
942 return { columns: canvas.width, rows: canvas.height / 2, cells: Uint32Array.from(out) }
943}
944
945// 즐겨찾기 파일(~/.claude/mod-hud-favorites.json)과 펼쳐 본 파일(~/.claude/mod-hud-seen.json)은 박스 key 의 배열이다. 읽지 못하면 빈 것으로 본다
946export function parseKeys(text: string): string[] {
947 try {
948 const parsed: unknown = JSON.parse(text)
949 return Array.isArray(parsed) ? parsed.filter((key): key is string => typeof key === 'string') : []
950 } catch {
951 return []
952 }
953}
954
955// 즐겨찾는 포켓몬을 PC BOX 맨 앞에 모은다. 그 안과 나머지는 원래 순서를 지킨다
956export function favoritesFirst(box: readonly BoxEntry[], favorites: ReadonlySet<string>): BoxEntry[] {
957 return [...box.filter(entry => favorites.has(entry.key)), ...box.filter(entry => !favorites.has(entry.key))]
958}
959
960// 박스 key 가 있는 페이지. 막 진화한 포켓몬의 줄로 페이지를 넘길 때 쓴다
961export function boxPageOfKey(input: NestInput, key: string): number {
962 const index = input.box.findIndex(entry => entry.key === key)
963 return index < 0 ? boxPageOf(input) : Math.floor(index / BOX_PER_PAGE)
964}
965
966// 키우미집 풀밭 줄 수. 머리줄은 누르면 접히게 패널이 따로 그린다
967export const DAYCARE_GROUND_ROWS = 4
968
969function drawDaycare(canvas: Canvas, eggs: HeldEgg[], frame: number) {
970 for (let y = 0; y < canvas.height; y++) {
971 for (let x = 0; x < canvas.width; x++) {
972 put(canvas, x, y, (x + (y >> 1)) % 4 === 0 ? C.daycareGroundAlt : C.daycareGround)
973 }
974 }
975 const shown = Math.floor((canvas.width - 2) / MINI_SLOT)
976 eggs.slice(0, shown).forEach((egg, i) => {
977 // 차곡차곡 쌓인 알이 가끔 하나씩 꼼지락거린다
978 const nudge = (frame + i * 5) % 24 === 0 ? -1 : 0
979 const x = 1 + i * MINI_SLOT
980 sprite(canvas, x, 1 + nudge, MINI_EGG, EGG_PALETTE)
981 })
982}
983
984// 알 줄을 그린다. 세션이 없으면 0줄이다. 키우미집 풀밭(drawDaycareGround)과 PC BOX 칸(drawBoxTile)은 따로 그린다
985export function nestRows(input: NestInput, width: number): number {
986 // 알 한 줄은 그림과 라벨 한 줄이고, 알이 있으면 그 아래 한 줄을 띄운다
987 const layout = eggLayout(input.eggs, width)
988 return layout.reduce((sum, row) => sum + row.rows + 1, 0) + (layout.length > 0 ? 1 : 0)
989}
990
991// 앞장선 포켓몬이 풀밭을 걸으면 풀밭이 이만큼 높아진다. 걷는 포켓몬과 통통 튀는 한 칸이 들어간다
992const WALK_GROUND_ROWS = 11
993// 풀밭을 걷는 포켓몬의 크기. 더 작으면 큰 포켓몬이 뭉개진다
994export const WALKER_SIZE = ICON_SIZE
995// 걷는 포켓몬이 통통 튀는 높이. 두 프레임마다 한 칸 뛴다
996const STEP_HOP = [0, 1, 1, 0]
997
998export function daycareGroundRows(hasWalker: boolean): number {
999 return hasWalker ? WALK_GROUND_ROWS : DAYCARE_GROUND_ROWS
1000}
1001
1002// 키우미집 풀밭과 맡긴 작은 알. 알이 없어도 그린다
1003// walker 는 맡긴 포켓몬 그림. 주면 풀밭을 높여 그 포켓몬이 좌우로 걷게 한다. null 이면 풀밭만 높이고 아무도 걷지 않는다
1004// before 는 막 진화한 포켓몬의 진화 전 그림. 주면 걷지 않고 가운데 서서 진화 장면(evolvePhase)을 보인다. elapsed 는 장면이 시작한 뒤 ms 다
1005export function drawDaycareGround(
1006 daycare: HeldEgg[],
1007 width: number,
1008 frame: number,
1009 walker?: Pixels | null,
1010 before?: Pixels | null,
1011 elapsed = 0,
1012): Uint32Array {
1013 const ground = canvasOf(width, daycareGroundRows(walker !== undefined) * 2)
1014 drawDaycare(ground, daycare, frame)
1015 if (walker && before) {
1016 const phase = evolvePhase(elapsed)
1017 // 빛나는 동안은 원작처럼 하얀 실루엣으로 진화 전과 뒤 모습을 번갈아 그린다
1018 const image =
1019 phase === 'start' ? before : phase === 'morph' ? silhouette(isMorphNew(elapsed) ? walker : before) : walker
1020 const x = Math.floor((width - image.width) / 2)
1021 if (phase === 'flash') {
1022 // 다 빛나면 풀밭 전체가 하얗게 번쩍인다
1023 for (let y = 0; y < ground.height; y++) {
1024 for (let dx = 0; dx < width; dx++) {
1025 put(ground, dx, y, C.sparkleHot)
1026 }
1027 }
1028 } else {
1029 blit(ground, x, ground.height - image.height, image)
1030 }
1031 if (phase === 'morph' || phase === 'done') {
1032 drawSparkles(ground, x - 2, 0, image.width + 4, ground.height, frame)
1033 }
1034 } else if (walker) {
1035 // 오른쪽 끝에서 왼쪽으로 한 칸씩 걷다가 끝에 닿으면 돌아온다. 아이콘은 왼쪽을 보므로 오른쪽으로 갈 때 뒤집는다
1036 const range = Math.max(1, width - walker.width)
1037 const step = frame % (range * 2)
1038 const isGoingRight = step >= range
1039 const x = isGoingRight ? step - range : range - step
1040 const bottom = ground.height - 1 - (STEP_HOP[frame % STEP_HOP.length] ?? 0)
1041 const image = isGoingRight ? mirrored(walker) : walker
1042 blit(ground, x, bottom - image.height + 1, image)
1043 }
1044 const out: number[] = []
1045 pixelCells(ground, out)
1046 return Uint32Array.from(out)
1047}
1048
1049function mirrored(image: Pixels): Pixels {
1050 const px = image.px.map((_, i) => {
1051 const x = i % image.width
1052 const y = Math.floor(i / image.width)
1053 return image.px[y * image.width + (image.width - 1 - x)]!
1054 })
1055 return { ...image, px }
1056}
1057
1058export function drawNest(input: NestInput, width: number, frame: number, now: number, lookup: SpriteLookup): Uint32Array {
1059 const out: number[] = []
1060 const color = { empty: C.labelEgg, egg: C.labelEgg, cracked: C.labelCracked, hatched: C.labelHatched }
1061 for (const row of eggLayout(input.eggs, width)) {
1062 const canvas = canvasOf(width, row.rows * 2)
1063 const total = row.slots.reduce((sum, slot) => sum + slot, 0)
1064 const starts = row.slots.map((_, i) => Math.floor((width - total) / 2) + row.slots.slice(0, i).reduce((sum, slot) => sum + slot, 0))
1065 row.eggs.forEach((egg, i) => drawSlot(canvas, starts[i]!, row.slots[i]!, egg, frame, now, lookup))
1066 pixelCells(canvas, out)
1067 textCells(
1068 width,
1069 row.eggs.map((egg, i) => {
1070 const slot = row.slots[i]!
1071 const text = egg.label.length > slot - 1 ? egg.label.slice(0, slot - 1) : egg.label
1072 const tint = egg.activity === 'mine' ? C.labelMine : egg.activity === 'working' ? C.labelWorking : color[egg.stage]
1073 return { at: starts[i]! + Math.floor((slot - text.length) / 2), text, color: tint }
1074 }),
1075 CLEAR,
1076 out,
1077 )
1078 }
1079 if (input.eggs.length > 0) {
1080 textCells(width, [], CLEAR, out)
1081 }
1082
1083 return Uint32Array.from(out)
1084}
1085hooks/png-lib.ts 327 lines1// 포켓몬 스프라이트 PNG 를 읽어 작은 도트로 줄인다. mod 환경에는 zlib 이 없어 inflate 를 직접 한다
2
3// 색이 없는 픽셀. Raster 의 터미널 기본색과 같은 값이라 그대로 칠하면 배경이 비친다
4export const CLEAR = 0x01000000
5
6export type Pixels = { width: number; height: number; px: number[] }
7
8// RFC 1951 inflate. 정적, 동적 허프만과 저장 블록을 모두 읽는다
9function inflate(data: Uint8Array): Uint8Array {
10 let pos = 0
11 let bit = 0
12 const out: number[] = []
13 const bits = (n: number) => {
14 let value = 0
15 for (let i = 0; i < n; i++) {
16 if (pos >= data.length) {
17 throw new Error('inflate: unexpected end')
18 }
19 value |= ((data[pos]! >> bit) & 1) << i
20 bit += 1
21 if (bit === 8) {
22 bit = 0
23 pos += 1
24 }
25 }
26 return value
27 }
28 type Huffman = { counts: number[]; symbols: number[] }
29 const build = (lengths: number[]): Huffman => {
30 const counts = new Array(16).fill(0)
31 for (const len of lengths) {
32 counts[len] += 1
33 }
34 counts[0] = 0
35 const offsets = new Array(16).fill(0)
36 for (let i = 1; i < 16; i++) {
37 offsets[i] = offsets[i - 1] + counts[i - 1]
38 }
39 const symbols = new Array(lengths.length).fill(0)
40 lengths.forEach((len, symbol) => {
41 if (len !== 0) {
42 symbols[offsets[len]++] = symbol
43 }
44 })
45 return { counts, symbols }
46 }
47 const decode = (h: Huffman) => {
48 let code = 0
49 let first = 0
50 let index = 0
51 for (let len = 1; len < 16; len++) {
52 code |= bits(1)
53 const count = h.counts[len]!
54 if (code - count < first) {
55 return h.symbols[index + (code - first)]!
56 }
57 index += count
58 first += count
59 first <<= 1
60 code <<= 1
61 }
62 throw new Error('inflate: bad code')
63 }
64 const LEN_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
65 const LEN_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
66 const DIST_BASE = [
67 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289,
68 16385, 24577,
69 ]
70 const DIST_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13]
71 const fixedLit = build([...new Array(144).fill(8), ...new Array(112).fill(9), ...new Array(24).fill(7), ...new Array(8).fill(8)])
72 const fixedDist = build(new Array(30).fill(5))
73 const ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]
74
75 let isLast = 0
76 while (!isLast) {
77 isLast = bits(1)
78 const type = bits(2)
79 if (type === 0) {
80 if (bit !== 0) {
81 bit = 0
82 pos += 1
83 }
84 const len = data[pos]! | (data[pos + 1]! << 8)
85 pos += 4
86 for (let i = 0; i < len; i++) {
87 out.push(data[pos++]!)
88 }
89 continue
90 }
91 let lit = fixedLit
92 let dist = fixedDist
93 if (type === 2) {
94 const nlen = bits(5) + 257
95 const ndist = bits(5) + 1
96 const ncode = bits(4) + 4
97 const codeLengths = new Array(19).fill(0)
98 for (let i = 0; i < ncode; i++) {
99 codeLengths[ORDER[i]!] = bits(3)
100 }
101 const codes = build(codeLengths)
102 const lengths: number[] = []
103 while (lengths.length < nlen + ndist) {
104 const symbol = decode(codes)
105 if (symbol < 16) {
106 lengths.push(symbol)
107 } else if (symbol === 16) {
108 const prev = lengths[lengths.length - 1] ?? 0
109 for (let n = bits(2) + 3; n > 0; n--) {
110 lengths.push(prev)
111 }
112 } else {
113 for (let n = symbol === 17 ? bits(3) + 3 : bits(7) + 11; n > 0; n--) {
114 lengths.push(0)
115 }
116 }
117 }
118 lit = build(lengths.slice(0, nlen))
119 dist = build(lengths.slice(nlen))
120 } else if (type !== 1) {
121 throw new Error('inflate: bad block')
122 }
123 for (;;) {
124 const symbol = decode(lit)
125 if (symbol < 256) {
126 out.push(symbol)
127 } else if (symbol === 256) {
128 break
129 } else {
130 const li = symbol - 257
131 const length = LEN_BASE[li]! + bits(LEN_EXTRA[li]!)
132 const di = decode(dist)
133 const back = DIST_BASE[di]! + bits(DIST_EXTRA[di]!)
134 const from = out.length - back
135 for (let i = 0; i < length; i++) {
136 out.push(out[from + i]!)
137 }
138 }
139 }
140 }
141 return Uint8Array.from(out)
142}
143
144// 인터레이스 없는 PNG 를 0xRRGGBB 픽셀로 읽는다. 반 넘게 투명한 픽셀은 CLEAR 다
145export function decodePng(bytes: Uint8Array): Pixels {
146 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
147 let pos = 8
148 let width = 0
149 let height = 0
150 let depth = 8
151 let colorType = 6
152 let palette: number[] = []
153 let alphas: number[] = []
154 const idat: number[] = []
155 while (pos < bytes.length) {
156 const len = view.getUint32(pos)
157 const type = String.fromCharCode(...bytes.subarray(pos + 4, pos + 8))
158 const body = bytes.subarray(pos + 8, pos + 8 + len)
159 if (type === 'IHDR') {
160 width = view.getUint32(pos + 8)
161 height = view.getUint32(pos + 12)
162 depth = body[8]!
163 colorType = body[9]!
164 if (body[12] !== 0) {
165 throw new Error('png: interlaced')
166 }
167 } else if (type === 'PLTE') {
168 palette = Array.from({ length: len / 3 }, (_, i) => (body[i * 3]! << 16) | (body[i * 3 + 1]! << 8) | body[i * 3 + 2]!)
169 } else if (type === 'tRNS') {
170 alphas = Array.from(body)
171 } else if (type === 'IDAT') {
172 for (const b of body) {
173 idat.push(b)
174 }
175 } else if (type === 'IEND') {
176 break
177 }
178 pos += 12 + len
179 }
180 // zlib 머리 두 바이트를 건너뛴다
181 const raw = inflate(Uint8Array.from(idat).subarray(2))
182 const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colorType]
183 if (channels === undefined || depth === 16) {
184 throw new Error(`png: color type ${colorType} depth ${depth}`)
185 }
186 const bpp = Math.max(1, (channels * depth) / 8)
187 const stride = Math.ceil((width * channels * depth) / 8)
188 const lines = new Uint8Array(stride * height)
189 let at = 0
190 for (let y = 0; y < height; y++) {
191 const filter = raw[at++]!
192 for (let x = 0; x < stride; x++) {
193 const a = x >= bpp ? lines[y * stride + x - bpp]! : 0
194 const b = y > 0 ? lines[(y - 1) * stride + x]! : 0
195 const c = x >= bpp && y > 0 ? lines[(y - 1) * stride + x - bpp]! : 0
196 let value = raw[at++]!
197 if (filter === 1) {
198 value += a
199 } else if (filter === 2) {
200 value += b
201 } else if (filter === 3) {
202 value += (a + b) >> 1
203 } else if (filter === 4) {
204 const p = a + b - c
205 const pa = Math.abs(p - a)
206 const pb = Math.abs(p - b)
207 const pc = Math.abs(p - c)
208 value += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
209 }
210 lines[y * stride + x] = value & 255
211 }
212 }
213 const sample = (y: number, index: number) => {
214 if (depth === 8) {
215 return lines[y * stride + index]!
216 }
217 const bitAt = index * depth
218 return (lines[y * stride + (bitAt >> 3)]! >> (8 - depth - (bitAt & 7))) & ((1 << depth) - 1)
219 }
220 const px: number[] = []
221 for (let y = 0; y < height; y++) {
222 for (let x = 0; x < width; x++) {
223 let rgb = 0
224 let alpha = 255
225 if (colorType === 3) {
226 const i = sample(y, x)
227 rgb = palette[i] ?? 0
228 alpha = alphas[i] ?? 255
229 } else if (colorType === 0 || colorType === 4) {
230 const g = sample(y, x * channels) * (255 / ((1 << depth) - 1))
231 rgb = (g << 16) | (g << 8) | g
232 alpha = colorType === 4 ? sample(y, x * channels + 1) : 255
233 } else {
234 rgb = (sample(y, x * channels) << 16) | (sample(y, x * channels + 1) << 8) | sample(y, x * channels + 2)
235 alpha = colorType === 6 ? sample(y, x * channels + 3) : 255
236 }
237 px.push(alpha < 128 ? CLEAR : rgb)
238 }
239 }
240 return { width, height, px }
241}
242
243// 투명한 테두리를 잘라 낸다
244export function crop(image: Pixels): Pixels {
245 let [left, top, right, bottom] = [image.width, image.height, -1, -1]
246 image.px.forEach((color, i) => {
247 if (color !== CLEAR) {
248 const x = i % image.width
249 const y = Math.floor(i / image.width)
250 left = Math.min(left, x)
251 right = Math.max(right, x)
252 top = Math.min(top, y)
253 bottom = Math.max(bottom, y)
254 }
255 })
256 if (right < 0) {
257 return { width: 1, height: 1, px: [CLEAR] }
258 }
259 const width = right - left + 1
260 const height = bottom - top + 1
261 const px: number[] = []
262 for (let y = top; y <= bottom; y++) {
263 for (let x = left; x <= right; x++) {
264 px.push(image.px[y * image.width + x]!)
265 }
266 }
267 return { width, height, px }
268}
269
270// 이보다 어두운 색은 테두리로 본다
271const OUTLINE_LUMA = 60
272const lumaOf = (color: number) => ((color >> 16) & 255) * 0.3 + ((color >> 8) & 255) * 0.59 + (color & 255) * 0.11
273
274// 비율을 지켜 size 안에 들어가게 줄인다. 칸마다 반 넘게 차 있으면 가장 많은 색으로 칠한다
275// 큰 포켓몬을 크게 줄이면 가는 테두리가 몸 색에 묻혀 뭉개지므로, 칸에 테두리가 넉넉히 있으면 테두리를 먼저 남긴다
276export function fit(image: Pixels, size: number): Pixels {
277 const scale = Math.max(1, Math.max(image.width, image.height) / size)
278 const width = Math.max(1, Math.round(image.width / scale))
279 const height = Math.max(1, Math.round(image.height / scale))
280 const px: number[] = []
281 for (let ty = 0; ty < height; ty++) {
282 for (let tx = 0; tx < width; tx++) {
283 const x0 = Math.floor(tx * scale)
284 const x1 = Math.max(x0 + 1, Math.floor((tx + 1) * scale))
285 const y0 = Math.floor(ty * scale)
286 const y1 = Math.max(y0 + 1, Math.floor((ty + 1) * scale))
287 const seen = new Map<number, number>()
288 let filled = 0
289 let dark = 0
290 let darkest = CLEAR
291 for (let y = y0; y < y1 && y < image.height; y++) {
292 for (let x = x0; x < x1 && x < image.width; x++) {
293 const color = image.px[y * image.width + x]!
294 if (color !== CLEAR) {
295 filled += 1
296 seen.set(color, (seen.get(color) ?? 0) + 1)
297 if (lumaOf(color) < OUTLINE_LUMA) {
298 dark += 1
299 darkest = darkest === CLEAR || lumaOf(color) < lumaOf(darkest) ? color : darkest
300 }
301 }
302 }
303 }
304 const area = (x1 - x0) * (y1 - y0)
305 if (dark * 3 >= area) {
306 px.push(darkest)
307 continue
308 }
309 if (filled * 2 < area) {
310 px.push(CLEAR)
311 continue
312 }
313 // 테두리가 조금 걸친 칸은 몸 색으로 칠한다
314 let best = CLEAR
315 let most = 0
316 for (const [color, count] of seen) {
317 if (lumaOf(color) >= OUTLINE_LUMA && count > most) {
318 best = color
319 most = count
320 }
321 }
322 px.push(best)
323 }
324 }
325 return { width, height, px }
326}
327hooks/sessions-lib.ts 255 lines1import type { Attention, BackgroundTask, Board, BoardEntry, Liveness, SessionStatus, TodoItem, TodoSummary } from '../types'
2import { parseIssue } from './issues-lib'
3
4// heartbeat 가 1분마다 오므로 3분 넘게 소식이 없으면 CLI 가 꺼진 것으로 본다
5export const STALE_MS = 3 * 60 * 1000
6// 끝났거나 응답이 없는 세션은 이만큼만 패널에 남긴다
7export const KEEP_MS = 60 * 60 * 1000
8// 이보다 오래된 상태 파일은 지운다
9export const PRUNE_MS = 24 * 60 * 60 * 1000
10
11const PROMPT_MAX = 60
12
13// iTerm2 Code Review 탭이 리뷰 세션에 보내는 기본 프롬프트의 첫머리
14const CODE_REVIEW_PROMPT = 'Review the pending changes in this repo'
15
16export const isCodeReviewPrompt = (prompt: string | null | undefined): boolean =>
17 prompt?.startsWith(CODE_REVIEW_PROMPT) === true
18
19// iTerm2 Code Review 탭이 띄운 세션. 표시가 없는 예전 상태 파일은 마지막 요청으로 알아본다
20export const isCodeReviewSession = (session: Pick<SessionStatus, 'isCodeReview' | 'prompt'>): boolean =>
21 session.isCodeReview === true || isCodeReviewPrompt(session.prompt)
22
23// 요청의 첫 줄만 짧게 남긴다
24export const firstLine = (text: string): string | null => {
25 const line = text
26 .split('\n')
27 .map(s => s.trim())
28 .find(Boolean)
29 if (!line) {
30 return null
31 }
32 return line.length > PROMPT_MAX ? `${line.slice(0, PROMPT_MAX)}…` : line
33}
34
35export const projectName = (root: string): string => root.split('/').filter(Boolean).pop() ?? root
36
37export const summarizeTodos = (items: readonly TodoItem[]): TodoSummary | null => {
38 if (items.length === 0) {
39 return null
40 }
41 const current = items.find(item => item.status === 'in_progress')
42 return {
43 done: items.filter(item => item.status === 'completed').length,
44 total: items.length,
45 current: current?.label ?? null,
46 }
47}
48
49// TaskCreate·TaskUpdate 는 한 항목씩 오므로 목록을 직접 고쳐 간다
50export const applyTaskUpdate = (
51 items: readonly TodoItem[],
52 id: string,
53 patch: { status?: string; label?: string },
54): TodoItem[] => {
55 if (patch.status === 'deleted') {
56 return items.filter(item => item.id !== id)
57 }
58 return items.map(item =>
59 item.id !== id
60 ? item
61 : {
62 ...item,
63 label: patch.label ?? item.label,
64 status:
65 patch.status === 'pending' || patch.status === 'in_progress' || patch.status === 'completed'
66 ? patch.status
67 : item.status,
68 },
69 )
70}
71
72export const livenessOf = (session: SessionStatus, now: number): Liveness => {
73 if (session.state === 'ended') {
74 return 'ended'
75 }
76 return now - session.updatedAt > STALE_MS ? 'stale' : session.state
77}
78
79// 마크다운 꾸밈을 벗긴 마지막 줄. 코드 블록 닫는 줄은 건너뛴다
80export const lastLine = (text: string): string | null =>
81 text
82 .split('\n')
83 .map(line => line.trim().replace(/^[#>*\-\s]+/, '').replace(/[*_`\s]+$/, ''))
84 .filter(line => line !== '' && !line.startsWith('```'))
85 .pop() ?? null
86
87export const isQuestion = (text: string): boolean => /[??]\)?$/.test(lastLine(text) ?? '')
88
89// 메인 턴이 끝난 뒤 세션이 나를 기다리는 이유. 내가 끊은 턴은 내가 보고 있으니 기다리는 것으로 치지 않는다
90export const attentionAfterTurn = (answer: string, isAborted: boolean, now: number): Attention | null => {
91 if (isAborted) {
92 return null
93 }
94 return isQuestion(answer)
95 ? { kind: 'question', since: now, text: firstLine(lastLine(answer) ?? '') }
96 : { kind: 'answered', since: now, text: null }
97}
98
99// 끝났다는 알림을 놓친 작업이 세션을 진행 중에 묶어 두지 않게, 이만큼 지난 작업은 끝난 것으로 본다
100export const BACKGROUND_MAX_MS = 60 * 60 * 1000
101
102// 도구 결과에서 백그라운드로 띄운 작업 id 를 뽑는다. Bash 는 backgroundTaskId, Agent 는 async_launched 의 agentId
103export const launchedTaskId = (result: unknown): string | null => {
104 if (!result || typeof result !== 'object') {
105 return null
106 }
107 const raw = result as { backgroundTaskId?: unknown; status?: unknown; agentId?: unknown }
108 if (typeof raw.backgroundTaskId === 'string' && raw.backgroundTaskId) {
109 return raw.backgroundTaskId
110 }
111 const isLaunched = raw.status === 'async_launched' || raw.status === 'remote_launched'
112 return isLaunched && typeof raw.agentId === 'string' && raw.agentId ? raw.agentId : null
113}
114
115// <task-notification> 입력에서 끝난 작업 id 를 뽑는다. 아직 도는 중이라는 알림은 빼고 본다
116// 서브에이전트가 결과를 돌려준 <agent-message> 도 끝난 것으로 본다. transcript 줄에서는 따옴표가 \" 로 escape 돼 있다
117export const finishedTaskIds = (text: string): string[] => [
118 ...[...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)]
119 .map(match => match[1] ?? '')
120 .filter(body => !/<status>\s*(running|pending)\s*<\/status>/.test(body))
121 .map(body => body.match(/<task-id>\s*([^<\s]+)\s*<\/task-id>/)?.[1] ?? null)
122 .filter((id): id is string => id !== null),
123 ...[...text.matchAll(/<agent-message from=\\?"([^"\\]+)\\?">(?:\s|\\n)*\[Subagent hand-back\]/g)].map(
124 match => match[1] ?? '',
125 ),
126]
127
128// 끝난 것과 너무 오래된 것을 뺀, 아직 도는 백그라운드 작업
129export const pendingTasks = (
130 tasks: readonly BackgroundTask[],
131 now: number,
132 finished: readonly string[] = [],
133): BackgroundTask[] => tasks.filter(task => !finished.includes(task.id) && now - task.at < BACKGROUND_MAX_MS)
134
135const KIND_ORDER: Record<Attention['kind'], number> = { permission: 0, question: 1, answered: 2 }
136
137const entryOf = (session: SessionStatus, since: number, withProject: boolean): BoardEntry => {
138 const name = (session.branch && parseIssue(session.branch)) ?? session.branch ?? session.project
139 return {
140 id: session.id,
141 branch: session.branch,
142 title: session.title ?? null,
143 label: withProject && name !== session.project ? `${session.project} ${name}` : name,
144 prompt: session.prompt,
145 todos: session.todos,
146 check: session.check,
147 since,
148 attention: session.attention ?? null,
149 }
150}
151
152// 세션을 내 차례, 진행 중, 빈 대기, 숨길 것으로 나눈다. 내 차례는 막힌 정도와 오래 기다린 순서로 둔다
153// merged 는 MR 이 머지된 브랜치. 그 브랜치에서 답을 끝낸 세션은 할 일이 끝난 것이라 빈 대기로 둔다
154export const triage = (
155 sessions: readonly SessionStatus[],
156 ownId: string | null,
157 now: number,
158 merged: ReadonlySet<string> = new Set(),
159): Board => {
160 const hidden = { stale: 0, ended: 0 }
161 const mine: Array<[SessionStatus, number]> = []
162 const running: Array<[SessionStatus, number]> = []
163 const idle: Array<[SessionStatus, number]> = []
164 for (const session of sessions) {
165 if (session.id === ownId || isCodeReviewSession(session)) {
166 continue
167 }
168 const liveness: Liveness = livenessOf(session, now)
169 if (liveness === 'stale' || liveness === 'ended') {
170 if (now - session.updatedAt <= KEEP_MS) {
171 hidden[liveness] += 1
172 }
173 continue
174 }
175 // 질문 도구와 권한 창은 턴 도중에 뜨므로 작업 중이어도 내 차례다. 새 요청을 보내면 attention 이 비워진다
176 const attention = session.attention ?? null
177 const isMergedDone = attention?.kind === 'answered' && session.branch !== null && merged.has(session.branch)
178 if (attention && !isMergedDone) {
179 mine.push([session, attention.since])
180 } else if (liveness === 'working') {
181 running.push([session, session.workingSince ?? session.updatedAt])
182 } else {
183 idle.push([session, attention?.since ?? session.waitingSince ?? session.updatedAt])
184 }
185 }
186 const hasManyProjects = new Set([...mine, ...running].map(([session]) => session.project)).size > 1
187 const toEntry = ([session, since]: [SessionStatus, number]) => entryOf(session, since, hasManyProjects)
188 return {
189 mine: mine
190 .map(toEntry)
191 .sort((a, b) => KIND_ORDER[a.attention!.kind] - KIND_ORDER[b.attention!.kind] || a.since - b.since),
192 running: running.map(toEntry).sort((a, b) => a.since - b.since),
193 // 어느 프로젝트에 열어 둔 것인지가 핵심이라 늘 프로젝트를 붙인다
194 idle: idle.map(([session, since]) => entryOf(session, since, true)).sort((a, b) => a.since - b.since),
195 hidden,
196 }
197}
198
199export const formatAgo = (ms: number): string => {
200 const minutes = Math.floor(ms / 60_000)
201 if (minutes < 1) {
202 return 'just now'
203 }
204 if (minutes < 60) {
205 return `${minutes}m ago`
206 }
207 const hours = Math.floor(minutes / 60)
208 return hours < 24 ? `${hours}h ago` : `${Math.floor(hours / 24)}d ago`
209}
210
211// 기다리거나 일한 시간. 오래될수록 병목이다
212export const formatFor = (ms: number): string => {
213 const minutes = Math.floor(ms / 60_000)
214 if (minutes < 1) {
215 return 'just now'
216 }
217 if (minutes < 60) {
218 return `for ${minutes}m`
219 }
220 const hours = Math.floor(minutes / 60)
221 return hours < 24 ? `for ${hours}h` : `for ${Math.floor(hours / 24)}d`
222}
223
224// transcript 에서 /resume 목록에 보이는 이름을 고른다. /rename 으로 지은 이름이 자동 제목보다 먼저고, 둘 다 마지막 것이 지금 이름이다
225export const pickTitle = (lines: string): string | null => {
226 let custom: string | null = null
227 let ai: string | null = null
228 for (const line of lines.split('\n')) {
229 try {
230 const entry = JSON.parse(line) as { type?: unknown; customTitle?: unknown; aiTitle?: unknown }
231 if (entry.type === 'custom-title' && typeof entry.customTitle === 'string' && entry.customTitle) {
232 custom = entry.customTitle
233 } else if (entry.type === 'ai-title' && typeof entry.aiTitle === 'string' && entry.aiTitle) {
234 ai = entry.aiTitle
235 }
236 } catch {
237 // 잘린 줄은 건너뛴다
238 }
239 }
240 return custom ?? ai
241}
242
243// Claude Code 가 transcript 를 두는 곳. 프로젝트 경로의 영숫자 아닌 글자를 - 로 바꾼 폴더다
244export const transcriptPathOf = (home: string, projectDir: string, id: string): string =>
245 `${home}/.claude/projects/${projectDir.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
246
247// 공유 캐시가 언제 조회됐고 다음 조회가 언제인지. 주기가 지났으면 어느 세션이든 곧 다시 부른다
248export const freshness = (fetchedAt: number | null, ttlMs: number, now: number): string => {
249 if (fetchedAt === null) {
250 return 'not fetched yet'
251 }
252 const left = Math.ceil((fetchedAt + ttlMs - now) / 60_000)
253 return `fetched ${formatAgo(now - fetchedAt)}, ${left > 0 ? `next in ${left}m` : 'refreshing soon'}`
254}
255types/index.d.ts 223 lines1export type Check = { name: string; isOk: boolean; at: number }
2
3export type TodoItem = { id: string; label: string; status: 'pending' | 'in_progress' | 'completed' }
4
5export type TodoSummary = { done: number; total: number; current: string | null }
6
7// 이슈 패널이 worktree 에 세션을 붙일 때 쓰는 부분만
8export type TreeSession = {
9 id: string
10 cwd: string
11 state: 'working' | 'waiting' | 'ended'
12 updatedAt: number
13 // 입력 대기에 들어간 시각. heartbeat 가 updatedAt 을 계속 바꾸므로 따로 둔다
14 waitingSince?: number
15 check?: Check
16}
17
18// 세션이 나를 기다리는 이유. permission 은 작업 중에도 생긴다
19export type Attention = {
20 kind: 'permission' | 'question' | 'answered'
21 since: number
22 // 권한 요청 문구나 질문 한 줄. answered 는 null
23 text: string | null
24}
25
26// 모든 세션이 ~/.claude/mod-sessions/<id>.json 에 쓰는 상태
27export type SessionStatus = TreeSession & {
28 root: string
29 project: string
30 branch: string | null
31 prompt: string | null
32 todos: TodoSummary | null
33 attention?: Attention | null
34 // /resume 목록에 보이는 이름. /rename 으로 지은 이름이나 자동 제목
35 title?: string | null
36 // 마지막 요청을 받은 시각
37 workingSince?: number
38 // 백그라운드로 띄우고 아직 끝났다는 알림을 받지 못한 작업
39 background?: BackgroundTask[]
40 // 턴은 끝났고 백그라운드 작업만 기다리는 중. heartbeat 가 작업이 끝났는지 다시 본다
41 waitingOnTasks?: boolean
42 // 이 세션 브랜치의 MR 상태(opened, merged, closed). 포켓몬 알이 금 가고 부화하는 기준이다
43 mrState?: string | null
44 // mrState 를 조회한 브랜치. 지금 브랜치와 다르면 mrState 는 모르는 것으로 친다. 없으면 이 필드가 생기기 전의 상태 파일이다
45 mrBranch?: string | null
46 // /mod-hud dashboard 로 대시보드라고 표시한 세션. 포켓몬 알이 없다
47 isDashboard?: boolean
48 // iTerm2 Claude Code 연동의 Code Review 탭이 띄운 리뷰 세션. 목록과 포켓몬 알에서 뺀다
49 isCodeReview?: boolean
50 // 세션이 끝난 이유(prompt_input_exit, clear, resume 등)
51 endReason?: string
52 // /clear 나 /resume 로 이 세션 직전에 같은 창에서 끝난 세션 id. 그 세션의 알을 물려받는다
53 predecessor?: string
54}
55
56// 백그라운드 Bash 의 backgroundTaskId 나 백그라운드 Agent 의 agentId 와 띄운 시각
57export type BackgroundTask = { id: string; at: number }
58
59export type Liveness = 'working' | 'waiting' | 'stale' | 'ended'
60
61// 패널 한 줄에 보일 세션
62export type BoardEntry = {
63 id: string
64 label: string
65 // /resume 목록에서 찾을 수 있는 이름
66 title: string | null
67 branch: string | null
68 prompt: string | null
69 todos: TodoSummary | null
70 check?: Check
71 // 내 차례면 기다리기 시작한 시각, 진행 중이면 요청을 받은 시각
72 since: number
73 attention: Attention | null
74}
75
76// 세션을 내가 할 일 기준으로 나눈 것. 이 세션은 넣지 않는다
77export type Board = {
78 mine: BoardEntry[]
79 running: BoardEntry[]
80 // 요청 없이 열려만 있는 세션. since 는 열린 시각
81 idle: BoardEntry[]
82 hidden: { stale: number; ended: number }
83}
84
85export type SessionsMeta = { refreshedAt: number | null; ownId: string | null }
86
87// action 은 열린 MR 에서 내가 움직여야 하는 것. 그 밖의 상태는 null
88export type Mr = { iid: number; state: string; sha: string; url: string; action: 'mergeable' | 'conflict' | null }
89
90export type Tree = { path: string; head: string; branch: string | null; isMain: boolean }
91
92export type Row = Tree & {
93 issue: string | null
94 ahead: number
95 dirty: number
96 session: TreeSession | null
97 mr: Mr | null
98 linear: string | null
99 note: string | null
100}
101
102export type IssuesMeta = { refreshedAt: number | null; isRefreshing: boolean; log: string[] }
103
104// 같은 저장소의 세션이 같이 쓰는 ~/.claude/ 아래 파일. 한 세션만 git, GitLab, Linear 를 부르고 나머지는 이 파일을 읽는다
105export type IssuesCache = {
106 fetchedAt: number | null
107 rows: Row[]
108 // 살아 있는 세션이 일한 브랜치의 MR. 세션 줄의 MR 링크와 머지 여부에 쓴다
109 branchMrs: Record<string, Mr>
110 // 이번 조회에서 MR 을 찾아본 브랜치. branchMrs 에 없으면 MR 이 없는 것이다. 여기 없는 브랜치는 아직 모른다
111 checked: string[]
112 // 최근 정리 기록
113 log: string[]
114 lease: { by: string; at: number } | null
115}
116
117// Linear 알림 한 건. get_notifications 응답에서 쓰는 필드만
118export type InboxItem = {
119 id: string
120 type: string
121 category: string | null
122 title: string
123 subtitle: string | null
124 url: string | null
125 issue: string | null
126 createdAt: number
127}
128
129// 모든 세션이 같이 쓰는 ~/.claude/mod-inbox.json. 한 세션만 Linear 를 부르고 나머지는 이 파일을 읽는다
130export type InboxCache = {
131 fetchedAt: number | null
132 // 마지막으로 조회에 성공한 시각. fetchedAt 과 다르면 마지막 조회가 실패한 것
133 okAt: number | null
134 notifications: InboxItem[]
135 // 이미 토스트했거나 첫 조회 때 있던 알림 id
136 seenIds: string[]
137 // 지금 조회를 맡은 세션
138 lease: { by: string; at: number } | null
139}
140
141export type Inbox = { items: InboxItem[]; fetchedAt: number | null; isOk: boolean }
142
143// 세션 정보를 붙인 포켓몬 알. key 는 알 이름, species 는 1세대 도감 번호
144export type DexEgg = { key: string; species: number; root: string; branch: string | null; label: string; at: number }
145
146// 키우미집에 있거나 세션이 든 알
147export type HeldEgg = { key: string; species: number }
148
149// 박스로 간 포켓몬
150export type BoxEntry = DexEgg
151
152// 모든 저장소와 세션이 같이 쓰는 ~/.claude/mod-hud-pokedex.json. 살아 있는 세션 중 id 가 가장 작은 세션만 쓴다
153export type Pokedex = {
154 // 마지막으로 키우미집을 채운 로컬 날짜 YYYY-MM-DD. 빈 문자열이면 아직 안 채웠다
155 day: string
156 // 알 key 와 그 알의 MR 이 처음 머지된 걸 본 시각
157 hatched: Record<string, number>
158 // 세션은 닫혔지만 MR 이 열려 있어 머지를 기다리는 알
159 pending: Array<DexEgg & { mrState: string }>
160 // 진행 중인 세션이 받아 갈 알. 매일 새로 채우고, MR 없이 닫힌 세션의 알도 돌아온다
161 daycare: HeldEgg[]
162 // 최근에 잡은 것이 앞
163 box: BoxEntry[]
164 // 세션 id 와 그 세션이 든 알. 세션이 닫혀도 남겨 두고 잠자기에서 돌아올 때 되찾는 데 쓴다
165 holds: Record<string, HeldEgg>
166 // 이미 정리한 닫힌 세션 id
167 done: string[]
168 // 세션 id 와 머지돼도 알이 깨지지 않을 브랜치. 박스로 보낸 브랜치나 알을 받을 때 이미 머지된 브랜치다
169 sent: Record<string, string | null>
170 // 앞장선 포켓몬이 먹은 경험치(일한 시간 ms). 막대는 하나라 앞장이 바뀌어도 남는다
171 xp: number
172 // 경험치를 마지막으로 더한 시각. 0 이면 아직 안 더했다
173 xpAt: number
174 // 앞장선 박스 포켓몬의 key. chosen 이면 사용자가 고른 것
175 lead: string | null
176 chosen: boolean
177 // 지금까지 박스에 들어온 종. 진화해서 사라진 종도 남는다
178 seen: number[]
179 // 이미 받은 전설
180 legends: number[]
181 // 가장 최근 진화. 모든 세션의 패널이 깜빡임을 그리는 데 쓴다
182 evolving: { key: string; from: number; at: number } | null
183 // 지금 나타나 있는 야생 전설. 몬스터볼을 던지면 잡히고, Run 하거나 한 시간이 지나면 달아난다
184 encounter: { species: number; at: number; root: string; branch: string | null; label: string } | null
185 // 가장 최근 전설 장면. appear 는 나타나는 장면, catch 는 잡는 장면. 모든 세션의 패널이 겹쳐 띄우는 데 쓴다
186 scene: { kind: 'appear' | 'catch'; species: number; at: number } | null
187 // 방금 달아난 전설. 사용자가 닫을 때까지 안내가 남는다
188 fled: { species: number } | null
189 // 달아난 전설. 새는 그 타입 최종 진화형을 다시 만들 때, 뮤츠와 뮤는 새 포켓몬이 박스에 들어올 때 다시 나타난다
190 escaped: number[]
191 // 다른 전설이 나타나 있어 차례를 기다리는 전설. 앞의 전설을 잡거나 놓치고 장면이 끝나면 이어서 나타난다
192 waiting: number[]
193}
194
195// 패널에 그릴 알 하나
196export type NestEgg = {
197 key: string
198 label: string
199 species: number
200 // 알을 못 받은 세션은 empty, 빈 둥지만 보인다
201 stage: 'empty' | 'egg' | 'cracked' | 'hatched'
202 isShaking: boolean
203 // 세션이 나를 기다리면 mine, 일하는 중이면 working. 라벨 색을 패널의 내 차례, 진행 중과 같게 칠한다
204 activity: 'mine' | 'working' | null
205 // 처음 머지된 걸 본 시각. 이 뒤 잠깐 동안 높이 뛴다
206 hatchedAt: number | null
207}
208
209declare module 'claude-code' {
210 interface PluginState {
211 'mod-hud': {
212 rows: Row[]
213 branchMrs: Record<string, Mr>
214 issuesMeta: IssuesMeta
215 board: Board
216 sessionsMeta: SessionsMeta
217 inbox: Inbox
218 pokedex: Pokedex
219 eggs: NestEgg[]
220 }
221 }
222}
223