실행 중인 세션의 종류와 상태를 입력창 위에 표시하고, 세션 패널에서 이 PC의 모든 대화를 폴더·날짜별로 관리합니다.

Claude Code를 더 빠르고 편하게 쓰는 한국어 mod 모음
Claude Code에 입력창 위 대시보드, 명령 팔레트, 세션 관리 패널을 추가합니다.
<img src="docs/images/hero-light.png" width="928" alt="Claude Code 입력창 위에 clickbar의 상태 줄과 버튼 줄, session-tracker의 세션 줄이 표시된 화면">
<sub>화면 예시입니다. 대화·폴더 이름은 가상의 예시입니다.</sub>
| mod | 한 줄 요약 | 명령 | | :- | :- | :- | | clickbar | 입력창 위에 컨텍스트·사용량 현황과 주요 명령 버튼을 표시하고, 명령·스킬·단축키·대화 기록을 한곳에서 검색해 실행하는 팔레트를 제공합니다 | /palette /idea /tips /assumptions /bubbles | | session-tracker | 실행 중인 세션의 종류와 상태를 입력창 위에 표시하고, 이 PC의 모든 대화를 폴더·날짜별로 관리합니다 | /sessions /sessions-here |
Claude Code v2.1.299 이상이 필요합니다. 터미널에서 다음 명령을 실행합니다.
claude plugin marketplace add SJvaca30/mulgogi-mods
claude plugin install clickbar@mulgogi-mods
claude plugin install session-tracker@mulgogi-mods
필요한 mod만 선택해 설치할 수 있습니다. 실행 중인 세션에는 /reload-plugins를 입력하면 바로 적용됩니다. /plugin 화면 상단에 2 mods active · clickbar, session-tracker가 표시되면 설치가 완료된 것입니다.
[!TIP] Claude Code 안에서 명령 한 줄로 설치할 수도 있습니다:
/plugin install clickbar --marketplace SJvaca30/mulgogi-mods
[compact now] 버튼이 나타납니다.[compact] [context]), 대화([resume] [sessions] [fork]), 답변([copy] [quote] [btw]), 도구([tasks] [palette]) 순서입니다. 화면이 좁으면 일부 버튼이 숨겨지며, 팔레트에서 실행할 수 있습니다./bubbles card(막대 없는 카드) · /bubbles mark(❯ 표식) · /bubbles off(끄기)로 모양을 바꿀 수 있고, Ctrl+O 전체 기록에서는 원래 모양으로 보입니다.✶ Pondering · 파일 2개 수정 · 도구 7회…).<img src="docs/images/palette-light.png" width="928" alt="명령 팔레트: 탭과 사용 한도, 고정한 명령, 대화·세션 명령, 설치한 스킬이 한국어 설명과 함께 표시된 화면">
[고정]으로 맨 위에 고정할 수 있습니다.[/명령]은 즉시 실행하고, [넣기]는 인자를 입력할 수 있도록 입력창에 명령만 넣습니다./idea로 저장한 메모), 가정(Claude의 가정 검토).Claude가 확인 없이 정한 가정을 log_assumption 도구로 기록합니다. 입력창 위 [assumptions N] 버튼이나 팔레트의 가정 탭에서 맞음·틀림을 선택하면, 틀린 가정만 수정하도록 요청합니다.
<img src="docs/images/sessions-light.png" width="928" alt="세션 패널: 보기 탭과 상태 필터, 고정한 대화와 폴더별 대화 목록, 상태·종류·관리 버튼이 표시된 화면">
/sessions, 같은 폴더 목록은 /sessions-here)./sessions): 실행 중인 대화와 기록만 남은 대화(유휴)를 폴더별·날짜별로 조회하고, 상태(켜짐·유휴)로 필터링합니다. 필터 버튼에는 해당 대화 수가 함께 표시됩니다. 줄 끝 [관리]에서 고정·이름 변경·보관·종료를 할 수 있으며, Claude가 답변하는 중에도 바로 열립니다.[이동]으로 해당 세션의 탭으로 전환하고, [열기]로 종료된 대화를 새 탭에서 다시 엽니다. 포그라운드 세션은 시작할 때 자신이 실행 중인 탭(Orca 탭·herdr 패널)을 세션 상태에 기록하므로, 이름을 붙이지 않은 세션도 정확한 탭으로 전환합니다. 백그라운드 세션은 탭 제목(세션 이름)으로 찾고, 탭이 없으면 새 탭에서 연결합니다. 백그라운드 세션을 claude agents 화면이나 이 mod가 연 탭에서 보고 있으면, 그 세션의 [이동]·[열기]는 처음 실행한 곳이 아니라 지금 보고 있는 프로그램(Orca·herdr)의 탭을 사용합니다. 직접 연 탭에서 claude attach로 보고 있으면 처음 실행한 곳을 따릅니다. 새 탭은 Claude 화면이 뜬 것을 확인한 뒤에만 앞으로 옮기고 그동안 그 줄에 "여는 중…"을 표시합니다. 연결할 작업이 이미 정리됐으면 실패한 탭을 닫고 기록으로 다시 엽니다. 다른 창에서 실행 중인 포그라운드 세션은 기록이 섞이지 않도록 새로 열지 않고 안내만 합니다. herdr는 터미널 안에서 실행하는 프로그램이므로 PowerShell·Windows Terminal에서도 herdr 안에서 Claude를 실행하면 같은 기능을 사용할 수 있습니다. 일반 터미널에서는 [여기서 열기](/resume <id>)와 [명령 복사]를 제공합니다.명령 팔레트와 세션 패널은 머리 줄 구성이 같습니다. 왼쪽에 탭(선택한 탭은 색을 채워 표시), 오른쪽 끝에 [닫기]가 있고 Esc로도 닫을 수 있습니다.
| 항목 | 내용 | | :- | :- | | Claude Code | v2.1.299 이상. mod 기능은 v2.1.287부터 제공되지만, 2.1.296 이하에서는 clickbar의 입력창 위 표시가 나타나지 않는 것을 확인했습니다 | | 테스트 환경 | Windows 11(Claude Code 2.1.299~2.1.301, Orca 터미널·herdr 0.9.4), macOS(자동 테스트와 Orca 터미널). Linux와 데스크톱 앱의 Code 탭은 테스트하지 않았습니다 | | 선택 사항 | Orca 또는 herdr: 탭 전환·새 탭 열기(다른 세션 이름 변경은 Orca만) / PowerShell 7(pwsh): 종료된 대화의 제목 표시(없으면 첫 질문을 표시합니다) | | 표시되지 않는 환경 | VS Code 확장의 채팅 창, claude -p(공식 문서 기준) |
[단축키] 탭은 macOS에서 Option 키(Option+P 등)와 이미지 붙여넣기 Ctrl+V로 표시합니다. Option+P를 눌렀을 때 π가 입력되면, 터미널에서 Option을 Alt로 전달하도록 설정합니다(Orca: 설정 → Terminal → Option as Alt).
[이동]·[열기]를 누르면 해당 줄 아래에 [여기서 열기]와 [명령 복사]가 표시됩니다. [여기서 열기]는 내장 /resume <id>로 현재 터미널에서 대화를 엽니다. 실행 중인 백그라운드 세션이면 현재 대화를 백그라운드로 전환하고 해당 세션에 연결합니다(Claude Code v2.1.285 이상, ←로 돌아옵니다). 다른 폴더의 종료된 대화는 [명령 복사]만 제공합니다. 다른 창에서 실행 중인 대화형 세션은 "다른 창"으로만 표시합니다. 현재 터미널에서 열면 두 터미널의 메시지가 하나의 기록에 섞이기 때문입니다.
Windows Terminal은 열린 탭 목록이나 탭에서 실행 중인 세션을 알려 주는 명령이 없어 탭 이동을 지원하지 않습니다. PowerShell이나 Windows Terminal에서 탭 이동을 쓰려면 그 안에서 herdr를 실행합니다.
| 기능 | 동작 | 끄는 방법 | | :- | :- | :- | | 가정 기록(clickbar) | Claude에게 log_assumption 도구를 제공합니다 | /assumptions off | | 명령 추천(clickbar) | 질문에 맞는 내장 명령을 한 번씩 추천합니다. 질문 내용은 변경하지 않습니다 | /tips off | | 작업 겹침 안내(session-tracker) | 같은 폴더의 다른 세션이 작업 중이거나 입력을 기다리면 질문에 그 세션의 이름과 요청을 알리는 안내 문구 한 줄을 추가합니다 | /sessions-here off | | 이름 변경 대리 입력(session-tracker) | 다른 세션의 이름을 변경하면 해당 세션의 Orca 탭에 /rename 새 이름을 입력합니다(Orca에서만). 승인 대기 중이 아니고 입력창이 비어 있을 때만 입력합니다 | 사용하지 않으면 동작하지 않습니다 |
[btw], 아이디어 탭의 [보내기], 가정 탭의 [틀린 가정 수정 요청]은 누르면 Claude에게 질문을 전송합니다. 누르지 않으면 아무것도 전송하지 않습니다.
~/.claude/plugins/store/에, 세션 상태는 ~/.claude/mods-state/session-tracker/에 저장합니다. 모두 이 PC에만 저장됩니다.claude plugin validate ./clickbar를 실행하면, mod가 사용하는 이벤트와 기능 목록을 확인할 수 있습니다.아닙니다. 팔레트 화면에만 표시됩니다. Claude가 스킬을 선택할 때 참고하는 SKILL.md의 설명은 변경되지 않습니다. / 메뉴의 한국어 설명도 Claude Code 내장 명령의 표시 문구에만 적용됩니다.
아닙니다. 팔레트는 해당 PC에 설치된 명령과 스킬만 표시합니다. 한국어 설명이 준비된 스킬은 용도별 분류에, 나머지는 "그 밖의 명령"에 원래 설명으로 표시됩니다.
마우스로 선택한 글의 각 줄 앞에 > 를 붙여 입력창에 넣습니다. Markdown 인용 표시이므로 Claude는 이 부분을 인용문으로 인식합니다. 그 아래에 질문을 이어서 작성하면 됩니다.
claude --version이 2.1.299 이상인지 확인합니다. 오래 실행 중인 백그라운드 세션은 시작 시점의 버전으로 계속 실행되므로, claude stop <id>로 종료한 뒤 claude attach <id>로 다시 엽니다. 회사 계정은 관리자가 사용자 설치 mod를 차단했을 수 있습니다.
claude plugin marketplace update mulgogi-mods # 마켓 목록 갱신
claude plugin update clickbar@mulgogi-mods # mod별 업데이트
claude plugin uninstall clickbar@mulgogi-mods # 삭제
자동 업데이트는 기본적으로 꺼져 있습니다. /plugin → Marketplaces → mulgogi-mods → Enable auto-update에서 켤 수 있습니다. 업데이트 후에는 /reload-plugins를 입력하거나 세션을 다시 시작합니다.
GitHub SSH 키가 없는 PC에서 Claude Code 안의 설치·업데이트가 실패하면, 환경 변수 CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1을 설정한 뒤 다시 시도합니다.
hooks/register.tsx 1307 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4import type { AgentRow, OnOffFilter, SessionItem, SessionRecord, SessionStatus, TermLocator, TitleEntry, ViewMode } from '../types'
5import { herdrHost, herdrPaneByLabel, herdrServerRunning, herdrShows, locatorFromEnv, orcaHost } from './hosts'
6import type { TerminalHost } from './hosts'
7import { cleanName, clipName, composerHasText, findTerminal, kindWord, launchVerdict, parseAgents, selfKindView, stateCounts, stateWord } from './panel'
8import type { Tone } from './panel'
9import { HEARTBEAT_MS, PROMPT_MAX, SKIP_OLDER_MS, STATUS_WORDS, activePeers, alivePeers, firstLine, overlapNote, parseRecord, rootKey, spanText } from './records'
10import {
11 DEFAULT_VIEW,
12 TITLE_SCRIPT,
13 arrange,
14 buildItems,
15 isScratchProject,
16 parseHistory,
17 pathKey,
18 reopenCommand,
19 parseIdList,
20 parseTitleScan,
21 parseView,
22 sinceText,
23} from './sessions'
24import type { HistoryInfo, TranscriptFile } from './sessions'
25
26const peers = atom({ plugin: 'session-tracker', key: 'peers' } as const, [])
27const agents = atom({ plugin: 'session-tracker', key: 'agents' } as const, [])
28const sessions = atom({ plugin: 'session-tracker', key: 'sessions' } as const, [])
29const view = atom({ plugin: 'session-tracker', key: 'view' } as const, DEFAULT_VIEW)
30const pins = atom({ plugin: 'session-tracker', key: 'pins' } as const, [])
31const archived = atom({ plugin: 'session-tracker', key: 'archived' } as const, [])
32const showArchived = atom({ plugin: 'session-tracker', key: 'showArchived' } as const, false)
33const managing = atom({ plugin: 'session-tracker', key: 'managing' } as const, '')
34const expanded = atom({ plugin: 'session-tracker', key: 'expanded' } as const, [])
35const showLegend = atom({ plugin: 'session-tracker', key: 'showLegend' } as const, false)
36// 묶음마다 처음에 보이는 꺼진 대화 수. 나머지는 [유휴 N개 더 보기]로 편다
37const OFF_PREVIEW = 5
38const subagents = atom({ plugin: 'session-tracker', key: 'subagents' } as const, 0)
39const readAt = atom({ plugin: 'session-tracker', key: 'readAt' } as const, 0)
40const selfKind = atom({ plugin: 'session-tracker', key: 'selfKind' } as const, '')
41const renaming = atom({ plugin: 'session-tracker', key: 'renaming' } as const, '')
42const renameDraft = atom({ plugin: 'session-tracker', key: 'renameDraft' } as const, '')
43const stopArmed = atom({ plugin: 'session-tracker', key: 'stopArmed' } as const, '')
44const orcaReady = atom({ plugin: 'session-tracker', key: 'orcaReady' } as const, false)
45const host = atom({ plugin: 'session-tracker', key: 'host' } as const, '')
46const opening = atom({ plugin: 'session-tracker', key: 'opening' } as const, '')
47const launching = atom({ plugin: 'session-tracker', key: 'launching' } as const, [])
48// 새 탭에서 Claude가 뜨기를 기다리는 최대 시간과 화면을 다시 읽는 간격. 넘기면 그 탭으로 옮기고 아직 여는 중이라고 알린다
49const LAUNCH_TIMEOUT_MS = 15_000
50const LAUNCH_POLL_MS = 800
51// 다른 세션이 /rename을 처리할 때까지 기다리는 시간, [끄기]를 한 번 누른 뒤 두 번째 누름을 기다리는 시간
52const RENAME_SETTLE_MS = 1500
53const STOP_CONFIRM_MS = 5000
54const PANE = 'session-tracker-panel'
55// 패널이 열려 있을 때만 15초마다 세션 목록을 읽는다
56let panelOpen = false
57// /sessions-here off로 Claude에게 붙이는 안내를 끈다
58const NOTE_SWITCH_KEY = 'note'
59const REFRESH_MS = 15_000
60// 세션 패널의 열 폭(글자 칸). 한글은 한 글자가 두 칸이다: "이 대화" 7칸, "입력 필요" 9칸, "백그라운드" 10칸
61const COL_ACTION = 8
62const COL_STATE = 10
63const COL_KIND = 11
64// band의 세션 줄 위 가로줄 머리 글과 그 폭(한글 두 글자라 8칸). clickbar 줄과 세션 줄을 가른다
65const RULE_LABEL = '── 세션 '
66const RULE_LABEL_WIDTH = 8
67const PEER_TONE: Record<Exclude<SessionStatus, 'ended'>, Tone> = { waiting: 'warning', working: 'success', idle: 'muted' }
68// 판정과 상관없이 사람의 답을 기다리는 도구(질문, 계획 승인)
69const NEEDS_PERSON = new Set(['AskUserQuestion', 'ExitPlanMode'])
70// band에서 다른 세션 이름 버튼의 최대 칸 수와, 한 줄에 이름으로 보일 최대 세션 수
71const PEER_NAME_MAX = 18
72const PEERS_SHOWN = 2
73
74// claude agents가 아는 켜진 세션의 이름. 모르면 undefined
75function liveName(id: string): string | undefined {
76 return lastRows.find(r => r.sessionId === id && r.isLive)?.name
77}
78
79// 날짜별 보기에서 줄 끝에 붙이는 폴더 이름: 경로의 마지막 부분(예: D 드라이브의 all 폴더 → all)
80function folderLabel(cwd: string): string {
81 return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
82}
83
84// 상태 색: warning=사람이 할 일 있음(굵게), success=돌고 있음, muted=기다리는 중(회색)
85function toneProps(tone: Tone): { color?: 'warning' | 'success'; bold?: boolean; dimColor?: boolean } {
86 if (tone === 'warning') return { color: 'warning', bold: true }
87 if (tone === 'success') return { color: 'success' }
88
89 return { dimColor: true }
90}
91
92// 이 세션의 상태 파일. 세션마다 자기 파일만 쓰므로 서로 덮어쓰지 않는다
93let self: { dir: string; record: SessionRecord } | undefined
94// 마지막으로 읽은 claude agents --json --all(켜진 것과 멈춘 백그라운드 세션)
95let lastRows: AgentRow[] = []
96// lastRows를 마지막으로 읽은 시각. band에 보일 세션의 이름을 모를 때 다시 읽는 간격을 지킨다
97let agentsAt = 0
98// 사람이 직접 보낸 마지막 질문(prompt.submit에서 받아 다음 메인 턴의 요청으로 쓴다)
99let userText: string | undefined
100// history.jsonl은 바뀌었을 때만 다시 읽는다
101let historyCache: { m: number; s: number; map: Map<string, HistoryInfo> } | undefined
102// $.store 키: 보기 설정, 고정, 보관, 제목 캐시(기록 파일 끝에서 읽은 것), 켜져 있을 때 본 이름
103const VIEW_KEY = 'view'
104const PINS_KEY = 'pins'
105const ARCHIVED_KEY = 'archived'
106const TITLES_KEY = 'titles'
107const NAMES_KEY = 'names'
108
109function log($: EngineInterface, what: string, error: unknown) {
110 $.ui.log(`session-tracker: ${what}: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
111}
112
113// 프로젝트 폴더를 건드리지 않도록 사용자 폴더 아래(~\.claude\mods-state)에 둔다
114async function stateDir($: EngineInterface, root: string) {
115 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
116
117 return home === undefined ? undefined : `${home}/.claude/mods-state/session-tracker/${rootKey(root)}`
118}
119
120async function init($: EngineInterface) {
121 const root = await $.session.root()
122 const dir = await stateDir($, root)
123 if (dir === undefined) return
124 const now = await $.clock.now()
125 // 이 세션이 떠 있는 탭(Orca 탭 handle·herdr 패널 id). 다른 세션의 [이동]이 이 값으로 정확히 찾아온다
126 const term = locatorFromEnv({ herdrEnv: await $.env.get('HERDR_ENV'), herdrPane: await $.env.get('HERDR_PANE_ID'), orcaHandle: await $.env.get('ORCA_TERMINAL_HANDLE') })
127 self = { dir, record: { id: await $.session.id(), root, status: 'idle', prompt: '', changedAt: now, at: now, ...(term === null ? {} : { term }) } }
128 await save($)
129}
130
131async function save($: EngineInterface) {
132 if (self === undefined) return
133 self.record = { ...self.record, at: await $.clock.now() }
134 await $.fs.write(`${self.dir}/${self.record.id}.json`, JSON.stringify(self.record))
135}
136
137async function setStatus($: EngineInterface, status: SessionStatus, prompt?: string) {
138 if (self === undefined) return
139 self.record = { ...self.record, status, changedAt: await $.clock.now(), prompt: prompt === undefined ? self.record.prompt : firstLine(prompt, PROMPT_MAX) }
140 await save($)
141}
142
143async function refresh($: EngineInterface) {
144 if (self === undefined) return
145 const now = await $.clock.now()
146 let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
147 try {
148 entries = await $.fs.list(self.dir)
149 } catch {
150 entries = [] // 폴더가 아직 없으면 다른 세션도 없다
151 }
152 const records: SessionRecord[] = []
153 for (const entry of entries) {
154 if (entry.kind !== 'file' || !entry.name.endsWith('.json') || entry.name === `${self.record.id}.json`) continue
155 if (entry.mtimeMs > 0 && now - entry.mtimeMs > SKIP_OLDER_MS) continue
156 try {
157 const record = parseRecord(String(await $.fs.read(`${self.dir}/${entry.name}`)))
158 if (record !== null) records.push(record)
159 } catch {
160 // 다른 세션이 쓰는 도중이면 다음 갱신 때 다시 읽는다
161 }
162 }
163 const list = alivePeers(records, self?.record.id ?? '', self?.record.root ?? '', now)
164 await update($, peers, () => list)
165 // band는 겹칠 수 있는 세션을 이름으로 보인다. 이름을 모르는 세션이 있으면 claude agents를 다시 읽는다(1분에 한 번까지)
166 const unnamed = activePeers(list).some(p => !lastRows.some(r => r.sessionId === p.id && r.isLive))
167 if (unnamed && now - agentsAt > 60_000) await safely($, '세션 이름 읽기', () => loadAgents($))
168}
169
170// 실행하는 프로그램은 claude, orca, 그리고 꺼진 대화 제목을 읽는 pwsh 도우미로만 고정한다
171async function loadAgents($: EngineInterface) {
172 // --all: 멈춘 백그라운드 세션(pid 없음)도 받아 꺼진 대화의 이름과 다시 켜는 방법을 안다
173 const run = await $.process.run(['claude', 'agents', '--json', '--all'], { timeoutMs: 20_000 })
174 if (run.exitCode === 0) {
175 lastRows = parseAgents(run.stdout, self?.record.id ?? '')
176 agentsAt = await $.clock.now()
177 const rows = lastRows.filter(r => r.isLive)
178 await update($, agents, () => rows)
179 // 시작할 때 환경변수로 짐작한 종류를 claude agents의 판정으로 바로잡는다
180 const mine = rows.find(r => r.isSelf)
181 if (mine !== undefined && kindWord(mine.kind) !== '') await update($, selfKind, () => mine.kind)
182 }
183 const running = (await $.agent.list()).filter(a => a.status === 'running' || a.status === 'waiting' || a.status === 'pending').length
184 await update($, subagents, () => running)
185 const now = await $.clock.now()
186 await update($, readAt, () => now)
187}
188
189async function homeDir($: EngineInterface) {
190 return (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
191}
192
193/** $.store의 제목 캐시. 기록 파일의 수정 시각·크기가 그대로면 다시 읽지 않는다 */
194function parseTitleCache(raw: unknown): Record<string, TitleEntry> {
195 const out: Record<string, TitleEntry> = {}
196 if (raw === null || typeof raw !== 'object') return out
197 for (const [id, v] of Object.entries(raw as Record<string, unknown>)) {
198 const e = v as Partial<TitleEntry>
199 if (typeof e?.m === 'number' && typeof e.s === 'number' && typeof e.t === 'string' && typeof e.c === 'string') {
200 out[id] = { m: e.m, s: e.s, t: e.t, k: e.k === 'custom' || e.k === 'ai' ? e.k : '', c: e.c }
201 }
202 }
203
204 return out
205}
206
207function parseNames(raw: unknown): Record<string, string> {
208 const out: Record<string, string> = {}
209 if (raw === null || typeof raw !== 'object') return out
210 for (const [id, v] of Object.entries(raw as Record<string, unknown>)) if (typeof v === 'string' && v !== '') out[id] = v
211
212 return out
213}
214
215/**
216 * 꺼진 대화의 제목을 /resume처럼 기록 파일 끝부분에서 읽는다. mod는 파일 일부만 읽을 수 없어 pwsh 도우미에 맡긴다.
217 * 도우미 스크립트는 mods-state에 써 두고, 읽을 파일 경로는 표준 입력으로 넘긴다(경로에 공백이 있어도 안전하게)
218 */
219async function readTitles($: EngineInterface, home: string, paths: string[]) {
220 const script = `${home}/.claude/mods-state/session-tracker/titles.ps1`
221 await $.fs.write(script, TITLE_SCRIPT)
222 const run = await $.process.run(['pwsh', '-NoProfile', '-NonInteractive', '-File', script], { stdin: paths.join('\n'), timeoutMs: 120_000 })
223
224 return run.exitCode === 0 ? parseTitleScan(run.stdout) : new Map<string, { t: string; k: 'custom' | 'ai' | ''; c: string }>()
225}
226
227/**
228 * 켜진 대화와 꺼진 대화(기록 파일이 남은 것)를 모아 세션 패널 목록을 만든다. 기록 파일은 이름과 수정 시각만 본다.
229 * 지워진 기록(30일 보관 기간이 지남)은 제목 캐시·이름 캐시·고정·보관에서도 지운다
230 */
231async function scanSessions($: EngineInterface) {
232 const home = await homeDir($)
233 if (home === undefined) return
234 const projects = `${home}/.claude/projects`
235 const files: TranscriptFile[] = []
236 for (const dir of await $.fs.list(projects)) {
237 if (dir.kind !== 'dir' || isScratchProject(dir.name)) continue
238 for (const f of await $.fs.list(`${projects}/${dir.name}`)) {
239 if (f.kind !== 'file' || !f.name.endsWith('.jsonl')) continue
240 files.push({ sessionId: f.name.slice(0, -'.jsonl'.length), slug: dir.name, path: `${projects}/${dir.name}/${f.name}`, mtimeMs: f.mtimeMs, size: f.size })
241 }
242 }
243 const historyPath = `${home}/.claude/history.jsonl`
244 if (await $.fs.exists(historyPath)) {
245 const st = await $.fs.stat(historyPath)
246 if (historyCache === undefined || historyCache.m !== st.mtimeMs || historyCache.s !== st.size) {
247 historyCache = { m: st.mtimeMs, s: st.size, map: parseHistory(String(await $.fs.read(historyPath))) }
248 }
249 }
250 const existing = new Set(files.map(f => f.sessionId))
251 const liveIds = new Set(lastRows.filter(r => r.isLive).map(r => r.sessionId))
252 // 제목: 꺼진 대화 중 처음 보거나 바뀐 파일만 읽는다. 켜진 대화는 claude agents의 이름을 쓴다
253 const titles = parseTitleCache(await $.store.get(TITLES_KEY))
254 const stale = files.filter(f => !liveIds.has(f.sessionId) && (titles[f.sessionId]?.m !== f.mtimeMs || titles[f.sessionId]?.s !== f.size))
255 let titlesChanged = Object.keys(titles).some(id => !existing.has(id))
256 if (stale.length > 0) {
257 try {
258 const found = await readTitles($, home, stale.map(f => f.path))
259 for (const f of stale) {
260 const hit = found.get(f.path)
261 // 제목을 못 찾은 파일도 적어 둬서 같은 파일을 다시 읽지 않는다
262 titles[f.sessionId] = { m: f.mtimeMs, s: f.size, t: hit?.t ?? '', k: hit?.k ?? '', c: hit?.c ?? '' }
263 }
264 titlesChanged = true
265 } catch (error) {
266 log($, '꺼진 대화 제목 읽기(pwsh)', error)
267 }
268 }
269 for (const id of Object.keys(titles)) if (!existing.has(id)) delete titles[id]
270 if (titlesChanged) await $.store.set(TITLES_KEY, titles)
271 // 켜져 있을 때 본 이름을 남겨 두면, 꺼진 뒤에도 같은 이름으로 보인다
272 const names = parseNames(await $.store.get(NAMES_KEY))
273 let namesChanged = false
274 for (const r of lastRows) {
275 if (r.name !== '' && !r.name.startsWith('세션 ') && names[r.sessionId] !== r.name) {
276 names[r.sessionId] = r.name
277 namesChanged = true
278 }
279 }
280 for (const id of Object.keys(names)) {
281 if (!existing.has(id) && !liveIds.has(id)) {
282 delete names[id]
283 namesChanged = true
284 }
285 }
286 if (namesChanged) await $.store.set(NAMES_KEY, names)
287 const now = await $.clock.now()
288 const items = buildItems({ rows: lastRows, files, titles, names, history: historyCache?.map ?? new Map(), now })
289 await update($, sessions, () => items)
290 // 지워진 대화는 고정·보관 목록에서도 뺀다(다른 패널이 동시에 고쳤을 수 있어 저장값을 새로 읽어 고친다)
291 const known = new Set(items.map(it => it.sessionId))
292 for (const key of [PINS_KEY, ARCHIVED_KEY]) {
293 const list = parseIdList(await $.store.get(key))
294 const kept = list.filter(id => known.has(id))
295 if (kept.length !== list.length) await $.store.set(key, kept)
296 }
297}
298
299// 고정·보관·보기 설정을 저장값에서 다시 읽는다. 다른 세션의 패널이 바꾼 것도 이때 반영된다
300async function loadView($: EngineInterface) {
301 const v = parseView(await $.store.get(VIEW_KEY))
302 const p = parseIdList(await $.store.get(PINS_KEY))
303 const a = parseIdList(await $.store.get(ARCHIVED_KEY))
304 await update($, view, () => v)
305 await update($, pins, () => p)
306 await update($, archived, () => a)
307}
308
309async function refreshPanel($: EngineInterface) {
310 await safely($, '세션 목록 읽기', () => loadAgents($))
311 await safely($, '모든 대화 읽기', () => scanSessions($))
312 await safely($, '보기 설정 읽기', () => loadView($))
313 // 버튼 모양은 같으므로 목록을 먼저 그리고, 누르기 전까지만 맞으면 된다
314 await safely($, '보는 곳 확인', () => checkViewer($))
315}
316
317// 저장값 고치기는 늘 새로 읽어 고친 뒤 쓴다. 여러 패널이 함께 열려 있어도 서로 덮어쓰지 않게
318async function setMode($: EngineInterface, mode: ViewMode) {
319 const v = parseView(await $.store.get(VIEW_KEY))
320 const next = { ...v, mode }
321 await $.store.set(VIEW_KEY, next)
322 await update($, view, () => next)
323}
324
325async function setFilter($: EngineInterface, mode: ViewMode, filter: OnOffFilter) {
326 const v = parseView(await $.store.get(VIEW_KEY))
327 const next = { ...v, filter: { ...v.filter, [mode]: filter } }
328 await $.store.set(VIEW_KEY, next)
329 await update($, view, () => next)
330}
331
332async function togglePin($: EngineInterface, id: string) {
333 const list = parseIdList(await $.store.get(PINS_KEY))
334 const next = list.includes(id) ? list.filter(x => x !== id) : [...list, id]
335 await $.store.set(PINS_KEY, next)
336 await update($, pins, () => next)
337}
338
339// 보관은 목록에서 숨기기만 한다. 고정도 함께 푼다. 기록은 그대로라 [복구]로 언제든 되살린다
340async function archiveItem($: EngineInterface, item: SessionItem) {
341 const list = parseIdList(await $.store.get(ARCHIVED_KEY))
342 const next = list.includes(item.sessionId) ? list : [...list, item.sessionId]
343 await $.store.set(ARCHIVED_KEY, next)
344 await update($, archived, () => next)
345 const pinned = parseIdList(await $.store.get(PINS_KEY))
346 if (pinned.includes(item.sessionId)) {
347 const kept = pinned.filter(x => x !== item.sessionId)
348 await $.store.set(PINS_KEY, kept)
349 await update($, pins, () => kept)
350 }
351 $.ui.toast(`"${item.name}"을 보관했습니다. [보관함]에서 [복구]로 되살립니다`)
352}
353
354async function restoreItem($: EngineInterface, item: SessionItem) {
355 const list = parseIdList(await $.store.get(ARCHIVED_KEY))
356 const next = list.filter(x => x !== item.sessionId)
357 await $.store.set(ARCHIVED_KEY, next)
358 await update($, archived, () => next)
359 $.ui.toast(`"${item.name}"을 목록으로 되돌렸습니다`)
360}
361
362/**
363 * 꺼진 대화를 다시 켠다. 멈춘 백그라운드 세션은 claude attach로 백그라운드로 다시 켜고,
364 * 기록만 남은 대화는 그 폴더의 새 Orca 탭에서 claude --resume으로 연다(포그라운드).
365 * 목록은 15초마다 읽으므로, 열기 직전에 다시 확인해 그 사이 켜진 대화를 한 번 더 열지 않는다(같은 기록에 둘이 쓰게 된다)
366 */
367async function openOff($: EngineInterface, item: SessionItem) {
368 try {
369 if (await isLiveNow($, item)) return
370 if (!(await $.fs.exists(item.cwd))) {
371 $.ui.toast(`이 대화의 폴더가 이 PC에 없습니다: ${item.cwd}`, { timeoutMs: 10000 })
372 return
373 }
374 } catch (error) {
375 $.ui.toast(`다시 열지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
376 return
377 }
378 await openSession($, item, item.reopen === 'attach' ? 'attach' : 'resume')
379}
380
381type LaunchResult = { kind: 'ok' | 'slow' } | { kind: 'failed'; reason: string; command: string }
382
383// [이동]·[열기]에 쓰는 터미널 프로그램(hosts.ts). herdr 패널 안이면 herdr, Orca를 쓸 수 있으면 Orca, 아니면 null(일반 터미널)
384async function termHost($: EngineInterface): Promise<TerminalHost | null> {
385 const name = await read($, host)
386 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 20_000 })
387
388 return name === 'herdr' ? herdrHost(run) : name === 'orca' ? orcaHost(run) : null
389}
390
391/**
392 * 새 탭에서 그 대화를 연다. 탭을 앞으로 띄우지 않고 열어, 화면에 Claude가 뜬 것을 확인한 뒤에만 그 탭으로 옮긴다.
393 * 붙지 못하면 오류만 남은 셸 탭을 닫고 결과를 돌려준다(바로 띄우면, 실패했을 때 오류가 찍힌 셸 창만 남고 여기 알림은 보이지 않는다).
394 * attach는 claude agents의 짧은 id만 받고 어느 폴더에서든 붙는다. resume은 기록을 폴더별로 찾으므로 그 폴더에서 실행한다
395 */
396async function launchTab($: EngineInterface, term: TerminalHost, item: SessionItem, how: 'attach' | 'resume'): Promise<LaunchResult> {
397 const base = how === 'attach' ? `claude attach ${item.shortId}` : `claude --resume ${item.sessionId}`
398 const made = await term.open({ item, command: base, needsCwd: how === 'resume', isWindows: (await $.env.get('OS')) === 'Windows_NT' })
399 if ('error' in made) return { kind: 'failed', reason: made.error, command: base }
400 for (let waited = 0; waited < LAUNCH_TIMEOUT_MS; waited += LAUNCH_POLL_MS) {
401 await $.clock.sleep(LAUNCH_POLL_MS)
402 const verdict = launchVerdict(await term.read(made.handle), item.name)
403 if (verdict.kind === 'failed') {
404 await term.close(made.handle)
405 return { kind: 'failed', reason: verdict.reason, command: base }
406 }
407 if (verdict.kind === 'ok') break
408 if (waited + LAUNCH_POLL_MS >= LAUNCH_TIMEOUT_MS) {
409 await term.focus(made.handle)
410 return { kind: 'slow' }
411 }
412 }
413 await term.focus(made.handle)
414
415 return { kind: 'ok' }
416}
417
418/**
419 * [이동]·[열기]로 새 탭을 여는 동안 그 줄에 "여는 중…"을 보이고(다시 눌러도 또 열지 않는다), 끝나면 결과를 알린다.
420 * 붙일 백그라운드 작업이 그사이 정리됐으면(No job matching) 기록으로 다시 연다. 짧은 id가 없는 세션도 기록으로 연다
421 */
422async function openSession($: EngineInterface, item: SessionItem, how: 'attach' | 'resume') {
423 if ((await read($, launching)).includes(item.sessionId)) return
424 const term = await termHost($)
425 if (term === null) return
426 await update($, launching, old => [...old, item.sessionId])
427 $.ui.toast(`"${item.name}"을(를) 새 탭에서 여는 중입니다…`)
428 try {
429 const first = how === 'attach' && item.shortId !== '' ? 'attach' : 'resume'
430 let result = await launchTab($, term, item, first)
431 let fellBack = false
432 if (result.kind === 'failed' && first === 'attach' && /No job matching/i.test(result.reason) && (await $.fs.exists(item.cwd))) {
433 fellBack = true
434 result = await launchTab($, term, item, 'resume')
435 }
436 $.ui.toast(
437 result.kind === 'failed'
438 ? `"${item.name}"을(를) 열지 못했습니다: ${result.reason} · 터미널에서 직접 실행: ${result.command}`
439 : result.kind === 'slow'
440 ? `"${item.name}" 탭이 아직 열리는 중입니다. 새 탭을 확인하세요`
441 : fellBack
442 ? `백그라운드 작업이 이미 정리돼 연결할 수 없어, "${item.name}"을(를) 기록으로 새 탭에서 다시 열었습니다`
443 : first === 'attach'
444 ? `"${item.name}"에 연결했습니다. 탭을 닫아도 백그라운드 세션은 계속 실행됩니다`
445 : `"${item.name}"을(를) 새 탭에서 다시 열었습니다`,
446 { timeoutMs: result.kind === 'failed' ? 15000 : 8000 },
447 )
448 } catch (error) {
449 $.ui.toast(`"${item.name}"을(를) 열지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
450 } finally {
451 await update($, launching, old => old.filter(id => id !== item.sessionId))
452 await refreshPanel($)
453 }
454}
455
456// 목록은 15초마다 읽으므로, 꺼진 대화를 열기 직전에 그사이 켜졌는지 한 번 더 본다(두 터미널이 같은 기록에 쓰면 메시지가 섞인다)
457async function isLiveNow($: EngineInterface, item: SessionItem) {
458 const fresh = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 20_000 })
459 if (fresh.exitCode !== 0 || !parseAgents(fresh.stdout, '').some(r => r.sessionId === item.sessionId && r.isLive)) return false
460 $.ui.toast(`"${item.name}"은 그사이 켜져 있어 다시 열지 않았습니다. 목록을 새로 읽습니다`)
461 await refreshPanel($)
462
463 return true
464}
465
466// 시작할 때 환경으로 정한 터미널 프로그램. 백그라운드 작업은 보는 곳에 따라 바꾸고(checkViewer), 보는 곳을 모르면 이 값으로 돌아간다
467let envHost: '' | 'orca' | 'herdr' = ''
468// orca 명령을 쓸 수 있는지(herdr 안이어도). 백그라운드 작업을 Orca 탭에서 보고 있는지 볼 때 쓴다
469let orcaAvailable = false
470
471// [이동]·[열기]에 쓸 터미널 프로그램을 세션마다 한 번 정한다. herdr 패널 안(HERDR_ENV=1)이면 herdr를 쓴다.
472// 아니면 Orca: Orca 안(ORCA_PANE_KEY)이면 묻지 않고, 밖이면 orca --version으로 본다. 둘 다 없으면 일반 터미널이라 /resume·명령 복사를 보인다
473async function checkOrca($: EngineInterface) {
474 let ready = (await $.env.get('ORCA_PANE_KEY')) !== undefined
475 if (!ready) {
476 try {
477 ready = (await $.process.run(['orca', '--version'], { timeoutMs: 5_000 })).exitCode === 0
478 } catch {
479 ready = false
480 }
481 }
482 const inHerdr = (await $.env.get('HERDR_ENV')) === '1'
483 orcaAvailable = ready
484 envHost = inHerdr ? 'herdr' : ready ? 'orca' : ''
485 await update($, orcaReady, () => envHost === 'orca')
486 await update($, host, () => envHost)
487}
488
489// 백그라운드 작업의 화면은 사용자가 attach한 탭(claude attach·claude agents)에 뜨지만, 작업 프로세스의 환경은 처음 띄운 곳의 것이다.
490// 그래서 세션 패널을 읽을 때마다 이 세션을 띄운 탭이 herdr나 Orca에 있는지 보고, 있으면 그 프로그램을 쓴다.
491// herdr는 창 제목(claude agents로 들어가면 세션 이름이 된다)과, 이 mod가 연 탭의 이름표로 찾는다(claude attach는 창 제목을 바꾸지 않는다)
492async function checkViewer($: EngineInterface) {
493 if ((await read($, selfKind)) !== 'background') return
494 const name = lastRows.find(r => r.isSelf)?.name ?? ''
495 if (name === '') return
496 const run = (argv: string[]) => $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
497 // herdr 서버가 떠 있을 때만 묻는다(herdr status는 서버를 띄우지 않고 상태만 알려 준다)
498 const status = await run(['herdr', 'status'])
499 const herdr = status !== null && status.exitCode === 0 && herdrServerRunning(status.stdout) ? await run(['herdr', 'agent', 'list']) : null
500 let found: '' | 'orca' | 'herdr' = ''
501 if (herdr !== null && herdr.exitCode === 0) {
502 if (herdrShows(herdr.stdout, name)) found = 'herdr'
503 else {
504 const tabs = await run(['herdr', 'tab', 'list'])
505 if (tabs !== null && tabs.exitCode === 0 && herdrPaneByLabel(herdr.stdout, tabs.stdout, name) !== null) found = 'herdr'
506 }
507 }
508 if (found === '' && orcaAvailable) {
509 const orca = await run(['orca', 'terminal', 'list', '--json'])
510 if (orca !== null && orca.exitCode === 0 && findTerminal(orca.stdout, name) !== null) found = 'orca'
511 }
512 const next = found === '' ? envHost : found
513 await update($, host, () => next)
514 await update($, orcaReady, () => next === 'orca')
515}
516
517// 대상 세션이 자기 기록에 남긴 위치. 기록은 그 세션의 루트 폴더별로 두므로 작업 폴더로 먼저 찾고, 없으면 다른 폴더들을 본다
518async function locatorOf($: EngineInterface, item: SessionItem): Promise<TermLocator | null> {
519 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
520 if (home === undefined) return null
521 const base = `${home}/.claude/mods-state/session-tracker`
522 const readAt = async (dir: string) => {
523 try {
524 return parseRecord(String(await $.fs.read(`${base}/${dir}/${item.sessionId}.json`)))?.term ?? null
525 } catch {
526 return null
527 }
528 }
529 const direct = await readAt(rootKey(item.cwd))
530 if (direct !== null) return direct
531 try {
532 for (const entry of await $.fs.list(base)) {
533 if (entry.kind !== 'dir' || entry.name === rootKey(item.cwd)) continue
534 const found = await readAt(entry.name)
535 if (found !== null) return found
536 }
537 } catch {
538 // 기록 폴더가 없으면 위치를 모른다
539 }
540
541 return null
542}
543
544// Orca 없이 그 대화를 이 터미널에서 연다(내장 /resume <id>). 꺼진 대화는 기록을 불러오고, 켜진 백그라운드 세션은
545// 지금 대화를 백그라운드로 보내고 이 터미널을 그 세션에 붙인다(Claude Code 2.1.285부터, ←로 agent view에 돌아온다).
546// $.command.run은 답변이 끝날 때까지 기다리므로, 일하는 중이면 그 뒤에 열린다
547async function openHere($: EngineInterface, item: SessionItem) {
548 await update($, opening, () => '')
549 try {
550 if (item.kind === 'off' && (await isLiveNow($, item))) return
551 if (self?.record.status === 'working') $.ui.toast(`답변이 끝나면 "${item.name}"을(를) 이 터미널에서 엽니다`)
552 await $.ui.close({ id: PANE })
553 await $.command.run({ command: 'resume', args: item.sessionId })
554 } catch (error) {
555 $.ui.toast(`/resume으로 열지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
556 }
557}
558
559// 새 터미널 탭에 붙여 넣을 명령을 복사한다. Orca가 없으면 다른 탭을 대신 열 수 없어서다
560async function copyCommand($: EngineInterface, item: SessionItem, surface: RenderSurface) {
561 await update($, opening, () => '')
562 const text = reopenCommand(item)
563 try {
564 const done = await $.ui.copy({ text, surface })
565 $.ui.toast(done.isCopied ? `명령을 복사했습니다. 새 터미널 탭에서 붙여 넣으세요: ${text}` : `복사하지 못했습니다. 새 탭에서 직접 치세요: ${text}`, { timeoutMs: 12000 })
566 } catch {
567 $.ui.toast(`복사하지 못했습니다. 새 탭에서 직접 치세요: ${text}`, { timeoutMs: 12000 })
568 }
569}
570
571// 설정 저장소(CCsetting)의 sync.ps1 위치. 저장소 위치는 ~/.claude/sync-config.json, 없으면 ~/CCsetting.
572// 이 저장소를 쓰지 않는 PC(mod를 받아 쓰는 다른 사람)에는 스크립트가 없으므로 undefined다
573async function findSyncScript($: EngineInterface): Promise<{ home: string; script: string } | undefined> {
574 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
575 if (home === undefined) return undefined
576 let repo = `${home}/CCsetting`
577 try {
578 const config = JSON.parse(String(await $.fs.read(`${home}/.claude/sync-config.json`))) as { repo?: unknown }
579 if (typeof config.repo === 'string' && config.repo !== '') repo = config.repo
580 } catch {
581 // 설정 파일이 없으면 기본 위치를 쓴다
582 }
583 const script = `${repo}/sync.ps1`
584
585 return (await $.fs.exists(script)) ? { home, script } : undefined
586}
587
588async function runSync($: EngineInterface) {
589 const found = await findSyncScript($)
590 if (found === undefined) return $.ui.toast('동기화 스크립트(CCsetting의 sync.ps1)를 찾지 못했습니다')
591 const { home, script } = found
592 $.ui.toast('동기화 중입니다…')
593 const run = await $.process.run(['pwsh', '-NoProfile', '-File', script, '-Quiet'], { timeoutMs: 180_000 })
594 let last = ''
595 try {
596 last = String(await $.fs.read(`${home}/.claude/sync.log`)).trim().split(/\r?\n/).pop() ?? ''
597 } catch {
598 // 기록이 없으면 종료 코드만 알린다
599 }
600 // 일부 파일만 보류됐으면 sync.ps1이 남긴 결과 파일에서 그 파일 이름을 꺼내 알린다(로그 마지막 줄에는 이름이 없다)
601 let held: string[] = []
602 try {
603 const status = JSON.parse(String(await $.fs.read(`${home}/.claude/sync.status.json`)).trim()) as { result?: unknown; held?: unknown }
604 if (status.result === 'held' && Array.isArray(status.held)) held = status.held.filter((x): x is string => typeof x === 'string')
605 } catch {
606 // 결과 파일이 없으면 로그 줄만 알린다
607 }
608 $.ui.toast(
609 run.exitCode !== 0
610 ? `동기화 실패(코드 ${run.exitCode}): ${firstLine(run.stderr || last, 160)}`
611 : held.length > 0
612 ? `동기화: 나머지는 올렸고, 비밀값으로 보이는 줄이 있는 파일 ${held.length}개는 보류했습니다. 그 줄을 고치거나 지운 뒤 다시 sync: ${firstLine(held.join(', '), 200)}`
613 : `동기화 끝: ${firstLine(last, 160) || '완료'}`,
614 { timeoutMs: held.length > 0 ? 20000 : 10000 },
615 )
616}
617
618async function openPanel($: EngineInterface) {
619 panelOpen = true
620 await $.ui.open({ id: PANE, title: '세션', closeOnEscape: true })
621 await refreshPanel($)
622}
623
624// band에서 다른 세션의 이름을 눌렀을 때. 탭을 다루는 프로그램(Orca·herdr)이 있으면 그 세션으로 옮기고, 없으면 세션 패널을 연다
625async function goToPeer($: EngineInterface, sessionId: string) {
626 // 백그라운드 작업이면 지금 보고 있는 곳(Orca·herdr)을 먼저 확인한다(세션 패널을 열지 않았어도 맞는 프로그램으로 옮기게)
627 await safely($, '보는 곳 확인', () => checkViewer($))
628 const row = lastRows.find(r => r.sessionId === sessionId && r.isLive)
629 if (row === undefined || (await read($, host)) === '') return await openPanel($)
630 const kind = row.kind === 'interactive' ? 'interactive' : 'background'
631 await switchTo($, { sessionId: row.sessionId, shortId: row.shortId, name: row.name, cwd: row.cwd, state: row.state, kind, isSelf: false, at: 0, reopen: '' })
632}
633
634// 그 세션이 떠 있는 탭으로 옮긴다. 세션이 자기 기록에 남긴 위치로 먼저 찾고, 없으면 탭 제목(= 세션 이름)으로 찾는다.
635// 탭이 없는 백그라운드 세션은 새 탭에서 붙인다(openSession, ← 로 agent view, 탭을 닫아도 세션은 계속 돈다).
636// 탭이 없는 포그라운드 세션은 다른 창에서 돌고 있어 여기서 열지 않는다(두 창이 한 기록에 쓰면 메시지가 섞인다).
637// 다른 터미널 프로그램에서 도는 세션이면 그 프로그램을 쓰라고 알린다
638async function switchTo($: EngineInterface, item: SessionItem) {
639 const { name } = item
640 const term = await termHost($)
641 if (term === null) return
642 try {
643 const where = await locatorOf($, item)
644 if (where !== null && where.host !== term.name && item.kind === 'interactive') {
645 $.ui.toast(`"${name}"은(는) ${where.host === 'herdr' ? 'herdr' : 'Orca'}에서 실행 중입니다. 그 프로그램의 탭에서 이어 쓰세요`, { timeoutMs: 10000 })
646 return
647 }
648 const handle = await term.locate(item, where !== null && where.host === term.name ? where.id : null)
649 if (handle !== null) {
650 if (!(await term.focus(handle))) $.ui.toast(`"${name}" 탭으로 전환하지 못했습니다`)
651 return
652 }
653 if (item.kind === 'background') return await openSession($, item, 'attach')
654 $.ui.toast(
655 `"${name}"은(는) ${term.name === 'herdr' ? 'herdr' : 'Orca'} 탭에서 찾지 못했습니다. 다른 창(cmd 등)에서 실행 중이라 여기서 열지 않습니다. 그 창에서 이어 쓰거나, 그 창을 닫은 뒤 [열기]로 다시 여세요`,
656 { timeoutMs: 12000 },
657 )
658 } catch (error) {
659 $.ui.toast(`"${name}" 탭으로 옮기지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
660 }
661}
662
663// 내장 /resume 선택 창을 연다. Claude가 일하는 중이면 끝난 뒤에 열린다
664async function openResume($: EngineInterface) {
665 try {
666 await $.command.run({ command: 'resume' })
667 } catch (error) {
668 $.ui.toast(`/resume을 열지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
669 }
670}
671
672// 이름을 누르면 그 줄이 입력칸으로 바뀐다. 입력하려면 패널이 키보드를 잡아야 해서 focus로 다시 연다
673async function startRename($: EngineInterface, row: SessionItem) {
674 await update($, stopArmed, () => '')
675 await update($, renameDraft, () => row.name)
676 await update($, renaming, () => row.sessionId)
677 await $.ui.open({ id: PANE, title: '세션', focus: true, closeOnEscape: true })
678}
679
680async function cancelRename($: EngineInterface) {
681 await update($, renaming, () => '')
682}
683
684/**
685 * 이 대화는 내장 /rename으로 바꾼다. 다른 세션은 mod가 직접 바꿀 수 없어, 그 세션의 Orca 탭 입력창에 /rename을 대신 친다.
686 * 남의 입력창에 글자를 넣는 일이라 세 가지를 확인하고, 하나라도 걸리면 바꾸지 않는다:
687 * 그 세션이 입력·승인을 기다리는 중이 아닐 것, Orca 탭이 있을 것, 그 탭 입력창이 비어 있을 것
688 */
689async function applyRename($: EngineInterface, row: SessionItem, raw: string) {
690 const name = cleanName(raw)
691 await update($, renaming, () => '')
692 if (name === '' || name === row.name) return
693 try {
694 if (row.isSelf) {
695 await $.command.run({ command: 'rename', args: name })
696 $.ui.toast(`이 대화의 이름을 "${name}"(으)로 바꿨습니다`)
697 } else {
698 if (row.state === 'blocked') {
699 $.ui.toast(`"${row.name}"은 입력이나 승인을 기다리는 중이라 이름을 바꾸지 않았습니다. 그 세션에서 답한 뒤 다시 하세요`, { timeoutMs: 10000 })
700 return
701 }
702 // [이동]과 같은 방법으로 그 세션의 Orca 탭을 찾는다(세션이 남긴 탭 handle, 없으면 탭 제목)
703 const where = await locatorOf($, row)
704 const handle = await orcaHost(argv => $.process.run(argv, { timeoutMs: 15_000 })).locate(row, where?.host === 'orca' ? where.id : null)
705 if (handle === null) {
706 $.ui.toast(
707 row.kind === 'background'
708 ? `"${row.name}"은 열린 Orca 탭이 없어 이름을 바꾸지 못했습니다. [이동]으로 탭을 연 뒤 다시 하세요`
709 : `"${row.name}"의 Orca 탭을 찾지 못해 이름을 바꾸지 못했습니다. 그 탭에서 /rename 으로 바꾸세요`,
710 { timeoutMs: 10000 },
711 )
712 return
713 }
714 const screen = await $.process.run(['orca', 'terminal', 'read', '--terminal', handle, '--screen', '--json'], { timeoutMs: 15_000 })
715 if (screen.exitCode !== 0 || composerHasText(screen.stdout)) {
716 $.ui.toast(`"${row.name}" 입력창에 쓰던 글이 있거나 화면을 읽지 못해, 덮어쓰지 않으려고 이름을 바꾸지 않았습니다`, { timeoutMs: 10000 })
717 return
718 }
719 const sent = await $.process.run(['orca', 'terminal', 'send', '--terminal', handle, '--text', `/rename ${name}`, '--enter', '--json'], { timeoutMs: 15_000 })
720 $.ui.toast(sent.exitCode === 0 ? `"${row.name}"의 이름을 "${name}"(으)로 바꿨습니다` : `이름을 바꾸지 못했습니다: ${firstLine(sent.stderr || sent.stdout, 120)}`)
721 }
722 } catch (error) {
723 $.ui.toast(`이름을 바꾸지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
724 return
725 }
726 // 상대 세션이 /rename을 처리할 시간을 준 뒤 목록을 다시 읽는다
727 await $.clock.sleep(RENAME_SETTLE_MS)
728 await refreshPanel($)
729}
730
731// [끄기]는 두 번 눌러야 끈다. 처음 누르면 [정말 끄기]로 바뀌고, 몇 초 안에 다시 누르지 않으면 되돌아간다.
732// isArmed는 그릴 때 본 값이다(누를 때 다시 읽으면 한 박자 늦게 바뀐다)
733async function pressStop($: EngineInterface, row: SessionItem, isArmed: boolean) {
734 if (!isArmed) {
735 await update($, stopArmed, () => row.sessionId)
736 $.clock.after(STOP_CONFIRM_MS, () => void update($, stopArmed, old => (old === row.sessionId ? '' : old)))
737 return
738 }
739 await update($, stopArmed, () => '')
740 const run = await $.process.run(['claude', 'stop', row.shortId || row.sessionId], { timeoutMs: 20_000 })
741 $.ui.toast(
742 run.exitCode === 0
743 ? `"${row.name}"을 껐습니다. 기록은 남아 있어 [resume]으로 다시 열 수 있습니다`
744 : `끄지 못했습니다: ${firstLine(run.stderr || run.stdout, 120)}`,
745 { timeoutMs: 10000 },
746 )
747 await refreshPanel($)
748}
749
750// 상태를 기록하는 훅은 모두 엔진의 일을 먼저 끝내고, 기록이 실패해도 결과는 그대로 돌려준다
751async function safely($: EngineInterface, what: string, work: () => Promise<void>) {
752 try {
753 await work()
754 } catch (error) {
755 log($, what, error)
756 }
757}
758
759export const register: Register = on => {
760 on('session.start', async ($, e, next) => {
761 const result = await next(e)
762 await $.command.register({
763 name: 'sessions-here',
764 description: '같은 폴더에서 실행 중인 다른 세션을 확인합니다. /sessions-here off·on으로 Claude에게 보내는 안내를 끄고 켭니다',
765 argumentHint: '[on|off]',
766 immediate: true,
767 })
768 await safely($, '시작', async () => {
769 await init($)
770 await refresh($)
771 })
772 // 백그라운드 관리자가 띄운 세션에만 CLAUDE_JOB_DIR가 있다. /background는 새 프로세스로 옮겨 가므로 종류는 시작할 때 한 번 정해진다
773 await safely($, '세션 종류 확인', async () => {
774 const jobDir = await $.env.get('CLAUDE_JOB_DIR')
775 await update($, selfKind, () => (jobDir !== undefined && jobDir !== '' ? 'background' : 'interactive'))
776 })
777 await safely($, 'Orca 확인', () => checkOrca($))
778 $.clock.every(HEARTBEAT_MS, () => void safely($, '살아 있음 기록', () => save($)))
779 $.clock.every(REFRESH_MS, () => void safely($, '다른 세션 읽기', () => refresh($)))
780 $.clock.every(REFRESH_MS, () => void (panelOpen ? refreshPanel($) : undefined))
781 // /sync는 설정 저장소(CCsetting)를 쓰는 PC에만 둔다. 없으면 clickbar의 [sync] 버튼도 생기지 않는다
782 await safely($, '동기화 명령 등록', async () => {
783 if ((await findSyncScript($)) === undefined) return
784 await $.command.register({
785 name: 'sync',
786 description: '설정 저장소(CCsetting)와 메모리·mod·설정을 즉시 동기화합니다',
787 immediate: true,
788 })
789 })
790 await $.command.register({
791 name: 'sessions',
792 description: '세션 패널을 엽니다. 이 PC의 모든 대화를 폴더·날짜별로 조회하고 이동·고정·보관합니다',
793 immediate: true,
794 })
795
796 return result
797 })
798
799 on('turn.start', async ($, e, next) => {
800 const result = await next(e)
801 // 2.1.299부터 서브에이전트·팀원의 턴에도 온다(e.agentId가 있다). 이 세션의 상태·요청은 메인 대화의 턴만 바꾼다
802 if ((e as { agentId?: string }).agentId !== undefined) return result
803 // 요청은 사람이 직접 보낸 질문만 쓴다. 알림·예약 작업·다른 세션의 메시지로 시작한 턴은 앞 요청을 그대로 둔다
804 const text = userText
805 userText = undefined
806 await safely($, '작업 시작 기록', () => setStatus($, 'working', text === undefined || text.trim() === '' ? undefined : text))
807
808 return result
809 })
810
811 on('turn.complete', async ($, e, next) => {
812 const result = await next(e)
813 if (e.agentId === undefined) await safely($, '작업 끝 기록', () => setStatus($, 'idle'))
814
815 return result
816 })
817
818 // 권한 판정이 "물어봄"이면 권한 창이 뜬다. bypass 모드에서는 도구 승인을 묻지 않지만, 질문(AskUserQuestion)·계획 승인(ExitPlanMode)은
819 // 판정과 상관없이 사람의 답을 기다린다(판정이 허용이어도 창이 닫히지 않는다).
820 // classic.Notification은 Team 플랜에서 user mod에 오지 않아(2026-10-11 확인) tool.check의 결과로 판단한다
821 on('tool.check', async ($, e, next) => {
822 const result = await next(e)
823 const asks = result.decision === 'ask' || (NEEDS_PERSON.has(e.tool) && result.decision !== 'deny')
824 if (e.agentId === undefined && asks) await safely($, '입력 필요 기록', () => setStatus($, 'waiting'))
825
826 return result
827 })
828
829 // 승인을 받아 도구가 실제로 돌기 시작하면 다시 "작업 중"이다
830 on('tool.call', async ($, e, next) => {
831 // 메인 대화의 도구만 본다. 메인이 답을 기다리는 동안 서브에이전트가 도구를 써도 "입력 필요"는 그대로다
832 if (e.agentId === undefined && self?.record.status === 'waiting') await safely($, '작업 재개 기록', () => setStatus($, 'working'))
833
834 return next(e)
835 })
836
837 on('session.end', async ($, e, next) => {
838 const result = await next(e)
839 await safely($, '종료 기록', () => setStatus($, 'ended'))
840
841 return result
842 })
843
844 // 다른 세션이 같은 폴더에서 작업 중일 때만 Claude에게 한 줄을 붙인다. 질문 글은 바꾸지 않는다
845 on('prompt.submit', async ($, e, next) => {
846 // 입력창(composer)이나 Remote Control로 사람이 보낸 질문만 다음 턴의 요청으로 기억한다
847 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') userText = e.text
848 let note: string | null = null
849 try {
850 if ((await $.store.get(NOTE_SWITCH_KEY)) !== 'off') note = overlapNote(await read($, peers), liveName)
851 } catch (error) {
852 log($, '안내 만들기', error)
853 }
854
855 return next(note === null ? e : { ...e, context: [...(e.context ?? []), note] })
856 })
857
858 on('command.run', { command: 'sync' }, async $ => {
859 try {
860 await runSync($)
861 } catch (error) {
862 $.ui.toast(`동기화를 실행하지 못했습니다: ${error instanceof Error ? error.message : String(error)}`)
863 }
864
865 return {}
866 })
867
868 on('command.run', { command: 'sessions' }, async $ => {
869 await openPanel($)
870
871 return {}
872 })
873
874 on('ui.close', { id: PANE }, async ($, e, next) => {
875 panelOpen = false
876 await update($, opening, () => '')
877 // 패널을 닫으면 고치던 이름, 한 번 누른 [끄기], 열어 둔 [관리], 보관함 보기는 없던 일로 한다
878 await update($, renaming, () => '')
879 await update($, stopArmed, () => '')
880 await update($, managing, () => '')
881 await update($, showArchived, () => false)
882
883 return next(e)
884 })
885
886 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
887 // clickbar의 [sessions]처럼 다른 mod가 이 패널을 직접 열면 /sessions를 거치지 않아 목록을 읽지 않았다.
888 // 처음 그릴 때 읽기 시작한다. 그래야 답변 중에도 패널이 바로 뜬다($.command.run은 답변이 끝날 때까지 기다린다)
889 if (!panelOpen) {
890 panelOpen = true
891 void refreshPanel($)
892 }
893 const elements = $.ui.resolve(e)
894 const { Box, Text, Button } = elements
895 // 입력칸이 없는 화면에서는 이름 고치기를 /rename 안내로 대신한다
896 const Input = 'Input' in elements ? elements.Input : undefined
897 const items = await read($, sessions)
898 const live = await read($, agents)
899 const running = await read($, subagents)
900 const at = await read($, readAt)
901 const editing = await read($, renaming)
902 const launchingIds = await read($, launching)
903 const draft = await read($, renameDraft)
904 const armed = await read($, stopArmed)
905 const managed = await read($, managing)
906 const opened = await read($, expanded)
907 const legendOn = await read($, showLegend)
908 const v = await read($, view)
909 const pinned = await read($, pins)
910 const hidden = await read($, archived)
911 const inArchive = await read($, showArchived)
912 // Orca가 없으면(일반 터미널) 다른 탭으로 옮기거나 다른 대화의 입력창에 대신 칠 수 없어, 줄 첫 칸과 이름 고치기를 바꿔 그린다
913 // 다른 대화의 이름을 그 탭 입력창에 쳐서 고치는 기능은 Orca에서만, [이동]·[열기]는 탭을 다루는 프로그램(Orca·herdr)이 있으면 쓴다
914 const hasOrca = await read($, orcaReady)
915 const hostName = await read($, host)
916 const hasHost = hostName !== ''
917 const openingId = await read($, opening)
918 // 꺼진 대화를 이 터미널에서 열 수 있는지(같은 폴더인지) 볼 때 쓰는 이 세션의 폴더
919 const here = self === undefined ? '' : pathKey(self.record.root)
920 const now = await $.clock.now()
921 // 개수는 보관하지 않은 대화만 센다. 상태 필터 버튼의 개수가 그 필터로 보이는 줄 수와 맞도록
922 const visible = items.filter(it => !hidden.includes(it.sessionId))
923 const counts = stateCounts(live.filter(r => !hidden.includes(r.sessionId)))
924 const offCount = visible.filter(it => it.kind === 'off').length
925 const archivedCount = items.length - visible.length
926 const layout = arrange(items, { view: v, pins: pinned, archived: hidden, showArchived: inArchive, now })
927 const filter = v.filter[v.mode]
928
929 // 회색 글자는 정보, 색 있는 [괄호]는 버튼이다(clickbar band와 같은 규칙). 고른 보기는 칠한 버튼으로 보인다
930 const button = (key: string, word: string, onPress: () => void, color: string = 'suggestion', bold = false) => (
931 <Button key={key} plain label={`[${word}]`} onPress={onPress}>
932 <Text color={color} bold={bold}>
933 {`[${word}]`}
934 </Text>
935 </Button>
936 )
937 // 고른 보기·필터는 [ ] 대신 같은 폭의 칠한 칸으로 그린다. 폭이 같아 골라도 옆 버튼이 밀리지 않는다(명령 팔레트 탭과 같은 모양)
938 const choice = (key: string, word: string, isOn: boolean, onPress: () => void) =>
939 isOn ? (
940 <Button key={key} plain label={word} onPress={onPress}>
941 <Text backgroundColor="suggestion" color="inverseText" bold>{` ${word} `}</Text>
942 </Button>
943 ) : (
944 button(key, word, onPress)
945 )
946 const closeManage = () => update($, managing, () => '')
947
948 // 자주 쓰지 않는 고정·보관·끄기·이름 고치기는 [관리]를 눌렀을 때만 그 줄 아래에 연다. 줄마다 버튼을 늘어놓지 않아 열이 맞는다
949 const manageLine = (it: SessionItem) => {
950 const isPinned = pinned.includes(it.sessionId)
951 const isArmed = armed === it.sessionId
952
953 return (
954 <Box key={`manage-${it.sessionId}`} paddingLeft={COL_ACTION + 1} columnGap={1} flexWrap="wrap">
955 {button(
956 `agent-pin-${it.sessionId}`,
957 isPinned ? '고정 해제' : '고정',
958 () => void togglePin($, it.sessionId).then(closeManage),
959 isPinned ? 'warning' : 'suggestion',
960 )}
961 {it.kind !== 'off' && (hasOrca || it.isSelf) && button(`agent-rename-menu-${it.sessionId}`, '이름 고치기', () => void closeManage().then(() => startRename($, it)))}
962 {!it.isSelf && button(`agent-archive-${it.sessionId}`, '보관', () => void archiveItem($, it).then(closeManage))}
963 {/* 다 쓴 백그라운드 세션을 끈다. 이 대화는 /stop, 포그라운드 세션은 그 탭을 닫아 끈다 */}
964 {it.kind === 'background' &&
965 !it.isSelf &&
966 button(`agent-stop-${it.sessionId}`, isArmed ? '정말 끄기' : '끄기', () => void pressStop($, it, isArmed), isArmed ? 'error' : 'suggestion', isArmed)}
967 {it.isSelf && <Text dimColor>이 대화는 보관·끄기 대신 /stop</Text>}
968 </Box>
969 )
970 }
971
972 // Orca가 없을 때 [열기]·[이동]을 누르면 그 줄 아래에 여는 방법을 편다. 켜진 백그라운드 세션과 같은 폴더의 꺼진 대화는
973 // 이 터미널에서 열 수 있고(/resume), 다른 폴더의 꺼진 대화는 그 폴더로 가는 명령을 복사한다(Claude Code의 /resume 목록도 그렇게 한다)
974 const toggleOpening = (it: SessionItem) => update($, opening, old => (old === it.sessionId ? '' : it.sessionId))
975 const openLine = (it: SessionItem) => {
976 const canHere = it.kind === 'background' || (here !== '' && pathKey(it.cwd) === here)
977
978 return (
979 <Box key={`opening-${it.sessionId}`} paddingLeft={COL_ACTION + 1} columnGap={1} flexWrap="wrap">
980 {canHere && button(`agent-here-${it.sessionId}`, '여기서 열기', () => void openHere($, it))}
981 {button(`agent-copy-${it.sessionId}`, '명령 복사', () => void copyCommand($, it, e.surface))}
982 <Text dimColor>
983 {it.kind === 'background'
984 ? '여기서 열면 지금 대화는 백그라운드로 옮겨지고 이 터미널이 그 세션에 붙습니다(←로 돌아옴) · 복사하면 새 탭에서 붙습니다'
985 : canHere
986 ? '여기서 열면 이 터미널이 그 대화로 바뀝니다(지금 대화는 기록이 남음) · 복사하면 새 탭에서 엽니다'
987 : '다른 폴더의 대화라 새 탭에서 엽니다. 명령을 복사해 붙여 넣으세요'}
988 </Text>
989 </Box>
990 )
991 }
992
993 const row = (it: SessionItem) => {
994 const isOff = it.kind === 'off'
995 const s = stateWord(it.state, it.kind)
996 const kindView = selfKindView(it.kind)
997 const isManaged = managed === it.sessionId
998
999 return (
1000 <Box key={`agent-${it.sessionId}`} flexDirection="column">
1001 <Box gap={1}>
1002 <Box width={COL_ACTION} flexShrink={0}>
1003 {it.isSelf ? (
1004 <Text color="suggestion" bold>
1005 이 대화
1006 </Text>
1007 ) : launchingIds.includes(it.sessionId) ? (
1008 // 새 탭에서 Claude가 뜨기를 기다리는 동안은 누를 수 없게 글로만 보인다
1009 <Text dimColor>여는 중…</Text>
1010 ) : isOff ? (
1011 button(`agent-open-${it.sessionId}`, '열기', () => void (hasHost ? openOff($, it) : toggleOpening(it)))
1012 ) : hasHost ? (
1013 button(`agent-go-${it.sessionId}`, '이동', () => void switchTo($, it))
1014 ) : it.kind === 'background' ? (
1015 button(`agent-go-${it.sessionId}`, '이동', () => void toggleOpening(it))
1016 ) : (
1017 // 다른 창에서 켜진 대화형 세션: 여기서 열면 두 터미널의 메시지가 한 기록에 섞이므로 버튼을 두지 않는다
1018 <Text dimColor>다른 창</Text>
1019 )}
1020 </Box>
1021 <Box width={COL_STATE} flexShrink={0}>
1022 {isOff ? <Text dimColor>{sinceText(now - it.at)}</Text> : <Text {...toneProps(s.tone)}>{s.word}</Text>}
1023 </Box>
1024 <Box width={COL_KIND} flexShrink={0}>
1025 {/* 켜진 대화의 종류는 입력창 위 배지와 같은 색(백그라운드 파랑, 포그라운드 노랑)으로, 유휴는 흐리게 */}
1026 {kindView === null ? <Text dimColor>{kindWord(it.kind) || '-'}</Text> : <Text color={kindView.color}>{kindWord(it.kind)}</Text>}
1027 </Box>
1028 {/* 켜진 대화의 이름은 밑줄로 눌러서 고칠 수 있음을 보인다. 꺼진 대화는 이름을 바꿀 수 없다 */}
1029 {editing === it.sessionId && Input !== undefined ? (
1030 <Box flexGrow={1} flexShrink={1} columnGap={1}>
1031 <Input
1032 key="rename-input"
1033 placeholder="새 이름"
1034 value={draft}
1035 autoFocus
1036 onInput={(text: string) => void update($, renameDraft, () => text)}
1037 onSubmit={(text: string) => void applyRename($, it, text)}
1038 />
1039 {button('rename-save', '저장', () => void applyRename($, it, draft))}
1040 {button('rename-cancel', '취소', () => void cancelRename($))}
1041 </Box>
1042 ) : (
1043 <Box flexGrow={1} flexShrink={1} columnGap={1}>
1044 {/* 이름 칸이 남는 폭을 모두 차지해 폴더 이름이 늘 [관리] 바로 앞에 맞춰 선다 */}
1045 <Box flexGrow={1} flexShrink={1}>
1046 {/* Orca가 없으면 다른 대화의 입력창에 /rename을 대신 칠 수 없어 이름을 누를 수 없게 둔다 */}
1047 {isOff || (!hasOrca && !it.isSelf) ? (
1048 <Text>{it.name}</Text>
1049 ) : (
1050 <Button key={`agent-rename-${it.sessionId}`} plain label={`${it.name} (이름 고치기)`} onPress={() => void startRename($, it)}>
1051 <Text bold={it.isSelf} underline>
1052 {it.name}
1053 </Text>
1054 </Button>
1055 )}
1056 </Box>
1057 {/* 날짜별 보기에서는 여러 폴더가 섞이므로 폴더 끝 이름을 함께 적는다. 줄지 않게 해서 이름이 대신 줄바꿈된다 */}
1058 {v.mode === 'date' && !inArchive && (
1059 <Box flexShrink={0}>
1060 <Text dimColor>{folderLabel(it.cwd)}</Text>
1061 </Box>
1062 )}
1063 </Box>
1064 )}
1065 <Box flexShrink={0}>
1066 {inArchive
1067 ? button(`agent-restore-${it.sessionId}`, '복구', () => void restoreItem($, it))
1068 : button(`agent-manage-${it.sessionId}`, isManaged ? '접기' : '관리', () => void update($, managing, old => (old === it.sessionId ? '' : it.sessionId)), isManaged ? 'warning' : 'suggestion')}
1069 </Box>
1070 </Box>
1071 {isManaged && !inArchive && manageLine(it)}
1072 {openingId === it.sessionId && !hasHost && !inArchive && openLine(it)}
1073 </Box>
1074 )
1075 }
1076
1077 const groupHead = (key: string, title: string, list: readonly SessionItem[]) => {
1078 const liveCount = list.filter(it => it.kind !== 'off').length
1079
1080 return (
1081 <Box key={`head-${key}`} gap={1}>
1082 <Text bold>{title}</Text>
1083 <Text dimColor>{`${list.length}개 (켜짐 ${liveCount} · 유휴 ${list.length - liveCount})`}</Text>
1084 </Box>
1085 )
1086 }
1087 // 묶음마다 꺼진 대화는 최근 몇 개만 보이고 나머지는 [더 보기]로 편다. [유휴] 필터와 보관함에서는 모두 보인다
1088 const groupBody = (key: string, title: string, list: readonly SessionItem[]) => {
1089 const showAll = filter === 'off' || inArchive || opened.includes(key)
1090 const shown: SessionItem[] = []
1091 let offShown = 0
1092 for (const it of list) {
1093 if (it.kind !== 'off' || showAll || offShown < OFF_PREVIEW) shown.push(it)
1094 if (it.kind === 'off') offShown += 1
1095 }
1096 const rest = list.length - shown.length
1097 const canFold = !inArchive && filter !== 'off' && opened.includes(key) && offShown > OFF_PREVIEW
1098
1099 return (
1100 <Box key={`group-${key}`} flexDirection="column" marginTop={1}>
1101 {groupHead(key, title, list)}
1102 {shown.map(row)}
1103 {(rest > 0 || canFold) && (
1104 <Box paddingLeft={COL_ACTION + 1}>
1105 {rest > 0
1106 ? button(`more-${key}`, `유휴 ${rest}개 더 보기`, () => void update($, expanded, old => [...old, key]))
1107 : button(`less-${key}`, '유휴 접기', () => void update($, expanded, old => old.filter(k => k !== key)))}
1108 </Box>
1109 )}
1110 </Box>
1111 )
1112 }
1113 const isEmpty = layout.pinned.length === 0 && layout.groups.length === 0
1114
1115 return (
1116 <Box flexDirection="column">
1117 {/* 머리 줄은 명령 팔레트와 같은 모양이다: 왼쪽에 보기 탭(고른 탭은 칠한 버튼), 오른쪽 끝에 [다시 읽기]·[닫기]와 닫는 키.
1118 탭 쪽만 줄고 꺾이게 해서, 좁은 화면에서도 버튼은 첫 줄 오른쪽에 남는다. 보기 설정은 이 PC에 저장된다 */}
1119 <Box key="head" columnGap={1}>
1120 <Box flexGrow={1} flexShrink={1} columnGap={1} flexWrap="wrap">
1121 {choice('view-folder', '폴더별', v.mode === 'folder' && !inArchive, () => void update($, showArchived, () => false).then(() => setMode($, 'folder')))}
1122 {choice('view-date', '날짜별', v.mode === 'date' && !inArchive, () => void update($, showArchived, () => false).then(() => setMode($, 'date')))}
1123 {choice('archive-toggle', `보관함 ${archivedCount}`, inArchive, () => void update($, showArchived, () => true))}
1124 </Box>
1125 <Box flexShrink={0} columnGap={1}>
1126 {button('panel-refresh', '다시 읽기', () => void refreshPanel($))}
1127 <Button key="panel-close" plain role="dismiss" label="[닫기]" onPress={() => void $.ui.close({ id: PANE })}>
1128 <Text color="suggestion">[닫기]</Text>
1129 </Button>
1130 <Text dimColor>Esc</Text>
1131 </Box>
1132 </Box>
1133 {/* 둘째 줄: 상태 필터 버튼에 개수를 붙이고, 켜진 대화의 상태별 개수를 이어 쓴다. 필터는 폴더별·날짜별마다 따로 기억한다.
1134 보관함에는 필터가 없어 개수만 보인다 */}
1135 {inArchive ? (
1136 <Text key="toolbar" dimColor>{`보관한 대화 ${archivedCount}개 · [폴더별]이나 [날짜별]을 누르면 목록으로 돌아갑니다`}</Text>
1137 ) : (
1138 <Box key="toolbar" columnGap={1} flexWrap="wrap">
1139 <Text dimColor>상태</Text>
1140 {choice('filter-all', `전체 ${visible.length}`, filter === 'all', () => void setFilter($, v.mode, 'all'))}
1141 {choice('filter-on', `켜짐 ${visible.length - offCount}`, filter === 'on', () => void setFilter($, v.mode, 'on'))}
1142 {choice('filter-off', `유휴 ${offCount}`, filter === 'off', () => void setFilter($, v.mode, 'off'))}
1143 {/* 상태별 개수는 한 묶음으로, 필터 버튼과 두 칸 띄운다. 꺾여도 줄 앞에 기호가 남지 않게 구분 기호는 개수 사이에만 둔다 */}
1144 {counts.length > 0 && (
1145 <Box key="state-counts" flexShrink={0} columnGap={1} marginLeft={1}>
1146 {counts.map((c, i) => (
1147 <Box key={`count-${c.word}`} columnGap={1}>
1148 {i > 0 && <Text dimColor>·</Text>}
1149 <Text {...toneProps(c.tone)}>{`${c.word} ${c.n}`}</Text>
1150 </Box>
1151 ))}
1152 </Box>
1153 )}
1154 </Box>
1155 )}
1156 {at === 0 && <Text dimColor>세션 목록을 불러오는 중입니다…</Text>}
1157 {at > 0 && isEmpty && <Text dimColor>{inArchive ? '보관한 대화가 없습니다' : '표시할 대화가 없습니다. 상태 필터를 [전체]로 바꿔 보세요'}</Text>}
1158 {/* 고정한 대화는 보기·상태 필터와 상관없이 맨 위에 한 번만 나온다 */}
1159 {layout.pinned.length > 0 && (
1160 <Box key="pinned" flexDirection="column" marginTop={1}>
1161 {groupHead('pinned', '고정', layout.pinned)}
1162 {layout.pinned.map(row)}
1163 </Box>
1164 )}
1165 {layout.groups.map(g => groupBody(g.key, g.title, g.items))}
1166 {inArchive && (
1167 <Box key="archive-note" marginTop={1}>
1168 <Text dimColor>보관은 목록에서 숨기기만 합니다. 다만 30일(cleanupPeriodDays)이 지나면 Claude Code가 기록을 지워 복구할 수 없습니다</Text>
1169 </Box>
1170 )}
1171 {running > 0 && (
1172 <Box key="subagents" gap={1} marginTop={1}>
1173 <Text dimColor>이 대화의 서브에이전트 {running}개 실행 중</Text>
1174 <Button key="panel-flow" plain label="[자세히]" onPress={() => void $.command.run({ command: 'flow' }).catch(() => $.ui.toast('/flow가 없습니다. agent-flow mod가 설치돼 있어야 합니다'))}>
1175 <Text color="suggestion">[자세히]</Text>
1176 </Button>
1177 </Box>
1178 )}
1179 {/* 범례는 [설명]을 눌렀을 때만 편다. 늘 펼쳐 두면 목록이 그만큼 아래로 밀린다 */}
1180 <Box key="footer" columnGap={1} flexWrap="wrap" marginTop={1}>
1181 {button('legend-toggle', legendOn ? '설명 접기' : '설명', () => void update($, showLegend, old => !old))}
1182 <Text dimColor>Claude Code 기본 목록</Text>
1183 <Button key="panel-resume" plain label="[resume]" onPress={() => void openResume($)}>
1184 <Text color="suggestion">[resume]</Text>
1185 </Button>
1186 </Box>
1187 {legendOn && (
1188 <Box key="legend" flexDirection="column">
1189 <Text dimColor>백그라운드: 탭을 닫아도 켜져 있음 · 포그라운드: 탭을 닫으면 꺼짐 · 유휴: 꺼져 있고 기록만 남음([열기]로 다시 켬)</Text>
1190 <Text dimColor>밑줄 친 이름을 누르면 바로 고칩니다(켜진 대화만) · [관리]: 고정·보관·끄기([끄기]는 두 번 눌러야 끔)</Text>
1191 <Text dimColor>목록은 15초마다 새로 읽습니다 · 고정·보관·보기 설정은 이 PC에만 저장 · [resume] 목록에서 Ctrl+A는 모든 폴더 · 패널은 Esc나 Ctrl+X X로도 닫힘</Text>
1192 {!hasHost && (
1193 <Text dimColor>
1194 탭을 다루는 프로그램(Orca·herdr) 밖이라 다른 탭으로 옮기지 못합니다: [열기]·[이동]은 이 터미널에서 열거나(/resume) 새 탭용 명령을 복사합니다 · 다른 창의 대화는 그 창에서 쓰고 이름도 그 창에서 /rename
1195 </Text>
1196 )}
1197 {hostName === 'herdr' && <Text dimColor>herdr 안에서는 다른 대화의 이름을 그 탭에서 /rename으로 바꿉니다</Text>}
1198 </Box>
1199 )}
1200 </Box>hooks/hosts.ts 222 lines1// 탭을 다루는 터미널 프로그램(호스트). 세션 패널의 [이동]·[열기]는 이 다섯 동작만 쓴다:
2// 세션이 떠 있는 탭 찾기, 그 탭으로 옮기기, 새 탭 열기(앞으로 띄우지 않음), 탭 화면 읽기, 탭 닫기.
3// Orca와 herdr를 지원한다. Windows Terminal·일반 PowerShell 창은 열린 탭 목록을 읽거나 탭을 골라 옮기는 명령이 없어
4// 이미 떠 있는 세션으로 옮길 수 없으므로 넣지 않았다(그때는 /resume·명령 복사로 연다)
5
6import type { SessionItem, TermLocator } from '../types'
7import { createdHandle, findTerminal, screenLines } from './panel'
8import { orcaHasPath } from './sessions'
9
10export type RunResult = { exitCode: number; stdout: string; stderr: string }
11export type Run = (argv: string[]) => Promise<RunResult>
12
13export type OpenRequest = {
14 item: Pick<SessionItem, 'cwd' | 'name'>
15 /** 실행할 명령(claude attach … / claude --resume …) */
16 command: string
17 /** 기록으로 다시 여는 명령이면 그 폴더에서 실행해야 한다(기록은 폴더별로 찾는다) */
18 needsCwd: boolean
19 isWindows: boolean
20}
21
22export interface TerminalHost {
23 name: TermLocator['host']
24 /** 세션이 떠 있는 탭(패널)의 handle. hint는 그 세션이 자기 기록에 남긴 위치(이 호스트의 것일 때만 온다) */
25 locate(item: SessionItem, hint: string | null): Promise<string | null>
26 focus(handle: string): Promise<boolean>
27 /** 새 탭을 앞으로 띄우지 않고 열어 명령을 실행한다. 실패하면 이유 */
28 open(req: OpenRequest): Promise<{ handle: string } | { error: string }>
29 read(handle: string): Promise<string[]>
30 close(handle: string): Promise<void>
31}
32
33/** orca terminal list 결과에 이 handle의 탭이 있는지 */
34export function orcaHasTerminal(stdout: string, handle: string): boolean {
35 try {
36 const parsed = JSON.parse(stdout) as { result?: { terminals?: { handle?: unknown }[] } }
37 const list = Array.isArray(parsed?.result?.terminals) ? parsed.result.terminals : []
38
39 return list.some(t => t.handle === handle)
40 } catch {
41 return false
42 }
43}
44
45export function orcaHost(run: Run): TerminalHost {
46 return {
47 name: 'orca',
48 async locate(item, hint) {
49 const list = await run(['orca', 'terminal', 'list', '--json'])
50 if (list.exitCode !== 0) return null
51 // 포그라운드 세션은 그 탭 안에서 도는 프로세스라, 세션이 남긴 탭 handle이 지금도 있으면 그 탭이다(이름 없는 세션도 찾는다).
52 // 백그라운드 작업이 남긴 handle은 처음 실행된 탭의 것이라 믿지 않고 제목으로 찾는다
53 if (hint !== null && item.kind === 'interactive' && orcaHasTerminal(list.stdout, hint)) return hint
54
55 return findTerminal(list.stdout, item.name)
56 },
57 async focus(handle) {
58 return (await run(['orca', 'terminal', 'switch', '--terminal', handle, '--json'])).exitCode === 0
59 },
60 async open(req) {
61 const worktrees = await run(['orca', 'worktree', 'list', '--json'])
62 const registered = worktrees.exitCode === 0 && orcaHasPath(worktrees.stdout, req.item.cwd)
63 // Orca에 없는 폴더는 지금 작업 공간에 연다. 그 폴더에서 실행해야 하면 옮긴 뒤 실행한다(cd /d는 cmd 문법이라 Windows에서는 셸을 cmd로 정한다)
64 const moved = !registered && req.needsCwd
65 const command = moved ? (req.isWindows ? `cd /d "${req.item.cwd}" && ${req.command}` : `cd '${req.item.cwd}' && ${req.command}`) : req.command
66 const shell = moved && req.isWindows ? ['--shell', 'cmd.exe'] : []
67 const made = await run(['orca', 'terminal', 'create', '--worktree', registered ? `path:${req.item.cwd}` : 'active', '--title', req.item.name, '--command', command, ...shell, '--json'])
68 const handle = made.exitCode === 0 ? createdHandle(made.stdout) : null
69
70 return handle === null ? { error: firstLine(made.stderr || made.stdout) || 'Orca 탭을 만들지 못했습니다' } : { handle }
71 },
72 async read(handle) {
73 return screenLines((await run(['orca', 'terminal', 'read', '--terminal', handle, '--screen', '--json'])).stdout)
74 },
75 async close(handle) {
76 await run(['orca', 'terminal', 'close', '--terminal', handle, '--tab', '--json'])
77 },
78 }
79}
80
81// herdr 응답은 { id, result: { type, … } } 꼴이다(herdr api schema로 확인). 봉투 없이 result만 오는 경우도 받는다
82function herdrResult(stdout: string): Record<string, unknown> | null {
83 try {
84 const parsed = JSON.parse(stdout) as { result?: unknown }
85 const result = parsed !== null && typeof parsed === 'object' && 'result' in parsed ? parsed.result : parsed
86
87 return result !== null && typeof result === 'object' ? (result as Record<string, unknown>) : null
88 } catch {
89 return null
90 }
91}
92
93type HerdrAgent = { pane_id?: unknown; tab_id?: unknown; terminal_title_stripped?: unknown; agent_session?: { kind?: unknown; value?: unknown } | null }
94
95// 창 제목 앞의 상태 표시(✳ 등)를 떼고 비교한다(Orca 탭 제목을 찾는 findTerminal과 같은 방법)
96const titleKey = (title: string) => title.replace(/^[^\p{L}\p{N}]+/u, '').trim()
97const agentsOf = (stdout: string) => {
98 const agents = herdrResult(stdout)?.agents
99
100 return Array.isArray(agents) ? (agents as HerdrAgent[]) : []
101}
102
103/** herdr status 출력(server: 아래 status: running|not running)에서 서버가 떠 있는지 */
104export function herdrServerRunning(stdout: string): boolean {
105 return /^server:\s*$[\s\S]*?^\s+status:\s*running\s*$/m.test(stdout.replace(/\r/g, ''))
106}
107
108/** herdr에 이 이름을 창 제목으로 띄운 Claude 패널이 있는지. 백그라운드 작업을 attach해서 보고 있는 곳을 찾을 때 쓴다 */
109export function herdrShows(stdout: string, name: string): boolean {
110 const key = titleKey(name)
111
112 return key !== '' && agentsOf(stdout).some(a => typeof a.terminal_title_stripped === 'string' && titleKey(a.terminal_title_stripped) === key)
113}
114
115/**
116 * herdr agent list에서 이 세션이 떠 있는 패널. 포그라운드 세션은 herdr의 Claude 연동이 패널마다 알려 둔 세션 id, 그다음 세션이
117 * 자기 기록에 남긴 패널(hint)로 찾는다. 둘 다 세션 프로세스의 환경에서 온 값이라, 백그라운드 작업은 작업을 처음 띄운 곳의 패널을
118 * 가리킬 수 있어 쓰지 않는다. 마지막으로 탭 제목(= 세션 이름)으로 찾는다
119 */
120export function herdrPaneOf(stdout: string, item: Pick<SessionItem, 'sessionId' | 'name' | 'kind'>, hint: string | null): string | null {
121 const agents = agentsOf(stdout)
122 const interactive = item.kind === 'interactive'
123 const sessionOf = (a: HerdrAgent) => (a.agent_session?.kind === 'id' && typeof a.agent_session.value === 'string' ? a.agent_session.value : null)
124 const bySession = interactive ? agents.find(a => sessionOf(a) === item.sessionId) : undefined
125 // 기록의 패널은 지금도 Claude가 떠 있고 다른 세션이 차지하지 않았을 때만 믿는다(herdr는 닫거나 옮긴 패널의 id를 다시 쓰지 않는다)
126 const byHint = interactive && hint !== null ? agents.find(a => a.pane_id === hint && (sessionOf(a) ?? item.sessionId) === item.sessionId) : undefined
127 const byTitle = agents.find(a => typeof a.terminal_title_stripped === 'string' && titleKey(a.terminal_title_stripped) === titleKey(item.name))
128 const hit = bySession ?? byHint ?? byTitle
129
130 return hit !== undefined && typeof hit.pane_id === 'string' ? hit.pane_id : null
131}
132
133/**
134 * 이름표(label)가 세션 이름인 탭에 떠 있는 Claude 패널. 이 mod는 새 탭을 열 때 이름표에 세션 이름을 단다.
135 * 그 탭에 붙은 Claude가 창 제목을 세션 이름으로 바꾸지 않아도, 다음 [이동]이 새 탭을 또 열지 않고 그 탭을 찾는다
136 */
137export function herdrPaneByLabel(agentsOut: string, tabsOut: string, name: string): string | null {
138 const tabs = herdrResult(tabsOut)?.tabs
139 const labeled = new Set(
140 (Array.isArray(tabs) ? (tabs as { tab_id?: unknown; label?: unknown }[]) : [])
141 .filter(t => typeof t.tab_id === 'string' && typeof t.label === 'string' && t.label.trim() === name)
142 .map(t => t.tab_id),
143 )
144 const hit = agentsOf(agentsOut).find(a => labeled.has(a.tab_id))
145
146 return hit !== undefined && typeof hit.pane_id === 'string' ? hit.pane_id : null
147}
148
149/** herdr tab create 결과에서 새 탭과 그 첫 패널. 둘을 "탭|패널"로 묶어 handle로 쓴다 */
150export function herdrCreated(stdout: string): { tab: string; pane: string } | null {
151 const result = herdrResult(stdout)
152 const tab = (result?.tab as { tab_id?: unknown } | undefined)?.tab_id
153 const pane = (result?.root_pane as { pane_id?: unknown } | undefined)?.pane_id
154
155 return typeof tab === 'string' && typeof pane === 'string' ? { tab, pane } : null
156}
157
158/** herdr pane read 결과의 화면 줄. CLI는 화면 글을 그대로 찍는다(herdr 0.9.4에서 확인). API 응답 모양({ result: { read: { text } } })도 받는다 */
159export function herdrLines(stdout: string): string[] {
160 const text = (herdrResult(stdout)?.read as { text?: unknown } | undefined)?.text
161
162 return (typeof text === 'string' ? text : stdout).split('\n')
163}
164
165export function herdrHost(run: Run): TerminalHost {
166 return {
167 name: 'herdr',
168 async locate(item, hint) {
169 const list = await run(['herdr', 'agent', 'list'])
170 if (list.exitCode !== 0) return null
171 const pane = herdrPaneOf(list.stdout, item, hint)
172 if (pane !== null) return pane
173 const tabs = await run(['herdr', 'tab', 'list'])
174
175 return tabs.exitCode === 0 ? herdrPaneByLabel(list.stdout, tabs.stdout, item.name) : null
176 },
177 async focus(handle) {
178 // 새로 연 탭은 "탭|패널", 찾은 세션은 패널 id다
179 const tab = handle.includes('|') ? (handle.split('|')[0] ?? '') : ''
180 const done = tab !== '' ? await run(['herdr', 'tab', 'focus', tab]) : await run(['herdr', 'agent', 'focus', handle])
181
182 return done.exitCode === 0
183 },
184 async open(req) {
185 // herdr는 새 탭의 폴더를 직접 받으므로 폴더로 옮기는 명령이 필요 없다
186 const made = await run(['herdr', 'tab', 'create', '--cwd', req.item.cwd, '--label', req.item.name, '--no-focus'])
187 const ids = made.exitCode === 0 ? herdrCreated(made.stdout) : null
188 if (ids === null) return { error: firstLine(made.stderr || made.stdout) || 'herdr 탭을 만들지 못했습니다' }
189 const ran = await run(['herdr', 'pane', 'run', ids.pane, req.command])
190 if (ran.exitCode !== 0) {
191 await run(['herdr', 'tab', 'close', ids.tab])
192 return { error: firstLine(ran.stderr || ran.stdout) || 'herdr 탭에서 명령을 실행하지 못했습니다' }
193 }
194
195 return { handle: `${ids.tab}|${ids.pane}` }
196 },
197 async read(handle) {
198 const pane = handle.includes('|') ? (handle.split('|')[1] ?? handle) : handle
199
200 return herdrLines((await run(['herdr', 'pane', 'read', pane, '--source', 'visible'])).stdout)
201 },
202 async close(handle) {
203 const tab = handle.includes('|') ? (handle.split('|')[0] ?? '') : ''
204 if (tab !== '') await run(['herdr', 'tab', 'close', tab])
205 },
206 }
207}
208
209function firstLine(text: string): string {
210 const line = text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
211
212 return line.length > 160 ? `${line.slice(0, 160)}…` : line
213}
214
215/** 세션이 자기 기록에 남길 위치: herdr 패널 안이면 패널 id, Orca 탭 안이면 탭 handle. 둘 다 아니면 null */
216export function locatorFromEnv(env: { herdrEnv?: string; herdrPane?: string; orcaHandle?: string }): TermLocator | null {
217 if (env.herdrEnv === '1' && env.herdrPane !== undefined && env.herdrPane !== '') return { host: 'herdr', id: env.herdrPane }
218 if (env.orcaHandle !== undefined && env.orcaHandle !== '') return { host: 'orca', id: env.orcaHandle }
219
220 return null
221}
222hooks/panel.ts 200 lines1// 세션 패널: `claude agents --json --all`이 주는 이 PC의 세션 목록과, 줄마다 쓰는 낱말·색.
2// 이동은 Orca의 터미널 탭 제목과 세션 이름을 맞춰 찾는다.
3
4import type { AgentRow } from '../types'
5
6type Raw = { sessionId?: unknown; id?: unknown; name?: unknown; cwd?: unknown; state?: unknown; status?: unknown; kind?: unknown; pid?: unknown }
7
8/** `claude agents --json [--all]` 출력에서 필요한 칸만 꺼낸다. pid가 있으면 켜져 있는 세션이다. 형식이 다르면 빈 목록 */
9export function parseAgents(stdout: string, selfId: string): AgentRow[] {
10 let list: unknown
11 try {
12 list = JSON.parse(stdout)
13 } catch {
14 return []
15 }
16 if (!Array.isArray(list)) return []
17
18 return (list as Raw[]).flatMap(r => {
19 const sessionId = typeof r.sessionId === 'string' ? r.sessionId : typeof r.id === 'string' ? r.id : ''
20 if (sessionId === '') return []
21 const state = typeof r.state === 'string' ? r.state : typeof r.status === 'string' ? r.status : ''
22
23 return [
24 {
25 sessionId,
26 shortId: typeof r.id === 'string' ? r.id : '',
27 name: typeof r.name === 'string' && r.name.trim() !== '' ? r.name.trim() : `세션 ${sessionId.slice(0, 8)}`,
28 cwd: typeof r.cwd === 'string' ? r.cwd : '',
29 state,
30 kind: typeof r.kind === 'string' ? r.kind : '',
31 isSelf: sessionId === selfId,
32 isLive: typeof r.pid === 'number',
33 },
34 ]
35 })
36}
37
38/** 상태는 기호 없이 낱말과 색으로만 나타낸다. warning=사람이 할 일 있음, success=돌고 있음, muted=기다리는 중 */
39export type Tone = 'warning' | 'success' | 'muted'
40
41export function stateWord(state: string, kind = ''): { word: string; tone: Tone } {
42 // blocked는 백그라운드 세션, waiting은 포그라운드 세션이 입력·승인을 기다릴 때 claude agents가 주는 값이다
43 if (state === 'blocked' || state === 'waiting') return { word: '입력 필요', tone: 'warning' }
44 if (state === 'working' || state === 'busy') return { word: '작업 중', tone: 'success' }
45 // done은 꺼진 것이 아니라 답을 마치고 다음 요청을 기다리는 상태다
46 if (state === 'done') return { word: '답변 완료', tone: 'muted' }
47 // 켜진 채 쉬고 있는 상태. 꺼진 대화를 뜻하는 "유휴"와 헷갈리지 않게 "대기 중"이라고 쓴다
48 if (state === 'idle') return { word: '대기 중', tone: 'muted' }
49 // 터미널에서 직접 연 포그라운드 세션은 claude agents가 상태를 주지 않을 때가 있다
50 if (state === '' && kind === 'interactive') return { word: '상태 모름', tone: 'muted' }
51
52 return { word: state === '' ? '알 수 없음' : state, tone: 'muted' }
53}
54
55/** 상태별 개수. 사람이 할 일(입력 필요)부터 늘어놓는다 */
56export function stateCounts(rows: readonly AgentRow[]): { word: string; tone: Tone; n: number }[] {
57 const counts = new Map<string, { word: string; tone: Tone; n: number }>()
58 for (const r of rows) {
59 const s = stateWord(r.state, r.kind)
60 counts.set(s.word, { ...s, n: (counts.get(s.word)?.n ?? 0) + 1 })
61 }
62 const rank: Record<Tone, number> = { warning: 0, success: 1, muted: 2 }
63
64 return [...counts.values()].sort((a, b) => rank[a.tone] - rank[b.tone])
65}
66
67/** 세션 종류 낱말. claude agents의 kind 값 background, interactive와, 꺼진 대화(기록만 남음) off */
68export function kindWord(kind: string): string {
69 if (kind === 'background') return '백그라운드'
70 if (kind === 'interactive') return '포그라운드'
71 if (kind === 'off') return '유휴'
72
73 return ''
74}
75
76/**
77 * band 세션 줄의 앞부분: 이 대화의 종류 배지와, 탭을 닫으면 어떻게 되는지(대화 규칙 2). 종류를 모르면 null.
78 * 백그라운드는 안전한 쪽이라 suggestion 색, 포그라운드는 탭을 닫으면 꺼지므로 warning 색 배지를 쓴다
79 */
80export function selfKindView(kind: string): { badge: string; color: 'suggestion' | 'warning'; hint: string } | null {
81 if (kind === 'background') return { badge: '백그라운드', color: 'suggestion', hint: '탭을 닫아도 켜져 있음 · 끄기 /stop' }
82 if (kind === 'interactive') return { badge: '포그라운드', color: 'warning', hint: '탭을 닫으면 꺼짐 · 유지 /background' }
83
84 return null
85}
86
87// 터미널에서 차지하는 칸 수: 한글·한자·전각 문자와 그림 문자는 두 칸
88const cellWidth = (c: string) => (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1faff}]/u.test(c) ? 2 : 1)
89
90/** 이름이 max칸을 넘으면 줄여 끝에 …를 붙인다. band에서 이름 버튼 하나가 한 줄을 다 차지하지 않게 한다 */
91export function clipName(name: string, max: number): string {
92 const chars = [...name]
93 if (chars.reduce((n, c) => n + cellWidth(c), 0) <= max) return name
94 const kept: string[] = []
95 let used = 0
96 for (const c of chars) {
97 if (used + cellWidth(c) > max - 1) break
98 kept.push(c)
99 used += cellWidth(c)
100 }
101
102 return `${kept.join('').trimEnd()}…`
103}
104
105/** 이름 입력을 정리한다: 줄바꿈·제어 문자를 빼고, 공백을 하나로 줄이고, 60자로 자른다 */
106export function cleanName(raw: string): string {
107 return raw
108 .replace(/[\u0000-\u001f\u007f]/g, ' ')
109 .replace(/\s+/g, ' ')
110 .trim()
111 .slice(0, 60)
112}
113
114/**
115 * `orca terminal read --screen --json` 결과에서 Claude 입력창에 쓰던 글이 있는지 본다.
116 * 입력창은 화면 아래쪽 ❯ 줄과 그 아래 가로줄(─) 사이다. 빈 입력창에 뜨는 안내 문구(Try "…")는 쓴 글로 보지 않는다.
117 * 입력창을 찾지 못하면(대화상자가 떠 있는 등) 쓴 글이 있는 것으로 본다. 확실하지 않으면 건드리지 않기 위해서다
118 */
119export function composerHasText(stdout: string): boolean {
120 let lines: string[]
121 try {
122 const parsed = JSON.parse(stdout) as { result?: { terminal?: { tail?: unknown } } }
123 const tail = parsed?.result?.terminal?.tail
124 lines = Array.isArray(tail) ? tail.map(String) : typeof tail === 'string' ? tail.split('\n') : []
125 } catch {
126 return true
127 }
128 // 오른쪽에 패널이 붙어 있으면 줄 뒤쪽에 패널 글자가 이어지므로, 패널 경계(│) 앞까지만 본다
129 const left = lines.map(l => l.split('│')[0] ?? '')
130 const promptAt = left.map(l => l.trimStart().startsWith('❯')).lastIndexOf(true)
131 if (promptAt < 0) return true
132 const borderAt = left.findIndex((l, i) => i > promptAt && /^─{10,}/.test(l.trim()))
133 if (borderAt < 0) return true
134 const first = left[promptAt]?.trimStart().slice(1).trim() ?? ''
135 const rest = left.slice(promptAt + 1, borderAt).join(' ').trim()
136 const typed = `${first} ${rest}`.trim()
137
138 return typed !== '' && !/^Try "/.test(typed)
139}
140
141/** orca terminal read --screen --json 결과의 화면 줄. 형식이 다르면 빈 배열 */
142export function screenLines(stdout: string): string[] {
143 try {
144 const parsed = JSON.parse(stdout) as { result?: { terminal?: { tail?: unknown } } }
145 const tail = parsed?.result?.terminal?.tail
146
147 return Array.isArray(tail) ? tail.map(String) : typeof tail === 'string' ? tail.split('\n') : []
148 } catch {
149 return []
150 }
151}
152
153/** orca terminal create --json 결과에서 새 탭의 handle. 없으면 null */
154export function createdHandle(stdout: string): string | null {
155 try {
156 const parsed = JSON.parse(stdout) as { result?: { terminal?: { handle?: unknown }; handle?: unknown } }
157 const handle = parsed?.result?.terminal?.handle ?? parsed?.result?.handle
158
159 return typeof handle === 'string' && handle !== '' ? handle : null
160 } catch {
161 return null
162 }
163}
164
165export type LaunchVerdict = { kind: 'ok' } | { kind: 'failed'; reason: string } | { kind: 'pending' }
166
167/**
168 * 새로 연 탭의 화면을 보고 Claude가 떴는지 판단한다. 입력창 테두리의 세션 이름(─ 이름 ─), 시작 머리글(Claude Code v…),
169 * 모드 줄 안내가 보이면 떴다. 그 전에 오류 줄(No job matching 등)이 보이면 실패와 그 줄, 둘 다 없으면 아직 여는 중이다
170 */
171export function launchVerdict(lines: readonly string[], name: string): LaunchVerdict {
172 // 오른쪽에 패널이 붙어 있으면 줄 뒤쪽에 패널 글자가 이어지므로, 패널 경계(│) 앞까지만 본다
173 const left = lines.map(l => l.split('│')[0] ?? '')
174 const label = name.slice(0, 20)
175 const isClaude = left.some(l => (label !== '' && l.includes(`─ ${label}`)) || /Claude Code v\d|shift\+tab to cycle|\? for shortcuts|── clickbar|── 세션/.test(l))
176 if (isClaude) return { kind: 'ok' }
177 const error = left.find(l => /No job matching|No conversation found|is not recognized as|command not found|Error:/i.test(l))
178
179 return error === undefined ? { kind: 'pending' } : { kind: 'failed', reason: error.trim().slice(0, 160) }
180}
181
182type OrcaTerminal = { handle?: unknown; title?: unknown }
183
184/** Orca 탭 제목 앞의 상태 기호(✳, ⠂ 같은 것)를 떼고 세션 이름과 맞는 탭을 찾는다 */
185export function findTerminal(stdout: string, name: string): string | null {
186 let parsed: unknown
187 try {
188 parsed = JSON.parse(stdout)
189 } catch {
190 return null
191 }
192 const result = (parsed as { result?: { terminals?: unknown } })?.result
193 const terminals = Array.isArray(result?.terminals) ? (result.terminals as OrcaTerminal[]) : []
194 const clean = (t: string) => t.replace(/^[^\p{L}\p{N}]+/u, '').trim()
195 const target = clean(name)
196 const hit = terminals.find(t => typeof t.title === 'string' && typeof t.handle === 'string' && target !== '' && clean(t.title) === target)
197
198 return hit === undefined ? null : (hit.handle as string)
199}
200hooks/records.ts 86 lines1// 같은 폴더 세션 추적: 세션마다 자기 상태 파일 하나를 쓰고, 같은 폴더의 다른 파일을 읽는다.
2// AI LABS 영상의 session-tracker를 다시 만든 것이다. 모델을 호출하지 않는다.
3
4import type { SessionRecord, SessionStatus } from '../types'
5
6// 1분마다 살아 있다고 쓰므로, 3분 넘게 소식이 없으면 꺼진 세션으로 본다(강제 종료되면 session.end가 오지 않는다)
7export const HEARTBEAT_MS = 60_000
8export const ALIVE_MS = 3 * 60_000
9// 이보다 오래된 파일은 열어 보지도 않는다
10export const SKIP_OLDER_MS = 24 * 3600_000
11export const PROMPT_MAX = 200
12export const NOTE_PROMPT_MAX = 120
13
14// waiting은 사람의 답(도구 승인·질문·계획 승인)을 기다리는 상태다. 세션 패널의 "입력 필요"와 같은 낱말을 쓴다
15export const STATUS_WORDS: Record<Exclude<SessionStatus, 'ended'>, string> = { working: '작업 중', waiting: '입력 필요', idle: '대기 중' }
16
17/** 폴더 경로를 파일 이름에 쓸 수 있는 키로 바꾼다. D:\all → D-all */
18export function rootKey(root: string): string {
19 return root.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '')
20}
21
22export function parseRecord(text: string): SessionRecord | null {
23 try {
24 const r = JSON.parse(text) as Partial<SessionRecord>
25 const ok =
26 typeof r.id === 'string' &&
27 typeof r.root === 'string' &&
28 typeof r.prompt === 'string' &&
29 typeof r.at === 'number' &&
30 typeof r.changedAt === 'number' &&
31 (r.status === 'working' || r.status === 'waiting' || r.status === 'idle' || r.status === 'ended')
32 if (!ok) return null
33 // 위치는 모양이 맞을 때만 받는다(예전 기록에는 없다)
34 const term = r.term !== undefined && (r.term.host === 'orca' || r.term.host === 'herdr') && typeof r.term.id === 'string' && r.term.id !== '' ? r.term : undefined
35
36 return { ...(r as SessionRecord), term }
37 } catch {
38 return null
39 }
40}
41
42/** 같은 폴더에서 살아 있는 다른 세션만 남긴다. 최근에 상태가 바뀐 것이 먼저 */
43export function alivePeers(records: readonly SessionRecord[], selfId: string, root: string, now: number): SessionRecord[] {
44 return records
45 .filter(r => r.id !== selfId && r.root === root && r.status !== 'ended' && now - r.at < ALIVE_MS)
46 .sort((a, b) => b.changedAt - a.changedAt)
47}
48
49export function firstLine(text: string, max: number): string {
50 const line = text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
51
52 return line.length > max ? `${line.slice(0, max)}…` : line
53}
54
55/**
56 * 같은 폴더에서 이 세션과 겹칠 수 있는 세션: 작업 중이거나, 사람의 답을 받으면 이어서 고칠 세션.
57 * 쉬는 세션은 겹치지 않고 할 일도 없다. band의 다른 세션 칸과 Claude에게 붙이는 안내가 같은 기준을 쓴다
58 */
59export function activePeers(peers: readonly SessionRecord[]): SessionRecord[] {
60 // 사람의 답을 기다리는 세션이 먼저(할 일이 있다), 그다음 작업 중. 같은 상태끼리는 alivePeers의 순서(최근에 바뀐 것이 먼저)를 지킨다
61 return peers.filter(p => p.status === 'working' || p.status === 'waiting').sort((a, b) => Number(b.status === 'waiting') - Number(a.status === 'waiting'))
62}
63
64/** 지금 상태가 이어진 시간: 방금, N분째, N시간째 */
65export function spanText(ms: number): string {
66 const minutes = Math.max(0, Math.floor(ms / 60_000))
67 if (minutes < 1) return '방금'
68 if (minutes < 60) return `${minutes}분째`
69
70 return `${Math.floor(minutes / 60)}시간째`
71}
72
73/** 다른 세션이 작업 중일 때만 Claude에게 붙이는 한 줄. 세션 이름을 알면 이름과 요청을 함께 쓴다. 없으면 null */
74export function overlapNote(peers: readonly SessionRecord[], nameOf?: (id: string) => string | undefined): string | null {
75 const working = activePeers(peers)
76 if (working.length === 0) return null
77 const items = working.slice(0, 2).map(p => {
78 const request = `"${firstLine(p.prompt, NOTE_PROMPT_MAX)}"`
79 const name = nameOf?.(p.id)
80
81 return name === undefined ? `요청: ${request}` : `${name}: ${request}`
82 })
83
84 return `참고(session-tracker): 같은 폴더에서 다른 Claude 세션 ${working.length}개가 작업 중이다(${items.join(', ')}). 같은 파일을 동시에 고치지 않도록, 겹칠 것 같으면 먼저 사용자에게 확인하라.`
85}
86hooks/sessions.ts 276 lines1// 세션 패널의 "모든 대화" 목록: 켜진 대화(claude agents)와 꺼진 대화(기록 파일만 남은 것)를 합쳐 묶는다.
2// 꺼진 대화의 제목은 /resume처럼 기록 파일 끝부분에서 읽는다(TITLE_SCRIPT). mod는 파일 일부만 읽을 수 없어서다.
3
4import type { AgentRow, OnOffFilter, SessionItem, SessionView, TitleEntry } from '../types'
5
6export const DEFAULT_VIEW: SessionView = { mode: 'folder', filter: { folder: 'all', date: 'all' } }
7
8const FILTERS: readonly OnOffFilter[] = ['all', 'on', 'off']
9
10/** $.store의 view를 검사해 꺼낸다. 모르는 값은 기본값으로 */
11export function parseView(raw: unknown): SessionView {
12 const v = (raw ?? {}) as { mode?: unknown; filter?: Record<string, unknown> }
13 const pick = (x: unknown): OnOffFilter => (FILTERS.includes(x as OnOffFilter) ? (x as OnOffFilter) : 'all')
14
15 return {
16 mode: v.mode === 'date' ? 'date' : 'folder',
17 filter: { folder: pick(v.filter?.folder), date: pick(v.filter?.date) },
18 }
19}
20
21/** $.store의 문자열 목록(pins, archived)을 검사해 꺼낸다 */
22export function parseIdList(raw: unknown): string[] {
23 return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
24}
25
26export type HistoryInfo = { cwd: string; firstPrompt: string; lastAt: number }
27
28const TITLE_MAX = 40
29
30function shorten(text: string): string {
31 const line = text.split('\n').find(l => l.trim() !== '')?.trim() ?? ''
32
33 return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX)}…` : line
34}
35
36/**
37 * ~/.claude/history.jsonl(보낸 질문마다 한 줄: display, project, sessionId, timestamp)을 대화별로 모은다.
38 * 첫 질문은 /rename 같은 슬래시 명령을 건너뛴 첫 진짜 질문이다(제목이 없을 때 쓴다)
39 */
40export function parseHistory(text: string): Map<string, HistoryInfo> {
41 const map = new Map<string, HistoryInfo>()
42 for (const line of text.split('\n')) {
43 if (line.trim() === '') continue
44 let o: { display?: unknown; project?: unknown; sessionId?: unknown; timestamp?: unknown }
45 try {
46 o = JSON.parse(line)
47 } catch {
48 continue
49 }
50 if (typeof o.sessionId !== 'string' || typeof o.project !== 'string') continue
51 const at = Number(o.timestamp)
52 const display = typeof o.display === 'string' ? o.display.trim() : ''
53 const old = map.get(o.sessionId)
54 const firstPrompt = old?.firstPrompt || (display !== '' && !display.startsWith('/') ? shorten(display) : '')
55 map.set(o.sessionId, { cwd: o.project, firstPrompt, lastAt: Math.max(old?.lastAt ?? 0, Number.isFinite(at) ? at : 0) })
56 }
57
58 return map
59}
60
61/** Claude Desktop의 임시 작업 폴더(scratch)와 Temp 아래 폴더는 목록에서 뺀다 */
62export function isScratchProject(dirName: string): boolean {
63 return /AppData-Local-Temp|scratch-workspaces/i.test(dirName)
64}
65
66/** slug는 기록 폴더 이름(~/.claude/projects/<slug>). Claude Code가 작업 폴더 경로의 영숫자 아닌 글자를 -로 바꿔 만든다 */
67export type TranscriptFile = { sessionId: string; slug: string; path: string; mtimeMs: number; size: number }
68
69/** 작업 폴더 경로 → 기록 폴더 이름. D:\all → D--all */
70export function projectSlug(cwd: string): string {
71 return cwd.replace(/[^A-Za-z0-9]/g, '-')
72}
73
74/** 제목 도우미 출력: [{ p: 경로, c: customTitle, a: aiTitle, w: cwd }] */
75export function parseTitleScan(stdout: string): Map<string, { t: string; k: 'custom' | 'ai' | ''; c: string }> {
76 const map = new Map<string, { t: string; k: 'custom' | 'ai' | ''; c: string }>()
77 let list: unknown
78 try {
79 list = JSON.parse(stdout.trim() || '[]')
80 } catch {
81 return map
82 }
83 for (const r of (Array.isArray(list) ? list : [list]) as { p?: unknown; c?: unknown; a?: unknown; w?: unknown }[]) {
84 if (typeof r?.p !== 'string') continue
85 const custom = typeof r.c === 'string' ? r.c.trim() : ''
86 const ai = typeof r.a === 'string' ? r.a.trim() : ''
87 map.set(r.p, { t: custom || ai, k: custom !== '' ? 'custom' : ai !== '' ? 'ai' : '', c: typeof r.w === 'string' ? r.w : '' })
88 }
89
90 return map
91}
92
93/**
94 * 켜진 대화와 꺼진 대화를 한 목록으로 만든다. 꺼진 대화는 기록 파일이 남아 있는 것만 넣는다
95 * (30일 보관 기간이 지나 지워진 대화는 열 수 없으므로). 이름은 이름 바꾸기(custom) → 멈춘 백그라운드 세션의 이름 →
96 * 켜져 있을 때 본 이름 → 자동 제목(ai) → 첫 질문 순으로 고르고, 이름도 질문도 없는 빈 세션은 뺀다.
97 * 작업 폴더는 기록 파일 → 멈춘 세션 → 질문 기록 → 같은 기록 폴더의 다른 대화 순으로 찾는다(아주 짧은 기록에는 폴더가 없다)
98 */
99export function buildItems(input: {
100 rows: readonly AgentRow[]
101 files: readonly TranscriptFile[]
102 titles: Readonly<Record<string, TitleEntry>>
103 names: Readonly<Record<string, string>>
104 history: ReadonlyMap<string, HistoryInfo>
105 now: number
106}): SessionItem[] {
107 const fileById = new Map(input.files.map(f => [f.sessionId, f]))
108 // 기록 폴더 이름 → 작업 폴더: 폴더를 알아낸 대화들에서 모은다
109 const slugCwd = new Map<string, string>()
110 const learn = (cwd: string | undefined) => {
111 if (cwd !== undefined && cwd !== '' && !slugCwd.has(projectSlug(cwd))) slugCwd.set(projectSlug(cwd), cwd)
112 }
113 for (const r of input.rows) learn(r.cwd)
114 for (const t of Object.values(input.titles)) learn(t.c)
115 for (const h of input.history.values()) learn(h.cwd)
116 const live = input.rows.filter(r => r.isLive)
117 const liveIds = new Set(live.map(r => r.sessionId))
118 const stopped = new Map(input.rows.filter(r => !r.isLive).map(r => [r.sessionId, r]))
119 const items: SessionItem[] = live.map(r => ({
120 sessionId: r.sessionId,
121 shortId: r.shortId,
122 name: r.name,
123 cwd: r.cwd,
124 state: r.state,
125 kind: r.kind === 'interactive' ? 'interactive' : 'background',
126 isSelf: r.isSelf,
127 at: fileById.get(r.sessionId)?.mtimeMs ?? input.now,
128 reopen: '',
129 }))
130 for (const f of input.files) {
131 if (liveIds.has(f.sessionId)) continue
132 const row = stopped.get(f.sessionId)
133 const title = input.titles[f.sessionId]
134 const hist = input.history.get(f.sessionId)
135 const name =
136 (title?.k === 'custom' ? title.t : '') || row?.name || input.names[f.sessionId] || (title?.k === 'ai' ? title.t : '') || hist?.firstPrompt || ''
137 const cwd = title?.c || row?.cwd || hist?.cwd || slugCwd.get(f.slug) || ''
138 if (name === '' || cwd === '') continue
139 items.push({ sessionId: f.sessionId, shortId: row?.shortId ?? '', name, cwd, state: '', kind: 'off', isSelf: false, at: f.mtimeMs, reopen: row !== undefined ? 'attach' : 'resume' })
140 }
141
142 return items
143}
144
145const LIVE_RANK: Record<string, number> = { blocked: 0, working: 1, busy: 1 }
146const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']
147
148export const pathKey = (p: string) => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
149
150function dayKey(at: number): string {
151 const d = new Date(at)
152
153 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
154}
155
156/** 날짜 묶음 제목: 오늘, 어제, 그 전은 10/12(일) */
157export function dayTitle(at: number, now: number): string {
158 const d = new Date(at)
159 const today = new Date(now)
160 const days = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime()) / 86_400_000)
161 if (days === 0) return '오늘'
162 if (days === -1) return '어제'
163
164 return `${d.getMonth() + 1}/${d.getDate()}(${WEEKDAYS[d.getDay()]})`
165}
166
167/** 꺼진 대화의 마지막 활동: 방금, 5분 전, 3시간 전, 2일 전 */
168export function sinceText(ms: number): string {
169 const minutes = Math.max(0, Math.floor(ms / 60_000))
170 if (minutes < 1) return '방금'
171 if (minutes < 60) return `${minutes}분 전`
172 if (minutes < 1440) return `${Math.floor(minutes / 60)}시간 전`
173
174 return `${Math.floor(minutes / 1440)}일 전`
175}
176
177export type Group = { key: string; title: string; items: SessionItem[] }
178
179/**
180 * 화면에 그릴 순서로 나눈다. 대화 하나는 한 곳에만 나온다:
181 * 보관한 대화는 보관함에만, 고정한 대화는 맨 위 "고정"에만(묶음·켜짐 필터와 상관없이), 나머지는 묶음 안에.
182 * 폴더별: 이 대화가 있는 폴더가 먼저, 폴더 안에서는 켜진 대화(입력 필요 → 작업 중 → 나머지)가 먼저, 꺼진 대화는 최근 것부터.
183 * 날짜별: 최근 날짜부터, 같은 날 안에서는 최근 활동부터
184 */
185export function arrange(
186 items: readonly SessionItem[],
187 opts: { view: SessionView; pins: readonly string[]; archived: readonly string[]; showArchived: boolean; now: number },
188): { pinned: SessionItem[]; groups: Group[] } {
189 const archived = new Set(opts.archived)
190 const byRecent = (a: SessionItem, b: SessionItem) => b.at - a.at
191 if (opts.showArchived) {
192 const list = items.filter(it => archived.has(it.sessionId)).sort(byRecent)
193
194 return { pinned: [], groups: list.length === 0 ? [] : [{ key: 'archived', title: '보관함', items: list }] }
195 }
196 const visible = items.filter(it => !archived.has(it.sessionId))
197 const pinOrder = new Map(opts.pins.map((id, i) => [id, i]))
198 const pinned = visible.filter(it => pinOrder.has(it.sessionId)).sort((a, b) => (pinOrder.get(a.sessionId) ?? 0) - (pinOrder.get(b.sessionId) ?? 0))
199 const filter = opts.view.filter[opts.view.mode]
200 const rest = visible.filter(it => !pinOrder.has(it.sessionId) && (filter === 'all' || (filter === 'on') === (it.kind !== 'off')))
201 const groups = new Map<string, Group>()
202 for (const it of rest) {
203 const key = opts.view.mode === 'folder' ? pathKey(it.cwd) : dayKey(it.at)
204 const title = opts.view.mode === 'folder' ? it.cwd : dayTitle(it.at, opts.now)
205 const g = groups.get(key) ?? { key, title, items: [] }
206 g.items.push(it)
207 groups.set(key, g)
208 }
209 const list = [...groups.values()]
210 if (opts.view.mode === 'date') {
211 for (const g of list) g.items.sort(byRecent)
212 list.sort((a, b) => b.key.localeCompare(a.key))
213 } else {
214 const rank = (it: SessionItem) => (it.kind === 'off' ? 3 : (LIVE_RANK[it.state] ?? 2))
215 for (const g of list) g.items.sort((a, b) => rank(a) - rank(b) || byRecent(a, b))
216 const latest = (g: Group) => Math.max(...g.items.map(it => it.at))
217 list.sort((a, b) => Number(b.items.some(it => it.isSelf)) - Number(a.items.some(it => it.isSelf)) || latest(b) - latest(a))
218 }
219
220 return { pinned, groups: list }
221}
222
223/**
224 * Orca 없이 새 터미널 탭에 붙여 넣을 명령. 백그라운드 세션(켜져 있거나 멈춘 것)은 id로 붙고,
225 * 기록만 남은 대화는 그 폴더로 가서 다시 연다(cd 없이 열면 지금 폴더의 대화로 열린다)
226 */
227export function reopenCommand(item: Pick<SessionItem, 'cwd' | 'sessionId' | 'shortId' | 'reopen' | 'kind'>): string {
228 // claude attach는 claude agents의 짧은 id만 받는다(긴 세션 id는 "No job matching"). 짧은 id가 없으면 기록으로 다시 연다
229 if ((item.kind === 'background' || item.reopen === 'attach') && item.shortId !== '') return `claude attach ${item.shortId}`
230
231 return `cd "${item.cwd}" && claude --resume ${item.sessionId}`
232}
233
234/** orca worktree list --json 결과에 이 폴더가 있는지. 없는 폴더에는 Orca 탭을 열 수 없다 */
235export function orcaHasPath(stdout: string, cwd: string): boolean {
236 try {
237 const parsed = JSON.parse(stdout) as { result?: { worktrees?: { path?: unknown }[] } }
238 const list = Array.isArray(parsed?.result?.worktrees) ? parsed.result.worktrees : []
239
240 return list.some(w => typeof w.path === 'string' && pathKey(w.path) === pathKey(cwd))
241 } catch {
242 return false
243 }
244}
245
246/**
247 * 기록 파일 끝 256KB(없으면 앞 64KB)에서 마지막 customTitle·aiTitle·cwd를 찾는 PowerShell 도우미.
248 * 표준 입력으로 받은 경로마다 { p, c, a, w }를 JSON 배열로 낸다. 한글이 깨지지 않게 \uXXXX로 내보낸다.
249 * 파일을 열기만 하고 고치지 않는다
250 */
251export const TITLE_SCRIPT = [
252 "$ErrorActionPreference = 'SilentlyContinue'",
253 '$paths = [Console]::In.ReadToEnd().Split([char]10) | ForEach-Object { $_.Trim() } | Where-Object { $_ }',
254 '$rxCustom = [regex]\'"customTitle":"((?:[^"\\\\]|\\\\.)*)"\'',
255 '$rxAi = [regex]\'"aiTitle":"((?:[^"\\\\]|\\\\.)*)"\'',
256 '$rxCwd = [regex]\'"cwd":"((?:[^"\\\\]|\\\\.)*)"\'',
257 'function Read-Part($fs, $offset, $count) { $buf = New-Object byte[] $count; [void]$fs.Seek($offset, [IO.SeekOrigin]::Begin); $n = $fs.Read($buf, 0, $count); [Text.Encoding]::UTF8.GetString($buf, 0, $n) }',
258 'function Get-Last($re, $text) { $m = $re.Matches($text); if ($m.Count -gt 0) { return ((\'"\' + $m[$m.Count - 1].Groups[1].Value + \'"\') | ConvertFrom-Json) }; return \'\' }',
259 '$out = foreach ($p in $paths) {',
260 ' try {',
261 ' $fs = [IO.File]::Open($p, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite)',
262 ' try {',
263 ' $len = $fs.Length; $n = [Math]::Min(262144, $len)',
264 ' $tail = Read-Part $fs ($len - $n) $n',
265 " $head = if ($len -gt $n) { Read-Part $fs 0 ([Math]::Min(65536, $len)) } else { '' }",
266 ' } finally { $fs.Dispose() }',
267 ' $c = Get-Last $rxCustom $tail; if (-not $c) { $c = Get-Last $rxCustom $head }',
268 ' $a = Get-Last $rxAi $tail; if (-not $a) { $a = Get-Last $rxAi $head }',
269 ' $w = Get-Last $rxCwd $tail; if (-not $w) { $w = Get-Last $rxCwd $head }',
270 ' [ordered]@{ p = $p; c = "$c"; a = "$a"; w = "$w" }',
271 ' } catch { }',
272 '}',
273 'ConvertTo-Json -InputObject @($out) -Compress -EscapeHandling EscapeNonAscii',
274].join('\n')
275
276types/index.d.ts 100 lines1export type SessionStatus = 'working' | 'waiting' | 'idle' | 'ended'
2
3/** 세션이 떠 있는 곳: Orca 탭 handle(ORCA_TERMINAL_HANDLE) 또는 herdr 패널 id(HERDR_PANE_ID). 세션이 시작할 때 자기 환경에서 읽는다 */
4export type TermLocator = { host: 'orca' | 'herdr'; id: string }
5
6/**
7 * 세션 하나가 자기 파일에 쓰는 상태. at은 마지막으로 쓴 시각(1분마다 갱신), changedAt은 상태가 바뀐 시각.
8 * term은 그 세션이 떠 있는 탭(세션 패널의 [이동]이 쓴다). 터미널 프로그램 밖이면 없다
9 */
10export type SessionRecord = {
11 id: string
12 root: string
13 status: SessionStatus
14 prompt: string
15 changedAt: number
16 at: number
17 term?: TermLocator
18}
19
20/**
21 * `claude agents --json --all`의 세션 하나. shortId는 백그라운드 세션의 짧은 id(claude stop·attach가 받는 값), 없으면 ''.
22 * isLive는 프로세스가 떠 있는지(pid가 있음). 멈춘 백그라운드 세션은 --all에만 나오고 pid가 없다
23 */
24export type AgentRow = { sessionId: string; shortId: string; name: string; cwd: string; state: string; kind: string; isSelf: boolean; isLive: boolean }
25
26/** 켜진 대화는 background·interactive, 꺼진 대화(기록만 남음)는 off. 화면에서는 off를 "유휴"라고 쓴다 */
27export type SessionKind = 'background' | 'interactive' | 'off'
28
29/**
30 * 세션 패널의 한 줄: 켜진 대화와 꺼진 대화를 함께 담는다.
31 * at은 마지막 활동 시각(기록 파일 수정 시각, ms). reopen은 꺼진 대화를 다시 켜는 방법:
32 * attach(멈춘 백그라운드 세션, 백그라운드로 다시 켬), resume(기록만 남은 대화, 포그라운드로 다시 엶), 켜진 대화는 ''
33 */
34export type SessionItem = {
35 sessionId: string
36 shortId: string
37 name: string
38 cwd: string
39 state: string
40 kind: SessionKind
41 isSelf: boolean
42 at: number
43 reopen: 'attach' | 'resume' | ''
44}
45
46export type ViewMode = 'folder' | 'date'
47export type OnOffFilter = 'all' | 'on' | 'off'
48/** 세션 패널 보기 설정. 상태 필터는 보기(폴더별·날짜별)마다 따로 기억한다 */
49export type SessionView = { mode: ViewMode; filter: Record<ViewMode, OnOffFilter> }
50
51/** 기록 파일 끝에서 읽은 제목·폴더. m·s는 읽을 때의 수정 시각·크기(바뀌면 다시 읽는다), k는 제목 종류 */
52export type TitleEntry = { m: number; s: number; t: string; k: 'custom' | 'ai' | ''; c: string }
53
54declare module 'claude-code' {
55 interface PluginState {
56 'session-tracker': {
57 /** 같은 폴더의 살아 있는 다른 세션들 */
58 peers: SessionRecord[]
59 /** 이 PC에서 켜져 있는 세션(claude agents에서 pid가 있는 것) */
60 agents: AgentRow[]
61 /** 세션 패널에 그릴 켜진 대화와 꺼진 대화 */
62 sessions: SessionItem[]
63 /** 세션 패널 보기 설정($.store의 view) */
64 view: SessionView
65 /** 고정한 대화의 sessionId, 고정한 순서대로(먼저 고정한 것이 위, $.store의 pins) */
66 pins: string[]
67 /** 보관한 대화의 sessionId($.store의 archived) */
68 archived: string[]
69 /** 보관함을 보고 있는지 */
70 showArchived: boolean
71 /** [관리]를 연 대화의 sessionId(그 줄 아래에 고정·보관·끄기가 열림), 아니면 '' */
72 managing: string
73 /** 꺼진 대화를 모두 펼친 묶음의 key */
74 expanded: string[]
75 /** 패널 아래 설명(범례)을 펼쳤는지 */
76 showLegend: boolean
77 /** 이 세션에서 돌고 있는 서브에이전트 수 */
78 subagents: number
79 /** 마지막으로 목록을 읽은 시각, 아직이면 0 */
80 readAt: number
81 /** 이 대화의 세션 종류: background, interactive, 모르면 빈 문자열 */
82 selfKind: string
83 /** 세션 패널에서 이름을 고치는 중인 세션의 sessionId, 아니면 '' */
84 renaming: string
85 /** 이름 입력칸의 글 */
86 renameDraft: string
87 /** [끄기]를 한 번 누른 세션의 sessionId. 한 번 더 누르면 끈다. 아니면 '' */
88 stopArmed: string
89 /** orca 명령을 쓸 수 있는지. 다른 대화의 이름을 그 탭 입력창에 쳐서 고치는 기능은 Orca에서만 한다 */
90 orcaReady: boolean
91 /** [이동]·[열기]에 쓰는 터미널 프로그램: herdr 패널 안이면 herdr, Orca를 쓸 수 있으면 orca, 아니면 ''(일반 터미널: /resume·명령 복사) */
92 host: '' | 'orca' | 'herdr'
93 /** Orca 없이 [열기]·[이동]을 누른 대화의 sessionId(그 줄 아래에 여기서 열기·명령 복사가 열림), 아니면 '' */
94 opening: string
95 /** 새 Orca 탭에서 여는 중인 대화의 sessionId. 그 줄의 [이동]·[열기] 자리에 "여는 중…"을 보이고 다시 누르지 않게 한다 */
96 launching: string[]
97 }
98 }
99}
100