오케스트레이션 현황 패널: orch-dash 가 모은 state.json 과 orch stage 기록만 읽어 결정 대기·사람만 항목, 작업 진행 표, 세션 관계와 공유 자원 보유자, Orca automation 실행 현황을 상태줄과 접는 패널로 보여 준다

Claude Code 개인 스킬 저장소. 티켓 하나를 끝까지 끌고 가는 흐름과, Orca 워커 여러 개로 프로젝트 하나를 끝내는 흐름을 스킬로 만든다. 일부 스킬은 Lauren Tan의 pstack(MIT)에서 가져왔다.
한눈에 보기: 스킬 카탈로그(스킬별 언제·무엇, docs/skills.md에서 생성), 사용 안내서(흐름과 orch 착지 과정 그림).
두 페이지는 한국어·English·日本語를 오른쪽 위에서 고른다. 링크 끝에 #en이나 #ja를 붙이면 그 언어로 열린다. 카탈로그는 python3 scripts/catalog/build.py <out.html>이 docs/skills.md와 번역본 docs/skills.{en,ja}.md로 만든다. 번역본 첫 줄의 translated-from 커밋 뒤에 docs/skills.md가 바뀌었으면 경고하고, 그 언어 하단에 원문보다 오래됐다고 표시한다. 안내서는 손으로 쓴 한 장짜리 scripts/catalog/guide.html이라, 스킬이 늘거나 흐름이 바뀌면 세 언어를 함께 고친다.
전제는 Orca(멀티 에이전트 IDE, orca CLI), GitHub, 티켓 트래커(Linear, Jira 어댑터 준비)다. 노트는 Obsidian vault 또는 일반 Markdown 폴더를 쓴다.
선택 도구: 교차 검토는 Codex 플러그인의 companion 스크립트, 브라우저 검증은 브라우저 자동화 도구를 쓴다. 없으면 그 단계만 다른 도구로 바꾼다.
이 저장소를 고쳐 가며 쓰는 PC는 체크아웃을 Claude Code가 직접 읽게 한다. 스킬을 고치면 떠 있는 세션에도 reload 한 번으로 반영되고, 원격 변경은 15분마다 자동으로 받는다. 설계와 실측 근거는 docs/platform.md.
체크아웃은 어느 경로에 두어도 된다. bootstrap.py는 자기 파일 위치로 체크아웃 경로를 정한다. 아래 <체크아웃>은 clone한 경로로 바꾼다(예시: ~/src/agent-skills).
git clone https://github.com/JeongJaeSoon/agent-skills <체크아웃>
python3 <체크아웃>/scripts/bootstrap.py # 바뀔 내용 보기
python3 <체크아웃>/scripts/bootstrap.py --write # 적용
bootstrap.py --write가 하는 일:
~/.claude/settings.json을 백업하고, env 블록의 CLAUDE_CODE_PLUGIN_DIRS에 체크아웃 경로를 넣는다. 마켓플레이스로 설치한 agent-skills@jeongjaesoon이 켜져 있으면 끈다. 둘 다 켜면 hook이 두 번 돈다.io.github.jeongjaesoon.agent-skills-sync)을 설치해 15분마다와 로그인 때 skills-sync sync를 돌린다. Linux면 crontab 한 줄을 출력한다./reload-plugins를 치면 마켓플레이스 사본만 빠지고 체크아웃은 붙지 않아, 재시작할 때까지 agent-skills가 통째로 없다. 체크아웃 폴더에서 claude를 처음 띄우면 폴더 신뢰 창이 한 번 뜬다.고치지 않고 쓰기만 하는 PC는 마켓플레이스로 설치해도 된다. 설치본은 커밋 단위 복사본이라 갱신은 claude plugin marketplace update jeongjaesoon 뒤 claude plugin update agent-skills@jeongjaesoon이다.
claude plugin marketplace add JeongJaeSoon/agent-skills
claude plugin install agent-skills@jeongjaesoon
/agent-skills:<이름>. 다른 플러그인과 겹치지 않으면 /<이름>도 된다.hooks/guard.py가 대신 결정한다. 이 플러그인의 스킬, orca orchestration 명령(reset·worker-abandon·gate-resolve 제외), orch 명령(heavy·set·init·backfill 제외)을 허용하고, 다른 터미널의 Orca 메일함 읽기를 거부한다. 나머지는 평소 권한 흐름을 탄다. 자세한 규칙은 스킬 카탈로그.ship-pr → deliver-ticket, dispatch-work → dispatch-card, use-obsidian → use-notes)은 legacy/의 안내용 별칭으로 남아 있다.예전 scripts/install.py(symlink와 settings 병합)로 설치했다면, 그 설치로 시작한 프로그램이 모두 끝난 뒤 python3 scripts/migrate.py(바뀔 내용 보기), --write(symlink·옛 규칙·옛 hook 제거 뒤 bootstrap.py --write) 순서로 옮긴다.
claude --settings '{"env":{"CLAUDE_CODE_PLUGIN_DIRS":"<worktree 절대 경로>"}}'. --plugin-dir을 더하면 메인 체크아웃과 두 벌이 함께 올라온다.skills-sync sync(push까지)와 skills-sync broadcast를 돌린다. 브로드캐스트는 SKILL.md가 바뀌었으면 /reload-skills, hook 연결(hooks/hooks.json)이나 manifest가 바뀌었으면 /reload-plugins를, 빈 입력칸에서 쉬고 있는 세션에만 보낸다. 권한 창이나 질문 창이 뜬 세션에 Enter가 가면 그 창에 답해 버리기 때문이다. 못 보낸 세션은 남겨 두었다가 다음 동기화 때 다시 시도한다.scripts/trigger-probe.sh를 돌려 발동이 달라졌는지 본다. 임시 저장소에서 헤드리스 세션을 띄우고 쓰기·셸·MCP 호출은 모두 거부하므로 다른 곳을 건드리지 않는다. 한 번에 0.2달러 안팎이 든다.bash scripts/trigger-probe.sh <체크아웃> "남은 작업들 병렬로 진행해줘" 3 [복사할 저장소]
트래커와 노트 저장소는 설정 없이도 동작한다. 설정 파일이 없으면 트래커는 Linear, 노트는 Obsidian vault Private이고, 노트는 연결된 Obsidian MCP 도구로, 트래커는 연결된 Linear MCP 도구(없으면 orca linear CLI)로 읽고 쓴다. Jira나 일반 Markdown 폴더, 다른 vault를 쓸 때만 ~/.claude/agent-skills.json을 둔다(선택). 형식은 use-tracker, use-notes 스킬에 있다.
recall이 지난 세션을 빨리 찾게 하려면 Orca 앱에서 Settings → Agent Session Search → Search inside sessions의 이 컴퓨터(Local Mac) 스위치를 켠다. CLI로는 켤 수 없고, 켜기 전에는 recall이 ~/.claude/projects의 transcript를 grep한다. 켜졌는지는 orca search --index-status의 enabled로 확인한다.
스킬별 호출 시점, 실제 내용, 동봉 파일, orch 명령, hook, pstack과의 차이는 스킬 카탈로그에 있다.
티켓 하나
| 스킬 | 언제 |
|---|---|
write-ticket | 티켓을 쓸 때. 후속 티켓 형식, 템플릿 |
deliver-ticket | 첫 수정부터 완료까지: 계획, 테스트, Codex 교차 검토, GitHub stack, E2E, 머지, 완료 기준 |
handoff-ticket | 끝난 뒤 다음 티켓을 새 Orca 카드로 |
dispatch-card | 이 세션은 계속하면서 다른 저장소나 곁가지를 카드로 |
end-session | 세션·카드·worktree 정리. Orca 워커와 브리프로 띄운 카드는 일을 마치면 스스로 닫는다 |
프로젝트 하나 (Orca 여러 워커)
| 스킬 | 언제 |
|---|---|
orchestrate | 코디네이터가 여러 티켓을 병렬로 착지시킬 때. 착지 순서, 독점 레인, main 가디언, QA 리드, 대시보드 |
measure-delivery | 프로젝트가 실제로 어땠는지: 후속 티켓 증가, 재작업, 토큰 |
머신 하나
| 스킬 | 언제 |
|---|---|
reap-resources | 죽은 세션이 남긴 Codex broker 트리, 고아 테스트 프로세스, Docker 잔여물, 머지된 브랜치를 점검하고 목록에 올린 것만 정리할 때. 에이전트가 만든 끝난 리소스와 다운로드·바탕화면의 7일(스크린샷은 1일) 지난 항목은 3시간 주기 automation이 휴지통으로 정리한다 |
secure-fill | 브라우저 인증 화면에 개발용 토큰 같은 비밀값을 넣을 때. 소유자가 매번 Touch ID로 승인하고(항목별로 승인 없이 채우게 둘 수 있다), 값은 에이전트를 거치지 않고 Keychain에서 페이지로 간다 |
tune-automode | auto mode 분류기 거부 하나를 그 행동 부류만 덮는 가장 작은 autoMode 규칙으로 바꿀 때. diff와 적용 스크립트를 만들고, 적용은 사용자가 ! python3 <경로> --apply <spec 해시>로 한다 |
채팅: watch-mentions(공개 채널의 멘션·이름 언급·참여 스레드 새 답글을 분류해 코디네이터에게 한 통으로 보내고, 작업 의뢰는 기표 초안으로).
어댑터: use-tracker(Linear, Jira), use-notes(Obsidian, Markdown).
글쓰기: write-plainly(한국어·영어 문체), brief-status(현황 보고: 완료·진행·결정·사람만, Telegram이면 markdown 없는 짧은 줄, Claude Code에서는 /agent-skills:brief로도), prune-comments(diff의 주석 정리), write-skill(스킬을 쓰거나 고친 뒤의 점검: 겹침 확인, prompt audit, 사내 이름 grep, 테스트, reload).
pstack: architect, arena, blast-radius, create-verification-skill, figure-it-out, how, interrogate, maintain-verification-skill, principles, recall, reflect, show-me-your-work, swarm, tdd, teach, why. 원본 이름을 유지해 upstream을 따라간다.
python3 scripts/pstack-sync.py # upstream 변경 확인 (쓰지 않음)
python3 scripts/pstack-sync.py --write # 충돌이 없을 때만 반영하고 pin 갱신
출처와 수정 내용은 vendor/pstack/NOTICE.md, 고정 커밋은 vendor/pstack/manifest.json.
orch land로 착지시킨다. 준비된 PR은 base보다 뒤처져 있어도 병렬로 머지되고, migration·CI·Dockerfile·compose 같은 공유 파일은 독점 레인에서 base당 하나씩 최신 base 위에서 머지된다.orch-dash serve: 목표, 완료 조건, 착지 순서, 의존 그래프, 워커, 토큰을 보여주는 대시보드(라이트·다크, 창 크기에 맞춤). 모델 호출 없이 JSON으로 갱신한다.mods/orch-panel/은 Claude Code 터미널 안에서 오케스트레이션 현황을 보는 mod(function hook 플러그인)다. 대시보드가 이력과 숫자를 보여 준다면, 이 mod는 지금 이 세션에서 볼 것만 보여 준다.
결정 2 · 사람만 1 · 자동화 ▲1. 0인 항목은 빼고, 모두 0이면 지운다./orch-panel: 결정 대기와 사람만 할 수 있는 일(권한·로그인·명령 실행)을 접고 펴는 패널. 결정은 답하는 법(d7: CSV)을 같이 보여 준다. 코디네이터 세션에서는 새 항목이 생길 때 토스트를 띄우고 패널을 연다.orch stage로 기록한 작업별 단계 표(stages.json)를 ▰▰▰▱▱ 3/5 진행 바와 머지·dev·prod 칸의 ✓ ✗ △ ◐ –(기호마다 색)로 그린다. 머지 칸과 PR 옆 ✗CI·◐CI·✎·✓는 state.json의 PR 목록에서 읽는다. 행을 누르면 각 칸의 근거가 펼쳐지고, 아래에 다음 줄과 표에 없는 열린 PR 수가 붙는다.◉ 작업 중 · ◍ 대기 · ○ idle · · open)으로 그리고, offline은 + offline N으로 접는다. 아래 공유 자원에 착지 lane과 orch hold로 잡은 자원(브라우저 등)의 보유자와 시간을 보인다.■ 완료 초록 · ■ 실패 빨강 · ◧ 실행 중 · □ 건너뜀)으로 보인다. 마지막 실행이 실패했거나 예정 시각을 10분 넘기면 ▲로 표시한다. Orca automation만 보이며, 세션 안의 CronCreate와 launchd 작업은 수집하지 않는다.state.json과 stages.json, 같은 디렉터리의 두 파일이다($ORCH_FLEET_STATE, 기본 ~/.local/state/agent-skills/dashboard). 5초마다 stat하고 둘 중 하나가 바뀌었을 때만 읽는다. gh·orca나 모델은 부르지 않는다. 네트워크는 패널의 ↻이 대시보드에 즉시 수집을 한 번 요청할 때뿐이다.체크아웃에서 쓰려면 CLAUDE_CODE_PLUGIN_DIRS에 <체크아웃>/mods/orch-panel을 더한다. 마켓플레이스로는 claude plugin install orch-panel@jeongjaesoon. 실제 fleet 없이 보려면:
python3 mods/orch-panel/scripts/demo_state.py /tmp/orch-demo
ORCH_FLEET_STATE=/tmp/orch-demo claude --plugin-dir mods/orch-panel
테스트는 claude plugin test mods/orch-panel.
for t in skills/orchestrate/scripts/test_*.py skills/use-tracker/scripts/test_tracker.py skills/reap-resources/scripts/test_reap.py skills/watch-mentions/scripts/test_mentions.py skills/secure-fill/scripts/test_secure_fill.py skills/tune-automode/scripts/test_automode_rule.py hooks/test_*.py scripts/test_sync.py; do python3 "$t"; done
bash scripts/pstack-sync-test.sh
claude plugin test mods/orch-panel
MIT. vendor/pstack과 거기서 가져온 스킬은 원저작자의 MIT 라이선스를 따른다(vendor/pstack/LICENSE).
hooks/register.tsx 514 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5 OrchPanelAutomation,
6 OrchPanelCell,
7 OrchPanelItem,
8 OrchPanelRun,
9 OrchPanelSession,
10 OrchPanelSnapshot,
11 OrchPanelStageRow,
12} from '../types'
13import {
14 DASH_URL,
15 STALE_COLLECTOR_MS,
16 STALE_ORCA_MS,
17 age,
18 ageMs,
19 automationIssue,
20 bar,
21 cadence,
22 parseAt,
23 progress,
24 sessionUrl,
25 snapshotOf,
26 stateDir,
27 statusText,
28 toastText,
29 tree,
30 until,
31} from './model'
32
33const PANE = 'orch'
34const POLL_MS = 5000
35const snapshot = atom({ plugin: 'orch-panel', key: 'snapshot' } as const, null)
36const seen = atom({ plugin: 'orch-panel', key: 'seen' } as const, null)
37const collapsed = atom({ plugin: 'orch-panel', key: 'collapsed' } as const, [])
38const expanded = atom({ plugin: 'orch-panel', key: 'expanded' } as const, [])
39
40const GLYPH: Record<string, string> = {
41 decision: '◆',
42 approval: '◆',
43 question: '?',
44 permission: '!',
45 prompt: '!',
46 login: '!',
47 run_command: '$',
48}
49
50// Stage marks as symbol plus theme color, so the table reads without color too.
51const MARK: Record<OrchPanelCell['mark'], { glyph: string; color?: string; dim?: boolean }> = {
52 ok: { glyph: '✓', color: 'success' },
53 fail: { glyph: '✗', color: 'error' },
54 partial: { glyph: '△', color: 'warning' },
55 checking: { glyph: '◐', color: 'warning' },
56 na: { glyph: '–', dim: true },
57}
58
59// The dashboard's phase colors, each with its own dot so the tree reads without color too.
60const PHASE: Record<OrchPanelSession['phase'], { glyph: string; color?: string; word: string }> = {
61 working: { glyph: '◉', color: 'permission', word: '작업 중' },
62 waiting: { glyph: '◍', color: 'warning', word: '대기' },
63 idle: { glyph: '○', color: 'success', word: 'idle' },
64 open: { glyph: '·', word: 'open' },
65 offline: { glyph: '◌', word: 'offline' },
66}
67
68// One block per automation run: filled for a run that ran, hollow for one its precheck skipped.
69const RUN: Record<string, { glyph: string; color?: string }> = {
70 completed: { glyph: '■', color: 'success' },
71 failed: { glyph: '■', color: 'error' },
72 error: { glyph: '■', color: 'error' },
73 running: { glyph: '◧', color: 'warning' },
74 dispatched: { glyph: '◧', color: 'warning' },
75 skipped: { glyph: '□' },
76 other: { glyph: '▪' },
77}
78
79// The name column is fixed so the columns after it line up; a truncating flex box drifted past its width live.
80const NAME_WIDTH = 24
81const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
82
83// Module state: a hot reload starts it over, and session.start fires again to refill it.
84const live = {
85 dir: '',
86 cwd: '',
87 mtimes: '',
88 parsed: null as OrchPanelSnapshot | null,
89 shownStatus: undefined as string | undefined,
90}
91
92async function poll($: EngineInterface) {
93 const now = await $.clock.now()
94 const [stat, stagesStat] = await Promise.all(
95 ['state.json', 'stages.json'].map(f => $.fs.stat(`${live.dir}/${f}`).catch(() => undefined)),
96 )
97 const mtimes = `${stat?.mtimeMs}:${stagesStat?.mtimeMs}`
98 if (!stat) {
99 live.mtimes = ''
100 live.parsed = { status: 'missing', checkedAt: now }
101 } else if (mtimes !== live.mtimes || !live.parsed) {
102 live.mtimes = mtimes
103 const [text, stagesText] = await Promise.all([
104 $.fs.read(`${live.dir}/state.json`).catch(() => ''),
105 stagesStat ? $.fs.read(`${live.dir}/stages.json`).catch(() => null) : null,
106 ])
107 live.parsed = snapshotOf(String(text), now, live.cwd, stagesText === null ? null : String(stagesText))
108 await announce($, live.parsed)
109 }
110 // checkedAt moves on every poll so an open pane's ages keep counting while the file is unchanged.
111 const parsed = live.parsed
112 await update($, snapshot, () => ({ ...parsed, checkedAt: now }))
113 const text = statusText(parsed)
114 if (text !== live.shownStatus) {
115 live.shownStatus = text
116 $.ui.status(text)
117 }
118}
119
120async function announce($: EngineInterface, next: OrchPanelSnapshot) {
121 if (next.status !== 'ok') return
122 const before = await read($, seen)
123 await update($, seen, () => next.items.map(i => i.key))
124 // The first read of a session only seeds: an existing backlog must not toast on every start or reload.
125 if (before === null || !next.isCoordinator) return
126 const fresh = next.items.filter(i => !before.includes(i.key))
127 const [first] = fresh
128 if (!first) return
129 $.ui.toast(fresh.length === 1 ? toastText(first) : `결정·사람만 새 항목 ${fresh.length}개`)
130 void $.ui.open({ id: PANE, title: 'orch' })
131}
132
133// The one network call: asking the dashboard for an immediate collection, as opening its page does.
134async function refresh($: EngineInterface) {
135 await $.http.fetch(`${DASH_URL}/api/fleet/state`).catch(() => undefined)
136 live.mtimes = ''
137 await poll($)
138}
139
140export const register: Register = on => {
141 on('session.start', async ($, e, next) => {
142 live.cwd = e.cwd
143 const [dir, home] = await Promise.all([$.env.get('ORCH_FLEET_STATE'), $.env.get('HOME')])
144 live.dir = stateDir({ ORCH_FLEET_STATE: dir, HOME: home })
145 await $.command.register({ name: 'orch-panel', description: '오케스트레이션 현황 패널을 연다 (결정 대기·사람만, 작업 진행)' })
146 // Not awaited: parsing a few hundred KB must not hold the first prompt.
147 void poll($)
148 $.clock.every(POLL_MS, () => void poll($))
149 return next(e)
150 })
151
152 on('command.run', { command: 'orch-panel' }, async $ => {
153 await $.ui.open({ id: PANE, title: 'orch' })
154 return { text: 'orch 패널을 열었다.' }
155 })
156
157 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
158 const { Box, Text, Button, Link } = $.ui.resolve(e)
159 const snap = await read($, snapshot)
160 const folded = await read($, collapsed)
161 const opened = await read($, expanded)
162 const now = snap?.checkedAt ?? (await $.clock.now())
163
164 if (!snap || snap.status === 'missing') {
165 return (
166 <Box flexDirection="column">
167 <Text color="warning">■ orch-dash 꺼짐</Text>
168 <Text dimColor>orch-dash ensure 로 켜면 결정 대기·사람만 항목이 여기 나온다</Text>
169 </Box>
170 )
171 }
172 if (snap.status === 'unreadable') {
173 return (
174 <Box flexDirection="column">
175 <Text color="error">■ state.json 을 읽지 못함</Text>
176 <Text dimColor wrap="truncate-end">{snap.error}</Text>
177 </Box>
178 )
179 }
180
181 const isStale = ageMs(snap.generatedAt, now) > STALE_COLLECTOR_MS
182 const isOrcaStale = !!snap.orca && (!snap.orca.isOk || ageMs(snap.orca.updatedAt, now) > STALE_ORCA_MS)
183 const flip = (ids: string[], id: string) => (ids.includes(id) ? ids.filter(x => x !== id) : [...ids, id])
184 const toggle = (id: string) => () => update($, collapsed, ids => flip(ids, id))
185 const expand = (id: string) => () => update($, expanded, ids => flip(ids, id))
186
187 const decideItems = snap.items.filter(i => i.kind === 'decide')
188 const humanItems = snap.items.filter(i => i.kind === 'human')
189 const total = snap.items.length
190 const isOpen = total > 0 && !folded.includes('needs')
191 const oldest = snap.items[0]
192 const summary = !oldest
193 ? '— 없음'
194 : `· 가장 오래된 것 ${oldest.decision ?? GLYPH[oldest.type] ?? ''} ${age(oldest.at, now)}`
195
196 const row = (item: OrchPanelItem, n: number) => {
197 const waited = ageMs(item.at, now)
198 const ageColor = waited > 86_400_000 ? 'error' : waited > 3_600_000 ? 'warning' : undefined
199 const who = item.kind === 'human' && item.sessionName ? `${item.sessionName}: ` : ''
200 return (
201 <Box key={item.key} flexDirection="column">
202 <Box>
203 <Text dimColor>{String(n).padStart(2)} </Text>
204 <Text color={item.kind === 'decide' ? 'warning' : 'permission'} bold>
205 {item.decision ?? GLYPH[item.type] ?? '·'}{' '}
206 </Text>
207 <Box flexGrow={1} flexShrink={1}>
208 <Text wrap="truncate-end">
209 {who}
210 {item.title}
211 {item.recommend ? <Text color="success"> → 추천 {item.recommend}</Text> : null}
212 </Text>
213 </Box>
214 <Text color={ageColor} dimColor={!ageColor}>
215 {' '}
216 {age(item.at, now)}
217 </Text>
218 {item.session ? <Link href={sessionUrl(item.session)} label=" ↗" /> : null}
219 </Box>
220 {item.decision ? (
221 <Text dimColor>
222 {' '}답: 프롬프트에 "{item.decision}: {item.recommend ?? '<답>'}"
223 </Text>
224 ) : null}
225 </Box>
226 )
227 }
228 const work = snap.work
229 const { done, total: rowCount } = progress(work)
230 const { filled, empty } = bar(done, rowCount)
231 const versions = [work.versions.prod && `prod ${work.versions.prod}`, work.versions.dev && `dev ${work.versions.dev}`]
232 .filter(Boolean)
233 .join(' · ')
234 const isWorkOpen = !folded.includes('work')
235 const mark = (cell: OrchPanelCell | null, width: number) => {
236 const m = cell ? MARK[cell.mark] : undefined
237 return (
238 <Box width={width}>
239 <Text color={m?.color} dimColor={!m || m.dim}>
240 {m?.glyph ?? '·'}
241 </Text>
242 </Box>
243 )
244 }
245 const evidence = (label: string, cell: OrchPanelCell | null) =>
246 cell && (cell.evidence || cell.by)
247 ? `${label} ${MARK[cell.mark].glyph} ${[cell.evidence, cell.by && `(${cell.by})`].filter(Boolean).join(' ')}`
248 : null
249 const stageRow = (r: OrchPanelStageRow) => {
250 const lines = [evidence('머지', r.merge), evidence('dev', r.dev), evidence('prod', r.prod)].filter(Boolean)
251 return (
252 <Box key={r.id} flexDirection="column">
253 <Box>
254 <Box flexGrow={1} flexShrink={1}>
255 <Button key={`row-${r.id}`} plain dimColor={r.isDone} onPress={expand(r.id)}>
256 {r.isChild ? ' ㄴ ' : ' '}
257 {r.title}
258 {r.isDone ? <Text dimColor> (끝남)</Text> : ''}
259 </Button>
260 </Box>
261 <Box width={20}>
262 <Text dimColor wrap="truncate-end">
263 {r.pr ?? '–'}
264 </Text>
265 {r.chip ? <Text color={r.chip.tone}> {r.chip.text}</Text> : null}
266 </Box>
267 {mark(r.merge, 5)}
268 {mark(r.dev, 4)}
269 {mark(r.prod, 4)}
270 </Box>
271 {opened.includes(r.id) && lines.length ? <Text dimColor>{' ' + lines.join(' · ')}</Text> : null}
272 </Box>
273 )
274 }
275 const workSection = !isWorkOpen ? null : work.error ? (
276 <Text color="error"> ■ stages.json 을 읽지 못함: {work.error}</Text>
277 ) : !rowCount ? (
278 <Text dimColor> 기록된 단계 없음 — orch stage add 로 행을 만든다</Text>
279 ) : (
280 <Box flexDirection="column">
281 <Box>
282 <Box flexGrow={1}>
283 <Text dimColor> 작업</Text>
284 </Box>
285 <Box width={20}>
286 <Text dimColor>PR</Text>
287 </Box>
288 <Box width={5}>
289 <Text dimColor>머지</Text>
290 </Box>
291 <Box width={4}>
292 <Text dimColor>dev</Text>
293 </Box>
294 <Box width={4}>
295 <Text dimColor>prod</Text>
296 </Box>
297 </Box>
298 {work.rows.map(stageRow)}
299 {work.next.map((line, i) => (
300 <Text key={`next-${i}`} dimColor wrap="truncate-end">
301 {i === 0 ? ' 다음: ' : ' '}
302 {line}
303 </Text>
304 ))}
305 </Box>
306 )
307
308 const isAutoOpen = opened.includes('section:automations')
309 const autos = snap.automations
310 const troubled = autos.filter(a => automationIssue(a, now))
311 const upcoming = autos
312 .filter(a => a.isEnabled && a.nextAt)
313 .sort((a, b) => parseAt(a.nextAt) - parseAt(b.nextAt))[0]
314 const isAutoSourceBad = !!snap.automationSource && !snap.automationSource.isOk
315 const runBlock = (r: OrchPanelRun, i: number) => {
316 const b = RUN[r.status] ?? (r.status.startsWith('skipped') ? RUN.skipped : RUN.other)
317 return (
318 <Text key={`run-${i}`} color={b?.color} dimColor={!b?.color}>
319 {b?.glyph}
320 </Text>
321 )
322 }
323 const autoRow = (a: OrchPanelAutomation) => {
324 const issue = automationIssue(a, now)
325 const last = a.recent[0]
326 return (
327 <Box key={a.id} flexDirection="column">
328 <Box>
329 <Box width={2} justifyContent="flex-end">
330 <Button
331 key={`auto-${a.id}`}
332 plain
333 dimColor
334 label={opened.includes(`auto:${a.id}`) ? '▾' : '▸'}
335 onPress={expand(`auto:${a.id}`)}
336 />
337 </Box>
338 <Box width={NAME_WIDTH}>
339 <Text dimColor={!a.isEnabled}>
340 {issue ? <Text color="error"> ▲</Text> : ''} {clip(a.isEnabled ? a.name : `${a.name} (꺼짐)`, NAME_WIDTH - (issue ? 4 : 2))}
341 </Text>
342 </Box>
343 <Box width={17}>
344 <Text dimColor wrap="truncate-end">
345 {cadence(a.rrule)}
346 </Text>
347 </Box>
348 <Box width={9}>
349 <Text dimColor={!issue} color={issue ? 'warning' : undefined}>
350 {a.isEnabled ? until(a.nextAt, now) : '–'}
351 </Text>
352 </Box>
353 <Box width={8}>
354 <Text dimColor>{a.lastAt ? `${age(a.lastAt, now)} 전` : '–'}</Text>
355 </Box>
356 <Box width={7}>{[...a.recent].reverse().map(runBlock)}</Box>
357 </Box>
358 {opened.includes(`auto:${a.id}`) ? (
359 <Text dimColor wrap="truncate-end">
360 {' '}
361 {issue ? `${issue} · ` : ''}마지막: {last ? `${last.status} ${age(last.at, now)} 전${last.summary ? ` — ${last.summary}` : ''}` : '없음'}
362 </Text>
363 ) : null}
364 </Box>
365 )
366 }
367 const autoSection = !isAutoOpen ? null : (
368 <Box flexDirection="column">
369 {isAutoSourceBad ? (
370 <Text color="warning"> ▲ Orca automation 목록을 못 읽음 ({age(snap.automationSource?.updatedAt ?? null, now)} 전 값)</Text>
371 ) : null}
372 {autos.length ? (
373 <Box>
374 <Box width={NAME_WIDTH + 2}>
375 <Text dimColor> 이름</Text>
376 </Box>
377 <Box width={17}>
378 <Text dimColor>주기</Text>
379 </Box>
380 <Box width={9}>
381 <Text dimColor>다음</Text>
382 </Box>
383 <Box width={8}>
384 <Text dimColor>마지막</Text>
385 </Box>
386 <Box width={7}>
387 <Text dimColor>최근</Text>
388 </Box>
389 </Box>
390 ) : (
391 <Text dimColor> 등록된 automation 없음</Text>
392 )}
393 {autos.map(autoRow)}
394 <Text dimColor> Orca automation 만 표시 (세션 CronCreate·launchd 는 수집 밖)</Text>
395 </Box>
396 )
397
398 // Collapsed until asked for: the tree is the coordinator's long view, not what changes minute to minute.
399 const isRelOpen = opened.includes('section:relations')
400 const rel = tree(snap.sessions, new Set(snap.items.map(i => i.session ?? '')))
401 const working = rel.lines.filter(l => l.session.phase === 'working').length
402 const waiting = rel.lines.filter(l => l.session.phase === 'waiting').length
403 const sessionLine = ({ session: s, prefix }: { session: OrchPanelSession; prefix: string }) => {
404 const p = PHASE[s.phase]
405 return (
406 <Box key={`s-${s.id}`}>
407 <Box flexGrow={1} flexShrink={1}>
408 <Text wrap="truncate-end">
409 <Text dimColor>{' ' + prefix}</Text>
410 <Text color={p.color} dimColor={!p.color}>
411 {p.glyph}{' '}
412 </Text>
413 {s.name}
414 {s.isMe ? <Text dimColor> (나)</Text> : ''}
415 {s.task ? <Text dimColor> {s.task}</Text> : ''}
416 {s.pr ? <Text dimColor={s.isPrMerged}> #{s.pr}{s.isPrMerged ? ' 머지됨' : ''}</Text> : ''}
417 </Text>
418 </Box>
419 <Text color={p.color} dimColor={!p.color}>
420 {' '}
421 {p.word}
422 </Text>
423 <Link href={sessionUrl(s.id)} label=" ↗" />
424 </Box>
425 )
426 }
427 const relSection = !isRelOpen ? null : (
428 <Box flexDirection="column">
429 {rel.lines.map(sessionLine)}
430 {rel.offline ? <Text dimColor> + offline {rel.offline}</Text> : null}
431 <Text dimColor> 공유 자원</Text>
432 {snap.holds.length === 0 ? (
433 <Text dimColor> 기록 없음 — 브라우저 같은 공유 자원은 orch hold <자원> 으로 잡는다</Text>
434 ) : (
435 snap.holds.map(hold => (
436 <Box key={`h-${hold.kind}-${hold.resource}`}>
437 <Box width={28}>
438 <Text wrap="truncate-end">
439 {' '}
440 {hold.kind === 'lane' ? `${hold.resource} 착지 lane` : hold.resource}
441 </Text>
442 </Box>
443 <Box flexGrow={1} flexShrink={1}>
444 <Text wrap="truncate-end">
445 <Text color="warning">■ </Text>
446 {hold.byName ?? hold.by ?? '?'}
447 {hold.note ? <Text dimColor> {hold.note}</Text> : ''}
448 </Text>
449 </Box>
450 <Text color={ageMs(hold.at, now) > 3_600_000 ? 'warning' : undefined} dimColor={ageMs(hold.at, now) <= 3_600_000}>
451 {' '}
452 {age(hold.at, now)}
453 </Text>
454 </Box>
455 ))
456 )}
457 </Box>
458 )
459
460 return (
461 <Box flexDirection="column">
462 <Box>
463 <Box flexGrow={1}>
464 <Text color={isStale ? 'warning' : undefined} dimColor={!isStale}>
465 {isStale ? '▲ ' : ''}orch-dash {age(snap.generatedAt, now)} 전
466 </Text>
467 {isOrcaStale ? <Text color="warning"> ▲ Orca {age(snap.orca?.updatedAt ?? null, now)} 전</Text> : null}
468 </Box>
469 <Button key="refresh" label="↻" plain onPress={() => refresh($)} />
470 </Box>
471 <Button key="toggle-needs" plain onPress={toggle('needs')}>
472 {isOpen ? '▾' : '▸'} 결정·사람만 <Text bold>{total}</Text>
473 {isOpen ? '' : <Text dimColor> {summary}</Text>}
474 </Button>
475 {isOpen && decideItems.length > 0 ? <Text dimColor> 결정 대기</Text> : null}
476 {isOpen ? decideItems.map((item, i) => row(item, i + 1)) : null}
477 {isOpen && humanItems.length > 0 ? <Text dimColor> 사람만</Text> : null}
478 {isOpen ? humanItems.map((item, i) => row(item, decideItems.length + i + 1)) : null}
479 <Box>
480 <Box flexGrow={1}>
481 <Button key="toggle-work" plain onPress={toggle('work')}>
482 {isWorkOpen ? '▾' : '▸'} 작업 진행 <Text color="success">{filled}</Text>
483 <Text dimColor>{empty}</Text>
484 {rowCount ? ` ${done}/${rowCount}` : <Text dimColor>— 기록 없음</Text>}
485 </Button>
486 </Box>
487 {versions ? <Text dimColor>{versions}</Text> : null}
488 </Box>
489 {workSection}
490 {isWorkOpen && work.untrackedPrs ? (
491 <Link href={`${DASH_URL}/#/fleet/prs`} label={` + 표에 없는 열린 PR ${work.untrackedPrs} ↗`} />
492 ) : null}
493 <Button key="toggle-automations" plain onPress={expand('section:automations')}>
494 {isAutoOpen ? '▾' : '▸'} 자동화 {autos.length}
495 {troubled.length || isAutoSourceBad ? (
496 <Text color="warning"> · ▲ {troubled.length || '수집 실패'}</Text>
497 ) : (
498 <Text dimColor> · 문제 없음</Text>
499 )}
500 {upcoming ? <Text dimColor> · 다음 {upcoming.name} {until(upcoming.nextAt, now)}</Text> : ''}
501 </Button>
502 {autoSection}
503 <Button key="toggle-relations" plain onPress={expand('section:relations')}>
504 {isRelOpen ? '▾' : '▸'} 관계 세션 {rel.lines.length}
505 {working ? <Text color="permission"> · ◉ 작업 중 {working}</Text> : ''}
506 {waiting ? <Text color="warning"> · ◍ 대기 {waiting}</Text> : ''}
507 {snap.holds.length ? <Text dimColor> · 자원 {snap.holds.length}</Text> : ''}
508 </Button>
509 {relSection}
510 </Box>
511 )
512 })
513}
514hooks/model.ts 330 lines1import type {
2 OrchPanelAutomation,
3 OrchPanelCell,
4 OrchPanelItem,
5 OrchPanelItemKind,
6 OrchPanelMark,
7 OrchPanelPhase,
8 OrchPanelPrChip,
9 OrchPanelSession,
10 OrchPanelSnapshot,
11 OrchPanelStageRow,
12 OrchPanelWork,
13} from '../types'
14
15// The same split as brief-status: what needs the owner's call, and what only a person at the keyboard can do.
16// PR items (changes_requested, ci_failed, ...) belong to the progress table, sync items to the dashboard.
17const KIND_OF: Record<string, OrchPanelItemKind> = {
18 decision: 'decide',
19 approval: 'decide',
20 question: 'decide',
21 prompt: 'human',
22 permission: 'human',
23 login: 'human',
24 run_command: 'human',
25}
26
27export const DASH_URL = 'http://127.0.0.1:4780'
28export const STALE_COLLECTOR_MS = 3 * 60_000
29export const STALE_ORCA_MS = 90_000
30
31type Raw = Record<string, unknown>
32
33const str = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
34
35export function stateDir(env: { ORCH_FLEET_STATE?: string; HOME?: string }): string {
36 const dir = env.ORCH_FLEET_STATE ?? '~/.local/state/agent-skills/dashboard'
37 return dir.startsWith('~/') && env.HOME ? env.HOME + dir.slice(1) : dir
38}
39
40function recommendLabel(raw: Raw): string | null {
41 const options = Array.isArray(raw.options) ? (raw.options as Raw[]) : []
42 const n = typeof raw.recommend === 'number' ? raw.recommend : 0
43 return str(options[n - 1]?.label)
44}
45
46export function snapshotOf(text: string, checkedAt: number, cwd: string, stagesText: string | null = null): OrchPanelSnapshot {
47 let state: Raw
48 try {
49 state = JSON.parse(text)
50 } catch (err) {
51 return { status: 'unreadable', checkedAt, error: String(err).slice(0, 200) }
52 }
53 const sessions = new Map<string, string>()
54 for (const s of (state.sessions as Raw[] | undefined) ?? []) {
55 if (str(s.id)) sessions.set(s.id as string, str(s.name) ?? (s.id as string))
56 }
57 const items: OrchPanelItem[] = []
58 for (const it of (state.items as Raw[] | undefined) ?? []) {
59 const kind = KIND_OF[it.type as string]
60 if (!kind || !str(it.key)) continue
61 const session = str(it.session)
62 items.push({
63 key: it.key as string,
64 kind,
65 type: it.type as string,
66 title: str(it.title) ?? '(제목 없음)',
67 at: str(it.at),
68 decision: str(it.decision),
69 recommend: recommendLabel(it),
70 session,
71 sessionName: session ? (sessions.get(session) ?? null) : null,
72 })
73 }
74 // Oldest first: the one waiting longest is the one most likely to be costing something.
75 items.sort((a, b) => parseAt(a.at) - parseAt(b.at))
76 const sources = (state.sources ?? {}) as Record<string, Raw | undefined>
77 const sourceOf = (raw: Raw | undefined) => (raw ? { updatedAt: str(raw.updated_at), isOk: raw.ok !== false } : null)
78 return {
79 status: 'ok',
80 checkedAt,
81 generatedAt: str(state.generated_at),
82 // The root id is `<id>::<worktree path>`: only the coordinator's own session gets toasts and an unasked pane.
83 isCoordinator: str(state.root)?.endsWith(`::${cwd}`) ?? false,
84 orca: sourceOf(sources.orca),
85 items,
86 work: workOf(stagesText, state),
87 sessions: sessionsOf(state, cwd),
88 automations: ((state.automations as Raw[] | undefined) ?? []).flatMap(a =>
89 str(a.id)
90 ? [{
91 id: a.id as string,
92 name: str(a.name) ?? (a.id as string),
93 rrule: str(a.rrule),
94 isEnabled: a.enabled !== false,
95 nextAt: str(a.next_run_at),
96 lastAt: str(a.last_run_at),
97 recent: ((a.recent as Raw[] | undefined) ?? []).map(r => ({
98 status: str(r.status) ?? 'unknown',
99 at: str(r.at),
100 summary: str(r.summary),
101 })),
102 }]
103 : [],
104 ),
105 automationSource: sourceOf(sources.automations),
106 holds: ((state.holds as Raw[] | undefined) ?? []).flatMap(raw =>
107 str(raw.resource)
108 ? [{
109 kind: raw.kind === 'lane' ? ('lane' as const) : ('resource' as const),
110 resource: raw.resource as string,
111 by: str(raw.by),
112 byName: sessions.get(str(raw.session) ?? '') ?? null,
113 note: str(raw.note),
114 at: str(raw.at),
115 }]
116 : [],
117 ),
118 }
119}
120
121const PHASES: readonly OrchPanelPhase[] = ['working', 'waiting', 'idle', 'open', 'offline']
122
123function sessionsOf(state: Raw, cwd: string): OrchPanelSession[] {
124 return ((state.sessions as Raw[] | undefined) ?? []).flatMap(s => {
125 if (!str(s.id)) return []
126 const pr = s.linked_pr as Raw | null | undefined
127 return [{
128 id: s.id as string,
129 name: str(s.name) ?? (s.id as string),
130 kind: str(s.kind) ?? 'task',
131 phase: PHASES.find(p => p === s.phase) ?? 'offline',
132 parent: str(s.parent),
133 task: str(s.task_title),
134 pr: typeof pr?.number === 'number' ? pr.number : null,
135 isPrMerged: pr?.state === 'merged',
136 isMe: (s.id as string).endsWith(`::${cwd}`),
137 }]
138 })
139}
140
141// The relations tree as drawn: offline sessions leave it (counted apart) unless something waits on them;
142// each line carries the prefix (`├─ `, `│ └─ `) its depth and place give it.
143export function tree(
144 sessions: OrchPanelSession[],
145 busy: Set<string>,
146): { lines: { session: OrchPanelSession; prefix: string }[]; offline: number } {
147 const shown = sessions.filter(s => s.phase !== 'offline' || busy.has(s.id))
148 const ids = new Set(shown.map(s => s.id))
149 const lines: { session: OrchPanelSession; prefix: string }[] = []
150 const walk = (parent: string | null, indent: string, seen: Set<string>) => {
151 const kids = shown.filter(s => (parent === null ? !s.parent || !ids.has(s.parent) : s.parent === parent))
152 kids.forEach((s, i) => {
153 if (seen.has(s.id)) return
154 const isLast = i === kids.length - 1
155 lines.push({ session: s, prefix: parent === null ? '' : indent + (isLast ? '└─ ' : '├─ ') })
156 walk(s.id, parent === null ? '' : indent + (isLast ? ' ' : '│ '), new Set([...seen, s.id]))
157 })
158 }
159 walk(null, '', new Set())
160 return { lines, offline: sessions.length - shown.length }
161}
162
163const MARKS: readonly OrchPanelMark[] = ['ok', 'fail', 'partial', 'checking', 'na']
164
165function cellOf(raw: unknown): OrchPanelCell | null {
166 const c = raw as Raw | undefined
167 const mark = MARKS.find(m => m === c?.mark)
168 return mark ? { mark, evidence: str(c?.evidence), by: str(c?.by) } : null
169}
170
171// The same reading as stages.py merge_cell: the merge column is never written, only read off the PR list.
172function mergeOf(pr: Raw): OrchPanelCell {
173 if (str(pr.merged_at) || pr.state === 'MERGED') {
174 const at = new Date(parseAt(str(pr.merged_at)))
175 return { mark: 'ok', evidence: at.getTime() ? `${at.getUTCMonth() + 1}/${at.getUTCDate()}` : '머지됨', by: null }
176 }
177 if (pr.state === 'CLOSED') return { mark: 'fail', evidence: '닫힘', by: null }
178 const why =
179 pr.ci === 'failure' ? 'CI 실패'
180 : pr.decision === 'CHANGES_REQUESTED' ? '변경 요청'
181 : pr.draft ? 'draft'
182 : '리뷰 대기'
183 return { mark: 'fail', evidence: why, by: null }
184}
185
186function chipOf(pr: Raw): OrchPanelPrChip | null {
187 if (pr.state !== 'OPEN') return null
188 if (pr.ci === 'failure') return { text: '✗CI', tone: 'error' }
189 if (pr.decision === 'CHANGES_REQUESTED') return { text: '✎', tone: 'warning' }
190 if (pr.ci === 'pending') return { text: '◐CI', tone: 'warning' }
191 if (pr.merge_state === 'CLEAN') return { text: '✓', tone: 'success' }
192 return null
193}
194
195export function workOf(stagesText: string | null, state: Raw): OrchPanelWork {
196 const prs = new Map<string, Raw>()
197 for (const p of (state.prs as Raw[] | undefined) ?? []) if (str(p.key)) prs.set(p.key as string, p)
198 let store: Raw = {}
199 let error: string | null = null
200 try {
201 store = stagesText ? JSON.parse(stagesText) : {}
202 } catch (err) {
203 error = String(err).slice(0, 200)
204 }
205 const byId = (store.rows ?? {}) as Record<string, Raw>
206 const order = ((store.order as string[] | undefined) ?? []).filter(id => byId[id])
207 const toRow = (id: string): OrchPanelStageRow => {
208 const r = byId[id] as Raw
209 const cells = (r.cells ?? {}) as Raw
210 const pr = str(r.pr)
211 const live = pr ? prs.get(pr) : undefined
212 return {
213 id,
214 title: str(r.title) ?? id,
215 pr,
216 isChild: !!str(r.parent),
217 isDone: !!str(r.done_at),
218 merge: live ? mergeOf(live) : cellOf(cells.merge),
219 dev: cellOf(cells.dev),
220 prod: cellOf(cells.prod),
221 chip: live ? chipOf(live) : null,
222 }
223 }
224 // Each parent followed by its children, in the order they were added: `orch stage show`'s order.
225 const rows = order
226 .filter(id => !str(byId[id]?.parent))
227 .flatMap(id => [toRow(id), ...order.filter(k => byId[k]?.parent === id).map(toRow)])
228 const tracked = new Set(rows.map(r => r.pr))
229 const versions = (store['env'] ?? {}) as Raw
230 return {
231 rows,
232 versions: { prod: str(versions.prod), dev: str(versions.dev) },
233 next: ((store.next as unknown[] | undefined) ?? []).filter((n): n is string => !!str(n)),
234 untrackedPrs: [...prs.values()].filter(p => p.state === 'OPEN' && !tracked.has(p.key as string)).length,
235 error,
236 }
237}
238
239// A row is through when it is marked done or every stage it has is verified.
240export function progress(work: OrchPanelWork): { done: number; total: number } {
241 const through = (r: OrchPanelStageRow) =>
242 r.isDone || [r.merge, r.dev, r.prod].every(c => c?.mark === 'ok' || c?.mark === 'na')
243 return { done: work.rows.filter(through).length, total: work.rows.length }
244}
245
246// `▰▰▰▱▱`: one cell per row up to ten, scaled past that.
247export function bar(done: number, total: number): { filled: string; empty: string } {
248 const width = Math.min(total, 10)
249 const filled = total ? Math.round((done / total) * width) : 0
250 return { filled: '▰'.repeat(filled), empty: '▱'.repeat(width - filled) }
251}
252
253export function counts(snapshot: OrchPanelSnapshot | null): { decide: number; human: number } {
254 const items = snapshot?.status === 'ok' ? snapshot.items : []
255 return {
256 decide: items.filter(i => i.kind === 'decide').length,
257 human: items.filter(i => i.kind === 'human').length,
258 }
259}
260
261// Plain text by necessity: the status line takes no color. Zero counts are left out, all zero clears it.
262export function statusText(snapshot: OrchPanelSnapshot | null): string | undefined {
263 if (snapshot?.status !== 'ok') return undefined
264 const { decide, human } = counts(snapshot)
265 const troubled = snapshot.automations.filter(a => automationIssue(a, snapshot.checkedAt)).length
266 const parts = [decide && `결정 ${decide}`, human && `사람만 ${human}`, troubled && `자동화 ▲${troubled}`].filter(Boolean)
267 return parts.length ? parts.join(' · ') : undefined
268}
269
270// The collector writes most times as `...Z`, but some sources as `+0900`, which Date.parse need not accept.
271export function parseAt(at: string | null): number {
272 return Date.parse((at ?? '').replace(/([+-]\d\d)(\d\d)$/, '$1:$2')) || 0
273}
274
275export function age(fromIso: string | null, now: number): string {
276 const t = parseAt(fromIso)
277 if (!t) return '?'
278 const s = Math.max(0, Math.round((now - t) / 1000))
279 if (s < 60) return `${s}s`
280 if (s < 3600) return `${Math.floor(s / 60)}m`
281 if (s < 86400) return `${Math.floor(s / 3600)}h`
282 return `${Math.floor(s / 86400)}d`
283}
284
285export function ageMs(fromIso: string | null, now: number): number {
286 const t = parseAt(fromIso)
287 return t ? now - t : Infinity
288}
289
290export function toastText(item: OrchPanelItem): string {
291 const head = item.kind === 'decide' ? '결정 대기' : '사람만'
292 return `${head}${item.decision ? ` ${item.decision}` : ''}: ${item.title}`
293}
294
295export function sessionUrl(session: string): string {
296 return `${DASH_URL}/#/fleet/session/${encodeURIComponent(session)}`
297}
298
299const STALL_MS = 10 * 60_000
300
301// What is wrong with an automation, if anything: its last run failed, or its next run is overdue (the scheduler
302// stopped, or the app was closed). A switched-off automation is not a problem.
303export function automationIssue(a: OrchPanelAutomation, now: number): string | null {
304 if (!a.isEnabled) return null
305 if (a.recent[0] && /fail|error/.test(a.recent[0].status)) return '마지막 실행 실패'
306 if (a.nextAt && now - parseAt(a.nextAt) > STALL_MS) return `예정 ${age(a.nextAt, now)} 지남`
307 return null
308}
309
310// The few cron shapes automations use, in words; anything else stays as written.
311export function cadence(rrule: string | null): string {
312 if (!rrule) return '?'
313 const m = (re: RegExp) => rrule.trim().match(re)
314 let g = m(/^(\d+) \*\/(\d+) \* \* \*$/)
315 if (g) return `${g[2]}시간마다 :${g[1]?.padStart(2, '0')}`
316 g = m(/^\*\/(\d+) (\d+)-(\d+) \* \* 1-5$/)
317 if (g) return `평일 ${g[2]}–${g[3]}시 ${g[1]}분마다`
318 g = m(/^\*\/(\d+) \* \* \* \*$/)
319 if (g) return `${g[1]}분마다`
320 g = m(/^(\d+) (\d+) \* \* \*$/)
321 if (g) return `매일 ${g[2]}:${g[1]?.padStart(2, '0')}`
322 return rrule
323}
324
325export function until(iso: string | null, now: number): string {
326 const t = parseAt(iso)
327 if (!t) return '–'
328 return t >= now ? `${age(new Date(now).toISOString(), t)} 후` : `${age(iso, now)} 지남`
329}
330types/index.d.ts 112 lines1// Kept small on purpose: the session holds only what the panes draw, never the collector's whole state.json.
2
3export type OrchPanelItemKind = 'decide' | 'human'
4
5export type OrchPanelItem = {
6 key: string
7 kind: OrchPanelItemKind
8 type: string
9 title: string
10 at: string | null
11 decision: string | null
12 recommend: string | null
13 session: string | null
14 sessionName: string | null
15}
16
17export type OrchPanelSource = { updatedAt: string | null; isOk: boolean }
18
19export type OrchPanelMark = 'ok' | 'fail' | 'partial' | 'checking' | 'na'
20
21export type OrchPanelCell = { mark: OrchPanelMark; evidence: string | null; by: string | null }
22
23// A PR's live state beside its key: what holds it, or that it is ready to merge.
24export type OrchPanelPrChip = { text: string; tone: 'success' | 'warning' | 'error' }
25
26export type OrchPanelStageRow = {
27 id: string
28 title: string
29 pr: string | null
30 isChild: boolean
31 isDone: boolean
32 merge: OrchPanelCell | null
33 dev: OrchPanelCell | null
34 prod: OrchPanelCell | null
35 chip: OrchPanelPrChip | null
36}
37
38// The `orch stage` record (stages.json) with its merge cells filled from state.json's PR list, as `orch stage show`.
39export type OrchPanelWork = {
40 rows: OrchPanelStageRow[]
41 versions: { prod: string | null; dev: string | null }
42 next: string[]
43 untrackedPrs: number
44 error: string | null
45}
46
47export type OrchPanelPhase = 'working' | 'waiting' | 'idle' | 'open' | 'offline'
48
49// One Orca session of the fleet tree: what the relations section draws, nothing more.
50export type OrchPanelSession = {
51 id: string
52 name: string
53 kind: string
54 phase: OrchPanelPhase
55 parent: string | null
56 task: string | null
57 pr: number | null
58 isPrMerged: boolean
59 isMe: boolean
60}
61
62// A landing lane's lock or a resource taken with `orch hold`, as the collector listed it.
63export type OrchPanelHold = {
64 kind: 'lane' | 'resource'
65 resource: string
66 by: string | null
67 byName: string | null
68 note: string | null
69 at: string | null
70}
71
72export type OrchPanelRun = { status: string; at: string | null; summary: string | null }
73
74// An Orca automation as the collector listed it, its runs newest first.
75export type OrchPanelAutomation = {
76 id: string
77 name: string
78 rrule: string | null
79 isEnabled: boolean
80 nextAt: string | null
81 lastAt: string | null
82 recent: OrchPanelRun[]
83}
84
85export type OrchPanelSnapshot =
86 | { status: 'missing'; checkedAt: number }
87 | { status: 'unreadable'; checkedAt: number; error: string }
88 | {
89 status: 'ok'
90 checkedAt: number
91 generatedAt: string | null
92 isCoordinator: boolean
93 orca: OrchPanelSource | null
94 items: OrchPanelItem[]
95 work: OrchPanelWork
96 sessions: OrchPanelSession[]
97 holds: OrchPanelHold[]
98 automations: OrchPanelAutomation[]
99 automationSource: OrchPanelSource | null
100 }
101
102declare module 'claude-code' {
103 interface PluginState {
104 'orch-panel': {
105 snapshot: OrchPanelSnapshot | null
106 seen: string[] | null
107 collapsed: string[]
108 expanded: string[]
109 }
110 }
111}
112