SLOPSHOPPER

session-tracker

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

newpanebandguardcommandtoast
v0.16.3MITupdated 2026-10-16SJvaca30/mulgogi-mods/session-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-tracker
│ ┃ 세션 ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 폴더별 [날짜별] [보관함 0] [다시 읽기] [닫기 │ session-tracker │ │ ┃ 상태 전체 0 [켜짐 0] [유휴 0] ⏺ Read(src/auth.ts) │ 같은 폴더에서 열린 다른 세션이 없습니다(이 mod가 켜진 세션만 보입니다) │ │ ┃ 표시할 대화가 없습니다. 상태 필터를 [전체]로 ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ 바꿔 보세요 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ [설명] Claude Code 기본 목록 [resume] ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions-here │ │ ── 세션 ──────────────────────────────────────────────────────────────────────────────────────────── 포그라운드 탭을 닫으면 꺼짐 · 유지 /background ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
── 세션 ──────────────────────────────────────────────────────────────────────────────────────────── 포그라운드 탭을 닫으면 꺼짐 · 유지 /background ⟨Claude Code's own drawing⟩
Pane · 세션
폴더별 [날짜별] [보관함 0] [다시 읽기] [닫기] Esc 상태 전체 0 [켜짐 0] [유휴 0] 표시할 대화가 없습니다. 상태 필터를 [전체]로 바꿔 보세요 [설명] Claude Code 기본 목록 [resume]
README

mulgogi-mods

Claude Code를 더 빠르고 편하게 쓰는 한국어 mod 모음

Claude Code에 입력창 위 대시보드, 명령 팔레트, 세션 관리 패널을 추가합니다.

Claude Code mods Platform License: MIT

<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

clickbar

입력창 위 대시보드

  • 상태 줄: 컨텍스트 사용률과 자동 압축까지 남은 양, 5시간·주간 사용 한도와 초기화 시각, API 환산 비용을 한 줄로 표시합니다. 항목마다 이름·막대·비율을 묶어 구분하고, 위험 수준에 가까워지면 막대와 비율의 색이 바뀌며 [compact now] 버튼이 나타납니다.
  • 버튼 줄: 자주 쓰는 명령을 용도별로 묶어 배치합니다. 컨텍스트([compact] [context]), 대화([resume] [sessions] [fork]), 답변([copy] [quote] [btw]), 도구([tasks] [palette]) 순서입니다. 화면이 좁으면 일부 버튼이 숨겨지며, 팔레트에서 실행할 수 있습니다.

대화 화면

  • 내 질문 카드: 대화에서 내 질문을 왼쪽에 색 막대가 있는 옅은 카드로 표시해, 칠하지 않은 Claude의 답변과 한눈에 구분합니다. 장식이 글자가 아니라 칠한 칸이라 마우스로 복사해도 질문 글만 복사됩니다. /bubbles card(막대 없는 카드) · /bubbles mark(❯ 표식) · /bubbles off(끄기)로 모양을 바꿀 수 있고, Ctrl+O 전체 기록에서는 원래 모양으로 보입니다.
  • 진행 상황: Claude가 작업하는 동안 스피너 줄에 이번 턴에 수정한 파일 수와 도구 호출 수를 함께 표시합니다(예: ✶ Pondering · 파일 2개 수정 · 도구 7회…).
  • 턴 요약: 파일을 수정한 턴이 끝나면 변경된 파일과 줄 수, 컨텍스트 증가량, API 환산 비용을 한 줄로 기록합니다. 질문·답변만 오간 턴은 기록하지 않습니다. 소요 시간과 완료 시각은 Claude Code의 기본 표시를 그대로 사용합니다.

명령 팔레트

<img src="docs/images/palette-light.png" width="928" alt="명령 팔레트: 탭과 사용 한도, 고정한 명령, 대화·세션 명령, 설치한 스킬이 한국어 설명과 함께 표시된 화면">

  • 내장 명령과 설치된 스킬을 용도별로 분류하고 한국어 설명을 제공합니다. 자주 쓰는 명령은 [고정]으로 맨 위에 고정할 수 있습니다.
  • [/명령]은 즉시 실행하고, [넣기]는 인자를 입력할 수 있도록 입력창에 명령만 넣습니다.
  • 탭 구성: 명령, 단축키(OS별 키 표기), 내 질문(보낸 질문으로 이동·재입력), 답변(복사), 아이디어(/idea로 저장한 메모), 가정(Claude의 가정 검토).
  • 머리 줄 아래에 5시간·주간 한도의 초기화 시각과 남은 시간을 표시합니다.
  • 명령 이름이나 설명으로 검색할 수 있습니다. 팔레트를 열 때마다 검색어는 초기화됩니다.

Claude의 가정 확인

Claude가 확인 없이 정한 가정을 log_assumption 도구로 기록합니다. 입력창 위 [assumptions N] 버튼이나 팔레트의 가정 탭에서 맞음·틀림을 선택하면, 틀린 가정만 수정하도록 요청합니다.

session-tracker

<img src="docs/images/sessions-light.png" width="928" alt="세션 패널: 보기 탭과 상태 필터, 고정한 대화와 폴더별 대화 목록, 상태·종류·관리 버튼이 표시된 화면">

  • 세션 줄: 현재 대화의 종류(백그라운드·포그라운드)와 탭을 닫았을 때의 동작을 입력창 위에 표시합니다. 같은 폴더에서 작업 중이거나 입력(도구 승인·질문 답변·계획 승인)을 기다리는 다른 세션이 있으면 그 세션의 이름·상태·이어진 시간·요청을 함께 표시하고, 이름을 누르면 그 세션의 탭으로 이동합니다. 쉬고 있는 세션은 겹칠 일도 할 일도 없어 표시하지 않습니다(전체 목록은 /sessions, 같은 폴더 목록은 /sessions-here).
  • 세션 패널(/sessions): 실행 중인 대화와 기록만 남은 대화(유휴)를 폴더별·날짜별로 조회하고, 상태(켜짐·유휴)로 필터링합니다. 필터 버튼에는 해당 대화 수가 함께 표시됩니다. 줄 끝 [관리]에서 고정·이름 변경·보관·종료를 할 수 있으며, Claude가 답변하는 중에도 바로 열립니다.
  • 탭 이동(Orca·herdr): Orca 탭이나 herdr 패널에서는 [이동]으로 해당 세션의 탭으로 전환하고, [열기]로 종료된 대화를 새 탭에서 다시 엽니다. 포그라운드 세션은 시작할 때 자신이 실행 중인 탭(Orca 탭·herdr 패널)을 세션 상태에 기록하므로, 이름을 붙이지 않은 세션도 정확한 탭으로 전환합니다. 백그라운드 세션은 탭 제목(세션 이름)으로 찾고, 탭이 없으면 새 탭에서 연결합니다. 백그라운드 세션을 claude agents 화면이나 이 mod가 연 탭에서 보고 있으면, 그 세션의 [이동]·[열기]는 처음 실행한 곳이 아니라 지금 보고 있는 프로그램(Orca·herdr)의 탭을 사용합니다. 직접 연 탭에서 claude attach로 보고 있으면 처음 실행한 곳을 따릅니다. 새 탭은 Claude 화면이 뜬 것을 확인한 뒤에만 앞으로 옮기고 그동안 그 줄에 "여는 중…"을 표시합니다. 연결할 작업이 이미 정리됐으면 실패한 탭을 닫고 기록으로 다시 엽니다. 다른 창에서 실행 중인 포그라운드 세션은 기록이 섞이지 않도록 새로 열지 않고 안내만 합니다. herdr는 터미널 안에서 실행하는 프로그램이므로 PowerShell·Windows Terminal에서도 herdr 안에서 Claude를 실행하면 같은 기능을 사용할 수 있습니다. 일반 터미널에서는 [여기서 열기](/resume <id>)와 [명령 복사]를 제공합니다.
  • 작업 겹침 방지: 세션 줄에 다른 세션이 보일 때(같은 폴더에서 작업 중이거나 입력을 기다릴 때) 보내는 질문에 그 세션의 이름과 요청을 알리는 안내 문구를 추가해, 같은 파일을 동시에 수정하지 않도록 합니다. 여러 세션이 같은 파일을 고쳐야 하면 git worktree로 폴더를 나누는 편이 안전합니다.

명령 팔레트와 세션 패널은 머리 줄 구성이 같습니다. 왼쪽에 탭(선택한 탭은 색을 채워 표시), 오른쪽 끝에 [닫기]가 있고 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를 실행합니다.

Claude에게 영향을 주는 기능

| 기능 | 동작 | 끄는 방법 | | :- | :- | :- | | 가정 기록(clickbar) | Claude에게 log_assumption 도구를 제공합니다 | /assumptions off | | 명령 추천(clickbar) | 질문에 맞는 내장 명령을 한 번씩 추천합니다. 질문 내용은 변경하지 않습니다 | /tips off | | 작업 겹침 안내(session-tracker) | 같은 폴더의 다른 세션이 작업 중이거나 입력을 기다리면 질문에 그 세션의 이름과 요청을 알리는 안내 문구 한 줄을 추가합니다 | /sessions-here off | | 이름 변경 대리 입력(session-tracker) | 다른 세션의 이름을 변경하면 해당 세션의 Orca 탭에 /rename 새 이름을 입력합니다(Orca에서만). 승인 대기 중이 아니고 입력창이 비어 있을 때만 입력합니다 | 사용하지 않으면 동작하지 않습니다 |

[btw], 아이디어 탭의 [보내기], 가정 탭의 [틀린 가정 수정 요청]은 누르면 Claude에게 질문을 전송합니다. 누르지 않으면 아무것도 전송하지 않습니다.

데이터와 보안

  • 네트워크 요청을 하지 않으며, mod가 스스로 모델을 호출하지 않습니다.
  • 설정·고정 목록·아이디어는 ~/.claude/plugins/store/에, 세션 상태는 ~/.claude/mods-state/session-tracker/에 저장합니다. 모두 이 PC에만 저장됩니다.
  • mod는 사용자 권한으로 실행되는 코드입니다. 설치 전에 저장소를 내려받아 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을 설정한 뒤 다시 시도합니다.

라이선스

MIT

Source 6 files
hooks/register.tsx 1307 lines
1import { 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 lines
1// 탭을 다루는 터미널 프로그램(호스트). 세션 패널의 [이동]·[열기]는 이 다섯 동작만 쓴다:
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}
222
hooks/panel.ts 200 lines
1// 세션 패널: `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}
200
hooks/records.ts 86 lines
1// 같은 폴더 세션 추적: 세션마다 자기 상태 파일 하나를 쓰고, 같은 폴더의 다른 파일을 읽는다.
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}
86
hooks/sessions.ts 276 lines
1// 세션 패널의 "모든 대화" 목록: 켜진 대화(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
276
types/index.d.ts 100 lines
1export 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