scrubjay in Claude Code's own interface: named rows for sjmcp calls and scrubjay scripts, live publish/sync steps above the prompt, toasts on failure

<img src="docs/banner.png" alt="scrubjay — recall what · where · when" width="100%">
Your AI coding sessions and setup, on every machine you own.
You use a coding agent — Claude Code, opencode, codex — on more than one machine. So you have two problems you probably solve by hand today:
scrubjay fixes both, and keeps it all on hardware you own.
Three repos, so the machinery can be public without leaking anything you wrote:
| Repo | What's in it | Visibility |
|---|---|---|
| scrubjay (this) | the scripts, hooks and docs — the logic, no content | public |
| scrubjay-data | your config: instructions, commands, agents, settings, memory, session log | private, your account |
| scrubjay-chats | your full transcripts — only if you pick the GitHub relay instead of a NAS | private, your account |
<sub>Diagram source: docs/overview.dot — dot -Tsvg docs/overview.dot -o docs/overview.svg.</sub>
How it flows. When a session starts, scrubjay pulls your config out of
scrubjay-dataand applies it into whichever agents this machine has. When a session ends, a hook writes one line to a shared log ("what ran, where, when") and ships the session itself — transcript, subagents, plans — off the machine. Where it ships is your choice: peer-to-peer to your own NAS (over WireGuard or a local mount), or to a private GitHub repo if you'd rather not run storage. The archive is keyed by machine, so each one's history stays its own and your agent can adapt one machine's rules for another.
Start on the laptop, finish on the workstation. You're deep in a debugging session on the couch and want the big screen. On the workstation you run /sjresume and pick the session out of a list — it comes back with every turn, the subagents it spawned, its task list, and its file history, so even /rewind still works. The conversation travels; your code doesn't, so git still does its job.
/sjresume # pick from what's resumable on your other machines
/sjresume the foolbox refactor # …or just describe it
Find the thing you already solved. Three weeks ago you fixed a nasty auth timeout. You don't remember which machine, which repo, or which agent. Ask for it by topic and scrubjay searches every session it ever archived — across all your machines and all your agents at once — then pulls the right conversation into your current one.
/sjrecall that auth timeout fix
Move between agents. You started in opencode and want to continue in Claude Code (or the other way round). /sjresume carries the whole conversation across and seeds a fresh session with it. Be clear on what this is: same-agent hand-off resumes the actual session, id and tool history intact; across different agents you keep the conversation but start a new session. That's usually what people mean anyway, and it works in every direction today.
Set up a new machine. Clone this repo, run the onboarder, and the machine has your instructions, commands, agents and settings — in each agent you use. Nothing to copy by hand.
Teams: pick up where a colleague left off. scrubjay has no concept of a user — only of a host. So if two people point their machines at the same archive, each one's /sjresume and /sjrecall simply see the other's sessions as "another host", and a colleague can continue your conversation with its full context instead of asking you to explain it.
Honesty note. That last one falls out of the design rather than being a feature we built and tested — sharing an archive also means sharing the config and memory that ride along with it, and there's no per-person access control. Treat it as a promising trick for a small, trusting team, not a supported multi-tenant mode.
Everything scrubjay moves is one of two things, and which one it is decides how it moves:
| Things you write | Things that happen | |
|---|---|---|
| For example | instructions, commands, agents, settings, memory | transcripts, subagents, plans |
| Direction | every machine, both ways — edits merge | one way only: machine → archive |
| So it uses | git | rsync (or a NAS mount) |
The second question is privacy: anything sensitive goes straight to storage you own, never a third party. That's the whole design in one line — what you author vs. what got recorded picks git vs. rsync; sensitive vs. not picks your NAS vs. GitHub. The long version is in Concepts.
Don't fork. Clone this repo straight from upstream — it's the app, and it keeps itself current with git pull. Nothing of yours goes in it. Your content lands in private repos on your own GitHub account, which the onboarder creates for you.
Easiest — let the agent do it. Clone, open your agent inside the clone, and ask. It reads AGENTS.md, asks you the handful of questions it needs, and runs the onboarder for you:
git clone git@github.com:henba1/scrubjay.git ~/.scrubjay/scrubjay
cd ~/.scrubjay/scrubjay && claude # or: opencode
# then just say: "set up scrubjay on this machine"
By hand — run the onboarder yourself; it asks the same questions:
git clone git@github.com:henba1/scrubjay.git ~/.scrubjay/scrubjay
~/.scrubjay/scrubjay/bin/onboard.sh # asks for your GitHub account (or set SCRUBJAY_OWNER)
Either way it creates your private repos, registers this machine, applies your config, and — if you chose a peer-to-peer relay — prints one line for you to paste into the receiving machine's authorized_keys. That paste is the one step nothing here will do for you, and that's deliberate: a new machine must not be able to grant itself access to your archive.
You'll need bash, jq, git, an SSH key on GitHub, and the gh CLI — without gh the onboarder prints the exact gh repo create commands and stops, so you can run them yourself. No root required. Full walkthrough: Onboarding.
Platforms. Linux and Windows via WSL 2 are supported. macOS should work but isn't regularly tested yet. Native Windows (PowerShell/CMD/Git Bash) is not supported — the config sync is built on symlinks, which Git Bash turns into copies. On WSL, install Claude Code inside the distro and launch it from there; a native-Windows Claude Code is a separate installation that scrubjay won't see. Details and the rest of the WSL caveats: Platforms.
Full docs — henba1.github.io/scrubjay:
/sj* command reference.The docs also publish an llms.txt index for agents.
Western scrub jays are a good example of episodic-like memory in animals. They cache food in places they control, and later recover it by what they buried, where they buried it, and when — including whether it's still worth eating. That is regarded as early evidence that any non-human animal remembers a specific past event, and not just a learned habit (Clayton & Dickinson, 1998).
FSL-1.1-ALv2 — the Functional Source License, with an Apache-2.0 future licence. © 2026 Hendrik Baacke.
hooks/register.tsx 302 lines1// SPDX-License-Identifier: FSL-1.1-ALv2
2// Copyright (c) 2026 Hendrik Baacke. See LICENSE.
3
4// scrubjay in Claude Code's own interface. Three things, each drawn the way the engine draws its own:
5// - sjmcp calls and scrubjay's scripts get a row named for what they do (`⏺ Recall("…")`), and
6// a one-line result in place of raw JSON or a wall of shell output;
7// - while a publish or sync runs, a band above the prompt follows its steps live, read from the
8// progress file bin/lib.sh writes (~/.scrubjay/run/<op>.tsv);
9// - a failed step raises a toast, the SessionStart sync included, which otherwise fails unseen.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register, Timer } from 'claude-code'
13
14import type { SjActivity, SjRun } from '../types'
15import { ICON, argsLine, isSjmcp, parseRun, reportOf, scriptOf, spinner, summarize, verbOf } from './parse'
16import type { Tone } from './parse'
17
18const runAtom = atom({ plugin: 'scrubjay-ui', key: 'run' } as const, null)
19const activityAtom = atom({ plugin: 'scrubjay-ui', key: 'activity' } as const, null)
20
21const OPS = ['publish', 'sync'] as const
22const COMMAND: Record<string, string> = { sjlog: 'publish', sjsync: 'sync' }
23const POLL_MS = 250
24const LINGER_MS = 5000 // the finished run stays on the band this long
25const GIVE_UP_MS = 90_000 // a watch that never sees its run begin stops
26
27const TONE_COLOR = { ok: 'success', warn: 'warning', error: 'error', run: undefined } as const
28const STEP_COLOR = { ok: 'success', fail: 'error', skip: 'inactive', run: 'warning' } as const
29
30// A watch is module state on purpose: a reload drops its timer with the old environment, and the
31// next trigger starts a fresh one. What the band draws lives in $.state and survives.
32let runDir = ''
33let timer: Timer | null = null
34let watchFrom = 0
35let isQuietWatch = false
36let toasted = 0
37
38async function dirOf($: EngineInterface): Promise<string> {
39 if (runDir) return runDir
40 const own = await $.env.get('SCRUBJAY_RUN_DIR')
41 const home = await $.env.get('HOME')
42 runDir = own || (home ? `${home}/.scrubjay/run` : '')
43
44 return runDir
45}
46
47function stop(): void {
48 timer?.cancel()
49 timer = null
50}
51
52// Read every op's file and follow the newest run that began since the watch did.
53async function poll($: EngineInterface): Promise<void> {
54 const dir = await dirOf($)
55 const now = await $.clock.now()
56 if (!dir) {
57 stop()
58
59 return
60 }
61 let best: SjRun | null = null
62 for (const op of OPS) {
63 const text = await $.fs.read(`${dir}/${op}.tsv`).catch(() => '')
64 const run = parseRun(text, now, isQuietWatch)
65 if (run && run.startedAt >= watchFrom - 2000 && (!best || run.startedAt > best.startedAt)) best = run
66 }
67 if (!best) {
68 if (now - watchFrom > GIVE_UP_MS) stop()
69
70 return
71 }
72 const run = best
73 await update($, runAtom, () => run)
74 if (run.endedAt === undefined) return
75 stop()
76 const failed = run.steps.filter(s => s.state === 'fail')
77 // The command's own activity line ("started") would outlive the run on the band: close it out.
78 const done = run.steps.filter(s => s.state === 'ok').length
79 const secs = Math.round(((run.endedAt ?? now) - run.startedAt) / 1000)
80 await setActivity($, {
81 command: run.op,
82 line: `${done} done${failed.length ? ` · ${failed.length} failed` : ''} · ${secs}s`,
83 tone: failed.length ? 'error' : 'ok',
84 })
85 if (failed.length && toasted !== run.startedAt) {
86 toasted = run.startedAt
87 $.ui.toast(
88 `scrubjay ${run.op}: ${failed.map(s => `${s.name} ${s.detail}`).join('; ')} — /sjdoctor ${failed.map(s => s.name).join(' ')}`,
89 )
90 }
91 $.clock.after(LINGER_MS, () => {
92 void update($, runAtom, cur => (cur?.startedAt === run.startedAt ? null : cur))
93 })
94}
95
96async function watch($: EngineInterface, opts: { quiet?: boolean; backMs?: number } = {}): Promise<void> {
97 watchFrom = (await $.clock.now()) - (opts.backMs ?? 0)
98 isQuietWatch = opts.quiet ?? false
99 if (!timer) timer = $.clock.every(POLL_MS, () => void poll($))
100}
101
102async function setActivity($: EngineInterface, a: Omit<SjActivity, 'at'>): Promise<void> {
103 const at = await $.clock.now()
104 await update($, activityAtom, () => ({ ...a, at }))
105}
106
107export const register: Register = on => {
108 // The SessionStart sync ran (or is running) just before this: follow it quietly, so a failure
109 // there reaches the person as a toast instead of a line only the model reads.
110 on('session.start', async ($, e, next) => {
111 await watch($, { quiet: true, backMs: 60_000 })
112
113 return next(e)
114 })
115
116 // A typed /sjlog or /sjsync: start following before the command's own shell step runs.
117 on('prompt.submit', async ($, e, next) => {
118 const name = e.command?.name
119 if (name === 'sjlog' || name === 'sjsync') await watch($)
120
121 return next(e)
122 }).catch(($, e, next) => next(e))
123
124 on('skill.prompt', async ($, e, next) => {
125 if (/^sj[a-z]+$/.test(e.skill)) {
126 await setActivity($, { command: COMMAND[e.skill] ?? e.skill.slice(2), line: 'started', tone: 'run' })
127 if (e.skill === 'sjlog' || e.skill === 'sjsync') await watch($)
128 }
129
130 return next(e)
131 })
132
133 on('tool.call', async ($, e, next) => {
134 if (isSjmcp(e.tool)) {
135 const verb = verbOf(e.tool)
136 await setActivity($, { command: verb.toLowerCase(), line: `${verb} ${argsLine(e.tool, e)}`, tone: 'run' })
137 const ran = await next(e)
138 const s = summarize(e.tool, ran.deny ?? ran.text ?? ran.result)
139 await setActivity($, { command: verb.toLowerCase(), line: s.line, tone: ran.isError || ran.deny ? 'error' : s.tone })
140
141 return ran
142 }
143 if (e.tool === 'Bash') {
144 const script = scriptOf(e.command)
145 if (script?.op) {
146 await watch($)
147 const ran = await next(e)
148 await poll($) // the run has ended: take its last lines now, not a tick later
149
150 return ran
151 }
152 }
153
154 return next(e)
155 }).catch(($, e, next) => next(e))
156
157 on('turn.complete', async ($, e, next) => {
158 const done = await read($, activityAtom)
159 if (done) {
160 $.clock.after(LINGER_MS, () => {
161 void update($, activityAtom, cur => (cur?.at === done.at ? null : cur))
162 })
163 }
164
165 return next(e)
166 })
167
168 // ── the band ────────────────────────────────────────────────────────────────────────────────
169 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
170 if (e.props.hasSurvey) return next(e)
171 const run = await read($, runAtom)
172 const activity = await read($, activityAtom)
173 const { Box, Text } = $.ui.resolve(e)
174 const showRun = run && !(run.isQuiet && run.endedAt !== undefined)
175
176 if (showRun) {
177 const secs = Math.max(0, Math.round(((run.endedAt ?? run.now) - run.startedAt) / 1000))
178 const isLive = run.endedAt === undefined
179
180 return (
181 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
182 <Text color="claude">◆</Text>
183 <Text bold>scrubjay {run.op}</Text>
184 {run.steps.map(s => (
185 <Text key={s.name}>
186 <Text color={STEP_COLOR[s.state]}>{s.state === 'run' ? spinner(run.now) : ICON[s.state]}</Text>
187 <Text dimColor={s.state !== 'fail'}> {s.name}</Text>
188 </Text>
189 ))}
190 <Text dimColor>· {isLive ? `${secs}s` : `done in ${secs}s`}</Text>
191 </Box>
192 )
193 }
194 if (activity) {
195 const isLive = activity.tone === 'run'
196
197 return (
198 <Box flexDirection="row" columnGap={1}>
199 <Text color="claude">◆</Text>
200 <Text bold>scrubjay {activity.command}</Text>
201 <Text color={TONE_COLOR[activity.tone]}>{isLive ? '→' : activity.tone === 'error' ? '✗' : '✓'}</Text>
202 <Text dimColor wrap="truncate-end">
203 {activity.line}
204 </Text>
205 </Box>
206 )
207 }
208
209 return next(e)
210 })
211
212 // ── the transcript rows ─────────────────────────────────────────────────────────────────────
213 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
214 const p = e.props
215 if (p.isInterrupted) return next(e)
216 let verb = ''
217 let args = ''
218 if (isSjmcp(p.tool)) {
219 verb = verbOf(p.tool)
220 args = argsLine(p.tool, p.input)
221 } else if (p.tool === 'Bash') {
222 const command = (p.input as { command?: unknown } | null)?.command
223 const script = typeof command === 'string' ? scriptOf(command) : null
224 if (!script) return next(e)
225 verb = script.verb
226 args = script.detail ? `"${script.detail}"` : ''
227 } else {
228 return next(e)
229 }
230 const { Box, Text } = $.ui.resolve(e)
231 const dot = p.isRunning ? undefined : p.isErrored ? 'error' : 'success'
232
233 return (
234 <Box flexDirection="row">
235 <Text color={dot} dimColor={p.isRunning}>
236 ⏺{' '}
237 </Text>
238 <Text bold>{verb}</Text>
239 {args !== '' && <Text wrap="truncate-end">({args})</Text>}
240 <Text dimColor> · scrubjay</Text>
241 </Box>
242 )
243 })
244
245 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
246 const p = e.props
247 if (p.isErrored) return next(e)
248 const { Box, Text } = $.ui.resolve(e)
249 const color = (t: Tone) => TONE_COLOR[t]
250
251 if (isSjmcp(p.tool)) {
252 const s = summarize(p.tool, p.output)
253
254 return (
255 <Box flexDirection="column">
256 <Text>
257 <Text dimColor>{' ⎿ '}</Text>
258 <Text color={color(s.tone)}>{s.line}</Text>
259 </Text>
260 {s.rows.map((r, i) => (
261 <Text key={String(i)} dimColor wrap="truncate-end">
262 {' '}
263 {r}
264 </Text>
265 ))}
266 </Box>
267 )
268 }
269
270 if (p.tool === 'Bash') {
271 const out = p.output as { stdout?: unknown } | null
272 const rep = typeof out?.stdout === 'string' ? reportOf(out.stdout) : null
273 if (!rep || rep.steps.length === 0) return next(e)
274 const width = Math.max(...rep.steps.map(s => s.name.length))
275
276 return (
277 <Box flexDirection="column">
278 <Text>
279 <Text dimColor>{' ⎿ '}</Text>
280 <Text>{rep.header}</Text>
281 </Text>
282 {rep.steps.map(s => (
283 <Text key={s.name}>
284 {' '}
285 <Text color={STEP_COLOR[s.state]}>{ICON[s.state]}</Text> <Text>{s.name.padEnd(width)}</Text>{' '}
286 <Text dimColor={s.state !== 'fail'}>{s.detail}</Text>
287 </Text>
288 ))}
289 {rep.rest.map((l, i) => (
290 <Text key={`r${i}`} color={l.startsWith('scrubjay:') ? 'warning' : undefined} dimColor={!l.startsWith('scrubjay:')}>
291 {' '}
292 {l}
293 </Text>
294 ))}
295 </Box>
296 )
297 }
298
299 return next(e)
300 })
301}
302hooks/parse.ts 245 lines1// SPDX-License-Identifier: FSL-1.1-ALv2
2// Copyright (c) 2026 Hendrik Baacke. See LICENSE.
3
4// Pure helpers: reading scrubjay's progress files, its sjmcp results and its scripts' output.
5// Nothing here touches `$`, so the tests exercise it directly.
6
7import type { SjRun, SjStep, StepState } from '../types'
8
9export const SJMCP = 'mcp__sjmcp__'
10
11const VERBS: Record<string, string> = {
12 sj_recall: 'Recall',
13 sj_list: 'Browse',
14 sj_get: 'Get',
15 sj_search_within: 'Find',
16 sj_status: 'Status',
17}
18
19export const isSjmcp = (tool: string): boolean => tool.startsWith(SJMCP)
20
21export const verbOf = (tool: string): string => {
22 const name = tool.slice(SJMCP.length)
23
24 return VERBS[name] ?? name
25}
26
27// The scrubjay scripts a Bash call may run, named for what the person would call the action. `op`
28// is the progress file the run writes, where it writes one.
29const SCRIPTS: ReadonlyArray<{ re: RegExp; verb: string; op?: string }> = [
30 { re: /publish-now\.sh\b/, verb: 'Publish', op: 'publish' },
31 { re: /sync-session\.sh\b/, verb: 'Sync', op: 'sync' },
32 { re: /sj-doctor\.sh\b/, verb: 'Doctor' },
33 { re: /sj-note\.sh\b/, verb: 'Note' },
34 { re: /sj-resume\.sh\b/, verb: 'Resume' },
35 { re: /sj-table\.sh\b/, verb: 'Table' },
36 { re: /sj-paste\.sh\b/, verb: 'Paste' },
37 { re: /onboard-memory\.sh\b/, verb: 'Memory setup' },
38 { re: /memory-sync\.sh\b/, verb: 'Memory sync' },
39]
40
41export type Script = { verb: string; op?: string; detail: string }
42
43// A segment *runs* a script when, past any VAR=value assignments, the script is the command itself
44// or what bash/sh is handed (a quoted path may hold spaces: "$(cd -P …)/bin/sj-doctor.sh"). Naming
45// it anywhere else (`sed -n 1p hooks/publish-now.sh`, `shellcheck …`) is not running it.
46const RUNS = (re: RegExp) =>
47 new RegExp(
48 String.raw`^\s*(?:[A-Za-z_]\w*=(?:"[^"]*"|'[^']*'|\S*)\s+)*(?:(?:ba)?sh\s+(?:-\S+\s+)*)?(?:"[^"]*?|\S*?)` +
49 re.source,
50 )
51
52export function scriptOf(command: string): Script | null {
53 const segments = command.split(/&&|\|\||;|\n/)
54 const hit = SCRIPTS.find(s => segments.some(seg => RUNS(s.re).test(seg)))
55 if (!hit) return null
56 // The one argument worth showing: /sjlog's authored topic, /sjnote's topic.
57 const topic = /SCRUBJAY_TOPIC="([^"]*)"/.exec(command) ?? /--topic "([^"]*)"/.exec(command)
58
59 return { verb: hit.verb, op: hit.op, detail: topic?.[1] ?? '' }
60}
61
62const STATES: ReadonlySet<string> = new Set(['run', 'ok', 'fail', 'skip'])
63
64/** A progress file (TSV <epoch> <step> <state> <detail>) as a run; null when it holds no #begin. */
65export function parseRun(tsv: string, now: number, isQuiet = false): SjRun | null {
66 let run: SjRun | null = null
67 const steps = new Map<string, SjStep>()
68
69 for (const line of tsv.split('\n')) {
70 const [ts, name, state, detail = ''] = line.split('\t')
71 if (ts === undefined || name === undefined || state === undefined) continue
72 const at = Number(ts) * 1000
73 if (name === '#begin') {
74 run = { op: state, label: detail, startedAt: at, steps: [], isQuiet, now }
75 steps.clear()
76 } else if (name === '#end') {
77 if (run) run.endedAt = at
78 } else if (STATES.has(state)) {
79 steps.set(name, { name, state: state as StepState, detail })
80 }
81 }
82 if (!run) return null
83 // A step still running at #end never reported back: skipped, never a success (sj_progress_render).
84 run.steps = [...steps.values()].map(s =>
85 run?.endedAt !== undefined && s.state === 'run'
86 ? { ...s, state: 'skip', detail: s.detail || 'did not report' }
87 : s,
88 )
89
90 return run
91}
92
93export const ICON: Record<StepState, string> = { run: '◌', ok: '✓', fail: '✗', skip: '–' }
94
95const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
96export const spinner = (now: number): string => SPIN[Math.floor(now / 100) % SPIN.length] ?? '·'
97
98const str = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
99
100const shortRef = (ref: string): string => {
101 const base = ref.split('/').pop() ?? ref
102
103 return base.length > 40 ? `${base.slice(0, 37)}…` : base
104}
105
106/** The arguments of an sjmcp call, as the row shows them: the query first, filters after. */
107export function argsLine(tool: string, input: unknown): string {
108 const a = (input ?? {}) as Record<string, unknown>
109 const filters = ['type', 'host', 'project', 'since', 'until']
110 .filter(k => str(a[k]) !== '')
111 .map(k => `${k} ${str(a[k])}`)
112 const join = (head: string, rest: string[]) => [head, ...rest].filter(Boolean).join(' · ')
113
114 switch (tool.slice(SJMCP.length)) {
115 case 'sj_recall':
116 return join(`"${str(a.query)}"`, filters)
117 case 'sj_list':
118 return join(str(a.type) || 'chats', filters.filter(f => !f.startsWith('type ')))
119 case 'sj_get': {
120 const slice = str(a.turns) ? `turns ${str(a.turns)}` : str(a.lines) ? `lines ${str(a.lines)}` : ''
121
122 return join(shortRef(str(a.ref)), [slice, str(a.format)])
123 }
124 case 'sj_search_within':
125 return `"${str(a.query)}" in ${shortRef(str(a.ref))}`
126 default:
127 return ''
128 }
129}
130
131/** The text of a tool's output, whatever shape the transcript kept it in. */
132export function textOf(output: unknown): string {
133 if (typeof output === 'string') return output
134 if (Array.isArray(output)) return output.map(textOf).join('\n')
135 if (output && typeof output === 'object') {
136 const o = output as Record<string, unknown>
137 if (typeof o.text === 'string') return o.text
138 if (Array.isArray(o.content)) return textOf(o.content)
139
140 return JSON.stringify(output)
141 }
142
143 return ''
144}
145
146const parseJson = (text: string): Record<string, unknown> | null => {
147 try {
148 const v: unknown = JSON.parse(text)
149
150 return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
151 } catch {
152 return null
153 }
154}
155
156export type Tone = 'ok' | 'warn' | 'error'
157export type Summary = { line: string; tone: Tone; rows: string[] }
158
159const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
160
161/** An sjmcp result in one line (and, for a recall, its top hits) in place of the raw JSON. */
162export function summarize(tool: string, output: unknown): Summary {
163 const text = textOf(output)
164 const o = parseJson(text)
165 if (!o) return { line: text.split('\n')[0]?.slice(0, 120) ?? '', tone: 'ok', rows: [] }
166 if (typeof o.error === 'string') {
167 const where = typeof o.host === 'string' ? ` — look on ${o.host}` : ''
168
169 return { line: `${o.error}${where}`, tone: 'error', rows: [] }
170 }
171
172 switch (tool.slice(SJMCP.length)) {
173 case 'sj_recall': {
174 const results = Array.isArray(o.results) ? (o.results as Record<string, unknown>[]) : []
175 const hosts = new Set(results.map(r => str(r.host)).filter(Boolean))
176 const line =
177 results.length === 0
178 ? 'no candidates'
179 : `${plural(results.length, 'candidate')}${hosts.size ? ` from ${plural(hosts.size, 'host')}` : ''}`
180 const rows = results.slice(0, 3).map(r =>
181 [str(r.date), str(r.host) || str(r.project), str(r.topic) || str(r.type)].filter(Boolean).join(' · '),
182 )
183
184 return { line, tone: results.length ? 'ok' : 'warn', rows }
185 }
186 case 'sj_list': {
187 const shown = Number(o.shown ?? 0)
188 const total = Number(o.total ?? shown)
189 const line = total === 0 ? 'nothing listed' : shown < total ? `${shown} of ${total} listed` : `${plural(total, 'item')} listed`
190
191 return { line, tone: total ? 'ok' : 'warn', rows: [] }
192 }
193 case 'sj_get': {
194 const lines = str(o.content).split('\n').length
195 const parts = [`pulled ${shortRef(str(o.path))}`, str(o.format), plural(lines, 'line')]
196 if (o.truncated === true) parts.push('truncated')
197
198 return { line: parts.filter(Boolean).join(' · '), tone: o.truncated === true ? 'warn' : 'ok', rows: [] }
199 }
200 case 'sj_search_within': {
201 const n = Number(o.matches ?? 0)
202
203 return { line: `${plural(n, 'match', 'matches')} in ${shortRef(str(o.path))}`, tone: n ? 'ok' : 'warn', rows: [] }
204 }
205 case 'sj_status': {
206 const counts = (o.counts ?? {}) as Record<string, number>
207 const line = Object.entries(counts)
208 .filter(([, n]) => n > 0)
209 .map(([k, n]) => `${n} ${k.replace('_', ' ')}`)
210 .join(' · ')
211 const missing = Array.isArray(o.unavailable) ? (o.unavailable as string[]) : []
212
213 return {
214 line: line || 'archive is empty',
215 tone: missing.length ? 'warn' : 'ok',
216 rows: missing.length ? [`not reachable here: ${missing.join(', ')}`] : [],
217 }
218 }
219 default:
220 return { line: text.split('\n')[0]?.slice(0, 120) ?? '', tone: 'ok', rows: [] }
221 }
222}
223
224export type Report = { header: string; steps: SjStep[]; rest: string[] }
225
226const STEP_LINE = /^ {2}([✓✗–…]) (\S+)\s+(.*)$/
227const FROM_ICON: Record<string, StepState> = { '✓': 'ok', '✗': 'fail', '–': 'skip', '…': 'run' }
228
229/** The step report sj_progress_render printed into a script's stdout, and the lines around it. */
230export function reportOf(stdout: string): Report | null {
231 const lines = stdout.replace(/\n$/, '').split('\n')
232 const at = lines.findIndex(l => /^scrubjay \S+/.test(l))
233 if (at < 0) return null
234 const steps: SjStep[] = []
235 let i = at + 1
236 for (; i < lines.length; i++) {
237 const m = STEP_LINE.exec(lines[i] ?? '')
238 if (!m) break
239 steps.push({ name: m[2] ?? '', state: FROM_ICON[m[1] ?? ''] ?? 'skip', detail: m[3] ?? '' })
240 }
241 const rest = [...lines.slice(0, at), ...lines.slice(i)].filter(l => l.trim() !== '' && !/^exit: \d+$/.test(l))
242
243 return { header: lines[at] ?? '', steps, rest }
244}
245types/index.d.ts 27 lines1export type StepState = 'run' | 'ok' | 'fail' | 'skip'
2
3/** One step of a scrubjay run, at its last reported state. */
4export type SjStep = { name: string; state: StepState; detail: string }
5
6/** A run as bin/lib.sh records it in ~/.scrubjay/run/<op>.tsv (sj_progress_begin … sj_progress_end). */
7export type SjRun = {
8 op: string
9 label: string
10 startedAt: number
11 endedAt?: number
12 steps: SjStep[]
13 /** Followed without the person asking (the SessionStart sync): the band stays out of it. */
14 isQuiet: boolean
15 /** When the band last redrew it, for the elapsed counter and the spinner frame. */
16 now: number
17}
18
19/** What the current /sj* command is doing, for the band while no run is being followed. */
20export type SjActivity = { command: string; line: string; tone: 'run' | 'ok' | 'warn' | 'error'; at: number }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'scrubjay-ui': { run: SjRun | null; activity: SjActivity | null }
25 }
26}
27