SLOPSHOPPER

session-compass

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-compass
│ ┃ Compass ✕ › fix the failing auth test and add an audit log call │ ┃ [ This session ][ All sessions ] │ ┃ app · /work/app (feat/auth-refresh) ⏺ Read(src/auth.ts) │ ┃ Goal: (no goal set) ⎿ Read 6 lines │ ┃ Now: - · Next: - ⏺ Update(src/auth.ts) │ ┃ status never set ⎿ 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 │ │ › /compass │ ⎿ session-compass: Compass opened. │ │ app · app (feat/auth-refresh) · Goal: "fix the failing auth test and add an audit log call" (no goa… ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app · app (feat/auth-refresh) · Goal: "fix the failing auth test and add an audit log call" (no goa… ⟨Claude Code's own drawing⟩
Pane · Compass
[ This session ][ All sessions ] app · /work/app (feat/auth-refresh) Goal: (no goal set) Now: - · Next: - status never set
README

session-compass

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 ✗

What it adds

Compass band

One line above the prompt: repo · worktree (branch) · Goal · Now · N artifacts · tests.

  • It is always up, from the moment the session starts, and updates as the goal, step and artifacts change. Only a survey on screen takes its place.
  • With no goal set, it shows your first prompt dimmed and (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.
  • Narrow terminals drop the artifact count, then tests, then shorten the worktree path.

/compass pane

  • This session tab: repo, worktree and branch; goal; step and next; how long ago the status was set; Test Board's pass/fail tally; and every artifact, grouped as Published artifacts, Files sent, Documents, Commits, Pushes and Pull requests. Press an entry to copy it (URL for artifacts and PRs, path for files and documents, the commit sha as the Bash tool reports it, branch name for pushes).
  • All sessions tab: the other recorded sessions, most recently active first, up to
  • Press a row to copy claude --resume <id>.

compass_status tool

The 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.

What counts as an artifact

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.
  • Git commits, pushes and created pull requests, as reported by the Bash tool.

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.

Install

/plugin marketplace add abhijitbansal/claude-skills
/plugin install session-compass@claude-skills

Limits

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.

Data

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.

Develop

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-compass
Source 8 files
hooks/register.tsx 358 lines
1import { 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}
358
hooks/band.tsx 63 lines
1import 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}
63
hooks/capture.ts 59 lines
1import 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}
59
hooks/pane.tsx 106 lines
1import 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}
106
hooks/io.ts 13 lines
1import 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)
13
hooks/record.ts 114 lines
1import 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}
114
hooks/git.ts 38 lines
1import 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}
38
types/index.d.ts 31 lines
1export 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