跨 Claude app(A 帳號)與 Orca(B 帳號)的 session 看板:顯示執行中/完成/等你回應,點擊即跳轉

Claude Code mod:在每個 session 的右側 Pane(側邊欄)顯示一個看板,依「用戶(帳號)」分組列出這台電腦上所有進行中的 Claude Code session,包括 Claude Desktop 和 CLI(例如 Orca 終端)。點擊任一列就能跳到那個 session。
/rename 的名稱,沒有的話用 transcript 裡 AI 產生的標題。◆ 等你回應、● 執行中、✓ 已完成)。Claude Desktop/Claude Code CLI。/board 才會出現。Pane 右上角的關閉鈕或 /board 可關閉。✕ 可以手動移除該列,該 session 之後狀態改變(再次執行或需要你回應)會自動重新出現。claude://code/continue?session=<hostSessionId>。orca terminal switch --terminal <handle>。mod 不需要每個 session 自己回報狀態,而是每 3 秒讀 Claude Code 自己維護的 session 註冊表:
| 資料 | 來源 |
|---|---|
session 清單、狀態(busy/waiting/idle)、名稱、cwd | ~/.claude/sessions/<pid>.json |
| 行程是否還活著 | ps -p <pids> -o pid= |
| Desktop session 屬於哪個帳號 | ~/Library/Application Support/Claude/claude-code-sessions/<帳號 uuid>/<組織>/<hostSessionId>.json |
| CLI 的帳號與顯示名稱 | ~/.claude.json 的 oauthAccount |
| Desktop 帳號的顯示名稱 | session 憑證呼叫 https://api.anthropic.com/api/oauth/profile,查不到就用 email @ 前段;結果快取在 mod 的 $.store(key accounts) |
| CLI 的 AI 標題 | awk 從 ~/.claude/projects/<cwd 轉成 - 的資料夾>/<sessionId>.jsonl 撈最後一筆 customTitle/aiTitle |
| Orca terminal handle | ps -E -p <pid> 讀行程環境變數 ORCA_TERMINAL_HANDLE |
移除紀錄存在 $.store(key dismissed),所有 session 共用。
mods/session-board/
.claude-plugin/plugin.json # mod manifest
hooks/hooks.json # 指向 register.tsx
hooks/register.tsx # 讀取 session、補用戶名與標題、跳轉
hooks/view.tsx # 看板畫面與樣式(Desktop/terminal 各自調整)
hooks/icons.ts # SVG 線條 icon 與終端替代字元
hooks/board.ts # 純邏輯:解析、分組、排序、移除、跳轉指令
types/index.d.ts # $.state 型別契約
tests/*.test.ts # claude plugin test 跑的測試
claude --version
claude plugin validate <repo>/mods/session-board
claude plugin test <repo>/mods/session-board
這個 mod 是在 Claude Code 2.1.289~2.1.292 上開發與驗證的。function hooks API 還在 early access,版本差太多時 validate 可能報錯,請依錯誤訊息和該版本產生的型別(mod 載入後會出現在 .claude-plugin/types/claude-code/index.d.ts)修正。
~/.claude/settings.json 的 env 加上 CLAUDE_CODE_PLUGIN_DIRS,直接指向 repo 裡的這個資料夾,之後 git pull 就會更新,不需要再複製: "env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/GitHub/agent-assets-kit/mods/session-board"
}
settings.json 再合併,不要整個覆蓋。CLAUDE_CODE_PLUGIN_DIRS 已經有值,用 : 串接(它是 path list)。~/.claude/settings.json,兩邊都會生效。停用:從 CLAUDE_CODE_PLUGIN_DIRS 移除這個路徑,重開 session。
這個 mod 是在下面這種環境寫的,新機器不一樣的地方需要調整:
ps -E、open、~/Library/Application Support/Claude/...。~/.claude。 如果新機器用 CLAUDE_CONFIG_DIR 指到別處,register.tsx 裡寫死的 ~/.claude/sessions、~/.claude/projects、~/.claude.json 要改。CLAUDE_CONFIG_DIR 來切換 CLI 帳號,目前只會讀預設那一份 ~/.claude.json。orca、/usr/local/bin/orca、/opt/homebrew/bin/orca(見 register.tsx 的 ORCA_FALLBACKS)。沒裝 Orca 不影響看板,只是 CLI session 不能跳轉。claude://code/continue?session=<hostSessionId> 是從 Claude Desktop app 內部找到的,不是公開文件記載的介面;新版 app 改掉的話,跳轉會失效。$.ui.open 會回 isPlaced: false),要更新 app;終端在 144 欄以下不會自動打開,打 /board 即可。awk: 用 macOS 內建 awk 撈標題;transcript 裡標題含跳脫的 " 時可能截斷。改完後在 mod 資料夾跑:
claude plugin validate .
claude plugin test .
.claude-plugin/types/ 和 tsconfig.json 是 Claude Code 載入 mod 時自動產生的,已在 repo 的 .gitignore 排除。
hooks/register.tsx 332 lines1// session-board:讀 Claude Code 自己維護的 session 註冊表(~/.claude/sessions/<pid>.json),
2// 在右側 Pane 依「用戶」分組列出這台電腦所有進行中的 session(Desktop 與 CLI 皆可),點擊即跳轉。
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { BoardEntry } from '../types'
7import type { Account, RegistryRecord } from './board'
8import {
9 POLL_MS,
10 TITLE_AWK,
11 TITLE_TTL_MS,
12 accountLabel,
13 basename,
14 chooseTitle,
15 detectEnv,
16 dismissKey,
17 emailLocalPart,
18 jumpCommands,
19 mapStatus,
20 parseAccountCache,
21 parseAlivePids,
22 parseDismissed,
23 parseOauthAccount,
24 parseOrcaHandle,
25 parseProfile,
26 parseRegistry,
27 pickTranscriptTitle,
28 projectDirName,
29 pruneDismissed,
30 toggleIn,
31 withoutDismissed,
32} from './board'
33import { BoardView } from './view'
34
35const entriesAtom = atom({ plugin: 'session-board', key: 'entries' } as const, [])
36const selfIdAtom = atom({ plugin: 'session-board', key: 'selfId' } as const, '')
37const collapsedUsersAtom = atom({ plugin: 'session-board', key: 'collapsedUsers' } as const, [])
38const dismissedAtom = atom({ plugin: 'session-board', key: 'dismissed' } as const, [])
39
40/** Orca CLI 可能的位置(desktop app 啟動的行程 PATH 不一定包含) */
41const ORCA_FALLBACKS: readonly string[] = ['/usr/local/bin/orca', '/opt/homebrew/bin/orca']
42const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'
43const ACCOUNT_CACHE_KEY = 'accounts'
44const DISMISSED_KEY = 'dismissed'
45const DESKTOP_SESSIONS = 'Library/Application Support/Claude/claude-code-sessions'
46/** 右側 Pane:docked 時希望的寬度(欄數),使用者拖過的寬度優先 */
47const PANE = { id: 'session-board', title: 'Sessions', columns: 46 } as const
48
49type $ = EngineInterface
50type CachedTitle = { readonly title: string; readonly checkedAt: number }
51
52// 模組變數只是快取;hot reload 後由 session.start 重建
53let home = ''
54let lastSerialized = ''
55let isPolling = false
56let accounts: Readonly<Record<string, Account>> = {}
57let cliAccountUuid = ''
58let titleCache: ReadonlyMap<string, CachedTitle> = new Map()
59let hostAccountCache: ReadonlyMap<string, string> = new Map()
60let handleCache: ReadonlyMap<number, string | null> = new Map()
61
62async function runText($: $, argv: readonly string[]): Promise<string> {
63 try {
64 const result = await $.process.run(argv, { timeoutMs: 5_000 })
65 return result.stdout
66 } catch {
67 return ''
68 }
69}
70
71async function readRegistry($: $): Promise<RegistryRecord[]> {
72 const dir = `${home}/.claude/sessions`
73 try {
74 const files = (await $.fs.list(dir)).filter(f => f.kind === 'file' && f.name.endsWith('.json'))
75 const records = await Promise.all(files.map(f => readRecord($, `${dir}/${f.name}`)))
76 return records.filter((r): r is RegistryRecord => r !== null)
77 } catch {
78 return []
79 }
80}
81
82async function readRecord($: $, path: string): Promise<RegistryRecord | null> {
83 try {
84 return parseRegistry(await $.fs.read(path))
85 } catch {
86 return null
87 }
88}
89
90/** 註冊表會留下已結束行程的檔案:用 ps 確認 pid 還活著 */
91async function filterAlive($: $, records: readonly RegistryRecord[]): Promise<RegistryRecord[]> {
92 if (records.length === 0) return []
93 const output = await runText($, ['ps', '-p', records.map(r => r.pid).join(','), '-o', 'pid='])
94 const alive = parseAlivePids(output)
95 return records.filter(r => alive.has(r.pid))
96}
97
98/** 帳號:快取 + CLI 的 ~/.claude.json + 本 session 自己的帳號(Desktop 由 profile 查名字) */
99async function loadAccounts($: $): Promise<void> {
100 const cached = parseAccountCache(await $.store.get(ACCOUNT_CACHE_KEY))
101 const cli = await readCliAccount($)
102 const own = await resolveOwnAccount($, cached)
103 cliAccountUuid = cli?.uuid ?? ''
104 accounts = {
105 ...cached,
106 ...(cli ? { [cli.uuid]: cli } : {}),
107 ...(own ? { [own.uuid]: own } : {}),
108 }
109 await $.store.set(ACCOUNT_CACHE_KEY, accounts)
110}
111
112async function readCliAccount($: $): Promise<Account | null> {
113 try {
114 return parseOauthAccount(await $.fs.read(`${home}/.claude.json`))
115 } catch {
116 return null
117 }
118}
119
120async function resolveOwnAccount($: $, cached: Readonly<Record<string, Account>>): Promise<Account | null> {
121 const uuid = await $.env.get('CLAUDE_CODE_ACCOUNT_UUID')
122 if (!uuid) return null
123 const known = cached[uuid]
124 if (known) return known
125 const email = (await $.env.get('CLAUDE_CODE_USER_EMAIL')) ?? ''
126 const profile = await fetchProfile($)
127 if (profile && profile.uuid === uuid) return profile
128 return { uuid, email, name: email ? emailLocalPart(email) : `帳號 ${uuid.slice(0, 8)}` }
129}
130
131async function fetchProfile($: $): Promise<Account | null> {
132 try {
133 const auth = await $.session.authorize()
134 if (!auth?.handle) return null
135 const response = await $.http.fetch(PROFILE_URL, {
136 auth: auth.handle,
137 headers: { 'anthropic-beta': 'oauth-2025-04-20' },
138 })
139 return response.ok ? parseProfile(response.text) : null
140 } catch (error) {
141 $.ui.log(`session-board: 查詢用戶資料失敗:${String(error)}`)
142 return null
143 }
144}
145
146/** Desktop session 屬於哪個帳號:claude-code-sessions/<帳號>/<組織>/<hostSessionId>.json */
147async function desktopAccountOf($: $, hostSessionId: string): Promise<string> {
148 const cached = hostAccountCache.get(hostSessionId)
149 if (cached !== undefined) return cached
150 const root = `${home}/${DESKTOP_SESSIONS}`
151 let found = ''
152 try {
153 for (const account of await $.fs.list(root)) {
154 if (account.kind !== 'dir') continue
155 for (const org of await $.fs.list(`${root}/${account.name}`)) {
156 if (org.kind === 'dir' && (await $.fs.exists(`${root}/${account.name}/${org.name}/${hostSessionId}.json`))) {
157 found = account.name
158 }
159 }
160 }
161 } catch {
162 found = ''
163 }
164 hostAccountCache = new Map([...hostAccountCache, [hostSessionId, found]])
165 return found
166}
167
168/** CLI 的 AI 標題在 transcript 裡,用 awk 撈最後一筆;有快取避免每次都掃 */
169async function transcriptTitleOf($: $, record: RegistryRecord, now: number): Promise<string> {
170 if (record.nameSource !== 'derived' && record.name) return ''
171 const cached = titleCache.get(record.sessionId)
172 if (cached && now - cached.checkedAt < TITLE_TTL_MS) return cached.title
173 const path = `${home}/.claude/projects/${projectDirName(record.cwd)}/${record.sessionId}.jsonl`
174 const title = (await $.fs.exists(path)) ? pickTranscriptTitle(await runText($, ['awk', TITLE_AWK, path])) : ''
175 titleCache = new Map([...titleCache, [record.sessionId, { title, checkedAt: now }]])
176 return title
177}
178
179/** Orca 終端裡的 CLI:從行程環境變數拿 terminal handle(同一個 pid 不會變,快取起來) */
180async function orcaHandleOf($: $, pid: number): Promise<string | undefined> {
181 const cached = handleCache.get(pid)
182 if (cached !== undefined) return cached ?? undefined
183 const handle = parseOrcaHandle(await runText($, ['ps', '-E', '-p', String(pid), '-o', 'command=']))
184 handleCache = new Map([...handleCache, [pid, handle ?? null]])
185 return handle
186}
187
188async function toEntry($: $, record: RegistryRecord, now: number): Promise<BoardEntry> {
189 const env = detectEnv(record.entrypoint)
190 const userKey =
191 env === 'desktop' && record.hostSessionId
192 ? (await desktopAccountOf($, record.hostSessionId)) || 'unknown'
193 : cliAccountUuid || 'unknown'
194 const account = accounts[userKey]
195 const terminalHandle = env === 'cli' ? await orcaHandleOf($, record.pid) : undefined
196 return {
197 sessionId: record.sessionId,
198 pid: record.pid,
199 env,
200 userKey,
201 userName: accountLabel(accounts, userKey),
202 userHint: account?.email ? emailLocalPart(account.email) : userKey.slice(0, 6),
203 status: mapStatus(record.status),
204 project: basename(record.cwd),
205 title: chooseTitle(record, await transcriptTitleOf($, record, now)),
206 ...(record.hostSessionId ? { hostSessionId: record.hostSessionId } : {}),
207 ...(terminalHandle ? { terminalHandle } : {}),
208 statusSince: record.statusUpdatedAt,
209 }
210}
211
212async function refresh($: $): Promise<void> {
213 if (!home || isPolling) return
214 isPolling = true
215 try {
216 const now = await $.clock.now()
217 const records = await filterAlive($, await readRegistry($))
218 const all = await Promise.all(records.map(r => toEntry($, r, now)))
219 // 移除紀錄存在 store,各 session 共用;每次輪詢同步一次
220 const dismissed = pruneDismissed(parseDismissed(await $.store.get(DISMISSED_KEY)), all)
221 const entries = withoutDismissed(all, dismissed)
222 const serialized = JSON.stringify(entries)
223 if (serialized !== lastSerialized) {
224 lastSerialized = serialized
225 await update($, entriesAtom, () => entries)
226 await update($, dismissedAtom, () => dismissed)
227 }
228 } catch (error) {
229 $.ui.log(`session-board: 讀取 session 失敗:${String(error)}`)
230 } finally {
231 isPolling = false
232 }
233}
234
235async function dismiss($: $, entry: BoardEntry): Promise<void> {
236 const key = dismissKey(entry)
237 const stored = parseDismissed(await $.store.get(DISMISSED_KEY))
238 const next = stored.includes(key) ? stored : [...stored, key]
239 await $.store.set(DISMISSED_KEY, next)
240 await update($, dismissedAtom, () => next)
241 await update($, entriesAtom, list => withoutDismissed(list, next))
242 lastSerialized = ''
243}
244
245async function runJump($: $, entry: BoardEntry): Promise<void> {
246 const commands = jumpCommands(entry)
247 if (commands.length === 0) {
248 $.ui.toast('這個 session 不在 Claude Desktop 或 Orca 裡,無法跳轉')
249 return
250 }
251 for (const argv of commands) {
252 if (!(await tryRun($, argv))) {
253 $.ui.toast(`跳轉失敗:${argv.join(' ')}`)
254 return
255 }
256 }
257}
258
259async function tryRun($: $, argv: readonly string[]): Promise<boolean> {
260 const candidates =
261 argv[0] === 'orca' ? [argv, ...ORCA_FALLBACKS.map(bin => [bin, ...argv.slice(1)])] : [argv]
262 for (const candidate of candidates) {
263 try {
264 const result = await $.process.run(candidate, { timeoutMs: 5_000 })
265 if (result.exitCode === 0) return true
266 } catch {
267 // 找不到執行檔就試下一個
268 }
269 }
270 return false
271}
272
273async function start($: $): Promise<void> {
274 home = (await $.env.get('HOME')) ?? ''
275 await loadAccounts($)
276 const id = await ownSessionId($)
277 await update($, selfIdAtom, () => id)
278 await refresh($)
279}
280
281/** 標示「目前」用:只影響顯示,拿不到就算了 */
282async function ownSessionId($: $): Promise<string> {
283 try {
284 return (await $.env.get('CLAUDE_CODE_SESSION_ID')) || (await $.session.id())
285 } catch {
286 return ''
287 }
288}
289
290/** /board:Pane 開著就關掉,沒開(或還沒擺上畫面)就打開 */
291async function togglePane($: $): Promise<string> {
292 const pane = (await $.ui.panes()).find(p => p.id === PANE.id)
293 if (pane?.isPlaced) {
294 await $.ui.close({ id: PANE.id })
295 return 'Session 看板已關閉'
296 }
297 const opened = await $.ui.open(PANE)
298 return opened.isPlaced ? 'Session 看板已打開' : `Session 看板已打開,但目前無法顯示:${opened.reason}`
299}
300
301export const register: Register = on => {
302 on('session.start', async ($, e, next) => {
303 try {
304 await start($)
305 } catch (error) {
306 $.ui.log(`session-board: 初始化失敗:${String(error)}`)
307 }
308 $.clock.every(POLL_MS, () => void refresh($))
309 await $.command.register({ name: 'board', description: '打開/關閉右側的 session 看板' })
310 // 自動打開:Desktop 與夠寬的全螢幕終端會擺在右側;太窄的終端會等到使用者 /board 才出現
311 void $.ui.open(PANE).catch(error => $.ui.log(`session-board: 打開看板失敗:${String(error)}`))
312 return next(e)
313 })
314
315 on('command.run', { command: 'board' }, async $ => ({ text: await togglePane($) }))
316
317 on('ui.render', { component: 'Pane', requestId: PANE.id }, async ($, e) =>
318 BoardView(
319 $.ui.resolve(e),
320 {
321 entries: await read($, entriesAtom),
322 selfId: await read($, selfIdAtom),
323 collapsedUsers: await read($, collapsedUsersAtom),
324 onToggleUser: userKey => update($, collapsedUsersAtom, list => toggleIn(list, userKey)),
325 onJump: entry => runJump($, entry),
326 onDismiss: entry => dismiss($, entry),
327 },
328 e.surface === 'desktop',
329 ),
330 )
331}
332hooks/board.ts 276 lines1// 純邏輯:解析 Claude Code 的 session 註冊表、帳號、標題,分組排序與跳轉指令。不碰 `$`,方便測試。
2import type { BoardEntry, SessionEnv, SessionStatus } from '../types'
3
4/** 讀取看板的間隔 */
5export const POLL_MS = 3_000
6/** CLI 的 AI 標題多久重新讀一次 transcript */
7export const TITLE_TTL_MS = 30_000
8/** 標題最長字數 */
9export const TITLE_MAX = 48
10
11export const ENV_LABEL: Readonly<Record<SessionEnv, string>> = {
12 desktop: 'Claude Desktop',
13 cli: 'Claude Code CLI',
14}
15
16export const STATUS_TEXT: Readonly<Record<SessionStatus, string>> = {
17 'needs-input': '等你回應',
18 running: '執行中',
19 done: '已完成',
20}
21
22/** 排序權重:需要你的排最前面 */
23const STATUS_RANK: Readonly<Record<SessionStatus, number>> = {
24 'needs-input': 0,
25 done: 1,
26 running: 2,
27}
28
29function asRecord(value: unknown): Record<string, unknown> | null {
30 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
31}
32
33function asText(value: unknown): string {
34 return typeof value === 'string' ? value.trim() : ''
35}
36
37/** ~/.claude/sessions/<pid>.json:每個 Claude Code 行程(Desktop 與 CLI)都會維護 */
38export type RegistryRecord = {
39 readonly pid: number
40 readonly sessionId: string
41 readonly cwd: string
42 readonly entrypoint: string
43 readonly name: string
44 readonly nameSource: string
45 readonly status: string
46 readonly hostSessionId?: string
47 readonly statusUpdatedAt: number
48}
49
50/** 註冊表檔案是外部資料:驗證後才採用;備用行程(spare)不列出 */
51export function parseRegistry(text: string): RegistryRecord | null {
52 try {
53 const r = asRecord(JSON.parse(text))
54 if (!r || r.spare === true) return null
55 if (typeof r.pid !== 'number' || typeof r.sessionId !== 'string') return null
56 const hostSessionId = asText(r.hostSessionId)
57 return {
58 pid: r.pid,
59 sessionId: r.sessionId,
60 cwd: asText(r.cwd),
61 entrypoint: asText(r.entrypoint),
62 name: asText(r.name),
63 nameSource: asText(r.nameSource),
64 status: asText(r.status),
65 ...(hostSessionId ? { hostSessionId } : {}),
66 statusUpdatedAt: typeof r.statusUpdatedAt === 'number' ? r.statusUpdatedAt : 0,
67 }
68 } catch {
69 return null
70 }
71}
72
73export function detectEnv(entrypoint: string): SessionEnv {
74 return entrypoint === 'claude-desktop' ? 'desktop' : 'cli'
75}
76
77/** 註冊表的 status:busy/waiting/idle */
78export function mapStatus(status: string): SessionStatus {
79 if (status === 'waiting') return 'needs-input'
80 if (status === 'busy') return 'running'
81 return 'done'
82}
83
84/** `ps -p a,b -o pid=` 的輸出 → 還活著的 pid */
85export function parseAlivePids(output: string): Set<number> {
86 return new Set(
87 output
88 .split('\n')
89 .map(line => Number.parseInt(line.trim(), 10))
90 .filter(pid => Number.isInteger(pid) && pid > 0),
91 )
92}
93
94/** `ps -E` 的輸出裡找 Orca 的 terminal handle */
95export function parseOrcaHandle(output: string): string | undefined {
96 return /ORCA_TERMINAL_HANDLE=(term_[A-Za-z0-9-]+)/.exec(output)?.[1]
97}
98
99export function basename(path: string): string {
100 const parts = path.split('/').filter(Boolean)
101 return parts.at(-1) ?? path
102}
103
104export function shortTitle(text: string): string {
105 const oneLine = text.replace(/\s+/g, ' ').trim()
106 if (oneLine.length <= TITLE_MAX) return oneLine
107 return `${oneLine.slice(0, TITLE_MAX - 1)}…`
108}
109
110export function emailLocalPart(email: string): string {
111 return email.split('@')[0] ?? email
112}
113
114/** Claude Code 存 transcript 的資料夾名稱:非英數字元換成 - */
115export function projectDirName(cwd: string): string {
116 return cwd.replace(/[^a-zA-Z0-9]/g, '-')
117}
118
119/** 從 transcript 找最後的 customTitle(/rename)與 aiTitle,輸出兩行 */
120export const TITLE_AWK =
121 'match($0,/"customTitle":"[^"]*"/){c=substr($0,RSTART+15,RLENGTH-16)} ' +
122 'match($0,/"aiTitle":"[^"]*"/){a=substr($0,RSTART+11,RLENGTH-12)} ' +
123 'END{print c; print a}'
124
125/** 解析 TITLE_AWK 的輸出:/rename 優先,其次 AI 標題 */
126export function pickTranscriptTitle(output: string): string {
127 const [custom = '', ai = ''] = output.split('\n').map(s => s.trim())
128 return custom || ai
129}
130
131/** 註冊表的名稱若是使用者或 Desktop 取的就直接用;自動推導的(如 bd2db-b7)改用 transcript 標題 */
132export function chooseTitle(record: RegistryRecord, transcriptTitle: string): string {
133 const isDerived = record.nameSource === 'derived' || !record.name
134 const title = isDerived ? transcriptTitle || record.name : record.name
135 return shortTitle(title || basename(record.cwd))
136}
137
138export type Account = { readonly uuid: string; readonly email: string; readonly name: string }
139
140/** ~/.claude.json 的 oauthAccount:CLI 登入的帳號 */
141export function parseOauthAccount(text: string): Account | null {
142 try {
143 const account = asRecord(asRecord(JSON.parse(text))?.oauthAccount)
144 const uuid = asText(account?.accountUuid)
145 const email = asText(account?.emailAddress)
146 if (!uuid) return null
147 const name = asText(account?.displayName) || asText(account?.fullName) || emailLocalPart(email)
148 return { uuid, email, name }
149 } catch {
150 return null
151 }
152}
153
154/** /api/oauth/profile 的回應 */
155export function parseProfile(text: string): Account | null {
156 try {
157 const account = asRecord(asRecord(JSON.parse(text))?.account)
158 const uuid = asText(account?.uuid)
159 const email = asText(account?.email) || asText(account?.email_address)
160 const name = asText(account?.display_name) || asText(account?.full_name) || emailLocalPart(email)
161 return uuid && name ? { uuid, email, name } : null
162 } catch {
163 return null
164 }
165}
166
167/** $.store 裡的帳號快取:外部資料,逐筆驗證 */
168export function parseAccountCache(raw: unknown): Readonly<Record<string, Account>> {
169 const record = asRecord(raw)
170 if (!record) return {}
171 return Object.fromEntries(
172 Object.entries(record).flatMap(([uuid, value]) => {
173 const v = asRecord(value)
174 const name = asText(v?.name)
175 return name ? [[uuid, { uuid, email: asText(v?.email), name }]] : []
176 }),
177 )
178}
179
180export function accountLabel(accounts: Readonly<Record<string, Account>>, uuid: string): string {
181 return accounts[uuid]?.name || (uuid ? `帳號 ${uuid.slice(0, 8)}` : '未知用戶')
182}
183
184export function sortEntries(entries: readonly BoardEntry[]): BoardEntry[] {
185 return [...entries].sort(
186 (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] || b.statusSince - a.statusSince,
187 )
188}
189
190/** 標題列用:各狀態的數量,依「等你回應 → 執行中 → 已完成」排列,0 的不列 */
191export function statusCounts(entries: readonly BoardEntry[]): { status: SessionStatus; count: number }[] {
192 const order: readonly SessionStatus[] = ['needs-input', 'running', 'done']
193 return order
194 .map(status => ({ status, count: entries.filter(e => e.status === status).length }))
195 .filter(item => item.count > 0)
196}
197
198export function countNeedsInput(entries: readonly BoardEntry[]): number {
199 return entries.filter(e => e.status === 'needs-input').length
200}
201
202export type UserGroup = {
203 readonly userKey: string
204 readonly userName: string
205 readonly userHint: string
206 readonly entries: readonly BoardEntry[]
207 readonly needsInput: number
208}
209
210/** 依用戶分組;沒有 session 的用戶自然不會出現。有事等你的用戶排前面 */
211export function groupByUser(entries: readonly BoardEntry[]): UserGroup[] {
212 const keys = [...new Set(entries.map(e => e.userKey))]
213 const groups = keys.map(userKey => {
214 const own = sortEntries(entries.filter(e => e.userKey === userKey))
215 return {
216 userKey,
217 userName: own[0]?.userName ?? userKey,
218 userHint: own[0]?.userHint || userKey.slice(0, 6),
219 entries: own,
220 needsInput: countNeedsInput(own),
221 }
222 })
223 return disambiguateNames(groups).sort(
224 (a, b) => b.needsInput - a.needsInput || a.userName.localeCompare(b.userName),
225 )
226}
227
228/** 兩個帳號顯示名稱相同時,補上 email 帳號名以便區分 */
229function disambiguateNames(groups: readonly UserGroup[]): UserGroup[] {
230 return groups.map(group => {
231 const isDuplicate = groups.some(
232 other => other.userKey !== group.userKey && other.userName === group.userName,
233 )
234 return isDuplicate ? { ...group, userName: `${group.userName}(${group.userHint})` } : group
235 })
236}
237
238export function toggleIn(list: readonly string[], key: string): string[] {
239 return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
240}
241
242/** 跳轉要執行的指令(每個都是 argv,不經 shell) */
243export function jumpCommands(entry: BoardEntry): string[][] {
244 if (entry.terminalHandle) {
245 return [
246 ['open', '-a', 'Orca'],
247 ['orca', 'terminal', 'switch', '--terminal', entry.terminalHandle],
248 ]
249 }
250 if (entry.env === 'desktop' && entry.hostSessionId) {
251 const url = `claude://code/continue?session=${encodeURIComponent(entry.hostSessionId)}&source=session-board`
252 return [['open', url]]
253 }
254 return []
255}
256
257/** 移除鍵:綁定當下的狀態時間,session 之後狀態改變(再次執行、等你回應)就會重新出現 */
258export function dismissKey(entry: BoardEntry): string {
259 return `${entry.sessionId}@${entry.statusSince}`
260}
261
262export function withoutDismissed(entries: readonly BoardEntry[], dismissed: readonly string[]): BoardEntry[] {
263 const hidden = new Set(dismissed)
264 return entries.filter(entry => !hidden.has(dismissKey(entry)))
265}
266
267/** 只保留仍對應到現存 session 的移除紀錄,避免無限累積 */
268export function pruneDismissed(dismissed: readonly string[], entries: readonly BoardEntry[]): string[] {
269 const live = new Set(entries.map(dismissKey))
270 return dismissed.filter(key => live.has(key))
271}
272
273export function parseDismissed(raw: unknown): string[] {
274 return Array.isArray(raw) ? raw.filter((k): k is string => typeof k === 'string') : []
275}
276hooks/view.tsx 228 lines1// 看板畫面(右側 Pane):只負責畫,不碰 `$`;互動由 register.tsx 傳入的 callback 處理。
2// Pane 是窄欄,所以每個 session 分兩行:第一行狀態 icon+標題+移除,第二行狀態文字、環境 badge、專案。
3import type { ElementTable } from 'claude-code'
4
5import type { BoardEntry, SessionEnv, SessionStatus } from '../types'
6import type { UserGroup } from './board'
7import { ENV_LABEL, STATUS_TEXT, groupByUser, statusCounts } from './board'
8import type { IconName } from './icons'
9import { ICON_GLYPH, ICON_SIZE, iconGroup, iconSvg } from './icons'
10
11/** 按鈕字元:都選沒有 emoji 版本的字元,不會被畫成彩色圖示 */
12const BUTTON_GLYPH = {
13 expanded: '▼',
14 collapsed: '►',
15 dismiss: '✕',
16} as const
17
18/** 色票:中間調,淺色與深色背景都讀得清楚 */
19const COLOR = {
20 muted: '#8A8A93',
21 hoverBg: '#80808022',
22 transparent: '#00000000',
23 status: {
24 'needs-input': '#F59E0B',
25 running: '#3B82F6',
26 done: '#22C55E',
27 } satisfies Record<SessionStatus, string>,
28 env: {
29 desktop: '#D97757',
30 cli: '#A78BFA',
31 } satisfies Record<SessionEnv, string>,
32} as const
33
34/** 第二行縮排:對齊標題(跳過狀態 icon 與間距) */
35const DETAIL_INDENT = 3
36
37export type BoardViewProps = {
38 readonly entries: readonly BoardEntry[]
39 readonly selfId: string
40 readonly collapsedUsers: readonly string[]
41 readonly onToggleUser: (userKey: string) => void
42 readonly onJump: (entry: BoardEntry) => void
43 readonly onDismiss: (entry: BoardEntry) => void
44}
45
46/** 單一 icon:Desktop 用 SVG,其他用替代字元(終端的 table 也有 Svg 但畫不出來,所以看 isDesktop) */
47function Icon(els: ElementTable, name: IconName, color: string, alt: string, isDesktop: boolean) {
48 if (isDesktop && 'Svg' in els) {
49 const { Box, Svg } = els
50 return (
51 <Box flexShrink={0}>
52 <Svg source={iconSvg(name, color)} alt={alt} width={ICON_SIZE} height={ICON_SIZE} />
53 </Box>
54 )
55 }
56 const { Text } = els
57 return <Text color={color}>{ICON_GLYPH[name]}</Text>
58}
59
60/** 膠囊 badge(Desktop):用 SVG 畫才能控制字級、全圓角與內嵌 icon;寬度依字數估算 */
61const BADGE = { height: 18, fontSize: 10.5, charWidth: 6.1, paddingX: 8, iconSize: 11, iconGap: 4 } as const
62
63function badgeWidth(label: string): number {
64 return Math.ceil(BADGE.paddingX * 2 + BADGE.iconSize + BADGE.iconGap + label.length * BADGE.charWidth)
65}
66
67function badgeSvg(env: SessionEnv, label: string, color: string): string {
68 const width = badgeWidth(label)
69 const { height, fontSize, paddingX, iconSize, iconGap } = BADGE
70 const textX = paddingX + iconSize + iconGap
71 return (
72 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
73 `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="${(height - 1) / 2}" ` +
74 `fill="${color}" fill-opacity="0.14" stroke="${color}" stroke-opacity="0.55"/>` +
75 iconGroup(env, color, paddingX, (height - iconSize) / 2, iconSize) +
76 `<text x="${textX}" y="50%" dominant-baseline="central" fill="${color}" ` +
77 `font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" ` +
78 `font-size="${fontSize}" font-weight="500">${label}</text></svg>`
79 )
80}
81
82function EnvBadge(els: ElementTable, entry: BoardEntry, isDesktop: boolean) {
83 const env = entry.env
84 const color = COLOR.env[env]
85 const label = ENV_LABEL[env]
86 if (isDesktop && 'Svg' in els) {
87 const { Box, Svg } = els
88 return (
89 <Box key={`badge-${entry.sessionId}`} flexShrink={0}>
90 <Svg source={badgeSvg(env, label, color)} alt={label} width={badgeWidth(label)} height={BADGE.height} />
91 </Box>
92 )
93 }
94 const { Text } = els
95 return (
96 <Text backgroundColor={color} color="#FFFFFF">
97 {` ${ICON_GLYPH[env]} ${label} `}
98 </Text>
99 )
100}
101
102/** 移除按鈕:常駐的淡色 x-mark;不用顯示/隱藏,避免版面跳動 */
103function DismissButton(els: ElementTable, entry: BoardEntry, props: BoardViewProps) {
104 const { Button } = els
105 return (
106 <Button
107 key={`dismiss-${entry.sessionId}`}
108 plain
109 dimColor
110 label={BUTTON_GLYPH.dismiss}
111 onPress={() => props.onDismiss(entry)}
112 />
113 )
114}
115
116function SessionDetail(els: ElementTable, entry: BoardEntry, isSelf: boolean, isDesktop: boolean) {
117 const { Box, Text } = els
118 const hasProject = entry.project !== '' && entry.project !== entry.title
119 return (
120 <Box flexDirection="row" alignItems="center" columnGap={1} paddingLeft={DETAIL_INDENT} overflow="hidden">
121 <Text color={COLOR.status[entry.status]} bold={entry.status === 'needs-input'}>
122 {STATUS_TEXT[entry.status]}
123 </Text>
124 {EnvBadge(els, entry, isDesktop)}
125 {hasProject ? Icon(els, 'folder', COLOR.muted, '專案', isDesktop) : null}
126 {hasProject ? (
127 <Text color={COLOR.muted} wrap="truncate-end">
128 {entry.project}
129 </Text>
130 ) : null}
131 {isSelf ? <Text color={COLOR.muted}>· 目前</Text> : null}
132 </Box>
133 )
134}
135
136function SessionRow(els: ElementTable, entry: BoardEntry, props: BoardViewProps, isDesktop: boolean) {
137 const { Box, Button } = els
138 const isSelf = entry.sessionId === props.selfId
139 return (
140 <Box
141 key={`row-${entry.sessionId}`}
142 flexDirection="column"
143 paddingX={1}
144 paddingY={isDesktop ? 1 : 0}
145 {...(isDesktop ? { borderStyle: 'round', borderColor: COLOR.transparent } : {})}
146 hover={{ backgroundColor: COLOR.hoverBg }}
147 >
148 <Box flexDirection="row" alignItems="center" columnGap={1}>
149 {Icon(els, entry.status, COLOR.status[entry.status], STATUS_TEXT[entry.status], isDesktop)}
150 <Box flexGrow={1} flexShrink={1} overflow="hidden">
151 <Button key={`jump-${entry.sessionId}`} plain label={entry.title} onPress={() => props.onJump(entry)} />
152 </Box>
153 {DismissButton(els, entry, props)}
154 </Box>
155 {SessionDetail(els, entry, isSelf, isDesktop)}
156 </Box>
157 )
158}
159
160/** 標題列:第一行標題,第二行各狀態計數;窄欄也排得下 */
161function BoardHeader(els: ElementTable, props: BoardViewProps, isDesktop: boolean) {
162 const { Box, Text } = els
163 return (
164 <Box flexDirection="column">
165 <Box flexDirection="row" alignItems="center" columnGap={1}>
166 {Icon(els, 'board', COLOR.muted, 'Sessions', isDesktop)}
167 <Text bold>Sessions</Text>
168 <Text color={COLOR.muted}>{props.entries.length}</Text>
169 </Box>
170 <Box flexDirection="row" alignItems="center" columnGap={2}>
171 {statusCounts(props.entries).map(({ status, count }) => (
172 <Box key={`count-${status}`} flexDirection="row" alignItems="center" columnGap={1}>
173 {Icon(els, status, COLOR.status[status], STATUS_TEXT[status], isDesktop)}
174 <Text color={COLOR.status[status]} bold={status === 'needs-input'}>
175 {count} {STATUS_TEXT[status]}
176 </Text>
177 </Box>
178 ))}
179 </Box>
180 </Box>
181 )
182}
183
184function UserGroupHeader(els: ElementTable, group: UserGroup, isOpen: boolean, props: BoardViewProps, isDesktop: boolean) {
185 const { Box, Text, Button } = els
186 return (
187 <Box flexDirection="row" alignItems="center" columnGap={1}>
188 <Button
189 key={`toggle-${group.userKey}`}
190 plain
191 dimColor
192 label={isOpen ? BUTTON_GLYPH.expanded : BUTTON_GLYPH.collapsed}
193 onPress={() => props.onToggleUser(group.userKey)}
194 />
195 {Icon(els, 'user', COLOR.muted, '用戶', isDesktop)}
196 <Text bold>{group.userName}</Text>
197 <Text color={COLOR.muted}>{group.entries.length}</Text>
198 {group.needsInput > 0 ? <Text color={COLOR.status['needs-input']}>· {group.needsInput} 個等你</Text> : null}
199 </Box>
200 )
201}
202
203export function BoardView(els: ElementTable, props: BoardViewProps, isDesktop: boolean) {
204 const { Box, Text } = els
205 if (props.entries.length === 0) {
206 return (
207 <Box flexDirection="column" rowGap={1}>
208 {BoardHeader(els, props, isDesktop)}
209 <Text color={COLOR.muted}>目前沒有進行中的 session</Text>
210 </Box>
211 )
212 }
213 return (
214 <Box flexDirection="column" rowGap={1}>
215 {BoardHeader(els, props, isDesktop)}
216 {groupByUser(props.entries).map(group => {
217 const isOpen = !props.collapsedUsers.includes(group.userKey)
218 return (
219 <Box key={`user-${group.userKey}`} flexDirection="column">
220 {UserGroupHeader(els, group, isOpen, props, isDesktop)}
221 {isOpen ? group.entries.map(entry => SessionRow(els, entry, props, isDesktop)) : null}
222 </Box>
223 )
224 })}
225 </Box>
226 )
227}
228hooks/icons.ts 57 lines1// 看板用的 icon:Desktop 畫 16×16 線條 SVG,終端改用不會被畫成 emoji 的字元。不碰 `$`。
2import type { SessionEnv, SessionStatus } from '../types'
3
4export type IconName = 'board' | 'user' | 'folder' | SessionEnv | SessionStatus
5
6/** 16×16 viewBox 的線條路徑;status 類的動畫另外加 */
7const PATHS: Readonly<Record<IconName, string>> = {
8 board: '<rect x="2" y="2.5" width="12" height="11" rx="2"/><path d="M2 6h12M6 6v7.5"/>',
9 user: '<circle cx="8" cy="5.5" r="2.8"/><path d="M2.8 13.8c.8-2.6 2.8-4 5.2-4s4.4 1.4 5.2 4"/>',
10 folder: '<path d="M2 4.5a1 1 0 0 1 1-1h3.2l1.5 1.6H13a1 1 0 0 1 1 1v6.4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1z"/>',
11 desktop: '<rect x="1.8" y="2.5" width="12.4" height="8.5" rx="1.5"/><path d="M5.5 13.8h5M8 11v2.8"/>',
12 cli: '<rect x="1.8" y="2.5" width="12.4" height="11" rx="1.8"/><path d="M4.6 6.4 6.8 8.4 4.6 10.4M8.4 10.6h3"/>',
13 'needs-input': '<path d="M2.5 3.5h11v7h-6l-3 2.6v-2.6h-2z"/><path d="M8 5.4v2.2M8 9.2v.1"/>',
14 running: '<circle cx="8" cy="8" r="5.5" stroke-opacity=".25"/><path d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5"/>',
15 done: '<circle cx="8" cy="8" r="5.5"/><path d="M5.4 8.2 7.2 10l3.4-3.8"/>',
16}
17
18/** 執行中的 icon 會轉、等你回應的會呼吸,其餘靜止 */
19const ANIMATION: Partial<Record<IconName, string>> = {
20 running:
21 '<animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="1.1s" repeatCount="indefinite"/>',
22 'needs-input': '<animate attributeName="opacity" values="1;.35;1" dur="1.6s" repeatCount="indefinite"/>',
23}
24
25/** 終端的替代字元:都選沒有 emoji 版本的 */
26export const ICON_GLYPH: Readonly<Record<IconName, string>> = {
27 board: '▤',
28 user: '◉',
29 folder: '▸',
30 desktop: '▣',
31 cli: '›',
32 'needs-input': '◆',
33 running: '●',
34 done: '✓',
35}
36
37export const ICON_SIZE = 14
38
39/** 線條 icon 的 SVG 原始碼;顏色由呼叫端給(色票見 view.tsx) */
40export function iconSvg(name: IconName, color: string, size: number = ICON_SIZE): string {
41 const animation = ANIMATION[name] ?? ''
42 return (
43 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16">` +
44 `<g fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
45 `${PATHS[name]}${animation}</g></svg>`
46 )
47}
48
49/** 給 badge 內嵌用:只回傳 <g>,位置由外層 SVG 決定 */
50export function iconGroup(name: IconName, color: string, x: number, y: number, size: number): string {
51 const scale = size / 16
52 return (
53 `<g transform="translate(${x} ${y}) scale(${scale})" fill="none" stroke="${color}" stroke-width="1.6" ` +
54 `stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</g>`
55 )
56}
57types/index.d.ts 40 lines1export type SessionStatus = 'needs-input' | 'running' | 'done'
2
3/** 執行環境:Claude Desktop 或 Claude Code CLI(Orca 終端也算 CLI) */
4export type SessionEnv = 'desktop' | 'cli'
5
6/** 看板上的一列:從 ~/.claude/sessions/<pid>.json 讀來再補上用戶名與標題 */
7export type BoardEntry = {
8 sessionId: string
9 pid: number
10 env: SessionEnv
11 /** 用戶識別(帳號 uuid),分組用 */
12 userKey: string
13 /** 顯示用的用戶名 */
14 userName: string
15 /** 同名用戶時用來區分(email 的 @ 前段) */
16 userHint: string
17 status: SessionStatus
18 /** 專案資料夾名稱 */
19 project: string
20 /** 任務標題 */
21 title: string
22 /** 跳轉用:Claude Desktop 的 session id(local_…) */
23 hostSessionId?: string
24 /** 跳轉用:Orca 的 terminal handle */
25 terminalHandle?: string
26 statusSince: number
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-board': {
32 entries: readonly BoardEntry[]
33 selfId: string
34 collapsedUsers: readonly string[]
35 /** 手動移除的 session(sessionId@statusSince),狀態再變就會重新出現 */
36 dismissed: readonly string[]
37 }
38 }
39}
40