Shows your other running sessions above the prompt and keeps a to-do list of finished chats, uncommitted work and commits that did not land

A Claude Code mod that puts a one-line bar above the prompt with your other running sessions, and keeps a to-do list of the chats you still need to get back to and the work you haven't committed. It works in the desktop app's Code tab and in the terminal.
I usually have several sessions going at once. I kept missing the ones that had finished while I was looking at something else, and commits that a pre-commit hook had quietly refused. This fixes both for me.
Each running session shows a mark, its name and how long ago it last changed. A red ● is waiting for you, a yellow ● is working (a background shell that is still running counts), and a green ✓ finished while you were in another chat. Click a name to open that chat. The 5-hour and weekly usage meters sit on the right and change colour at 70% and 90%.
After the names come two counters, hidden at zero: ☐ n for chats to come back to, and ⚠ n for work that isn't committed or a commit that didn't land. Clicking either opens the to-do list.
A session that finishes while you aren't watching it becomes a to-do. Opening the chat doesn't clear it. Pressing Done does, and so does carrying on in that chat or archiving it. A session left waiting on you for more than a day becomes a to-do too. Each item has Open, Done, Snooze 3 h and a snooze until 08:00.
Below that, Uncommitted lists the repos with work in them, newest first.
After any Bash or PowerShell command that runs git commit, the mod reads HEAD from the .git files. If the commit didn't land, you get a toast (at most one per repo every 10 minutes) and a ⚠ item that stays until a commit lands on that branch. A commit you make by hand counts, and so does a cherry-pick. A pull or a reset doesn't.
Files edited in a session that are still uncommitted 10 minutes after the turn ends add a ⚠ item as well. It clears once git reports all of those files clean or committed. The guard never blocks or changes a command.
A repo is known by its real path, so a junction or symlink to it is the same repo.
| Command | What it does |
|---|---|
/todo here | This chat shows the to-do list above the prompt instead of the bar (the last chat to ask wins) |
/todo off | No chat shows the list |
/todo | Prints the list (scans the repos first when no chat is showing it) |
/todo <text> | Adds your own to-do item |
/dirty | Scans the repos now and lists uncommitted or unpushed work, newest first |
If Claude Code refuses one of these names, the mod registers /hub-todo or /hub-dirty instead.
In any Claude Code chat (2.1.275 or later):
/plugin install session-hub --marketplace leikauskas/session-hub
Or run it from a clone. This loads it for one terminal session:
git clone https://github.com/leikauskas/session-hub
claude --plugin-dir ./session-hub
To load a clone everywhere, desktop chats included, put the folder's full path in CLAUDE_CODE_PLUGIN_DIRS, either in your environment or in the env block of ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/code/session-hub"
}
}
New sessions pick it up. Sessions that are already running don't.
I built and tested it on Windows. The macOS side (HOME, ~/Library/Application Support, ps, open) is in place but has had much less use, so if something looks off on a Mac, open an issue.
| Option | Default | Controls |
|---|---|---|
todo | true | The to-do list |
commitGuard | true | The commit guard and the Uncommitted section |
scanRoots | ~/dev, ~/code, ~/Documents | Folders whose direct subfolders are scanned for repos |
ignoreRepos | none | Repos that still show in /dirty but never become a to-do |
~ is your home folder, on Windows too.
It reads ~/.claude/sessions/*.json, the desktop app's session records (those changed in the last 48 hours or belonging to a running session), the process list (tasklist on Windows, ps elsewhere), the .git files of repos it checks, and git --no-optional-locks status with a 5-second timeout, which never rewrites the index.
It writes its own plugin store and state, and ~/.claude/mods/session-hub-usage.json, where sessions share the latest usage reading. It makes no network or model calls.
claude plugin validate .
claude plugin test .
The tests run against a small invented machine in tests/world.ts, so they never read or write real files.
Pure rules live in hooks/lib/ and never call the engine. File and process readers are in hooks/sources/, and the drawing is in hooks/ui/. hooks/register.tsx wires everything up, and types/index.d.ts is the state contract the drawing reads.
MIT
hooks/register.tsx 183 lines1// session-hub: the session bar above every prompt with its two chips (☐ open
2// to-dos, ⚠ work not committed), the to-do list in one chat, and the commit
3// guard. This file only wires events to the parts.
4
5import { atom, read } from 'claude-code'
6import type { EngineInterface, Register, ResolveInput } from 'claude-code'
7
8import type { HubTodo } from '../types'
9import { openList, runDirty, runTodo } from './commands'
10import { planCommit, settleCommit } from './commits'
11import { checkEdits, resetEdits, trackEdit } from './edits'
12import { tickRepos, tickStore } from './home'
13import { cancelTimers, hub, isIgnoredRepo, resetHub } from './hub'
14import { callFacts } from './lib/git'
15import { listModel } from './lib/list'
16import { readOptions } from './lib/options'
17import { DESKTOP, isPersonChat } from './lib/registry'
18import { openTarget } from './sources/open'
19import { findPlace } from './sources/place'
20import { readRegistry } from './sources/sessions'
21import { flushRev, loadNow, step } from './store-sync'
22import { markDone, snooze, tickSessions } from './todos'
23import { drawBar } from './ui/bar'
24import { drawList } from './ui/list'
25
26const SCAN_MS = 3000
27const STORE_MS = 10000
28const REPOS_MS = 5 * 60 * 1000
29
30// Atoms are declared in each file that reads or writes them (the engine
31// reads them off the source); these are read while drawing, so a change
32// redraws the bar, the list and the pane. hub.ts declares the same ones for writing.
33const rowsAtom = atom({ plugin: 'session-hub', key: 'rows' } as const, [])
34const nowAtom = atom({ plugin: 'session-hub', key: 'now' } as const, 0)
35const limitsAtom = atom({ plugin: 'session-hub', key: 'limits' } as const, [])
36const todosAtom = atom({ plugin: 'session-hub', key: 'todos' } as const, [])
37const reposAtom = atom({ plugin: 'session-hub', key: 'repos' } as const, [])
38const reposAtAtom = atom({ plugin: 'session-hub', key: 'reposAt' } as const, 0)
39const isHomeAtom = atom({ plugin: 'session-hub', key: 'isHome' } as const, false)
40
41const TODO_HELP = 'The to-do list: add an item, list them, or show the list here (here|off)'
42const DIRTY_HELP = 'Repos with uncommitted or unpushed work'
43
44// A name the engine refuses (a built-in's) falls back to its hub- twin.
45async function registerCommands($: EngineInterface) {
46 await $.command.register({ name: 'todo', description: TODO_HELP, argumentHint: '[text|here|off]' })
47 .catch(() => $.command.register({ name: 'hub-todo', description: TODO_HELP, argumentHint: '[text|here|off]' }))
48 .catch(() => undefined)
49 await $.command.register({ name: 'dirty', description: DIRTY_HELP })
50 .catch(() => $.command.register({ name: 'hub-dirty', description: DIRTY_HELP }))
51 .catch(() => undefined)
52}
53
54// What the start waits for: the commands, so they exist for the first turn.
55// Everything else runs from a timer, off the path of the first prompt.
56//
57// A person's chat (a terminal REPL, or a desktop chat: its process is
58// headless, but its own registry entry is an interactive one with a desktop
59// id) reads, settles and polls. A `-p` or SDK run keeps only the commit guard
60// and the turn-end edit record; so does a `claude -p` started from a desktop
61// chat's shell (the desktop entrypoint, no desktop id). The desktop may write
62// the entry, or its id, a moment after the start, so a non-interactive start
63// looks a few times before it decides.
64const SELF_TRIES = 6
65const SELF_RETRY_MS = 5000
66
67async function boot($: EngineInterface, isInteractive: boolean, attempt: number) {
68 hub.place ??= await findPlace($)
69 const place = hub.place
70 if (place === null) return
71 place.selfId = await $.session.id()
72 const self = (await readRegistry($, place, hub.sessions).catch(() => []))
73 .find(entry => entry.sessionId === place.selfId)
74 const isKnown = self !== undefined && (self.entrypoint !== DESKTOP || self.hostSessionId !== undefined)
75 if (!isInteractive && !isKnown && attempt < SELF_TRIES) {
76 hub.timers.push($.clock.after(SELF_RETRY_MS, () => void boot($, isInteractive, attempt + 1).catch(() => undefined)))
77
78 return
79 }
80 hub.isPerson = isInteractive || isPersonChat(self)
81 await isIgnoredRepo($, '')
82 if (!hub.isPerson) return
83 // The stored items first, so the first detection writes nothing it has;
84 // then sessions, so this chat knows its own desktop id before the home.
85 await step($, 'store', () => loadNow($))
86 await step($, 'sessions', () => tickSessions($))
87 await step($, 'store', () => tickStore($))
88 hub.timers.push($.clock.every(SCAN_MS, () => void step($, 'sessions', () => tickSessions($))))
89 hub.timers.push($.clock.every(STORE_MS, () => void step($, 'store', () => tickStore($))))
90 if (hub.options.commitGuard) hub.timers.push($.clock.every(REPOS_MS, () => void step($, 'repos', () => tickRepos($))))
91}
92
93const counts = (todos: readonly HubTodo[]) => ({ todo: todos.length, risk: todos.filter(todo => todo.isRisk).length })
94
95function actions($: EngineInterface) {
96 const isWindows = hub.place?.isWindows ?? true
97 const saved = (work: Promise<void>) => void work.catch(() => $.ui.toast('Could not save'))
98
99 return {
100 openSession: (link: string) => void openTarget($, link, isWindows),
101 openList: () => void openList($, isWindows),
102 done: (todo: HubTodo) => saved(markDone($, todo)),
103 snooze: (todo: HubTodo) => saved(snooze($, todo)),
104 }
105}
106
107async function drawListFromState($: EngineInterface, e: ResolveInput) {
108 const model = listModel({
109 todos: await read($, todosAtom),
110 repos: await read($, reposAtom),
111 reposAt: await read($, reposAtAtom),
112 now: await read($, nowAtom),
113 })
114
115 return drawList($, e, model, actions($))
116}
117
118export const register: Register = (on, options) => {
119 resetHub(readOptions(options))
120 resetEdits()
121
122 on('session.start', async ($, e, next) => {
123 const started = await next(e)
124 cancelTimers()
125 await registerCommands($).catch(() => undefined)
126 hub.timers.push($.clock.after(0, () => void boot($, e.isInteractive, 1).catch(() => undefined)))
127
128 return started
129 })
130
131 // The edit check runs from a timer, off the turn's path.
132 on('turn.complete', async ($, e, next) => {
133 const completed = await next(e)
134 if (e.agentId === undefined && hub.options.commitGuard) {
135 $.clock.after(0, () => void step($, 'edits', () => checkEdits($)))
136 }
137
138 return completed
139 }).catch(($, e, next) => next(e))
140
141 // Observes only: always hands back what the tool returned, at once. Before
142 // the command it reads HEAD from the .git files; the verdict comes later.
143 on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
144 const plan = hub.options.commitGuard ? await planCommit($, e).catch(() => null) : null
145 const ran = await next(e)
146 if (plan !== null) {
147 const facts = callFacts(ran, e)
148 $.clock.after(0, () => void settleCommit($, plan, facts).catch(() => undefined).then(() => flushRev($)).catch(() => undefined))
149 }
150
151 return ran
152 }).catch(($, e, next) => next(e))
153
154 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
155 const ran = await next(e)
156 if (hub.options.commitGuard && ran.deny === undefined && ran.isError !== true) {
157 trackEdit(e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path, await $.clock.now())
158 }
159
160 return ran
161 }).catch(($, e, next) => next(e))
162
163 on('command.run', { command: ['todo', 'hub-todo'] }, ($, e) => runTodo($, e.args))
164 .catch(() => ({ text: 'Could not read the to-do list. Try again.' }))
165 on('command.run', { command: ['dirty', 'hub-dirty'] }, $ => runDirty($))
166 .catch(() => ({ text: 'Could not read the repos. Try again.' }))
167
168 // The list chat shows the list; every other chat the bar, or nothing.
169 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170 if (e.props.hasSurvey) return next(e)
171 if (await read($, isHomeAtom)) return drawListFromState($, e)
172 const rows = await read($, rowsAtom)
173 const limits = await read($, limitsAtom)
174 const { todo, risk } = counts(await read($, todosAtom))
175 if (rows.length === 0 && limits.length === 0 && todo === 0) return next(e)
176
177 return drawBar($, e, { rows, todo, risk, limits, now: await read($, nowAtom) }, actions($))
178 })
179
180 // The list as a pane: the id is commands.ts LIST_PANE, spelled here so the engine reads it off the source.
181 on('ui.render', { component: 'Pane', requestId: 'todo' }, ($, e) => drawListFromState($, e))
182}
183hooks/commands.ts 83 lines1// /todo and /dirty. `/todo here` makes this chat show the to-do list above
2// the prompt, `/todo off` stops it, `/todo` answers the list as text, and
3// `/todo <text>` adds an item.
4
5import type { EngineInterface } from 'claude-code'
6
7import { listChatLink, refreshRepos, setHome } from './home'
8import { hub, openItems, publish } from './hub'
9import { isHomeAlive } from './lib/home'
10import { dirtyText, listModel, listText } from './lib/list'
11import { manualItem } from './lib/todo'
12import { openTarget } from './sources/open'
13import { loadHome } from './sources/store'
14import { flushRev, loadNow } from './store-sync'
15import { addItem } from './todos'
16
17export const LIST_PANE = 'todo'
18
19const countsText = () => {
20 const todos = hub.view.todos
21 const risk = todos.filter(todo => todo.isRisk).length
22
23 return [`☐ ${todos.length}`, risk > 0 ? `⚠ ${risk}` : null].filter(Boolean).join(' · ')
24}
25
26// From the ☐ and ⚠ chips: the running chat that shows the list, else the
27// list as a pane; a pane the surface will not place shows the counts instead.
28export async function openList($: EngineInterface, isWindows: boolean) {
29 const link = listChatLink()
30 if (link !== null) {
31 await openTarget($, link, isWindows)
32
33 return
34 }
35 const opened = await $.ui.open({ id: LIST_PANE, title: 'To do' }).catch(() => null)
36 if (opened === null || !opened.isPlaced) $.ui.toast(countsText())
37}
38
39// The list as the store has it now (a `-p` run never loads otherwise); with
40// no running list chat anywhere, the repo scan runs on demand.
41async function currentList($: EngineInterface, now: number) {
42 await loadNow($)
43 const home = await loadHome($).catch(() => null)
44 if (!isHomeAlive(home, hub.liveIds) && hub.options.commitGuard) await refreshRepos($).catch(() => undefined)
45 await flushRev($).catch(() => undefined)
46 await publish($, { todos: openItems(now) })
47
48 return listModel({ todos: hub.view.todos, repos: hub.view.repos, reposAt: hub.view.reposAt, now })
49}
50
51export async function runTodo($: EngineInterface, args: string) {
52 const text = args.trim()
53 const word = text.toLowerCase()
54 if (word === 'here') {
55 await setHome($, true)
56 if (hub.options.commitGuard) await refreshRepos($).catch(() => undefined)
57 await flushRev($).catch(() => undefined)
58
59 return { text: 'To-do list: this chat' }
60 }
61 if (word === 'off') {
62 await setHome($, false)
63
64 return { text: 'To-do list: no chat' }
65 }
66 if (!hub.options.todo) return { text: 'To-do list is off' }
67 const now = await $.clock.now()
68 if (text === '') return { text: listText(await currentList($, now)) }
69 const item = manualItem(text, now, Math.random().toString(36).slice(2, 8))
70 await addItem($, item, now)
71 await flushRev($)
72
73 return { text: `☐ ${item.title}` }
74}
75
76export async function runDirty($: EngineInterface) {
77 if (!hub.options.commitGuard) return { text: 'Commit guard is off' }
78 await refreshRepos($).catch(() => undefined)
79 await flushRev($).catch(() => undefined)
80
81 return { text: dirtyText(hub.view.repos, await $.clock.now()) }
82}
83hooks/commits.ts 85 lines1// Commit verification around a Bash or PowerShell call that runs `git commit`.
2// Before the command: HEAD from the .git files (no git process). After it,
3// off the tool's path: the engine's own commit record, or HEAD again. It only
4// observes; anything it cannot resolve gets no verdict at all.
5
6import type { EngineInterface } from 'claude-code'
7
8import { hub, linkOfSelf } from './hub'
9import { readableHead } from './lib/checks'
10import { commitDir, commitOutcome, findCommit } from './lib/git'
11import type { CallFacts } from './lib/git'
12import type { GitDirs, Head } from './lib/gitfs'
13import { basename, repoKey } from './lib/paths'
14import { commitFailed, commitLanded } from './repo-checks'
15import { findRepo, headOf } from './sources/gitfs'
16import { putPending } from './store-sync'
17
18// Claude often fails, fixes and retries in one turn: one toast per repo per
19// 10 minutes; the item carries every attempt.
20const TOAST_GAP_MS = 10 * 60 * 1000
21
22// `ref`: the branch the commit is judged on (a `checkout -b` names a new one).
23export type CommitPlan = { dirs: GitDirs; before: Head; ref: string | null; movesHead: boolean }
24
25type ShellCall = { command: string }
26
27// A folder spelled through a variable, `~` or a popd, or a git dir with no
28// work tree, is unknown; so is a reftable repo: no verdict.
29export async function planCommit($: EngineInterface, e: ShellCall): Promise<CommitPlan | null> {
30 const call = findCommit(e.command)
31 if (call === null || !call.isDirKnown) return null
32 const dirs = await findRepo($, commitDir(await $.session.cwd(), call), hub.realPaths)
33 if (dirs === null) return null
34 const head = await headOf($, dirs)
35 if (head === null) return null
36
37 return {
38 dirs,
39 before: head,
40 ref: call.newBranch === null ? head.ref : `refs/heads/${call.newBranch}`,
41 movesHead: call.movesHead,
42 }
43}
44
45function toastFailure($: EngineInterface, repo: string, detail: string | null, at: number) {
46 const key = repoKey(repo)
47 if (at - (hub.toastAt.get(key) ?? Number.NEGATIVE_INFINITY) < TOAST_GAP_MS) return
48 hub.toastAt.set(key, at)
49 const name = basename(repo)
50 $.ui.toast(detail === null ? `Commit did not land · ${name}` : `Commit did not land · ${name}\n${detail}`)
51}
52
53// The engine's record wins. Otherwise HEAD decides, and only on the branch the
54// commit runs on, in a command that moved HEAD no other way; a refusal or an
55// interruption counts only when HEAD did not move.
56export async function settleCommit($: EngineInterface, plan: CommitPlan, facts: CallFacts) {
57 if (facts.isDenied) return
58 const repo = plan.dirs.root
59 const at = await $.clock.now()
60 if (facts.engineCommit !== null) {
61 await commitLanded($, repo, at)
62
63 return
64 }
65 if (plan.movesHead) return
66 // Still running: any chat settles it from the files later; no toast now.
67 if (facts.isBackgrounded) {
68 await putPending($, { repo, headBefore: plan.before.sha, refBefore: plan.ref, startedAt: at })
69
70 return
71 }
72 const after = readableHead(plan.before.sha, await headOf($, plan.dirs))
73 if (after === null || after.ref !== plan.ref) return
74 if (after.sha !== plan.before.sha) {
75 await commitLanded($, repo, at)
76
77 return
78 }
79 if (facts.isRefused || facts.isInterrupted) return
80 const { verdict, detail } = commitOutcome(plan.before.sha, after.sha, facts.output)
81 if (verdict !== 'failed') return
82 toastFailure($, repo, detail, at)
83 await commitFailed($, repo, at, detail, linkOfSelf(), after)
84}
85hooks/edits.ts 97 lines1// Session edits: paths this session's Edit, Write and NotebookEdit calls
2// changed, with when. At the end of a turn, the repos where some are still
3// uncommitted get a dirty check in $.store; any chat settles it 10 minutes
4// later (lib/checks.ts, repo-checks.ts), even after this chat has closed.
5// Each path is kept as the file's real path inside the repo's real root, so a
6// file edited through a junction, or spelled in another letter case, is the
7// file git knows.
8
9import type { EngineInterface } from 'claude-code'
10
11import { hub, isIgnoredRepo, linkOfSelf } from './hub'
12import { dirtyCheckId, mergeCheck } from './lib/checks'
13import { basename, joinPath, repoKey } from './lib/paths'
14import { askDirtyFiles } from './repo-checks'
15import { findRepo, realPathOf } from './sources/gitfs'
16import { loadCheck } from './sources/store'
17import { putCheck } from './store-sync'
18
19const edits = {
20 // Path → when this session last changed it.
21 paths: new Map<string, number>(),
22 // Folder → its real path and repo root (null: not in a repo, as of `at`;
23 // an edit made after that is looked at again, as after a `git init`).
24 folders: new Map<string, { real: string; repo: string | null; at: number }>(),
25}
26
27export const resetEdits = () => {
28 Object.assign(edits, { paths: new Map(), folders: new Map() })
29}
30
31export const trackEdit = (path: string, at: number) => {
32 if (path !== '' && hub.options.todo) edits.paths.set(path, at)
33}
34
35const folderOf = (path: string) => path.replace(/[\\/][^\\/]*$/, '') || path
36
37async function folderInfo($: EngineInterface, folder: string, editedAt: number, at: number) {
38 const known = edits.folders.get(folder)
39 if (known !== undefined && (known.repo !== null || known.at >= editedAt)) return known
40 const real = await realPathOf($, folder, hub.realPaths)
41 const info = { real, repo: (await findRepo($, real, hub.realPaths))?.root ?? null, at }
42 edits.folders.set(folder, info)
43
44 return info
45}
46
47// The file's own real path (its letter case on disk), else its folder's.
48async function realFile($: EngineInterface, path: string, folder: string) {
49 const stat = await $.fs.stat(path, { resolve: true }).catch(() => null)
50
51 return stat?.realPath ?? joinPath(folder, basename(path))
52}
53
54type Group = { repo: string; paths: string[]; spelled: string[]; lastEditAt: number }
55
56async function groupByRepo($: EngineInterface, at: number) {
57 const groups = new Map<string, Group>()
58 for (const [path, editedAt] of edits.paths) {
59 const { real, repo } = await folderInfo($, folderOf(path), editedAt, at)
60 if (repo === null) {
61 edits.paths.delete(path)
62 continue
63 }
64 const group = groups.get(repoKey(repo)) ?? { repo, paths: [], spelled: [], lastEditAt: 0 }
65 group.paths.push(await realFile($, path, real))
66 group.spelled.push(path)
67 group.lastEditAt = Math.max(group.lastEditAt, editedAt)
68 groups.set(repoKey(repo), group)
69 }
70
71 return groups
72}
73
74// Run off the turn's path, from a timer the turn.complete hook sets. git is
75// asked through the one routine (which also cuts down the repo's dirty item);
76// the check takes the files still uncommitted, merged with the stored check
77// read fresh. A repo git cannot answer for still gets its check, which backs
78// off and gives up only after a day. Ignored repos get none.
79export async function checkEdits($: EngineInterface) {
80 if (edits.paths.size === 0 || !hub.options.todo) return
81 const at = await $.clock.now()
82 const owner = hub.place?.selfId ?? (await $.session.id())
83 for (const group of (await groupByRepo($, at)).values()) {
84 for (const path of group.spelled) edits.paths.delete(path)
85 if (await isIgnoredRepo($, group.repo)) continue
86 const dirty = await askDirtyFiles($, group.repo, group.paths)
87 const isDirty = new Set((dirty ?? []).map(path => path.toLowerCase()))
88 const paths = dirty === null ? group.paths : group.paths.filter(path => isDirty.has(path.toLowerCase()))
89 if (paths.length === 0) continue
90 const id = dirtyCheckId(group.repo)
91 const link = linkOfSelf()
92 const next = { repo: group.repo, paths, lastEditAt: group.lastEditAt, at, owner, ...(link === null ? {} : { link }) }
93 const stored = (await loadCheck($, id).catch(() => null)) ?? hub.checks.get(id)
94 await putCheck($, id, mergeCheck(stored, next))
95 }
96}
97hooks/home.ts 114 lines1// The list chat (the home: the chat that shows the to-do list), the repo
2// scan, and the store tick that settles what any person's chat may settle.
3
4import type { EngineInterface } from 'claude-code'
5
6import { hub, isIgnoredRepo, openItems, publish } from './hub'
7import { isHomeAlive, isHomeChat } from './lib/home'
8import type { Home } from './lib/home'
9import { expandHome, repoKey } from './lib/paths'
10import { linkOf } from './lib/sessions'
11import { askDirtyFiles, CHECKS_AT_ONCE, settleChecks, settleCommitItems, settlePending } from './repo-checks'
12import { pool } from './sources/git'
13import { scanRepos } from './sources/repos'
14import { findPlace } from './sources/place'
15import { loadHome, loadScan, saveHome, saveScan } from './sources/store'
16import { reloadIfMoved, step } from './store-sync'
17import { prunePass } from './todos'
18
19const SCAN_READ_MS = 60000
20
21async function homePass($: EngineInterface, at: number) {
22 const place = hub.place
23 if (place === null) return
24 const home = await loadHome($)
25 const wasHome = hub.isHome
26 hub.home = home
27 hub.isHome = isHomeChat(home, place.selfId, hub.selfHost)
28 // This chat is home under new ids (after /clear or a restart): record them.
29 const self: Home = { sessionId: place.selfId, hostSessionId: hub.selfHost, pid: hub.selfPid }
30 const isStale = home !== null && (home.sessionId !== self.sessionId ||
31 home.hostSessionId !== self.hostSessionId || home.pid !== self.pid)
32 if (hub.isHome && isStale && (self.hostSessionId !== null || home?.hostSessionId === null)) {
33 await saveHome($, self, at)
34 hub.home = self
35 }
36 await publish($, { isHome: hub.isHome })
37 // Another chat's scan changes every 5 minutes: a look once a minute will do.
38 if (!hub.isHome && at - hub.scanReadAt >= SCAN_READ_MS) {
39 hub.scanReadAt = at
40 const scan = await loadScan($)
41 if (scan !== null) await publish($, { repos: scan.repos, reposAt: scan.at })
42 }
43 // Just became home, restarted as home, or made home before this chat's
44 // start had run: scan now, not in 5 minutes.
45 if (hub.isHome && (!wasHome || !hub.hasScanned) && hub.options.commitGuard) void step($, 'repos', () => refreshRepos($))
46}
47
48// Every 10 s in a person's chat. Commit items are read in the home chat only
49// (elsewhere on demand, with the scan).
50export async function tickStore($: EngineInterface) {
51 const at = await $.clock.now()
52 await reloadIfMoved($)
53 await prunePass($, at)
54 await homePass($, at)
55 if (hub.options.commitGuard) {
56 await settlePending($, at)
57 if (hub.isHome) await settleCommitItems($)
58 await settleChecks($, at)
59 }
60 await publish($, { todos: openItems(at) })
61}
62
63// The repo scan: in the home chat every 5 minutes, elsewhere on demand. It
64// shows repos and saves the snapshot; for a repo holding a dirty item it
65// asks git about that item's own files, and judges nothing from its listing.
66// A headless run only shows.
67export async function refreshRepos($: EngineInterface) {
68 hub.place ??= await findPlace($)
69 const place = hub.place
70 if (place === null) return
71 await isIgnoredRepo($, '')
72 const itemRepos = [...hub.items.values()].flatMap(item => (item.repo === undefined ? [] : [item.repo]))
73 const repos = await scanRepos($, {
74 roots: hub.options.scanRoots.map(root => expandHome(root, place.home, place.sep)),
75 extra: itemRepos,
76 ignored: hub.ignored ?? new Set(),
77 itemRepos: new Set(itemRepos.map(repoKey)),
78 sep: place.sep,
79 realPaths: hub.realPaths,
80 })
81 const at = await $.clock.now()
82 await publish($, { repos, reposAt: at })
83 if (!hub.isPerson) return
84 await saveScan($, { at, repos }).catch(() => undefined)
85 hub.hasScanned = true
86 const dirty = [...hub.items.values()].flatMap(item => (item.kind === 'dirty' && item.repo !== undefined ? [item.repo] : []))
87 await pool(dirty.map(repo => () => askDirtyFiles($, repo)), CHECKS_AT_ONCE)
88 if (!hub.isHome) await settleCommitItems($)
89 await publish($, { todos: openItems(at) })
90}
91
92export async function tickRepos($: EngineInterface) {
93 if (hub.isHome) await refreshRepos($)
94}
95
96// /todo here (the last one wins) and /todo off.
97export async function setHome($: EngineInterface, isOn: boolean) {
98 const at = await $.clock.now()
99 const sessionId = hub.place?.selfId ?? (await $.session.id())
100 const home: Home = { sessionId, hostSessionId: hub.selfHost, pid: hub.selfPid }
101 await saveHome($, isOn ? home : null, at)
102 hub.home = isOn ? home : null
103 hub.isHome = isOn
104 await publish($, { isHome: isOn })
105}
106
107// The running chat that shows the list, when it is another one: its link.
108export const listChatLink = () => {
109 const home = hub.home
110 if (hub.isHome || home === null || home.hostSessionId === null || !isHomeAlive(home, hub.liveIds)) return null
111
112 return linkOf(home.hostSessionId)
113}
114hooks/hub.ts 135 lines1// The module's working memory and what $.state last received. A hot reload
2// starts this over; the host's $.state and the plugin's $.store remain.
3// The steps that keep it current: store-sync.ts (the store), todos.ts
4// (sessions and to-dos), repo-checks.ts (git) and home.ts (home and scan).
5
6import { atom, update } from 'claude-code'
7import type { EngineInterface, PluginState, Timer } from 'claude-code'
8
9import type { HubRow, HubTodo } from '../types'
10import type { DirtyCheck, PendingCommit } from './lib/checks'
11import type { Home } from './lib/home'
12import type { Options } from './lib/options'
13import { DEFAULTS } from './lib/options'
14import { expandHome, repoKey } from './lib/paths'
15import type { Facts, Finish } from './lib/sessions'
16import { openTodos } from './lib/todo'
17import type { TodoItem, TodoState } from './lib/todo'
18import { realPathOf } from './sources/gitfs'
19import { findPlace } from './sources/place'
20import type { Place } from './sources/place'
21import { newSessionCache } from './sources/sessions'
22import { newUsageCache } from './sources/usage'
23
24export type View = PluginState['session-hub']
25
26const rowsAtom = atom({ plugin: 'session-hub', key: 'rows' } as const, [])
27const nowAtom = atom({ plugin: 'session-hub', key: 'now' } as const, 0)
28const limitsAtom = atom({ plugin: 'session-hub', key: 'limits' } as const, [])
29const todosAtom = atom({ plugin: 'session-hub', key: 'todos' } as const, [])
30const reposAtom = atom({ plugin: 'session-hub', key: 'repos' } as const, [])
31const reposAtAtom = atom({ plugin: 'session-hub', key: 'reposAt' } as const, 0)
32const isHomeAtom = atom({ plugin: 'session-hub', key: 'isHome' } as const, false)
33
34// No revision is ever this: the first poll always loads.
35export const NEVER_LOADED = '\u0000'
36
37const emptyView = (): View => ({
38 rows: [], now: 0, limits: [], todos: [], repos: [], reposAt: 0, isHome: false,
39})
40
41const fresh = (options: Options) => ({
42 options,
43 place: null as Place | null,
44 selfHost: null as string | null,
45 selfPid: null as number | null,
46 isHome: false,
47 // A person's chat (desktop or terminal) detects, settles and polls; a
48 // headless or SDK run keeps only the commit guard and its edit record.
49 isPerson: false,
50 sessions: newSessionCache(),
51 usage: newUsageCache(),
52 // Spelling → real path; the ignore list as repoKeys of real paths.
53 realPaths: new Map<string, string>(),
54 ignored: null as Set<string> | null,
55 items: new Map<string, TodoItem>(),
56 states: new Map<string, TodoState>(),
57 pending: new Map<string, PendingCommit>(),
58 checks: new Map<string, DirtyCheck>(),
59 facts: new Map<string, Facts>(),
60 // The sessions the bar names.
61 rows: [] as HubRow[],
62 finishes: [] as Finish[],
63 // Session ids and desktop ids of running sessions; ids of those in a turn.
64 liveIds: new Set<string>(),
65 busyIds: new Set<string>(),
66 home: null as Home | null,
67 // Store sync (store-sync.ts): the revision last loaded, or null while this
68 // chat's landed writes owe the revision a bump; and its writes under way.
69 seenRev: NEVER_LOADED as string | null,
70 inFlight: 0,
71 // When this chat last read another chat's repo scan; whether it saved one.
72 scanReadAt: 0,
73 hasScanned: false,
74 // repoKey → when a failed commit there last raised a toast.
75 toastAt: new Map<string, number>(),
76 // What $.state last received, for the commands' text and change checks.
77 view: emptyView(),
78 written: new Map<string, string>(),
79 running: new Set<string>(),
80 timers: [] as Timer[],
81})
82
83export const hub = fresh(DEFAULTS)
84
85export const cancelTimers = () => {
86 for (const timer of hub.timers) timer.cancel()
87 hub.timers = []
88}
89
90export const resetHub = (options: Options) => {
91 cancelTimers()
92 Object.assign(hub, fresh(options))
93}
94
95// Writes the changed values of `patch` to $.state; an unchanged value is
96// skipped, so the drawings redraw only on a change.
97export async function publish($: EngineInterface, patch: Partial<View>) {
98 for (const [name, value] of Object.entries(patch)) {
99 const json = JSON.stringify(value)
100 if (hub.written.get(name) === json) continue
101 switch (name) {
102 case 'rows': await update($, rowsAtom, () => value as View['rows']); break
103 case 'now': await update($, nowAtom, () => value as number); break
104 case 'limits': await update($, limitsAtom, () => value as View['limits']); break
105 case 'todos': await update($, todosAtom, () => value as View['todos']); break
106 case 'repos': await update($, reposAtom, () => value as View['repos']); break
107 case 'reposAt': await update($, reposAtAtom, () => value as number); break
108 case 'isHome': await update($, isHomeAtom, () => value as boolean); break
109 default: continue
110 }
111 hub.written.set(name, json)
112 hub.view = { ...hub.view, [name]: value }
113 }
114}
115
116export const openItems = (at: number): HubTodo[] => (hub.options.todo ? openTodos(hub.items, hub.states, at) : [])
117
118export const linkOfSelf = () => (hub.selfHost === null ? null : `claude://claude.ai/epitaxy/${hub.selfHost}`)
119
120// The ignore list's real paths (`~` is the home folder), resolved once.
121export async function isIgnoredRepo($: EngineInterface, repo: string) {
122 if (hub.ignored === null) {
123 hub.place ??= await findPlace($)
124 const home = hub.place?.home ?? null
125 const sep = hub.place?.sep ?? '/'
126 const keys = new Set<string>()
127 for (const path of hub.options.ignoreRepos) {
128 keys.add(repoKey(await realPathOf($, expandHome(path, home, sep), hub.realPaths)))
129 }
130 hub.ignored = keys
131 }
132
133 return hub.ignored.has(repoKey(repo))
134}
135hooks/lib/git.ts 303 lines1// Reading git commits out of Bash and PowerShell command lines, reading a
2// tool result's facts, and deciding whether a commit landed. No processes:
3// the adapters read HEAD and pass the results in.
4
5import { joinPath } from './paths'
6
7export type CommitCall = {
8 // cd / Set-Location / Push-Location targets and -C dirs, in the order they apply.
9 dirs: string[]
10 isAmend: boolean
11 // False when a target is a variable, `~`, `-`, missing, or a popd: the
12 // folder the commit ran in is then unknown, and nothing is judged.
13 isDirKnown: boolean
14 // The command also checks out, resets, pulls...: a moved HEAD proves nothing.
15 movesHead: boolean
16 // `checkout -b <name>` / `switch -c <name>` (no start point) before the
17 // commit: a new branch at the same commit, where the commit is then judged.
18 newBranch: string | null
19}
20
21export type CommitVerdict = 'landed' | 'nothing' | 'failed'
22
23const SEPARATORS = new Set([';', '|', '&', '\n', '{', '}'])
24
25// Splits on && || ; | & braces and newlines that stand outside quotes, so a
26// PowerShell `if ($?) { git commit }` block is a segment of its own.
27const segments = (command: string) => {
28 const found: string[] = []
29 let current = ''
30 let quote: '"' | "'" | null = null
31 for (let i = 0; i < command.length; i += 1) {
32 const char = command[i]!
33 if (quote !== null) {
34 current += char
35 if (char === quote) quote = null
36 continue
37 }
38 if (char === '"' || char === "'") {
39 quote = char
40 current += char
41 continue
42 }
43 if (SEPARATORS.has(char)) {
44 found.push(current)
45 current = ''
46 if ((char === '&' || char === '|') && command[i + 1] === char) i += 1
47 continue
48 }
49 current += char
50 }
51 found.push(current)
52
53 return found
54}
55
56// Words of one segment, quotes removed; a quoted part never splits.
57const words = (segment: string) => {
58 const list: string[] = []
59 let current = ''
60 let hasWord = false
61 let quote: '"' | "'" | null = null
62 for (const char of segment) {
63 if (quote !== null) {
64 if (char === quote) quote = null
65 else current += char
66 continue
67 }
68 if (char === '"' || char === "'") {
69 quote = char
70 hasWord = true
71 continue
72 }
73 if (/\s/.test(char)) {
74 if (hasWord) list.push(current)
75 current = ''
76 hasWord = false
77 continue
78 }
79 current += char
80 hasWord = true
81 }
82 if (hasWord) list.push(current)
83
84 return list
85}
86
87const CD = new Set(['cd', 'chdir', 'pushd', 'set-location', 'sl', 'push-location'])
88const POP = new Set(['popd', 'pop-location'])
89const CD_FLAGS = new Set(['-path', '-literalpath', '/d', '-l', '-p', '--'])
90// Words that may stand before the command itself.
91const LEADING = new Set(['&', '!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', 'time', 'command', 'exec'])
92const GIT = /(^|[\\/])git(\.exe)?$/i
93const ENV_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/
94// A target spelled through a variable, the home folder or the previous folder.
95const UNRESOLVABLE = /[$%`]|^~|^-$/
96
97// The command's words, after leading keywords and VAR=value prefixes, and
98// those prefixes (GIT_DIR and GIT_WORK_TREE name another repo).
99const commandWords = (segment: string) => {
100 const list = words(segment.replace(/^\s*[({]+/, '').replace(/[)}]+\s*$/, ''))
101 const env = new Map<string, string>()
102 while (list.length > 0) {
103 const first = list[0]!
104 const assignment = ENV_ASSIGNMENT.exec(first)
105 if (assignment !== null) env.set(assignment[1]!, assignment[2]!)
106 else if (!LEADING.has(first.toLowerCase())) break
107 list.shift()
108 }
109
110 return { list, env }
111}
112
113// The folder a cd goes to; null when it cannot be known.
114const cdTarget = (list: readonly string[]) => {
115 const target = list.slice(1).find(word => !CD_FLAGS.has(word.toLowerCase()))
116
117 return target === undefined || UNRESOLVABLE.test(target) ? null : target
118}
119
120// Global options that take their value as the next word (or after `=`).
121const VALUE_OPTIONS = new Set(['-c', '--namespace', '--exec-path', '--config-env'])
122// Verbs that move HEAD without a commit: with one in the command, a moved
123// HEAD says nothing about the commit.
124const MOVES_HEAD = new Set(['checkout', 'switch', 'reset', 'rebase', 'merge', 'pull', 'cherry-pick', 'revert', 'stash', 'am'])
125
126const NEW_BRANCH_FLAGS: Readonly<Record<string, readonly string[]>> = {
127 checkout: ['-b', '-B'],
128 switch: ['-c', '-C', '--create', '--force-create'],
129}
130const QUIET = new Set(['-q', '--quiet'])
131
132// The branch a `checkout -b X` / `switch -c X` creates at HEAD; null for any
133// other form (a start point, a plain checkout, other options).
134const newBranchOf = (verb: string | undefined, rest: readonly string[]) => {
135 const flags = NEW_BRANCH_FLAGS[verb ?? '']
136 const at = rest.findIndex(word => flags?.includes(word) === true)
137 const name = at < 0 ? undefined : rest[at + 1]
138 const others = rest.filter((_, index) => index !== at && index !== at + 1)
139
140 return name === undefined || name.startsWith('-') || UNRESOLVABLE.test(name) || !others.every(word => QUIET.has(word))
141 ? null
142 : name
143}
144
145// git's global options, the verb, and the folder it works in. A git folder
146// named without a work tree (`--git-dir`, `GIT_DIR`) is another repo: unknown.
147const gitCall = (list: readonly string[], env: ReadonlyMap<string, string>) => {
148 const dirs: string[] = []
149 let isDirKnown = true
150 let gitDir = env.get('GIT_DIR') ?? null
151 let workTree = env.get('GIT_WORK_TREE') ?? null
152 let i = 1
153 while (i < list.length && list[i]!.startsWith('-')) {
154 const option = list[i]!
155 const cut = option.indexOf('=')
156 const name = cut < 0 ? option : option.slice(0, cut)
157 const inline = cut < 0 ? undefined : option.slice(cut + 1)
158 if (name === '-C') {
159 const dir = list[i + 1]
160 if (dir === undefined || UNRESOLVABLE.test(dir)) isDirKnown = false
161 else dirs.push(dir)
162 i += 2
163 } else if (name === '--git-dir' || name === '--work-tree') {
164 const value = inline ?? list[i + 1] ?? ''
165 if (name === '--git-dir') gitDir = value
166 else workTree = value
167 i += inline === undefined ? 2 : 1
168 } else {
169 i += VALUE_OPTIONS.has(name) && inline === undefined ? 2 : 1
170 }
171 }
172 if (workTree !== null) {
173 if (workTree === '' || UNRESOLVABLE.test(workTree)) isDirKnown = false
174 else dirs.push(workTree)
175 } else if (gitDir !== null) {
176 isDirKnown = false
177 }
178
179 return { verb: list[i], rest: list.slice(i + 1), dirs, isDirKnown }
180}
181
182// The first `git commit` the command runs, the directories it runs in, and
183// whether the command moves HEAD some other way too.
184export const findCommit = (command: string): CommitCall | null => {
185 const cds: string[] = []
186 let isDirKnown = true
187 let movesHead = false
188 let newBranch: string | null = null
189 let found: Omit<CommitCall, 'movesHead' | 'newBranch'> | null = null
190 for (const segment of segments(command)) {
191 const { list, env } = commandWords(segment)
192 const head = list[0]?.toLowerCase()
193 if (head === undefined) continue
194 if (CD.has(head) && found === null) {
195 const target = cdTarget(list)
196 if (target === null) isDirKnown = false
197 else cds.push(target)
198 continue
199 }
200 if (POP.has(head) && found === null) {
201 isDirKnown = false
202 continue
203 }
204 if (!GIT.test(head)) continue
205 const call = gitCall(list, env)
206 const branch = found === null && call.dirs.length === 0 ? newBranchOf(call.verb, call.rest) : null
207 if (branch !== null) newBranch = branch
208 else if (MOVES_HEAD.has(call.verb ?? '')) movesHead = true
209 if (found !== null || call.verb !== 'commit') continue
210 if (call.rest.includes('--dry-run') || call.rest.includes('-h') || call.rest.includes('--help')) continue
211 found = { dirs: [...cds, ...call.dirs], isAmend: call.rest.includes('--amend'), isDirKnown: isDirKnown && call.isDirKnown }
212 }
213
214 return found === null ? null : { ...found, movesHead, newBranch }
215}
216
217export const commitDir = (base: string, call: CommitCall) =>
218 call.dirs.reduce((dir, part) => joinPath(dir, part), base)
219
220const CLEAN = /nothing to commit, working tree clean|nothing to commit \(/i
221const NOT_STAGED = /no changes added to commit|nothing added to commit/i
222// Lines that say a hook failed, in order of preference. A line that only
223// names a hook (`running pre-commit hook`) is no failure.
224const FAILING: readonly RegExp[] = [
225 /\.{3,}\s*Failed\s*$/,
226 /\b(husky|pre-commit|commit-msg|prepare-commit-msg)\b.*\b(failed|exited with code [1-9]\d*|exit code [1-9]\d*)/i,
227 /\blint-staged\b.*\bfailed\b/i,
228 /\bhook\b.*\bfailed\b/i,
229 /\bleaks? found\b/i,
230 /^\s*[✖✗×]\s+\S/,
231]
232const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g
233const LINE_ROOM = 160
234
235const hookLineOf = (output: string) => {
236 const lines = output.replace(ANSI, '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
237 for (const pattern of FAILING) {
238 const line = lines.find(one => pattern.test(one))
239 if (line !== undefined) return line.slice(0, LINE_ROOM)
240 }
241
242 return null
243}
244
245// HEAD before and after (null: no commit yet), and what the command printed.
246// The caller never passes a HEAD it could not read.
247export const commitOutcome = (
248 before: string | null,
249 after: string | null,
250 output: string,
251): { verdict: CommitVerdict; detail: string | null } => {
252 if (after !== null && after !== before) return { verdict: 'landed', detail: null }
253 if (CLEAN.test(output)) return { verdict: 'nothing', detail: null }
254 if (NOT_STAGED.test(output)) return { verdict: 'failed', detail: 'nothing staged' }
255
256 return { verdict: 'failed', detail: hookLineOf(output) }
257}
258
259export type CallFacts = {
260 // A hook or a person denied the call: the command never ran.
261 isDenied: boolean
262 // The output reads as a refusal: judged only when HEAD did not move.
263 isRefused: boolean
264 isInterrupted: boolean
265 // Still running when the call returned.
266 isBackgrounded: boolean
267 // The engine's own record of the commit it saw land.
268 engineCommit: string | null
269 output: string
270}
271
272type Ran = { deny?: string; isError?: boolean; text?: string; result?: unknown }
273
274const REFUSAL = /denied this tool|doesn't want to proceed|tool use was rejected|permission .* denied/i
275
276const fieldOf = (record: unknown, key: string): unknown =>
277 typeof record === 'object' && record !== null ? (record as Record<string, unknown>)[key] : undefined
278
279export const callFacts = (ran: Ran, input: { run_in_background?: boolean }): CallFacts => {
280 const result = ran.result
281 const text = typeof ran.text === 'string'
282 ? ran.text
283 : typeof result === 'string'
284 ? result
285 : [fieldOf(result, 'stdout'), fieldOf(result, 'stderr')].filter(part => typeof part === 'string').join('\n')
286 const sha = fieldOf(fieldOf(fieldOf(result, 'gitOperation'), 'commit'), 'sha')
287 const isBackgrounded = input.run_in_background === true ||
288 typeof fieldOf(result, 'backgroundTaskId') === 'string' ||
289 typeof fieldOf(result, 'timedOutAfterMs') === 'number' ||
290 fieldOf(result, 'backgroundedByUser') === true ||
291 fieldOf(result, 'backgroundedToDeliverMessage') === true ||
292 fieldOf(result, 'backgroundedByTurnAbort') === true
293
294 return {
295 isDenied: ran.deny !== undefined,
296 isRefused: ran.deny !== undefined || (ran.isError === true && REFUSAL.test(text)),
297 isInterrupted: fieldOf(result, 'interrupted') === true,
298 isBackgrounded,
299 engineCommit: typeof sha === 'string' && sha !== '' ? sha : null,
300 output: text,
301 }
302}
303hooks/lib/list.ts 87 lines1// The to-do list: two sections, "To do" and "Uncommitted", each cut to a few
2// rows with a "+n more" row; and the same list as plain text for /todo.
3
4import type { HubRepo, HubTodo } from '../../types'
5import { age, oneLine } from './format'
6import { listRepos, riskyRepos } from './porcelain'
7
8export type ListInput = {
9 todos: readonly HubTodo[]
10 repos: readonly HubRepo[]
11 // When the repo snapshot was taken.
12 reposAt: number
13 now: number
14}
15
16export type Capped<T> = { shown: T[]; more: number }
17
18export type ListModel = {
19 todos: Capped<HubTodo>
20 repos: Capped<HubRepo>
21 reposTitle: string
22 // How long ago the repos were scanned: "now", "2m ago".
23 reposAgo: string
24 now: number
25}
26
27export const CAPS = { todos: 8, repos: 5 } as const
28
29const cap = <T,>(list: readonly T[], room: number): Capped<T> => ({
30 shown: list.slice(0, room),
31 more: Math.max(0, list.length - room),
32})
33
34const join = (parts: readonly (string | null)[]) => parts.filter((part): part is string => part !== null && part !== '').join(' · ')
35
36export const todoLine = (todo: HubTodo, now: number) =>
37 `☐ ${join([oneLine(todo.title), todo.detail === null ? null : oneLine(todo.detail), todo.project, age(todo.eventAt, now)])}`
38
39// What is at risk in a repo, without its name.
40export const repoFacts = (repo: HubRepo, now: number) => join([
41 repo.files > 0 ? `${repo.files} ${repo.files === 1 ? 'file' : 'files'}` : null,
42 repo.newestAt !== null ? age(repo.newestAt, now) : null,
43 repo.ahead > 0 ? `↑${repo.ahead}` : null,
44 repo.hasCommits && !repo.hasUpstream ? 'no remote' : null,
45 repo.hasCommits ? null : 'no commits',
46 repo.isIgnored ? 'ignored' : null,
47])
48
49export const repoLine = (repo: HubRepo, now: number) => join([repo.name, repoFacts(repo, now)])
50
51const agoOf = (at: number, now: number) => {
52 const text = age(at, now)
53
54 return text === 'now' ? 'now' : `${text} ago`
55}
56
57export const listModel = (input: ListInput): ListModel => ({
58 todos: cap(input.todos, CAPS.todos),
59 repos: cap(listRepos(input.repos, input.now), CAPS.repos),
60 reposTitle: `Uncommitted · ${agoOf(input.reposAt, input.now)}`,
61 reposAgo: agoOf(input.reposAt, input.now),
62 now: input.now,
63})
64
65export const isEmptyList = (model: ListModel) => model.todos.shown.length === 0 && model.repos.shown.length === 0
66
67const section = <T,>(title: string, list: Capped<T>, line: (item: T) => string, more = '') =>
68 list.shown.length === 0 ? [] : [
69 title,
70 ...list.shown.map(item => ` ${line(item)}`),
71 ...(list.more > 0 ? [` +${list.more} more${more}`] : []),
72 ]
73
74export const NO_ITEMS = 'No open items'
75
76export const listText = (model: ListModel) => isEmptyList(model) ? NO_ITEMS : [
77 ...section('To do', model.todos, todo => todoLine(todo, model.now)),
78 ...section(model.reposTitle, model.repos, repo => `⚠ ${repoLine(repo, model.now)}`, ' · /dirty'),
79].join('\n')
80
81// Every scanned repo with something at risk, ignored and old ones included.
82export const dirtyText = (repos: readonly HubRepo[], now: number) => {
83 const list = riskyRepos(repos)
84
85 return list.length === 0 ? 'No uncommitted work' : list.map(repo => `⚠ ${repoLine(repo, now)}`).join('\n')
86}
87hooks/lib/options.ts 31 lines1// The plugin's userConfig values, with the manifest's defaults behind them.
2
3export type Options = {
4 todo: boolean
5 commitGuard: boolean
6 scanRoots: string[]
7 ignoreRepos: string[]
8}
9
10export const DEFAULTS: Options = {
11 todo: true,
12 commitGuard: true,
13 scanRoots: ['~/dev', '~/code', '~/Documents'],
14 ignoreRepos: [],
15}
16
17const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
18
19const list = (value: unknown, fallback: string[]) => {
20 if (typeof value === 'string') return value.trim() === '' ? [] : [value]
21
22 return Array.isArray(value) ? value.filter((one): one is string => typeof one === 'string') : fallback
23}
24
25export const readOptions = (raw: Readonly<Record<string, unknown>>): Options => ({
26 todo: flag(raw.todo, DEFAULTS.todo),
27 commitGuard: flag(raw.commitGuard, DEFAULTS.commitGuard),
28 scanRoots: list(raw.scanRoots, DEFAULTS.scanRoots),
29 ignoreRepos: list(raw.ignoreRepos, DEFAULTS.ignoreRepos),
30})
31hooks/lib/registry.ts 74 lines1// One file per running session in <config>/sessions/<pid>.json, written by
2// every Claude Code process (desktop, CLI, SDK).
3
4import { num, parseObject, pick, str } from './json'
5
6export type Entry = {
7 pid: number
8 sessionId: string
9 cwd?: string
10 name?: string
11 // busy | shell | waiting | idle (see statusKind)
12 status?: string
13 waitingFor?: string
14 statusUpdatedAt?: number
15 updatedAt?: number
16 startedAt?: number
17 kind?: string
18 entrypoint?: string
19 hostSessionId?: string
20 // The file's mtime, from the listing.
21 writtenAt?: number
22}
23
24const TEXT_FIELDS = ['cwd', 'name', 'status', 'waitingFor', 'kind', 'entrypoint', 'hostSessionId'] as const
25const TIME_FIELDS = ['statusUpdatedAt', 'updatedAt', 'startedAt'] as const
26
27export const parseEntry = (text: string | null): Entry | null => {
28 const raw = parseObject(text)
29 const pid = num(raw?.pid)
30 const sessionId = str(raw?.sessionId)
31 if (raw === null || pid === undefined || sessionId === undefined) return null
32
33 return {
34 pid,
35 sessionId,
36 ...pick(raw, TEXT_FIELDS, str),
37 ...pick(raw, TIME_FIELDS, num),
38 } as Entry
39}
40
41export const DESKTOP = 'claude-desktop'
42
43// A chat a person has open: the desktop's (`claude-desktop`, with its desktop
44// id) or a terminal's. Background SDK sessions (`sdk-cli`: memory observers,
45// `claude -p` runs) are not the person's own conversations; nor is a
46// `claude -p` started from a desktop chat's shell, which inherits the desktop
47// entrypoint but gets no desktop id.
48export const isPersonChat = (entry: Entry | null | undefined) =>
49 entry !== null && entry !== undefined &&
50 (entry.kind === undefined || entry.kind === 'interactive') &&
51 !(entry.entrypoint ?? '').startsWith('sdk') &&
52 (entry.entrypoint !== DESKTOP || entry.hostSessionId !== undefined)
53
54export const isPersonal = (entry: Entry, selfId: string) => entry.sessionId !== selfId && isPersonChat(entry)
55
56// The statuses the engine writes (2.1.299): `busy` (a turn runs), `shell` (the
57// turn ended, a background shell still runs: the engine draws it as working),
58// `waiting` (with `waitingFor`) and `idle`. Anything else counts as idle,
59// never as needing the user.
60export type StatusKind = 'working' | 'waiting' | 'idle'
61
62export const statusKind = (status: string | undefined): StatusKind =>
63 status === 'busy' || status === 'shell' ? 'working' : status === 'waiting' ? 'waiting' : 'idle'
64
65const LIVE_STALE_MS = 24 * 60 * 60 * 1000
66
67// A pid in the claude.exe list is not proof on its own: the desktop app's own
68// processes are claude.exe too, and pids are reused. A file also has to be
69// written within a day, or say the session is working or waiting. `pids`
70// null: the list could not be read, and every file counts as running.
71export const isLiveEntry = (entry: Entry, pids: ReadonlySet<number> | null, now: number) =>
72 pids === null || (pids.has(entry.pid) &&
73 (statusKind(entry.status) !== 'idle' || now - Math.max(entry.updatedAt ?? 0, entry.writtenAt ?? 0) < LIVE_STALE_MS))
74hooks/sources/open.ts 13 lines1// Opens a session link or a file the way the desktop does: through the shell
2// handler (links inside the desktop are https-only, so claude:// goes this way).
3
4import type { EngineInterface } from 'claude-code'
5
6export async function openTarget($: EngineInterface, target: string, isWindows: boolean) {
7 const argv = isWindows ? ['explorer.exe', target] : ['open', target]
8 // explorer.exe exits 1 even when it opened the target: only a failed start counts.
9 await $.process.run(argv, { timeoutMs: 10000 }).catch(() => {
10 $.ui.toast('Could not open that')
11 })
12}
13hooks/sources/place.ts 39 lines1// Where things live on this machine. The variable names are literals: the
2// engine reads them off the source.
3
4import type { EngineInterface } from 'claude-code'
5
6export type Place = {
7 isWindows: boolean
8 sep: string
9 home: string | null
10 // <config>/sessions: the registry of running sessions.
11 registry: string
12 // The desktop app's session records, or null off the desktop.
13 app: string | null
14 // The last usage reading, shared by every session.
15 shared: string
16 selfId: string
17}
18
19export async function findPlace($: EngineInterface): Promise<Place | null> {
20 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
21 const sep = isWindows ? '\\' : '/'
22 const home = isWindows ? await $.env.get('USERPROFILE') : await $.env.get('HOME')
23 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home === undefined ? null : `${home}${sep}.claude`)
24 if (config === null) return null
25 const appData = isWindows
26 ? await $.env.get('APPDATA')
27 : home === undefined ? undefined : `${home}/Library/Application Support`
28
29 return {
30 isWindows,
31 sep,
32 home: home ?? null,
33 registry: `${config}${sep}sessions`,
34 app: appData === undefined ? null : `${appData}${sep}Claude${sep}claude-code-sessions`,
35 shared: `${config}${sep}mods${sep}session-hub-usage.json`,
36 selfId: await $.session.id(),
37 }
38}
39