SLOPSHOPPER

scrubjay-ui

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

bandrowsguardtoastprompttimer
★ 14v0.1.0NOASSERTIONupdated 2026-10-16henba1/scrubjay/mods/scrubjay-ui
A shopper browsing a rack in a slop shop
README

<img src="docs/banner.png" alt="scrubjay — recall what · where · when" width="100%">

License: FSL-1.1-ALv2 tests codecov Docs Built for opencode Built for Claude Code shell: bash

scrubjay

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:

  1. Your setup doesn't travel. The instructions, commands and agents you tuned on the laptop aren't on the workstation.
  2. Your chats don't travel — and then they're gone. Yesterday's session, the one where you worked out the tricky thing, is on the other machine. Or it aged out.

scrubjay fixes both, and keeps it all on hardware you own.

  • One config, every machine. Write your instructions, commands and agents once. Every machine picks them up — and so does every agent, since the same setup lands in Claude Code and opencode.
  • Every session archived, and searchable in plain English. When a session ends it's copied to your own NAS automatically. Later you ask "what was that fix for the auth timeout?" and get the real conversation back — whichever machine and whichever agent it happened on.
  • Yours. Your config and chats live in private repos on your own account; transcripts go straight to your own storage. This repo is only the machinery, which is why you can read every line of what runs on your machines.

Three repos, so the machinery can be public without leaking anything you wrote:

RepoWhat's in itVisibility
scrubjay (this)the scripts, hooks and docs — the logic, no contentpublic
scrubjay-datayour config: instructions, commands, agents, settings, memory, session logprivate, your account
scrubjay-chatsyour full transcripts — only if you pick the GitHub relay instead of a NASprivate, your account

scrubjay — system overview

<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-data and 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.

What you'd actually use it for

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.

The core idea: two kinds of sync

Everything scrubjay moves is one of two things, and which one it is decides how it moves:

Things you writeThings that happen
For exampleinstructions, commands, agents, settings, memorytranscripts, subagents, plans
Directionevery machine, both ways — edits mergeone way only: machine → archive
So it usesgitrsync (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.

Quick start

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.

Documentation

Full docs — henba1.github.io/scrubjay:

  • Concepts — the two-kinds-of-sync model and what lives in the database.
  • Harnesses — Claude Code, opencode, codex: what's supported, and what syncs into opencode.
  • Onboarding — install on a new machine, the repo layout, and machine-local pointers.
  • Day-to-day — the hooks that keep it hands-off, finding a past chat, troubleshooting.
  • Query the archive (MCP) — recall a past session by topic from inside a live Claude session.
  • Slash commands — the /sj* command reference.
  • Transcripts: relay + NAS — the peer-to-peer paths (WireGuard / SSH) to your own NAS.
  • Reference — the by-hand command cheatsheet and environment toggles.

The docs also publish an llms.txt index for agents.

Why the name?

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

License

FSL-1.1-ALv2 — the Functional Source License, with an Apache-2.0 future licence. © 2026 Hendrik Baacke.

  • You may read it, run it, modify it, fork it, and use it inside your own organisation, commercially included. All of that is a Permitted Purpose under the licence.
  • You may not make it available to others as a commercial product or sell a service that implements scrubjay or constituent parts thereof.
Source 3 files
hooks/register.tsx 302 lines
1// 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}
302
hooks/parse.ts 245 lines
1// 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}
245
types/index.d.ts 27 lines
1export 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