SLOPSHOPPER

session-hub

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

panebandguardcommandtoastprocess
v0.1.0MITupdated 2026-10-18leikauskas/session-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hub
│ ┃ todo ✕ › fix the failing auth test and add an audit log call │ ┃ No open items │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ ⎿ session-hub: No open items │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · todo
No open items
README

session-hub

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.

The bar

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.

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.

The commit guard

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.

Commands

CommandWhat it does
/todo hereThis chat shows the to-do list above the prompt instead of the bar (the last chat to ask wins)
/todo offNo chat shows the list
/todoPrints the list (scans the repos first when no chat is showing it)
/todo <text>Adds your own to-do item
/dirtyScans 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.

Install

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.

Options

OptionDefaultControls
todotrueThe to-do list
commitGuardtrueThe commit guard and the Uncommitted section
scanRoots~/dev, ~/code, ~/DocumentsFolders whose direct subfolders are scanned for repos
ignoreReposnoneRepos that still show in /dirty but never become a to-do

~ is your home folder, on Windows too.

What it reads and writes

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.

Development

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.

License

MIT

Source 36 files
hooks/register.tsx 183 lines
1// 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}
183
hooks/commands.ts 83 lines
1// /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}
83
hooks/commits.ts 85 lines
1// 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}
85
hooks/edits.ts 97 lines
1// 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}
97
hooks/home.ts 114 lines
1// 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}
114
hooks/hub.ts 135 lines
1// 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}
135
hooks/lib/git.ts 303 lines
1// 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}
303
hooks/lib/list.ts 87 lines
1// 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}
87
hooks/lib/options.ts 31 lines
1// 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})
31
hooks/lib/registry.ts 74 lines
1// 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))
74
hooks/sources/open.ts 13 lines
1// 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}
13
hooks/sources/place.ts 39 lines
1// 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