Shows what each session is building, where it stands and what it produced: a compass band, a /compass pane and a compass_status tool

A Claude Code mod. Come back to a session and see what it is building, where it stands and everything it has produced, without scrolling back.
Cubby · worktrees/claude-mods (feat/session-mods) · Goal: Ship the session mods · Now: PR review · 7 artifacts · tests 412 ✓ 0 ✗
One line above the prompt: repo · worktree (branch) · Goal · Now · N artifacts · tests.
(no goal set).tests appears when test-board is installed and has parsed runs. It clears when those runs leave test-board 60 minutes after they finished; closing the test-board band with ✕ leaves it up.tests, then shorten the worktree path./compass paneclaude --resume <id>.compass_status toolThe model sees it as mcp__session-compass__compass_status with optional goal, step and next strings. A short standing reminder in the prompt asks Claude to call it when the goal is first clear and whenever the phase changes (spec, plan, implement, review, PR, done), not every turn. A call with no non-empty field is refused.
Captured automatically from successful tool calls:
Artifact publishes (claude.ai URLs; opens, reads and lists are ignored).SendUserFile deliveries (attachments that uploaded without error).Write of an .html, .md, .pdf, .png or .svg file under .scratch/, docs/, reports/ or test-checklists/, or under a Claude scratchpad directory.A git checkout, switch, worktree, commit or push from the main session also refreshes the recorded worktree and branch. A subagent's git commands do not.
/plugin marketplace add abhijitbansal/claude-skills
/plugin install session-compass@claude-skills
Goal 80 characters, step and next 60, 100 artifacts per session, 50 sessions. Longer text is truncated; past the caps the oldest artifacts and sessions are dropped.
Records live in the plugin's local store. Nothing leaves the machine and the plugin makes no model calls. It reads Test Board's latest runs when Test Board is installed. The store keeps the first 80 characters of a session's first prompt, worktree paths, commit subjects and artifact URLs until the session ages out of the 50-session cap; avoid pasting secrets into a first prompt.
claude plugin validate plugins/session-compass
claude plugin test plugins/session-compass
claude --plugin-dir plugins/session-compass # lays .claude-plugin/types (gitignored)
npx -y -p typescript@5.6.3 tsc -p plugins/session-compasshooks/register.tsx 358 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { Artifact, SessionRecord } from '../types'
4import { bandView, formatBand, tally } from './band'
5import { capture } from './capture'
6import { PANE_ID, paneView, type Tab } from './pane'
7import { GIT_TIMEOUT_MS, INDEX, PREFIX, isRecord, recordKey } from './io'
8import {
9 LIMITS, addArtifact, applyStatus, basename, capArtifacts, mergeStored, newRecord, orderIndex, sortByActive, touch, withFirstPrompt,
10 type StatusInput, type Where,
11} from './record'
12
13// Atoms sit in this file: the host's state scan reads only atoms declared here.
14const record = atom({ plugin: 'session-compass', key: 'record' } as const, null)
15const tab = atom({ plugin: 'session-compass', key: 'tab' } as const, 'this' as Tab)
16
17export const STATUS_TOOL_ID = 'mcp__session-compass__compass_status'
18const STATUS_TOOL = {
19 name: 'compass_status',
20 description:
21 'Record what this session is building (goal), where it stands (step), and what comes next. ' +
22 'Call it when the goal is first clear and whenever the phase changes ' +
23 '(spec, plan, implement, review, PR, done), not every turn.',
24 inputSchema: {
25 type: 'object',
26 properties: {
27 goal: { type: 'string', description: 'What this session is building (up to 80 chars)' },
28 step: { type: 'string', description: 'Where it stands now (up to 60 chars)' },
29 next: { type: 'string', description: 'What comes next (up to 60 chars)' },
30 },
31 additionalProperties: false,
32 },
33 isDeferred: false,
34}
35const REMINDER = {
36 id: 'session-compass:reminder',
37 text: 'Keep the session compass current with the compass_status tool when the goal or phase changes.',
38 scope: 'session',
39} as const
40const CAPTURED = new Set(['Artifact', 'SendUserFile', 'Write', 'Bash'])
41const GIT_MOVE = /\bgit\s+(checkout|switch|worktree|commit|push)\b/
42
43export async function loadRecord($: EngineInterface, id: string): Promise<SessionRecord | undefined> {
44 const raw = await $.store.get(recordKey(id))
45 return isRecord(raw) ? raw : undefined
46}
47
48// The index is last-write-wins across concurrent sessions, so it can lose an id
49// whose record is still stored. Every read merges those back in, newest first.
50export async function loadIndex($: EngineInterface): Promise<string[]> {
51 const raw = await $.store.get(INDEX)
52 const listed = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
53 const known = new Set(listed)
54 const missing = (await $.store.keys())
55 .filter(k => k.startsWith(PREFIX)).map(k => k.slice(PREFIX.length)).filter(id => !known.has(id))
56 if (missing.length === 0) return listed
57 const recs = (await Promise.all([...listed, ...missing].map(id => loadRecord($, id)))).filter(isRecord)
58 const sorted = sortByActive(recs).map(r => r.id)
59 return [...sorted, ...listed.filter(id => !sorted.includes(id))]
60}
61
62async function writeAll($: EngineInterface, rec: SessionRecord, maxSessions: number): Promise<void> {
63 await $.store.set(recordKey(rec.id), rec)
64 const { index, dropped } = orderIndex(await loadIndex($), rec.id, maxSessions)
65 for (const id of dropped) await $.store.delete(recordKey(id))
66 await $.store.set(INDEX, index)
67}
68
69async function trimOthers($: EngineInterface, keepId: string): Promise<void> {
70 for (const id of await loadIndex($)) {
71 if (id === keepId) continue
72 const other = await loadRecord($, id)
73 if (other !== undefined && other.artifacts.length > LIMITS.trimArtifacts) {
74 await $.store.set(recordKey(id), capArtifacts(other, LIMITS.trimArtifacts))
75 }
76 }
77}
78
79// Frees space before a retry: shrink the index, drop what falls off it, cap the
80// artifacts of every session that stays.
81async function freeSpace($: EngineInterface, keepId: string): Promise<void> {
82 const { index, dropped } = orderIndex(await loadIndex($), keepId, LIMITS.trimSessions)
83 for (const id of dropped) await $.store.delete(recordKey(id))
84 await $.store.set(INDEX, index)
85 await trimOthers($, keepId)
86}
87
88// While the start-time read failed twice, the atom holds a fresh record. Before
89// the first write, try the store once more and fold what it holds into the atom.
90async function verifyStore($: EngineInterface, state: Chain, rec: SessionRecord): Promise<SessionRecord> {
91 if (!state.isStoreUnverified) return rec
92 let stored: SessionRecord | undefined
93 try {
94 stored = await loadRecord($, rec.id)
95 } catch {
96 return rec
97 }
98 state.isStoreUnverified = false
99 if (stored === undefined) return rec
100 const merged = await update($, record, cur => (cur === null ? cur : mergeStored(cur, stored)))
101 return merged ?? rec
102}
103
104export async function saveRecord($: EngineInterface, state: Chain, first: SessionRecord): Promise<void> {
105 const rec = await verifyStore($, state, first)
106 try {
107 await writeAll($, rec, LIMITS.sessions)
108 } catch {
109 try {
110 await freeSpace($, rec.id)
111 await writeAll($, capArtifacts(rec, LIMITS.trimArtifacts), LIMITS.trimSessions)
112 } catch (err) {
113 $.ui.log(`session-compass: store write failed, kept in session only: ${String(err).slice(0, 120)}`)
114 }
115 }
116}
117
118export async function listSessions($: EngineInterface, excludeId: string): Promise<SessionRecord[]> {
119 const ids = (await loadIndex($)).filter(id => id !== excludeId)
120 const recs = await Promise.all(ids.map(id => loadRecord($, id)))
121 return sortByActive(recs.filter(isRecord)).slice(0, LIMITS.sessions)
122}
123
124async function git($: EngineInterface, args: readonly string[]): Promise<string | undefined> {
125 try {
126 const ran = await $.process.run(['git', ...args], { timeoutMs: GIT_TIMEOUT_MS })
127 const out = ran.stdout.trim()
128 return ran.exitCode === 0 && out !== '' ? out : undefined
129 } catch {
130 return undefined
131 }
132}
133
134export async function locate($: EngineInterface, prev?: Where, startCwd?: string): Promise<Where> {
135 const cwd = startCwd ?? (await $.session.cwd().catch(() => prev?.worktree ?? ''))
136 const repoRoot = await $.session.repo().then(r => r?.root).catch(() => undefined)
137 const worktree = (await git($, ['rev-parse', '--show-toplevel'])) ?? prev?.worktree ?? cwd
138 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? prev?.branch ?? ''
139 const repo = repoRoot !== undefined ? basename(repoRoot) : prev?.repo ?? basename(worktree)
140 return { repo, worktree, branch }
141}
142
143// Test Board's latest runs, when Test Board is installed (U14 decides whether
144// this read needs a `dependencies` entry). Never throws.
145export async function readTestBoard($: EngineInterface): Promise<unknown[] | undefined> {
146 try {
147 const held = await $.state.get({ plugin: 'test-board', key: 'latest' } as never)
148 const value = (held as { value?: unknown }).value
149 return Array.isArray(value) ? value : undefined
150 } catch {
151 return undefined
152 }
153}
154
155// The model supplies the input; anything that is not a string counts as left out.
156function onlyStrings(input: Record<string, unknown>): StatusInput {
157 const pick = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
158 return { goal: pick(input.goal), step: pick(input.step), next: pick(input.next) }
159}
160
161// Folds new artifacts (and a fresh branch, after a git move) into the record
162// with CAS updates, then saves what was written. Callers queue it so saves never interleave.
163async function applyCapture($: EngineInterface, state: Chain, found: Artifact[], isGitMove: boolean): Promise<void> {
164 if ((await read($, record)) === null) return
165 let written = await update($, record, cur => (cur === null ? cur : found.reduce(addArtifact, cur)))
166 if (written === null) return
167 if (isGitMove) {
168 const where = await locate($, written)
169 written = await update($, record, cur => (cur === null ? cur : { ...cur, ...where }))
170 }
171 if (written !== null) await saveRecord($, state, written)
172}
173
174export type Chain = { chain: Promise<void>; isTurnRunning: boolean; isStoreUnverified: boolean }
175
176async function touchRecord($: EngineInterface, state: Chain, change: (r: SessionRecord, now: number) => SessionRecord) {
177 await queue(state, 'touch', async () => {
178 const now = await $.clock.now()
179 const written = await update($, record, cur => (cur === null ? cur : change(cur, now)))
180 if (written !== null) await saveRecord($, state, written)
181 }, $).catch(() => undefined)
182}
183
184// Record writes run one at a time so store saves never interleave. The chain
185// link is catch-wrapped so one failure never poisons it; the caller gets the
186// job's own settled promise and decides what a failure means.
187function queue(state: Chain, kind: string, job: () => Promise<void>, $: EngineInterface): Promise<void> {
188 const run = state.chain.then(job)
189 state.chain = run.catch((err: unknown) => {
190 try {
191 $.ui.log(`session-compass: ${kind} failed: ${String(err).slice(0, 120)}`)
192 } catch {
193 // a throwing log must not leave the chain rejected
194 }
195 })
196 return run
197}
198
199function logPane($: EngineInterface, what: string, err: unknown): void {
200 try {
201 $.ui.log(`session-compass: pane ${what} failed: ${String(err).slice(0, 120)}`)
202 } catch {
203 // a throwing log must not become an unhandled rejection
204 }
205}
206
207export const register: Register = on => {
208 // One promise chain per load: capture passes run one at a time.
209 const state: Chain = { chain: Promise.resolve(), isTurnRunning: false, isStoreUnverified: false }
210
211 on('session.start', async ($, e, next) => {
212 await $.tool.register(STATUS_TOOL)
213 await $.command.register({ name: 'compass', description: 'Open the session compass pane' })
214 const id = await $.session.id()
215 const now = await $.clock.now()
216 // A new load starts verified; a read that fails twice flips the flag until the store answers.
217 state.isStoreUnverified = false
218 await queue(state, 'start', async () => {
219 let stored: SessionRecord | undefined
220 try {
221 stored = await loadRecord($, id).catch(() => loadRecord($, id))
222 } catch (err) {
223 state.isStoreUnverified = true
224 $.ui.log(`session-compass: store read failed, starting fresh: ${String(err).slice(0, 120)}`)
225 }
226 const where = await locate($, stored, e.cwd)
227 const started = stored ? { ...stored, ...where, lastActive: now } : newRecord(id, where, now)
228 await update($, record, () => started)
229 if (!state.isStoreUnverified) await saveRecord($, state, started)
230 }, $).catch(() => undefined)
231 return next(e)
232 }).catch(($, e, next) => {
233 try {
234 $.ui.log(`session-compass: start failed (the hook error is not passed to .catch)`)
235 } catch {
236 // a throwing log must not mask the original failure
237 }
238 return next(e)
239 })
240
241 on('turn.start', async ($, e, next) => {
242 state.isTurnRunning = true
243 return next(e)
244 }).catch(($, e, next) => next(e))
245
246 on('prompt.submit', async ($, e, next) => {
247 const ran = await next(e)
248 // Only a prompt the person typed that entered as a new turn counts; a drop
249 // or a peer/notification/plugin prompt changes nothing.
250 const isPerson = e.origin === undefined || e.origin.kind === 'composer' || e.origin.kind === 'bridge'
251 if (ran.drop === undefined && e.turnId === undefined && isPerson) {
252 await touchRecord($, state, (r, now) => touch(withFirstPrompt(r, e.text), now))
253 }
254 return ran
255 }).catch(($, e, next) => next(e))
256
257 on('turn.complete', async ($, e, next) => {
258 // A subagent's turn (agentId set) is not the main loop's.
259 if (e.agentId === undefined) {
260 state.isTurnRunning = false
261 await touchRecord($, state, touch)
262 }
263 return next(e)
264 }).catch(($, e, next) => next(e))
265
266 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
267 // The compass line is always up; only a survey on screen takes its place.
268 const rec = await read($, record)
269 if (e.props.hasSurvey || rec === null) return next(e)
270 const line = formatBand(rec, tally(await readTestBoard($)), e.props.bodyColumns)
271 return bandView($.ui.resolve(e), line, await next(e))
272 })
273
274 on('command.run', { command: 'compass' }, async $ => {
275 await $.ui.open({ id: PANE_ID, title: 'Compass' })
276 return { text: 'Compass opened.' }
277 })
278
279 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
280 const shown = await read($, tab)
281 const rec = await read($, record)
282 // A corrupt or missing index never crashes the pane: loadIndex rebuilds from session keys.
283 let isOthersUnreadable = false
284 const others = shown === 'all'
285 ? await listSessions($, rec?.id ?? '').catch((err: unknown) => {
286 isOthersUnreadable = true
287 $.ui.log(`session-compass: could not read other sessions: ${String(err).slice(0, 120)}`)
288 return []
289 })
290 : []
291 const tests = shown === 'this' ? tally(await readTestBoard($)) : undefined
292 return paneView($.ui.resolve(e), {
293 tab: shown, rec, others, isOthersUnreadable, tests, now: await $.clock.now(), columns: e.props.bodyColumns,
294 }, {
295 onTab: next => { update($, tab, () => next).catch(err => logPane($, 'tab change', err)) },
296 onCopy: text => {
297 $.ui.copy({ text, surface: e.surface }).then(
298 done => { if (!done.isCopied) $.ui.toast(`Copy failed: ${done.reason}`) },
299 err => logPane($, 'copy', err),
300 )
301 },
302 })
303 })
304
305 on('tool.call', { tool: STATUS_TOOL_ID }, async ($, e) => {
306 // A subagent's status would overwrite the main session's goal, step and next.
307 if (e.agentId !== undefined) return { deny: 'session-compass: compass_status is for the main session only' }
308 const input = onlyStrings(e as unknown as Record<string, unknown>)
309 const outcome: { deny?: string } = {}
310 const done = await queue(state, 'status', async () => {
311 const now = await $.clock.now()
312 let error: string | undefined
313 const written = await update($, record, cur => {
314 error = undefined
315 if (cur === null) return cur
316 const out = applyStatus(cur, input, now)
317 if ('error' in out) {
318 error = out.error
319 return cur
320 }
321 return out
322 })
323 if (written === null) outcome.deny = 'session-compass: no session record yet'
324 else if (error !== undefined) outcome.deny = error
325 else await saveRecord($, state, written)
326 }, $).then(() => true, () => false)
327 if (!done) return { deny: 'session-compass: status not saved, try again' }
328 // A registered tool's result is read like an MCP tool's: a string or content
329 // blocks. An object here fails the host's shape check and the status is lost.
330 return outcome.deny === undefined ? { result: 'ok' } : { deny: outcome.deny }
331 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'session compass is busy; try again' }))
332
333 on('tool.call', async ($, e, next) => {
334 const ran = await next(e)
335 const tool = String(e.tool)
336 if (!CAPTURED.has(tool) || ran.deny !== undefined) return ran
337 const input = e as unknown as Record<string, unknown>
338 let found: Artifact[] = []
339 try {
340 found = capture({ tool, input, result: ran.result, isError: ran.isError === true, now: await $.clock.now() })
341 } catch (err) {
342 $.ui.log(`session-compass: capture failed: ${String(err).slice(0, 120)}`)
343 }
344 // A subagent's git move happens in its own worktree: it must not relocate this session.
345 const command = typeof input.command === 'string' ? input.command : ''
346 const isGitMove = tool === 'Bash' && input.agentId === undefined && GIT_MOVE.test(command)
347 // Decided before waiting: a call that captures nothing never queues behind the chain.
348 if (found.length === 0 && !isGitMove) return ran
349 await queue(state, 'capture', () => applyCapture($, state, found, isGitMove), $).catch(() => undefined)
350 return ran
351 }).catch(($, e, next) => next(e))
352
353 on('prompt.compose', async ($, e, next) => {
354 const composed = await next(e)
355 return { sections: [...composed.sections, REMINDER] }
356 })
357}
358hooks/band.tsx 63 lines1import type { EngineInterface, RenderElement } from 'claude-code'
2import type { SessionRecord } from '../types'
3import { basename, truncate } from './record'
4
5// Pure view builders: no `$` here (the host does not follow it out of register.tsx).
6export type Tally = { passed: number; failed: number }
7export type BandLine = { text: string; isDim: boolean }
8type Els = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Text'>
9
10export function tally(runs: readonly unknown[] | undefined): Tally | undefined {
11 if (runs === undefined) return undefined
12 let passed = 0
13 let failed = 0
14 let isAny = false
15 for (const run of runs) {
16 const r = run as { parsed?: unknown; passed?: unknown; failed?: unknown }
17 if (r.parsed !== true || typeof r.passed !== 'number' || typeof r.failed !== 'number') continue
18 passed += r.passed
19 failed += r.failed
20 isAny = true
21 }
22 return isAny ? { passed, failed } : undefined
23}
24
25const lastTwo = (path: string): string => path.split('/').filter(Boolean).slice(-2).join('/')
26
27export function formatBand(rec: SessionRecord, tests: Tally | undefined, columns: number): BandLine {
28 const isDim = rec.goal === undefined
29 const goal = rec.goal !== undefined ? `Goal: ${rec.goal}` : `Goal: "${rec.firstPrompt ?? '…'}" (no goal set)`
30 const now = rec.step !== undefined ? `Now: ${rec.step}` : undefined
31 const count = rec.artifacts.length
32 const artifacts = `${count} artifact${count === 1 ? '' : 's'}`
33 const testText = tests ? `tests ${tests.passed} ✓ ${tests.failed} ✗` : undefined
34 const attempts = [
35 { wt: lastTwo(rec.worktree), hasArtifacts: true, hasTests: true },
36 { wt: lastTwo(rec.worktree), hasArtifacts: false, hasTests: true },
37 { wt: lastTwo(rec.worktree), hasArtifacts: false, hasTests: false },
38 { wt: basename(rec.worktree), hasArtifacts: false, hasTests: false },
39 ]
40 let text = ''
41 for (const a of attempts) {
42 text = [
43 `${rec.repo} · ${a.wt} (${rec.branch})`,
44 goal,
45 now,
46 a.hasArtifacts ? artifacts : undefined,
47 a.hasTests ? testText : undefined,
48 ].filter((s): s is string => s !== undefined).join(' · ')
49 if (text.length <= columns) return { text, isDim }
50 }
51 return { text: truncate(text, columns), isDim }
52}
53
54// The compass line stacked above whatever the next band drew.
55export function bandView({ Box, Text }: Els, line: BandLine, below: RenderElement): RenderElement {
56 return (
57 <Box flexDirection="column">
58 <Text dimColor={line.isDim}>{line.text}</Text>
59 {below}
60 </Box>
61 )
62}
63hooks/capture.ts 59 lines1import type { Artifact } from '../types'
2import { gitArtifacts, type GitOperation } from './git'
3import { basename, truncate } from './record'
4
5export type CaptureInput = {
6 tool: string
7 input: Readonly<Record<string, unknown>>
8 result: unknown
9 isError: boolean
10 now: number
11}
12
13const DOC_EXT = /\.(html|md|pdf|png|svg)$/i
14const DOC_DIR = /(^|\/)(\.scratch|docs|reports|test-checklists)\//
15const SCRATCHPAD = /\/claude-\d+\/.+\/scratchpad\//
16const CLAUDE_URL = /^https:\/\/claude\.ai\//
17const TITLE_MAX = 120
18
19const text = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
20const fields = (v: unknown): Record<string, unknown> =>
21 typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {}
22
23export function isDeliverable(path: string): boolean {
24 return DOC_EXT.test(path) && (DOC_DIR.test(path) || SCRATCHPAD.test(path))
25}
26
27export function capture(c: CaptureInput): Artifact[] {
28 if (c.isError) return []
29 const result = fields(c.result)
30 switch (c.tool) {
31 case 'Artifact': {
32 const action = text(c.input.action)
33 if ((action !== undefined && action !== 'publish') || result.opened === true) return []
34 const url = text(result.url)
35 if (url === undefined || !CLAUDE_URL.test(url)) return []
36 const filePath = text(c.input.file_path)
37 const title = text(result.title) ?? text(c.input.title) ?? (filePath ? basename(filePath) : url)
38 return [{ kind: 'artifact', title: truncate(title, TITLE_MAX), url, at: c.now }]
39 }
40 case 'SendUserFile': {
41 const list = Array.isArray(result.attachments) ? result.attachments : []
42 return list
43 .map(a => (text(fields(a).upload_error) === undefined ? text(fields(a).path) : undefined))
44 .filter((p): p is string => p !== undefined)
45 .map((path): Artifact => ({ kind: 'file', path, at: c.now }))
46 }
47 case 'Write': {
48 const path = text(c.input.file_path)
49 return path !== undefined && isDeliverable(path) ? [{ kind: 'doc', path, at: c.now }] : []
50 }
51 case 'Bash': {
52 const output = `${text(result.stdout) ?? ''}\n${text(result.stderr) ?? ''}`
53 return gitArtifacts(result.gitOperation as GitOperation | undefined, output, c.now)
54 }
55 default:
56 return []
57 }
58}
59hooks/pane.tsx 106 lines1import type { EngineInterface } from 'claude-code'
2import type { Artifact, SessionRecord } from '../types'
3import type { Tally } from './band'
4import { ago, basename, truncate } from './record'
5
6// Pure view builder: no `$` here (the host does not follow it out of register.tsx).
7// The tab atom, command.run, ui.render hook and every `$` call live there.
8export const PANE_ID = 'compass'
9export const ALL_ROWS = 50
10export type Tab = 'this' | 'all'
11type Els = Pick<ReturnType<EngineInterface['ui']['resolve']>, 'Box' | 'Text' | 'Button'>
12export type PaneData = {
13 tab: Tab
14 rec: SessionRecord | null
15 others: readonly SessionRecord[]
16 isOthersUnreadable: boolean
17 tests: Tally | undefined
18 now: number
19 columns: number
20}
21export type PaneActions = { onTab: (tab: Tab) => void; onCopy: (text: string) => void }
22
23const KINDS = ['artifact', 'file', 'doc', 'commit', 'push', 'pr'] as const
24const KIND_LABEL: Record<Artifact['kind'], string> = {
25 artifact: 'Published artifacts', file: 'Files sent', doc: 'Documents',
26 commit: 'Commits', push: 'Pushes', pr: 'Pull requests',
27}
28
29function label(a: Artifact): string {
30 switch (a.kind) {
31 case 'artifact': return `${a.title} ${a.url}`
32 case 'file': case 'doc': return a.path
33 case 'commit': return `${a.sha.slice(0, 7)} ${a.subject} (${a.branch})`
34 case 'push': return `${a.branch} to ${a.remote}`
35 case 'pr': return `#${a.number} ${a.url}`
36 }
37}
38
39function copyText(a: Artifact): string {
40 switch (a.kind) {
41 case 'artifact': case 'pr': return a.url
42 case 'file': case 'doc': return a.path
43 case 'commit': return a.sha
44 case 'push': return a.branch
45 }
46}
47
48export function paneView({ Box, Text, Button }: Els, d: PaneData, act: PaneActions) {
49 const tabs = (
50 <Box flexDirection="row">
51 <Button key="tab-this" label="This session" variant={d.tab === 'this' ? 'primary' : 'secondary'}
52 onPress={() => act.onTab('this')} />
53 <Button key="tab-all" label="All sessions" variant={d.tab === 'all' ? 'primary' : 'secondary'}
54 onPress={() => act.onTab('all')} />
55 </Box>
56 )
57
58 if (d.tab === 'all') {
59 const rows = d.others.slice(0, ALL_ROWS)
60 const age = (o: SessionRecord) => ` · ${ago(d.now, o.lastActive)}`
61 return (
62 <Box flexDirection="column">
63 {tabs}
64 {d.isOthersUnreadable && <Text dimColor>Could not read other sessions.</Text>}
65 {rows.length === 0 && !d.isOthersUnreadable && <Text dimColor>No other sessions recorded.</Text>}
66 {rows.map(o => {
67 const head = `${o.repo} · ${basename(o.worktree)}/${o.branch} · ${o.goal ?? o.firstPrompt ?? '(no goal)'} · ${o.step ?? '-'}`
68 return (
69 <Button key={`s-${o.id}`} plain onPress={() => act.onCopy(`claude --resume ${o.id}`)}>
70 {truncate(head, Math.max(20, d.columns - age(o).length - 4))}
71 <Text dimColor>{age(o)}</Text>
72 </Button>
73 )
74 })}
75 </Box>
76 )
77 }
78
79 const { rec } = d
80 if (rec === null) {
81 return <Box flexDirection="column">{tabs}<Text dimColor>No record for this session yet.</Text></Box>
82 }
83 const status = rec.statusAt === undefined ? 'status never set' : `status set ${ago(d.now, rec.statusAt)}`
84 const kinds = KINDS.filter(k => rec.artifacts.some(a => a.kind === k))
85 return (
86 <Box flexDirection="column">
87 {tabs}
88 <Text>{truncate(`${rec.repo} · ${rec.worktree} (${rec.branch})`, d.columns)}</Text>
89 <Text>{`Goal: ${rec.goal ?? '(no goal set)'}`}</Text>
90 <Text>{`Now: ${rec.step ?? '-'} · Next: ${rec.next ?? '-'}`}</Text>
91 <Text dimColor>{status}</Text>
92 {d.tests !== undefined && <Text>{`Tests: ${d.tests.passed} ✓ ${d.tests.failed} ✗`}</Text>}
93 {kinds.map(kind => (
94 <Box key={`g-${kind}`} flexDirection="column">
95 <Text bold>{KIND_LABEL[kind]}</Text>
96 {rec.artifacts.filter(a => a.kind === kind).map((a, i) => (
97 <Button key={`a-${kind}-${i}`} plain onPress={() => act.onCopy(copyText(a))}>
98 {truncate(label(a), d.columns)}
99 </Button>
100 ))}
101 </Box>
102 ))}
103 </Box>
104 )
105}
106hooks/io.ts 13 lines1import type { SessionRecord } from '../types'
2
3// Pure store-layout helpers. The host follows `$` only inside the file that
4// holds register (register.tsx), so every helper that takes `$` lives there.
5export const INDEX = 'index'
6export const PREFIX = 'session:'
7export const GIT_TIMEOUT_MS = 5_000
8export const recordKey = (id: string): string => `${PREFIX}${id}`
9
10export const isRecord = (v: unknown): v is SessionRecord =>
11 typeof v === 'object' && v !== null && typeof (v as SessionRecord).id === 'string' &&
12 Array.isArray((v as SessionRecord).artifacts)
13hooks/record.ts 114 lines1import type { Artifact, SessionRecord } from '../types'
2
3export const LIMITS = {
4 goal: 80,
5 step: 60,
6 next: 60,
7 firstPrompt: 80,
8 artifacts: 100,
9 sessions: 50,
10 trimArtifacts: 50,
11 trimSessions: 40,
12} as const
13
14export type StatusInput = { goal?: string; step?: string; next?: string }
15export type Where = { repo: string; worktree: string; branch: string }
16
17const STATUS_FIELDS = ['goal', 'step', 'next'] as const
18
19export function truncate(text: string, max: number): string {
20 const flat = text.replace(/\s+/g, ' ').trim()
21 return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
22}
23
24export function basename(path: string): string {
25 const parts = path.split('/').filter(Boolean)
26 return parts[parts.length - 1] ?? path
27}
28
29export function newRecord(id: string, where: Where, now: number): SessionRecord {
30 return { id, ...where, lastActive: now, artifacts: [] }
31}
32
33export function applyStatus(
34 rec: SessionRecord,
35 input: StatusInput,
36 now: number,
37): SessionRecord | { error: string } {
38 const given = STATUS_FIELDS.filter(k => (input[k] ?? '').trim() !== '')
39 if (given.length === 0) {
40 return { error: 'compass_status needs at least one non-empty field of: goal, step, next' }
41 }
42 const patch: StatusInput = {}
43 for (const k of given) patch[k] = truncate(input[k] ?? '', LIMITS[k])
44 return { ...rec, ...patch, statusAt: now, lastActive: now }
45}
46
47export function artifactKey(a: Artifact): string {
48 switch (a.kind) {
49 case 'artifact': return `artifact:${a.url}`
50 case 'file': return `file:${a.path}`
51 case 'doc': return `doc:${a.path}`
52 case 'commit': return `commit:${a.sha}`
53 case 'push': return `push:${a.remote}:${a.branch}`
54 case 'pr': return `pr:${a.number}`
55 }
56}
57
58export function addArtifact(rec: SessionRecord, a: Artifact): SessionRecord {
59 const key = artifactKey(a)
60 const rest = rec.artifacts.filter(x => artifactKey(x) !== key)
61 return { ...rec, artifacts: [a, ...rest].slice(0, LIMITS.artifacts) }
62}
63
64// A record the store could not be read for starts empty; once the store answers,
65// what was stored fills the empty fields and the artifacts merge, newest first.
66export function mergeStored(cur: SessionRecord, stored: SessionRecord): SessionRecord {
67 const base: SessionRecord = {
68 ...cur,
69 goal: cur.goal ?? stored.goal,
70 step: cur.step ?? stored.step,
71 next: cur.next ?? stored.next,
72 firstPrompt: cur.firstPrompt ?? stored.firstPrompt,
73 statusAt: cur.statusAt ?? stored.statusAt,
74 artifacts: [],
75 }
76 const oldestFirst = [...cur.artifacts, ...stored.artifacts].sort((a, b) => a.at - b.at)
77 return oldestFirst.reduce(addArtifact, base)
78}
79
80export function capArtifacts(rec: SessionRecord, max: number): SessionRecord {
81 return rec.artifacts.length <= max ? rec : { ...rec, artifacts: rec.artifacts.slice(0, max) }
82}
83
84export function touch(rec: SessionRecord, now: number): SessionRecord {
85 return { ...rec, lastActive: now }
86}
87
88export function withFirstPrompt(rec: SessionRecord, text: string): SessionRecord {
89 if (rec.firstPrompt !== undefined || text.trim() === '') return rec
90 return { ...rec, firstPrompt: truncate(text, LIMITS.firstPrompt) }
91}
92
93export function orderIndex(
94 index: readonly string[],
95 id: string,
96 max: number,
97): { index: string[]; dropped: string[] } {
98 const ordered = [id, ...index.filter(x => x !== id)]
99 return { index: ordered.slice(0, max), dropped: ordered.slice(max) }
100}
101
102export function sortByActive(recs: readonly SessionRecord[]): SessionRecord[] {
103 return [...recs].sort((a, b) => b.lastActive - a.lastActive)
104}
105
106export function ago(now: number, then: number): string {
107 const minutes = Math.max(0, Math.round((now - then) / 60_000))
108 if (minutes < 1) return 'just now'
109 if (minutes < 60) return `${minutes}m ago`
110 const hours = Math.round(minutes / 60)
111 if (hours < 48) return `${hours}h ago`
112 return `${Math.round(hours / 24)}d ago`
113}
114hooks/git.ts 38 lines1import type { Artifact } from '../types'
2import { truncate } from './record'
3
4export type GitOperation = {
5 commit?: { sha: string; kind: string; branch?: string }
6 push?: { branch: string }
7 pr?: { number: number; url?: string; action: string }
8}
9
10const COMMIT_LINE = /^\[(.+?)(?: \(root-commit\))? ([0-9a-f]{7,})\] (.+)$/m
11const PUSH_REMOTE = /^To (\S+)$/m
12const PR_URL = /https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+/
13const SUBJECT_MAX = 120
14
15export function gitArtifacts(op: GitOperation | undefined, output: string, now: number): Artifact[] {
16 if (op === undefined) return []
17 const found: Artifact[] = []
18 if (op.commit !== undefined) {
19 const line = COMMIT_LINE.exec(output)
20 found.push({
21 kind: 'commit',
22 sha: op.commit.sha,
23 subject: truncate(line?.[3] ?? '', SUBJECT_MAX),
24 branch: op.commit.branch ?? line?.[1] ?? 'HEAD',
25 at: now,
26 })
27 }
28 if (op.push !== undefined) {
29 const remote = PUSH_REMOTE.exec(output)?.[1] ?? 'origin'
30 found.push({ kind: 'push', branch: op.push.branch, remote, at: now })
31 }
32 if (op.pr !== undefined && op.pr.action === 'created') {
33 const url = op.pr.url ?? PR_URL.exec(output)?.[0] ?? ''
34 found.push({ kind: 'pr', number: op.pr.number, url, at: now })
35 }
36 return found
37}
38types/index.d.ts 31 lines1export type Artifact =
2 | { kind: 'artifact'; title: string; url: string; at: number }
3 | { kind: 'file'; path: string; at: number }
4 | { kind: 'doc'; path: string; at: number }
5 | { kind: 'commit'; sha: string; subject: string; branch: string; at: number }
6 | { kind: 'push'; branch: string; remote: string; at: number }
7 | { kind: 'pr'; url: string; number: number; at: number }
8
9export type SessionRecord = {
10 id: string
11 repo: string
12 worktree: string
13 branch: string
14 goal?: string
15 step?: string
16 next?: string
17 firstPrompt?: string
18 statusAt?: number
19 lastActive: number
20 artifacts: Artifact[]
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'session-compass': {
26 record: SessionRecord | null
27 tab: 'this' | 'all'
28 }
29 }
30}
31