hub セッションから、リポジトリごとの Claude Code バックグラウンドセッションへタスクを依頼し、状態をペインで見る mod

hub として使う Claude Code セッションから、~/develop/<org>/<repo>/ ごとのバックグラウンド セッション(claude --bg)へタスクを依頼し、その状態をペインで見るための mod。
hub は Obsidian リポジトリで起動した Claude で、この mod は Obsidian リポジトリの project settings(.claude/settings.json)でだけ有効にする。Work ごとに hub が複数いてよい。
tmux-project と tmux-claude-attach が PATH にあること。どちらも ikmski/nix-config が ~/.local/bin に置く。この mod が使うのは次の形:tmux-project -l: リポジトリの候補tmux-project -d <path>: tmux session を用意するだけで client を切り替えず、session 名を出すtmux-claude-attach -d -t <session> (-i <sessionId> | -n [LABEL]): window を作るだけで 切り替えず、window 名を出す<hub> は依頼した時点の hub の セッション名で、claude --bg で argv から起動したセッションは返信先を知らないため。 hub の名前が取れないときは返信先を付けずに送り、そう伝える。hub_dispatch: 1 リポジトリに複数のセッションを持てる。セッション名を 指定しなければリポジトリで新しいセッションを起動し(名前は <repo>、2 つ目から <repo>-<用途> か <repo>-2 …)、指定すればそのセッションへ送る(止まっていれば再開する)。 リポジトリとセッションの対応は名前でなく cwd で取る(別 org に同名のリポジトリがありうるため)。 open: true なら、そのあと hub_open と同じように tmux へ出す。hub_open: リポジトリの tmux session と、その中の Claude session の window を ユーザーの画面を切り替えずに用意し、どの session の何という window かを返す。セッション名を 指定すればそのバックグラウンドセッションに attach し、省略すれば新しい Claude session を始める。/hub で 開閉を切り替える(ペインの閉じるマークや ctrl+x x でも閉じる)。/open は人が使うので画面を切り替える。登録しているコマンド・ツール・フックの一覧は claude plugin validate plugins/session-hub が出す。
claude plugin validate plugins/session-hub
claude plugin test plugins/session-hubhooks/register.tsx 428 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow } from '../types'
5
6const PANE = 'hub-sessions'
7// Fits "<name> · <state> · <age>" and the repository path under ~/develop
8// without taking the dock's default share from the transcript.
9const PANE_COLUMNS = 40
10// Every poll starts a `claude agents` process, so keep it slow.
11const POLL_MS = 15_000
12
13const sessions = atom({ plugin: 'session-hub', key: 'sessions' } as const, [], {
14 shape: 'sessions-1',
15})
16const error = atom({ plugin: 'session-hub', key: 'error' } as const, '', {
17 shape: 'error-1',
18})
19
20type Repo = { name: string; path: string }
21
22// tmux-project -l prints "<mark> <name>\t<path>", <mark> "*" for a live tmux
23// session; reusing it keeps the repository roots in one place
24// (~/.config/tmux-project/roots).
25export function parseRepos(stdout: string): Repo[] {
26 return stdout
27 .split('\n')
28 .filter(line => line.includes('\t'))
29 .map(line => {
30 const [name = '', path = ''] = line.split('\t')
31 return { name: name.replace(/^[* ] /, '').trim(), path: path.trim() }
32 })
33}
34
35// Exact name, then exact path, then a unique substring of the path.
36export function resolveRepo(repos: Repo[], query: string): Repo | string {
37 const q = query.trim()
38 const exact = repos.filter(r => r.name === q || r.path === q)
39 const partial = exact.length > 0 ? exact : repos.filter(r => r.path.includes(q))
40 const [only] = partial
41 if (only && partial.length === 1) return only
42 if (partial.length === 0) return `No repository matches "${q}".`
43 return `"${q}" matches several repositories: ${partial.map(r => r.path).join(', ')}`
44}
45
46const sanitize = (s: string) => s.replace(/[.:]/g, '_')
47
48// The same rule as tmux-project and tmux-claude-attach: the directory name, or
49// <org>-<repo> when another directory's session already has it; a further
50// session of the repository adds -<label>, or -2, -3 ... when it has none or
51// the name is taken.
52export function newSessionName(repo: Repo, rows: AgentRow[], label = ''): string {
53 const [dir = '', org = ''] = repo.path.split('/').reverse()
54 const clash = rows.some(r => r.name === sanitize(dir) && r.cwd !== repo.path)
55 const base = clash ? sanitize(`${org}-${dir}`) : sanitize(dir)
56 const stem = label.trim() === '' ? base : `${base}-${sanitize(label.trim())}`
57 const taken = new Set(rows.map(r => r.name))
58 if (!taken.has(stem)) return stem
59 let n = 2
60 while (taken.has(`${stem}-${n}`)) n++
61 return `${stem}-${n}`
62}
63
64// Matched by cwd, not by name: two orgs can hold repositories of one name.
65// Running sessions first, newest first (left idle for about an hour a
66// session's process stops; only `--all` lists it, and resuming keeps its context).
67export function workersOf(rows: AgentRow[], path: string): AgentRow[] {
68 return rows
69 .filter(r => r.kind === 'background' && r.cwd === path)
70 .sort((a, b) => Number(isStopped(a)) - Number(isStopped(b)) || b.startedAt - a.startedAt)
71}
72
73export function findSession(rows: AgentRow[], name: string): AgentRow | undefined {
74 return rows
75 .filter(r => r.kind === 'background' && r.name === name)
76 .sort((a, b) => Number(isStopped(a)) - Number(isStopped(b)) || b.startedAt - a.startedAt)[0]
77}
78
79export function isStopped(row: AgentRow): boolean {
80 return row.state === 'stopped'
81}
82
83async function listRows($: EngineInterface): Promise<AgentRow[]> {
84 const ran = await $.process.run(['claude', 'agents', '--json', '--all'])
85 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || `claude agents exited ${ran.exitCode}`)
86 return JSON.parse(ran.stdout) as AgentRow[]
87}
88
89async function listRepos($: EngineInterface): Promise<Repo[]> {
90 const ran = await $.process.run(['tmux-project', '-l'])
91 if (ran.exitCode !== 0) throw new Error(ran.stderr.trim() || 'tmux-project -l failed')
92 return parseRepos(ran.stdout)
93}
94
95async function refresh($: EngineInterface): Promise<void> {
96 let rows: AgentRow[]
97 try {
98 rows = await listRows($)
99 } catch (err) {
100 await update($, error, () => String(err))
101 return
102 }
103 const before = await read($, sessions)
104 for (const row of rows) {
105 const old = before.find(o => o.sessionId === row.sessionId)
106 const was = old && label(old)
107 if (row.kind === 'background' && was !== undefined && was !== label(row)) {
108 $.ui.toast(`${row.name}: ${was} → ${label(row)}`)
109 }
110 }
111 await update($, sessions, () => rows)
112 await update($, error, () => '')
113}
114
115function label(row: AgentRow): string {
116 return row.state ?? row.status
117}
118
119// The reply address is this session's name as it is listed now. A session
120// started from argv has no sender to reply to, so the task has to name one.
121async function hubName($: EngineInterface, rows: AgentRow[]): Promise<string | undefined> {
122 const me = await $.session.id()
123 return rows.find(r => r.sessionId === me)?.name
124}
125
126export type DispatchArgs = { task: string; repo?: string; session?: string; label?: string; open?: boolean }
127
128// Without `session` a new session is started in `repo`; with it the task goes
129// to that session (resumed when stopped). With `open` the session is then put
130// in a tmux window, as hub_open does.
131export async function dispatch($: EngineInterface, args: DispatchArgs): Promise<string> {
132 const rows = await listRows($)
133 const hub = await hubName($, rows)
134 const text = hub
135 ? `${args.task}\n\n終わったら SendMessage で ${hub} へ結果を返してください。`
136 : args.task
137 const warn = hub ? '' : '\n(このセッションの名前が取れないため、依頼文に返信先を付けていません)'
138
139 if (args.session) {
140 const worker = findSession(rows, args.session)
141 if (!worker) {
142 const names = rows.filter(r => r.kind === 'background').map(r => r.name)
143 return `No session named "${args.session}". Sessions: ${names.join(', ') || '(none)'}`
144 }
145 if (isStopped(worker)) {
146 const ran = await $.process.run(['claude', '--resume', worker.sessionId, '--bg', text], {
147 cwd: worker.cwd,
148 })
149 await refresh($)
150 if (ran.exitCode !== 0) return `Could not resume ${worker.name}: ${ran.stderr.trim()}`
151 return `Resumed ${worker.name} (${worker.cwd}).${warn}${await openAfter($, args, worker.cwd, worker.sessionId)}`
152 }
153 const sent = await $.session.send({ to: { sessionId: worker.sessionId }, text })
154 if (!sent.isDelivered) return `Not delivered to ${worker.name}: ${sent.reason}`
155 await refresh($)
156 return `Sent to ${worker.name} (${worker.cwd}).${warn}${await openAfter($, args, worker.cwd, worker.sessionId)}`
157 }
158
159 if (!args.repo) return 'Name a repository to start a session in, or a session to send to.'
160 const repo = resolveRepo(await listRepos($), args.repo)
161 if (typeof repo === 'string') return repo
162 const name = newSessionName(repo, rows, args.label)
163 const ran = await $.process.run(['claude', '--bg', '--name', name, text], { cwd: repo.path })
164 await refresh($)
165 if (ran.exitCode !== 0) return `Could not start a session in ${repo.path}: ${ran.stderr.trim()}`
166 if (!args.open) return `Started ${name} in ${repo.path}. Follow up with session "${name}".${warn}`
167 // `claude --bg` prints no session id, so the new session is found by its name.
168 const started = workersOf(await listRows($), repo.path).find(r => r.name === name)
169 const opened = started
170 ? await openAfter($, args, repo.path, started.sessionId)
171 : `\n(${name} がまだ一覧に無いため tmux には出していません)`
172 return `Started ${name} in ${repo.path}. Follow up with session "${name}".${warn}${opened}`
173}
174
175async function openAfter($: EngineInterface, args: DispatchArgs, path: string, sessionId: string): Promise<string> {
176 return args.open ? `\n${await openDetached($, path, ['-i', sessionId])}` : ''
177}
178
179// The repository, and the -i argument for tmux-claude-attach when a session is named.
180async function target(
181 $: EngineInterface,
182 query: string,
183 sessionName?: string,
184): Promise<{ repo: Repo; attachArgs: string[] } | string> {
185 const repo = resolveRepo(await listRepos($), query)
186 if (typeof repo === 'string') return repo
187 if (!sessionName) return { repo, attachArgs: [] }
188 const worker = workersOf(await listRows($), repo.path).find(r => r.name === sessionName)
189 if (!worker) return `No session named "${sessionName}" in ${repo.path}.`
190 return { repo, attachArgs: ['-i', worker.sessionId] }
191}
192
193export type OpenArgs = { repo: string; session?: string; label?: string }
194
195// For the model: the user's screen stays where it is. Without a session the
196// window starts a new Claude session.
197export async function hubOpen($: EngineInterface, args: OpenArgs): Promise<string> {
198 const found = await target($, args.repo, args.session)
199 if (typeof found === 'string') return found
200 const attachArgs = args.session ? found.attachArgs : ['-n', ...(args.label ? [args.label] : [])]
201 return openDetached($, found.repo.path, attachArgs)
202}
203
204async function openDetached($: EngineInterface, path: string, attachArgs: string[]): Promise<string> {
205 const project = await $.process.run(['tmux-project', '-d', path])
206 if (project.exitCode !== 0) return `tmux-project failed: ${project.stderr.trim()}`
207 const session = project.stdout.trim()
208 if (!session) return `tmux-project -d printed no session name for ${path}.`
209 const attach = await $.process.run(['tmux-claude-attach', '-d', '-t', session, ...attachArgs])
210 if (attach.exitCode !== 0) return `tmux-claude-attach failed: ${attach.stderr.trim()}`
211 await refresh($)
212 return `${session} の window ${attach.stdout.trim()} に出した。入るには prefix S 等で切り替える。`
213}
214
215// For /open: switches this client to the session.
216async function open($: EngineInterface, query: string, sessionName?: string): Promise<string> {
217 const found = await target($, query, sessionName)
218 if (typeof found === 'string') return found
219 const { repo, attachArgs } = found
220 const project = await $.process.run(['tmux-project', repo.path])
221 if (project.exitCode !== 0) return `tmux-project failed: ${project.stderr.trim()}`
222 const ls = await $.process.run(['tmux', 'list-sessions', '-F', '#{session_name}\t#{session_path}'])
223 const session = parseRepos(ls.stdout).find(s => s.path === repo.path)?.name
224 if (!session) return `No tmux session has ${repo.path} as its directory.`
225 // The picker popup needs a client to show on: this pane's.
226 const client = (await $.process.run(['tmux', 'display-message', '-p', '#{client_name}'])).stdout.trim()
227 if (client) attachArgs.push('-c', client)
228 const attach = await $.process.run(['tmux-claude-attach', '-t', session, ...attachArgs])
229 if (attach.exitCode !== 0) return `tmux-claude-attach failed: ${attach.stderr.trim()}`
230 await refresh($)
231 return `Opened ${session} in tmux.`
232}
233
234function openPane($: EngineInterface) {
235 return $.ui.open({ id: PANE, title: 'Hub sessions', columns: PANE_COLUMNS })
236}
237
238function age(startedAt: number, now: number): string {
239 const min = Math.floor((now - startedAt) / 60_000)
240 return min < 60 ? `${min}m` : `${Math.floor(min / 60)}h${min % 60}m`
241}
242
243function short(path: string): string {
244 return path.replace(/^\/home\/[^/]+\/develop\//, '')
245}
246
247export const register: Register = on => {
248 on('session.start', async ($, e, next) => {
249 const started = await next(e)
250
251 await $.command.register({ name: 'hub', description: 'Open or close the hub sessions pane' })
252 await $.command.register({
253 name: 'dispatch',
254 description: 'Start a new session in a repository with a task: /dispatch [-n <label>] <repo> <task>',
255 })
256 await $.command.register({
257 name: 'send',
258 description: 'Send a task to an existing session: /send <session> <task>',
259 })
260 await $.command.register({ name: 'repos', description: 'List repositories and their sessions' })
261 await $.command.register({
262 name: 'open',
263 description: "Open a repository's tmux session and attach to a Claude session: /open <repo> [session]",
264 })
265
266 await $.tool.register({
267 name: 'hub_dispatch',
268 description:
269 'Hand a task to a background Claude Code session of a repository under ~/develop. ' +
270 'Without `session`, starts a new session in `repo` with `claude --bg` (a repository can ' +
271 'have several). With `session`, sends the task to that existing session, resuming it ' +
272 'when stopped. The task is sent with a request to reply to this hub by SendMessage. ' +
273 'With `open`, the session is then put in a tmux window as hub_open does. ' +
274 'hub_sessions lists the sessions and their names.',
275 inputSchema: {
276 type: 'object',
277 properties: {
278 task: { type: 'string', description: 'What the session should do' },
279 repo: {
280 type: 'string',
281 description: 'Repository name or absolute path to start a new session in',
282 },
283 session: {
284 type: 'string',
285 description: 'Name of an existing session to send to; omit to start a new one',
286 },
287 label: {
288 type: 'string',
289 description: "Short purpose for a new session's name (review → <repo>-review)",
290 },
291 open: {
292 type: 'boolean',
293 description: "Also put the session in a tmux window, without switching the user's screen",
294 },
295 },
296 required: ['task'],
297 },
298 isDeferred: false,
299 })
300 await $.tool.register({
301 name: 'hub_open',
302 description:
303 "Set up a repository's tmux session and a window with a Claude session in it, without " +
304 "switching the user's screen (`tmux-project -d`, then `tmux-claude-attach -d`). With " +
305 '`session`, the window attaches to that background session; without it, the window starts ' +
306 'a new Claude session named after `label`. Reports which tmux session and window to enter.',
307 inputSchema: {
308 type: 'object',
309 properties: {
310 repo: { type: 'string', description: 'Repository name or absolute path' },
311 session: {
312 type: 'string',
313 description: 'Name of a background session of the repository to attach to',
314 },
315 label: {
316 type: 'string',
317 description: 'Label for a new Claude session, used when `session` is omitted',
318 },
319 },
320 required: ['repo'],
321 },
322 isDeferred: false,
323 })
324 await $.tool.register({
325 name: 'hub_sessions',
326 description: 'List the active Claude Code sessions (`claude agents --json`) with repository paths.',
327 isDeferred: false,
328 })
329
330 await refresh($)
331 $.clock.every(POLL_MS, () => refresh($))
332 void openPane($)
333
334 return started
335 })
336
337 on('tool.call', { tool: 'mcp__session-hub__hub_dispatch' }, async ($, e: any) => {
338 const text = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
339 const args = {
340 task: String(e.task ?? ''),
341 repo: text(e.repo),
342 session: text(e.session),
343 label: text(e.label),
344 open: e.open === true,
345 }
346 return { result: await dispatch($, args) }
347 }).catch(($, e, next) => ({ deny: `session-hub: hub_dispatch failed: ${String(next.error)}` }))
348
349 on('tool.call', { tool: 'mcp__session-hub__hub_open' }, async ($, e: any) => {
350 const text = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
351 const args = { repo: String(e.repo ?? ''), session: text(e.session), label: text(e.label) }
352 return { result: await hubOpen($, args) }
353 }).catch(($, e, next) => ({ deny: `session-hub: hub_open failed: ${String(next.error)}` }))
354
355 on('tool.call', { tool: 'mcp__session-hub__hub_sessions' }, async $ => {
356 const rows = await listRows($)
357 await update($, sessions, () => rows)
358 // An array result is read as content blocks, so hand the rows over as text.
359 return { result: JSON.stringify(rows.map(r => ({ ...r, repo: short(r.cwd) })), null, 2) }
360 }).catch(($, e, next) => ({ deny: `session-hub: hub_sessions failed: ${String(next.error)}` }))
361
362 on('command.run', { command: 'hub' }, async $ => {
363 // The pane opened at session start waits undrawn on a narrow terminal;
364 // /hub then draws it rather than closing what the person never saw.
365 if ((await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)) {
366 await $.ui.close({ id: PANE })
367 return { text: 'Hub sessions pane closed.' }
368 }
369 await openPane($)
370 return { text: 'Hub sessions pane opened.' }
371 })
372
373 on('command.run', { command: 'dispatch' }, async ($, e) => {
374 const words = e.args.trim().split(/\s+/)
375 const label = words[0] === '-n' ? words.splice(0, 2)[1] : undefined
376 const [repo, ...rest] = words
377 const task = rest.join(' ')
378 if (!repo || !task) return { text: 'Usage: /dispatch [-n <label>] <repo> <task>' }
379 return { text: await dispatch($, { repo, task, label }) }
380 })
381
382 on('command.run', { command: 'send' }, async ($, e) => {
383 const [session, ...rest] = e.args.trim().split(/\s+/)
384 const task = rest.join(' ')
385 if (!session || !task) return { text: 'Usage: /send <session> <task>' }
386 return { text: await dispatch($, { session, task }) }
387 })
388
389 on('command.run', { command: 'open' }, async ($, e) => {
390 const [repo, session] = e.args.trim().split(/\s+/)
391 if (!repo) return { text: 'Usage: /open <repo> [session]' }
392 return { text: await open($, repo, session) }
393 })
394
395 on('command.run', { command: 'repos' }, async $ => {
396 const [repos, rows] = await Promise.all([listRepos($), listRows($)])
397 const lines = repos.map(r => {
398 const ws = workersOf(rows, r.path).map(w => `${w.name}[${label(w)}]`)
399 return ws.length === 0 ? short(r.path) : `${short(r.path)} ${ws.join(' ')}`
400 })
401 return { text: lines.join('\n') }
402 })
403
404 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
405 const { Box, Text } = $.ui.resolve(e)
406 const rows = (await read($, sessions)).filter(r => r.kind === 'background')
407 const err = await read($, error)
408 const now = Date.now()
409 const color = (r: AgentRow) =>
410 label(r) === 'working' ? 'yellow' : r.status === 'idle' ? 'green' : undefined
411
412 return (
413 <Box flexDirection="column">
414 {err !== '' && <Text color="red">{err}</Text>}
415 {rows.length === 0 && <Text dimColor>No background sessions.</Text>}
416 {rows.map(r => (
417 <Box flexDirection="column">
418 <Text color={color(r)} bold={!isStopped(r)} dimColor={isStopped(r)}>
419 {r.name} · {label(r)} · {age(r.startedAt, now)}
420 </Text>
421 <Text dimColor> {short(r.cwd)}</Text>
422 </Box>
423 ))}
424 </Box>
425 )
426 })
427}
428types/index.d.ts 21 lines1// One row of `claude agents --json`, the fields this mod reads.
2export type AgentRow = {
3 sessionId: string
4 id?: string | null
5 name: string
6 cwd: string
7 kind: string
8 status: string
9 state?: string | null
10 startedAt: number
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'session-hub': {
16 sessions: Shaped<AgentRow[]>
17 error: Shaped<string>
18 }
19 }
20}
21