SLOPSHOPPER

session-activity

Side-effects ledger (/ledger) with a hold on production SQL writes, and a waiting-on tracker for background work

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-activity
│ ┃ Side-effects ledger ✕ › fix the failing auth test and add an audit log call │ ┃ 1 action · this session All sessions │ ┃ ⏺ Read(src/auth.ts) │ ┃ 01:53 ✓ git push ⎿ Read 6 lines │ ┃ rm -rf build && git push --force origin ⏺ Update(src/auth.ts) │ ┃ main ⎿ 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 │ │ › /ledger │ ⎿ session-activity: Ledger pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Side-effects ledger
1 action · this session All sessions 01:53 ✓ git push rm -rf build && git push --force origin main
README

Claude Code mods for running a lot of sessions at once

Five Claude Code mods built from how I actually use Claude Code: four to six sessions at a time, mostly in one repo, a lot of PRs going to production, and a pile of rules I keep forgetting to check.

Claude built all five. The full story, with screenshots, videos and the bugs a real run caught, is on my site: I asked Claude to read 30 of my Claude Code sessions and build the mods I needed

The session-fleet pane listing two live sessions, one waiting on a permission prompt

The prompt

This is what I gave Claude Code. It read my last 30 sessions, came back with ten ideas, and built all ten as these five mods.

look at the last 30 sessions and see how we use claude to work - then look at https://code.claude.com/docs/en/plugins/mods/overview and suggest 10 possible mods for our workflow that would help me be better informed as to what each session is doing.

Run it on your own sessions. Your ten ideas will probably come out different from mine, and I'd like to see them.

The five mods

ModWhat it gives youCommands
session-fleetA board of every live session, a "needs you" row when another session is blocked on you, and a guard when two sessions write to the same worktree/fleet
ship-trackerA row per PR: CI → merged → production deployment → live, a warning when a merge never deploys, and a receipt line after every answer/ship-track <pr>
session-hygieneConfigurable tripwires (typecheck before push, review before push, mutation-check new tests and more), plus a handoff card of loose ends from earlier sessions/handoff, /handoff clear
session-activityA ledger of everything that left the machine, a hold on database writes, and what each session is waiting on/ledger, /waiting
context-gaugeContext fill in the status line, warnings before compaction, and a snapshot of in-flight work that survives compaction/context-log

They work on their own. Install any one, or all five.

Install

You need a Claude Code version with mods (see the mods docs), git, and for ship-tracker and the handoff card, the GitHub CLI signed in.

A mod is code that runs with your permissions inside Claude Code. Read it before you install it. Each one is a single hooks/register.tsx file, and claude plugin validate <folder> lists exactly which events it hooks and what it calls.

From the marketplace

In a Claude Code session:

/plugin marketplace add bennewton999/claude-code-mods
/plugin install session-fleet@bennewton-mods
/plugin install ship-tracker@bennewton-mods
/plugin install session-hygiene@bennewton-mods
/plugin install session-activity@bennewton-mods
/plugin install context-gauge@bennewton-mods

Then /reload-plugins, or start a new session.

From a clone

git clone https://github.com/bennewton999/claude-code-mods ~/claude-code-mods

Load them in every session by adding the folders to env in ~/.claude/settings.json (colon-separated):

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/session-fleet:/Users/you/claude-code-mods/ship-tracker:/Users/you/claude-code-mods/session-hygiene:/Users/you/claude-code-mods/session-activity:/Users/you/claude-code-mods/context-gauge",
    "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
  }
}

Or try one for a single session:

claude --plugin-dir ~/claude-code-mods/session-fleet

CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop app sessions hot-reload a mod when its files change (an interactive terminal session already does). A session reads it at startup, so sessions that were already open pick up the mods, and the watch, after one restart.

What each one does

session-fleet

Every session writes one record to a key-value store that all sessions on the machine share ($.store), with a heartbeat every 30 seconds. Three minutes without a heartbeat and the session counts as gone.

  • /fleet opens a pane with every live session: a status dot, its first prompt (there's no session title API), the worktrees it's touching, branch, PR, last action and how long ago. Waiting sessions sort to the top, and a worktree two live sessions share gets a ⚠.
  • Switch by clicking. In the desktop app, click another session's title in the pane to jump to it, the same as clicking it in the sidebar. Terminal sessions show their title as plain text.
  • Project pills. Each session gets a pill naming its repo. To name and color your own projects, set projectPills to Label:regex:color entries separated by ;, the regex tested against the repo folder name:
  "pluginConfigs": {
    "session-fleet": {
      "options": { "projectPills": "Work:acme|billing:#2563eb; Blog:blog:#059669" }
    }
  }
  • Needs-you row. When another session is blocked on you, a row shows above your prompt with the reason and how long it has waited, plus a toast. "Blocked" means a permission prompt, a question dialog, or a turn that ended with a question mark. That last one is a guess.
  • Worktree guard. The first session to edit a file or run a mutating git command (commit, checkout, rebase, push…) in a worktree claims it. A second live session that tries gets a Proceed or Stop question naming the other session, its branch and last action. Claims let go after 30 minutes idle.

The needs-you row above the prompt The worktree guard question

ship-tracker

  • Picks up PRs from gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.
  • Every 45 seconds it reads the PR state and checks from GitHub. After the merge, it finds the GitHub deployment for the merge commit in the Production environment. Live means that deployment succeeded.
  • Merged for 15 minutes with no production deployment? The row says no prod deploy!. That's the silent Vercel git-trigger failure that got me before.
  • A receipt line after every answer: duration, tool calls, files edited, shell commands, outward actions (push, PR, MCP writes) and context fill.

Two PRs in the ship band

session-hygiene

Tripwires for rules you keep forgetting. When one trips you get a transcript line and a toast, and Claude gets a reminder with the tool result so it can act on it.

Every rule is a plugin option. You set them on the install screen, or later in /config. Out of the box only the two generic rules are on; the rest stay off until you point them at your repos.

OptionDefaultWhat it does
holdEnvSourcingonAsks Run it or Stop before a command sources a .env file into the shell
mutationCheckTestsonWhen Claude writes a new test file, reminds it to prove the test fails without the code it covers
reviewBeforePushoffFlags a git push with edits made since the last local /code-review
uncheckedReposnonePath fragments (/my-app/) of repos whose CI doesn't typecheck or build. There it flags a push after TypeScript edits with no typecheck, and a merge after package.json or next.config changes with no local build
middlewareRouteReposnoneNext.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it
bannedStringsnoneCase-insensitive text that should never be written into a file
supabaseoffAsks before a command uses a service-role key; after apply_migration, reminds Claude to run NOTIFY pgrst, 'reload schema' and to revoke default grants on new tables

If you load the mod from a clone instead of the marketplace, set the same options in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "session-hygiene": {
      "options": {
        "uncheckedRepos": ["/my-app/"],
        "bannedStrings": ["lorem ipsum"],
        "supabase": true
      }
    }
  }
}

The handoff card: after each turn a session saves its open loops (open PRs, uncommitted or unpushed worktrees, a question it left you). A new session lists loops from sessions that ended or went quiet, re-checked live first. /handoff shows them again, /handoff clear dismisses them.

A tripwire, and Claude responding to it The handoff card in a new session

session-activity

  • /ledger lists everything that left the machine: MCP write tools, git push, gh pr create and merge, gh api writes, Vercel deploys, curl writes, publishes, SQL writes. A button switches to all sessions in the last 24 hours. Reads stay out of it.
  • Database write hold. Any execute_sql that writes (INSERT, UPDATE, DELETE, DDL, GRANT…) stops for a Run it or Stop question. SELECTs and NOTIFY pass. Words inside comments and quoted strings don't count.
  • Waiting-on. The spinner shows pending background work (Thinking · 2 bg · agent 3m…), and a row above the prompt lists background commands, agents, workflows and monitors still running after Claude stops. /waiting lists them, /waiting clear resets.

The ledger pane

context-gauge

  • The status line shows ctx 62% · 124k/200k · compacted 1×.
  • Toasts at 70% and 85%, and from 80% a row above the prompt with a Compact now button.
  • Before any compaction it snapshots what's in flight (task, latest request, files edited, PR links, last actions), asks the summarizer to keep it, logs it, and hands it back to Claude with your next prompt.
  • /context-log lists every compaction this session with its snapshot.

The context log after a compaction

Privacy

Everything these mods record stays on your machine in Claude Code's per-plugin store. The only network calls are gh requests to GitHub for PR and deployment state (ship-tracker, and the handoff card's re-check).

Known issues

  • Tested in the terminal. The desktop app's Code tab should draw the same panes and rows, but I haven't seen it yet.
  • In one long session, /ledger reported the pane as opened but it never drew. It works in fresh sessions; the built-in diff pane seemed to be in front of it.
  • The mods API is still early access, so an update to Claude Code can break things here.
  • The needs-you "ended with a question" check is a heuristic and will be wrong sometimes.

Who made this

I'm Ben Newton. I write about building with AI at benenewton.com, and I'm building BlackOps Center, the platform that runs my site, my posts and the notes these Claude sessions write into. If you want to see it, start at blackopscenter.com/start.

Claude Code wrote the mods with its built-in plugin-authoring skill. Issues and PRs are welcome.

MIT licensed.

Source 1 files
hooks/register.tsx 331 lines
1import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
2
3// session-activity:
4//   4. side-effects ledger: every action that leaves the machine (MCP writes,
5//      pushes, PRs, deploys, posts, emails, SQL writes) is logged with its
6//      result and link; /ledger opens it as a pane (this session or all
7//      sessions in the last 24h). Writes to a database through execute_sql are
8//      held with a question first.
9//   5. waiting-on tracker: background commands, subagents, workflows and
10//      monitors, plus foreground calls running long, shown beside the spinner
11//      and in the band above the prompt with elapsed time.
12
13const PANE = 'ledger'
14const LEDGER = 'l:'
15const DAY = 24 * 60 * 60_000
16const LONG_MS = 15_000 // a foreground call running this long counts as waiting
17const STALE_MS = 3 * 60 * 60_000
18
19type Entry = { ts: number; kind: string; detail: string; ok: boolean; link: string | null }
20type Ledger = { sessionId: string; task: string; entries: Entry[] }
21type Wait = { id: string; label: string; kind: string; since: number; background: boolean }
22
23let sessionId = ''
24let ledger: Ledger = { sessionId: '', task: '', entries: [] }
25let showAll = false
26let timers: Timer[] = []
27const waits = new Map<string, Wait>() // keyed by tool_use_id
28
29const HUMAN = new Set(['composer', 'bridge', 'sdk'])
30const OUTWARD =
31  /^(post|patch|put|create|update|delete|send|publish|apply|upload|trash|merge|fire|schedule|bind|set|reply|forward|share|deploy|log|calibrate|promote|rollback|add|remove|buy|cancel|respond|label|unlabel|mark|archive)_/
32const LOCAL_SERVERS =
33  /^mcp__(Claude_Browser|computer-use|claude-in-chrome|visualize|ccd_session|ccd_view|ccd_window|ccd_sidebar|ccd_directory|ccd_connectors|terminal|filesystem|puppeteer|config-registry|mcp-registry)/
34const SQL_WRITE =
35  /\b(insert\s+into|update\s+[\w."]+\s+set|delete\s+from|alter\s+(table|type|function|policy|view)|drop\s+\w|truncate\s|create\s+(or\s+replace\s+)?\w|grant\s|revoke\s|comment\s+on|copy\s+\w)/i
36
37const short = (s: string, n: number) => {
38  const one = s.replace(/\s+/g, ' ').trim()
39  return one.length > n ? one.slice(0, n - 1) + '…' : one
40}
41const elapsed = (ms: number) => {
42  const s = Math.max(0, Math.round(ms / 1000))
43  return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h${Math.round((s % 3600) / 60)}m`
44}
45const hhmm = (ts: number) => new Date(ts).toTimeString().slice(0, 5)
46const firstUrl = (text: string) => text.match(/https:\/\/[^\s"'<>)\]]+/)?.[0] ?? null
47
48type Call = { tool: string } & Record<string, unknown>
49
50// What a call does to the outside world, or null when it stays local.
51function classify(call: Call): { kind: string; detail: string } | null {
52  const t = call.tool
53  if (t === 'Bash' && typeof call.command === 'string') {
54    const c = call.command
55    const rules: Array<[RegExp, string]> = [
56      [/\bgit\s+push\b/, 'git push'],
57      [/\bgh\s+pr\s+(create|merge|close|comment|review|edit|ready)\b/, 'gh pr'],
58      [/\bgh\s+(issue|release)\s+(create|close|comment|edit)\b/, 'gh'],
59      [/\bgh\s+api\b.*(-X|--method)\s*(POST|PUT|PATCH|DELETE)/i, 'gh api write'],
60      [/\bvercel\b.*(--prod|\bdeploy\b|\balias\b|\benv\s+(add|rm)\b|\bpromote\b|\brollback\b)/, 'vercel'],
61      [/\bcurl\b.*(-X\s*(POST|PUT|PATCH|DELETE)|--data|\s-d\s)/i, 'http write'],
62      [/\b(npm|bun|pnpm)\s+publish\b/, 'publish'],
63      [/\bsupabase\s+(db\s+push|migration\s+up|functions\s+deploy)\b/, 'supabase cli'],
64      [/\bfastlane\b|\baltool\b|\bxcrun\s+notarytool\b/, 'app store'],
65    ]
66    for (const [re, kind] of rules) if (re.test(c)) return { kind, detail: short(c, 90) }
67    return null
68  }
69  if (t === 'Artifact' && (call.action === undefined || call.action === 'publish' || call.action === 'delete'))
70    return { kind: `artifact ${String(call.action ?? 'publish')}`, detail: short(String(call.file_path ?? call.url ?? ''), 90) }
71  if (!t.startsWith('mcp__') || LOCAL_SERVERS.test(t)) return null
72
73  const server = t.split('__')[1] ?? ''
74  let name = t.split('__').pop() ?? t
75  let args: Record<string, unknown> = call
76  if (name === 'use_tool' && typeof call.name === 'string') {
77    name = call.name
78    args = (call.args as Record<string, unknown>) ?? {}
79  }
80  if (name === 'execute_sql') {
81    const q = String(call.query ?? '')
82    if (!SQL_WRITE.test(stripSql(q))) return null
83    return { kind: 'sql write', detail: short(q, 90) }
84  }
85  if (!OUTWARD.test(name)) return null
86  const hint = ['text', 'title', 'subject', 'to', 'name', 'domain', 'id', 'note_id', 'post_id', 'query']
87    .map(k => args[k])
88    .find(v => typeof v === 'string' && v.length > 0) as string | undefined
89  const where = /supabase/.test(server) ? 'supabase ' : /vercel/.test(server) ? 'vercel ' : ''
90  return { kind: `${where}${name}`, detail: hint ? short(hint, 80) : '' }
91}
92
93// Drop comments and quoted strings so words inside them don't count as SQL.
94const stripSql = (q: string) =>
95  q.replace(/--[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/'(?:[^']|'')*'/g, "''").replace(/\$\$[\s\S]*?\$\$/g, ' ')
96
97async function persist($: Engine) {
98  if (sessionId) await $.store.set(LEDGER + sessionId, ledger)
99}
100
101async function allToday($: Engine): Promise<Array<Entry & { task: string; mine: boolean }>> {
102  const now = await $.clock.now()
103  const rows: Array<Entry & { task: string; mine: boolean }> = []
104  for (const k of await $.store.keys()) {
105    if (!k.startsWith(LEDGER)) continue
106    const l = (await $.store.get(k)) as Ledger | undefined
107    if (!l) continue
108    const recent = l.entries.filter(e => now - e.ts < DAY)
109    if (recent.length === 0 && l.sessionId !== sessionId) {
110      await $.store.delete(k)
111      continue
112    }
113    for (const e of recent) rows.push({ ...e, task: l.task, mine: l.sessionId === sessionId })
114  }
115  return rows.sort((a, b) => b.ts - a.ts)
116}
117
118function waitLabel(call: Call): { label: string; kind: string } {
119  if (call.tool === 'Bash') return { label: String(call.description ?? short(String(call.command ?? ''), 40)), kind: 'bg' }
120  if (call.tool === 'Agent' || call.tool === 'Task')
121    return { label: String(call.description ?? call.subagent_type ?? 'agent'), kind: 'agent' }
122  if (call.tool === 'Workflow') return { label: 'workflow', kind: 'workflow' }
123  if (call.tool === 'Monitor') return { label: String(call.description ?? 'monitor'), kind: 'monitor' }
124  const name = call.tool.split('__').pop() ?? call.tool
125  return { label: name, kind: 'call' }
126}
127
128async function prune($: Engine) {
129  const now = await $.clock.now()
130  for (const [k, w] of waits) if (now - w.since > STALE_MS) waits.delete(k)
131}
132
133function waitingSummary(now: number) {
134  const list = [...waits.values()].filter(w => w.background || now - w.since > LONG_MS)
135  return list.sort((a, b) => a.since - b.since)
136}
137
138export const register: Register = on => {
139  on('session.start', async ($, e, next) => {
140    sessionId = await $.session.id()
141    ledger = ((await $.store.get(LEDGER + sessionId)) as Ledger | undefined) ?? { sessionId, task: '', entries: [] }
142    await $.command.register({ name: 'ledger', description: 'Everything this session sent outside the machine, as a pane' })
143    await $.command.register({
144      name: 'waiting',
145      description: 'What this session is waiting on (background commands, agents, workflows). /waiting clear forgets them.',
146      argumentHint: '[clear]',
147    })
148    for (const t of timers) t.cancel()
149    timers = [$.clock.every(5000, () => void (waits.size > 0 ? $.ui.invalidate('ui.render') : undefined))]
150    return next(e)
151  })
152
153  on('command.run', { command: 'ledger' }, async $ => {
154    await $.ui.open({ id: PANE, title: 'Side-effects ledger' })
155    return { text: 'Ledger pane opened.' }
156  })
157
158  on('command.run', { command: 'waiting' }, async ($, e) => {
159    if ((e.args ?? '').trim() === 'clear') {
160      waits.clear()
161      $.ui.invalidate('ui.render')
162      return { text: 'Cleared.' }
163    }
164    const now = await $.clock.now()
165    const list = waitingSummary(now)
166    return {
167      text: list.length
168        ? list.map(w => `${w.kind.padEnd(8)} ${elapsed(now - w.since).padStart(5)}  ${w.label}`).join('\n')
169        : 'Not waiting on anything.',
170    }
171  })
172
173  on('prompt.submit', async ($, e, next) => {
174    const kind = e.origin?.kind
175    if ((!kind || HUMAN.has(kind)) && !ledger.task && !e.text.startsWith('<')) {
176      ledger.task = short(e.text, 70)
177      await persist($)
178    }
179    // A background task finished: <task-notification> names its tool-use id
180    if (kind === 'task-notification' || e.text.includes('<task-notification>')) {
181      for (const m of e.text.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>[\s\S]*?<status>([^<]+)<\/status>/g)) {
182        if (m[2] !== 'running') waits.delete(m[1]!.trim())
183      }
184      $.ui.invalidate('ui.render')
185    }
186    return next(e)
187  })
188
189  on('tool.call', async ($, e, next) => {
190    const call = e as unknown as Call
191    const isMain = e.agentId === undefined
192    const effect = isMain ? classify(call) : null
193
194    // 4. hold database writes until the user says go
195    if (effect?.kind === 'sql write') {
196      let answer = 'Stop'
197      try {
198        answer = await $.ui.ask(`Write to the database with execute_sql? ${short(String(call.query ?? ''), 220)}`, {
199          header: 'SQL write',
200          options: ['Run it', 'Stop'],
201        })
202      } catch {
203        answer = 'Stop'
204      }
205      if (answer !== 'Run it') {
206        ledger.entries.push({ ts: await $.clock.now(), kind: 'sql write', detail: `STOPPED: ${effect.detail}`, ok: false, link: null })
207        await persist($)
208        return { deny: 'session-activity: the user stopped this database write. Ask them before writing to the database.' }
209      }
210    }
211
212    // 5. track what we are waiting on
213    const background = call.run_in_background === true || ((call.tool === 'Agent' || call.tool === 'Workflow') && call.run_in_background !== false) || call.tool === 'Monitor'
214    if (isMain) {
215      const { label, kind } = waitLabel(call)
216      waits.set(e.tool_use_id, { id: e.tool_use_id, label: short(label, 50), kind, since: await $.clock.now(), background: false })
217    }
218
219    let ran
220    try {
221      ran = await next(e)
222    } finally {
223      const w = waits.get(e.tool_use_id)
224      if (w) {
225        // A background launch stays on the list until its notification arrives
226        if (background && !w.background) waits.set(e.tool_use_id, { ...w, background: true })
227        else waits.delete(e.tool_use_id)
228      }
229      void prune($)
230    }
231
232    if (effect && !('deny' in ran && ran.deny !== undefined)) {
233      ledger.entries.push({
234        ts: await $.clock.now(),
235        kind: effect.kind,
236        detail: effect.detail,
237        ok: !ran.isError,
238        link: firstUrl(ran.text ?? ''),
239      })
240      ledger.entries = ledger.entries.slice(-300)
241      await persist($)
242      $.ui.invalidate('ui.render')
243    }
244    return ran
245  })
246
247  on('turn.complete', async ($, e, next) => {
248    // Foreground calls can't outlive a main-loop turn
249    if (e.agentId === undefined) for (const [k, w] of waits) if (!w.background) waits.delete(k)
250    return next(e)
251  })
252
253  // 5. spinner suffix while working
254  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
255    const now = await $.clock.now()
256    const list = waitingSummary(now)
257    if (list.length === 0) return next(e)
258    const bg = list.filter(w => w.background).length
259    const oldest = list[0]!
260    const bits = [bg ? `${bg} bg` : '', `${oldest.kind} ${elapsed(now - oldest.since)}`].filter(Boolean)
261    return next({ ...e, props: { ...e.props, suffix: ` · ${bits.join(' · ')}…` } })
262  })
263
264  // 5. band: background work still running
265  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
266    const below = await next(e)
267    if (e.props.hasSurvey) return below
268    const now = await $.clock.now()
269    const bg = waitingSummary(now).filter(w => w.background)
270    if (bg.length === 0) return below
271    const { Box, Text } = $.ui.resolve(e)
272    const cols = e.props.bodyColumns ?? 80
273    return (
274      <Box flexDirection="column">
275        <Text>
276          <Text color="cyan">⧗ Waiting on </Text>
277          <Text>
278            {short(bg.map(w => `${w.kind} "${w.label}" ${elapsed(now - w.since)}`).join(' · '), Math.max(30, cols - 14))}
279          </Text>
280        </Text>
281        {below}
282      </Box>
283    )
284  })
285
286  // 4. ledger pane
287  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
288    const { Box, Text, Button, Link } = $.ui.resolve(e)
289    const cols = Math.max(30, e.props.bodyColumns ?? 60)
290    const rows = showAll
291      ? await allToday($)
292      : [...ledger.entries].reverse().map(x => ({ ...x, task: ledger.task, mine: true }))
293    return (
294      <Box flexDirection="column" gap={1}>
295        <Box gap={2}>
296          <Text dimColor>
297            {rows.length} action{rows.length === 1 ? '' : 's'} · {showAll ? 'all sessions, last 24h' : 'this session'}
298          </Text>
299          <Button
300            key="scope"
301            label={showAll ? 'This session' : 'All sessions'}
302            plain
303            onPress={() => {
304              showAll = !showAll
305              $.ui.invalidate('ui.render')
306            }}
307          />
308        </Box>
309        {rows.length === 0 && <Text dimColor>Nothing has left the machine yet.</Text>}
310        {rows.slice(0, 80).map((r, i) => (
311          <Box key={`${r.ts}-${i}`} flexDirection="column">
312            <Text>
313              <Text dimColor>{hhmm(r.ts)} </Text>
314              <Text color={r.ok ? 'green' : 'red'}>{r.ok ? '✓' : '✗'} </Text>
315              <Text bold>{r.kind}</Text>
316              {showAll && !r.mine && <Text dimColor> · {short(r.task || 'other session', 30)}</Text>}
317            </Text>
318            {r.detail && <Text dimColor>  {short(r.detail, cols - 2)}</Text>}
319            {r.link && (
320              <Box>
321                <Text>  </Text>
322                <Link href={r.link} label={short(r.link.replace(/^https:\/\//, ''), cols - 4)} />
323              </Box>
324            )}
325          </Box>
326        ))}
327      </Box>
328    )
329  })
330}
331