SLOPSHOPPER

repo-sessions

Pane listing the Claude Code and Codex sessions working in this repo or folder

newpanebandcommandstatusprocess
v0.1.0MITupdated 2026-10-03gblacher2/repo-sessions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · repo-sessions
│ ┃ Sessions here ✕ › fix the failing auth test and add an audit log call │ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /sessions-here │ ⎿ repo-sessions: Sessions strip expanded. │ │ app 0 busy · 0 idle [ Collapse ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
app 0 busy · 0 idle [ Collapse ]
README

repo-sessions

A Claude Code mod that shows which Claude Code sessions and Codex threads are working in the current repository or folder, and whether each one is busy or idle.

Sessions pane in light and dark mode

What it shows

  • Strip above the prompt (default): the repository, how many other sessions are busy and idle, and a chip per session (busy first) with its agent color and how long it has been in that state. It shows in every session, collapsed, and lists nothing when no other session works in this repository.
  • Timeline (the strip's button, or /sessions-here): expands the strip into a swimlane of every session on a shared 10-minute axis, busy periods drawn as bars in the agent's color.
  • Click a session to open it: Claude desktop sessions through claude://code/continue, Codex threads through codex://threads/<id>. Terminal sessions have no link and are not clickable.
  • Side pane (/sessions-pane, optional): the same timeline with a header, docked beside the conversation.

A session counts as "here" when it works in this folder, a subfolder, a parent folder (not your home folder or /), or another git worktree of the same repository. The desktop Code tab draws SVG; the terminal draws a text version.

How sessions are found

AgentSourceBusy when
Claude Code~/.claude/sessions/<pid>.json (skipped when the process has exited)the session reports busy
Codex~/.codex/sessions/**/rollout-*.jsonl written in the last 60 minutes; title from ~/.codex/session_index.jsonlits log was written in the last 30 seconds

A Codex thread waiting for your approval shows as idle. Activity history starts when the mod loads.

Install

Requires Claude Code 2.1.286 or later on macOS (clicking a session uses open and the Claude and Codex desktop apps' links).

git clone https://github.com/gblacher2/repo-sessions.git ~/repo-sessions

Add the folder to the env block of ~/.claude/settings.json, then start a new session:

"env": {
  "CLAUDE_CODE_PLUGIN_DIRS": "~/repo-sessions"
}

For a single session: claude --plugin-dir ~/repo-sessions.

Development

claude plugin validate .
npx -p typescript tsc -p .
claude plugin test .

tsconfig.json extends .claude-plugin/types/tsconfig.json, which Claude Code generates when it loads the mod (run /plugin-types otherwise). claude plugin test needs the hooks-module rollout switch on for your account.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 382 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Peer, Snapshot, Subagent } from '../types'
4
5import { BUCKET_MS, HISTORY_BUCKETS, ago, chipWidth, nameSvg, paneSvg, pixelTextSvg, relativeCwd, spriteSvg, stripText, summarySvg } from './draw'
6
7const PANE = 'repo-sessions'
8const TITLE = 'Sessions here'
9const POLL_MS = 3000
10// A Codex thread counts as busy while its log was written this recently,
11// and is listed at all while written within the window.
12const CODEX_BUSY_MS = 30_000
13const CODEX_WINDOW_MIN = 60
14
15const SNAPSHOT = { plugin: 'repo-sessions', key: 'snapshot' } as const
16const EXPANDED = { plugin: 'repo-sessions', key: 'isExpanded' } as const
17
18type SessionFile = {
19  pid: number
20  sessionId: string
21  cwd: string
22  name?: string
23  status?: string
24  statusUpdatedAt?: number
25  updatedAt?: number
26  startedAt?: number
27  hostSessionId?: string
28}
29
30type Found = Omit<Peer, 'branch' | 'isWorktree' | 'history'>
31
32const isInside = (child: string, parent: string) =>
33  child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`)
34
35// Git's shared .git dir per folder: two worktrees of one repo share it.
36const commonDirCache = new Map<string, string | null>()
37// Codex rollout file -> its cwd, read once from the file's first line.
38const codexCwdCache = new Map<string, string | null>()
39let home: string | null = null
40let selfId = ''
41let polling = false
42// Busy/idle per 15 s bucket for each session, kept across scans.
43const histories = new Map<string, { bucket: number; values: number[] }>()
44
45function recordHistory(key: string, isBusy: boolean, now: number) {
46  const bucket = Math.floor(now / BUCKET_MS)
47  const h = histories.get(key) ?? { bucket, values: new Array<number>(HISTORY_BUCKETS).fill(0) }
48  const shift = Math.min(HISTORY_BUCKETS, bucket - h.bucket)
49  if (shift > 0) h.values = [...h.values.slice(shift), ...new Array<number>(shift).fill(0)]
50  h.bucket = bucket
51  if (isBusy) h.values[HISTORY_BUCKETS - 1] = 1
52  histories.set(key, h)
53  return [...h.values]
54}
55
56async function run($: EngineInterface, argv: string[]) {
57  try {
58    const r = await $.process.run(argv, { timeoutMs: 5000 })
59    return r.exitCode === 0 ? r.stdout : null
60  } catch {
61    return null
62  }
63}
64
65async function commonDir($: EngineInterface, cwd: string) {
66  if (!commonDirCache.has(cwd)) {
67    const out = await run($, ['git', '-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'])
68    commonDirCache.set(cwd, out === null ? null : out.trim())
69  }
70  return commonDirCache.get(cwd) ?? null
71}
72
73async function claudeSessions($: EngineInterface, now: number): Promise<Found[]> {
74  const dir = `${home}/.claude/sessions`
75  const entries = (await $.fs.exists(dir)) ? await $.fs.list(dir) : []
76  const files: SessionFile[] = []
77  for (const entry of entries) {
78    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
79    try {
80      files.push(JSON.parse(await $.fs.read(`${dir}/${entry.name}`)))
81    } catch {
82      // mid-write; the next tick reads it
83    }
84  }
85  if (files.length === 0) return []
86
87  const ps = await run($, ['ps', '-o', 'pid=', '-p', files.map(f => f.pid).join(',')])
88  const alive = new Set((ps ?? '').split('\n').map(l => Number(l.trim())).filter(Boolean))
89
90  return files
91    .filter(f => alive.has(f.pid) && typeof f.cwd === 'string')
92    .map(f => ({
93      id: f.sessionId,
94      agent: 'claude' as const,
95      name: f.name || f.sessionId.slice(0, 8),
96      cwd: f.cwd,
97      status: f.status === 'busy' ? ('busy' as const) : ('idle' as const),
98      since: f.statusUpdatedAt ?? f.updatedAt ?? f.startedAt ?? now,
99      isSelf: f.sessionId === selfId,
100      // Desktop sessions reopen through the Claude app; terminal ones have no link.
101      openUrl: /^local_[A-Za-z0-9-]{1,64}$/.test(f.hostSessionId ?? '')
102        ? `claude://code/continue?session=${f.hostSessionId}`
103        : undefined,
104    }))
105}
106
107async function codexThreads($: EngineInterface, now: number): Promise<Found[]> {
108  const out = await run($, [
109    'find', `${home}/.codex/sessions`, '-name', 'rollout-*.jsonl',
110    '-mmin', `-${CODEX_WINDOW_MIN}`, '-exec', 'stat', '-f', '%m %N', '{}', '+',
111  ])
112  if (!out) return []
113
114  const recent = out.split('\n').filter(Boolean).map(line => {
115    const space = line.indexOf(' ')
116    return { mtime: Number(line.slice(0, space)) * 1000, path: line.slice(space + 1) }
117  })
118  if (recent.length === 0) return []
119
120  const index = await run($, ['tail', '-n', '1000', `${home}/.codex/session_index.jsonl`])
121  const names = new Map<string, string>()
122  for (const line of (index ?? '').split('\n')) {
123    try {
124      const row = JSON.parse(line)
125      if (row.id && row.thread_name) names.set(row.id, row.thread_name)
126    } catch {
127      // partial line
128    }
129  }
130
131  const found: Found[] = []
132  for (const { mtime, path } of recent) {
133    if (!codexCwdCache.has(path)) {
134      const head = await run($, ['head', '-c', '4000', path])
135      const match = head?.match(/"cwd":("(?:[^"\\]|\\.)*")/)
136      codexCwdCache.set(path, match?.[1] ? JSON.parse(match[1]) : null)
137    }
138    const cwd = codexCwdCache.get(path)
139    if (!cwd) continue
140    const id = path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/)?.[1] ?? path
141    found.push({
142      id,
143      agent: 'codex',
144      name: names.get(id) ?? id.slice(0, 8),
145      cwd,
146      status: now - mtime < CODEX_BUSY_MS ? 'busy' : 'idle',
147      since: mtime,
148      isSelf: false,
149      openUrl: /^[0-9a-f-]{36}$/.test(id) ? `codex://threads/${id}` : undefined,
150    })
151  }
152  return found
153}
154
155async function scan($: EngineInterface) {
156  if (polling) return
157  polling = true
158  try {
159    if (home === null) home = ((await run($, ['printenv', 'HOME'])) ?? '').trim()
160    const now = await $.clock.now()
161    const root = await $.session.root()
162    const myCommon = await commonDir($, root)
163
164    const all = [...(await claudeSessions($, now)), ...(await codexThreads($, now))]
165    const peers: Peer[] = []
166    for (const f of all) {
167      // A session in a parent folder counts too, but not one opened at home or /.
168      const isBroad = f.cwd === home || f.cwd === '/'
169      const sameFolder = isInside(f.cwd, root) || (!isBroad && isInside(root, f.cwd))
170      const sameRepo = !sameFolder && myCommon !== null && (await commonDir($, f.cwd)) === myCommon
171      if (!sameFolder && !sameRepo) continue
172      const branch = myCommon === null ? null : await run($, ['git', '-C', f.cwd, 'branch', '--show-current'])
173      const history = recordHistory(`${f.agent}:${f.id}`, f.status === 'busy', now)
174      peers.push({ ...f, branch: branch?.trim() || null, isWorktree: sameRepo, history })
175    }
176    peers.sort((a, b) =>
177      a.isSelf !== b.isSelf ? (a.isSelf ? -1 : 1)
178      : a.status !== b.status ? (a.status === 'busy' ? -1 : 1)
179      : b.since - a.since,
180    )
181
182    const subagents: Subagent[] = (await $.agent.list())
183      .filter(a => a.status === 'running')
184      .map(a => ({ id: a.id, label: a.name ?? a.description, type: a.type }))
185
186    // The strip above the prompt shows the counts; clear any status entry an
187    // earlier version left.
188    $.ui.status(undefined)
189
190    const snap: Snapshot = { root, peers, subagents, checkedAt: now }
191    await $.state.set(SNAPSHOT, snap)
192  } finally {
193    polling = false
194  }
195}
196
197export const register: Register = on => {
198  on('session.start', async ($, e, next) => {
199    const started = await next(e)
200    selfId = await $.session.id()
201    await $.command.register({
202      name: 'sessions-here',
203      description: 'Expand or collapse the sessions strip above the prompt',
204    })
205    await $.command.register({
206      name: 'sessions-pane',
207      description: 'Open the sessions timeline in a side pane',
208    })
209    // The side pane opens only on /sessions-pane: close one left open by an
210    // earlier version or a previous load.
211    await $.ui.close({ id: PANE })
212    await scan($)
213    $.clock.every(POLL_MS, () => scan($))
214
215    return started
216  })
217
218  on('command.run', { command: 'sessions-here' }, async $ => {
219    const { value: isExpanded = false } = await $.state.get(EXPANDED)
220    await $.state.set(EXPANDED, !isExpanded)
221    await scan($)
222
223    return { text: isExpanded ? 'Sessions strip collapsed.' : 'Sessions strip expanded.' }
224  })
225
226  on('command.run', { command: 'sessions-pane' }, async $ => {
227    await scan($)
228    await $.ui.open({ id: PANE, title: TITLE })
229
230    return { text: 'Sessions pane opened.' }
231  })
232
233  // Default view: a strip above the prompt, collapsed to one row of chips,
234  // expanding in place to the full timeline.
235  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
236    const { value: snap } = await $.state.get(SNAPSHOT)
237    const { value: isExpanded = false } = await $.state.get(EXPANDED)
238    const others = (snap?.peers ?? []).filter(p => !p.isSelf)
239    if (!snap || e.props.hasSurvey) return next(e)
240
241    const toggle = () => $.state.set(EXPANDED, !isExpanded)
242    const label = isExpanded ? 'Collapse' : 'Timeline'
243
244    if (e.surface !== 'terminal') {
245      const { Box, Button, Svg } = $.ui.resolve(e)
246      const header = summarySvg(snap)
247      const timeline = isExpanded ? paneSvg(snap, 560, { withHeader: false, isFixed: true }) : null
248
249      // Chips that fit beside the header and the toggle; the rest fold into +N.
250      let room = Math.min(1100, Math.max(320, e.props.bodyColumns * 7.5)) - header.width - 110
251      const shown = others.filter(p => {
252        const w = chipWidth(p.name, ago(snap.checkedAt - p.since), p.openUrl !== undefined)
253        if (w > room) return false
254        room -= w
255        return true
256      })
257      const hidden = others.length - shown.length
258      const more = hidden > 0 ? pixelTextSvg(`+${hidden}`) : null
259
260      return (
261        <Box flexDirection="column">
262          <Box flexDirection="row" alignItems="center" justifyContent="space-between" gap={1}>
263            <Box flexDirection="row" alignItems="center" gap={2}>
264              <Svg source={header.source} width={header.width} height={header.height} alt={`${others.length} other sessions in this repo`} />
265              {shown.map(p => {
266                const isBusy = p.status === 'busy'
267                const sprite = spriteSvg(p.agent, isBusy)
268                const time = pixelTextSvg(ago(snap.checkedAt - p.since))
269                const url = p.openUrl
270
271                return (
272                  <Box key={`chip-${p.agent}-${p.id}`} flexDirection="row" alignItems="center" gap={1}>
273                    <Svg source={sprite.source} width={sprite.width} height={sprite.height} alt={`${p.agent} ${p.status}`} />
274                    {url ? (
275                      <Button
276                        key={`open-${p.agent}-${p.id}`}
277                        label={p.name}
278                        plain
279                        dimColor={!isBusy}
280                        onPress={() => $.process.run(['open', url])}
281                      />
282                    ) : (
283                      <Svg source={nameSvg(p.name, isBusy).source} width={nameSvg(p.name, isBusy).width} height={16} alt={p.name} />
284                    )}
285                    <Svg source={time.source} width={time.width} height={time.height} alt={time.text} />
286                  </Box>
287                )
288              })}
289              {more && <Svg source={more.source} width={more.width} height={more.height} alt={more.text} />}
290            </Box>
291            <Button key="toggle" label={label} dimColor onPress={toggle} />
292          </Box>
293          {timeline && (
294            <Svg source={timeline.source} width={timeline.width} height={timeline.height} alt="Session activity, last 10 minutes" />
295          )}
296        </Box>
297      )
298    }
299
300    const { Box, Button, Text } = $.ui.resolve(e)
301    const busy = others.filter(p => p.status === 'busy').length
302
303    return (
304      <Box flexDirection="column">
305        <Box flexDirection="row" gap={2}>
306          <Text wrap="truncate-end">
307            <Text bold>{snap.root.split('/').pop()}</Text>
308            <Text dimColor> {busy} busy · {others.length - busy} idle</Text>
309          </Text>
310          {others.map(p => {
311            const url = p.openUrl
312
313            return (
314              <Box key={`chip-${p.agent}-${p.id}`} flexDirection="row">
315                <Text color={p.status === 'busy' ? '#0ca30c' : 'gray'}>{p.status === 'busy' ? '●' : '○'}</Text>
316                <Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>■ </Text>
317                {url ? (
318                  <Button
319                    key={`open-${p.agent}-${p.id}`}
320                    label={p.name}
321                    plain
322                    dimColor={p.status !== 'busy'}
323                    onPress={() => $.process.run(['open', url])}
324                  />
325                ) : (
326                  <Text dimColor={p.status !== 'busy'} wrap="truncate-end">{p.name}</Text>
327                )}
328              </Box>
329            )
330          })}
331          <Button key="toggle" label={label} dimColor onPress={toggle} />
332        </Box>
333        {isExpanded &&
334          others.map(p => (
335            <Text key={`row-${p.agent}-${p.id}`} wrap="truncate-end">
336              <Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>{stripText(p.history)}</Text>
337              <Text dimColor> {p.status} {ago(snap.checkedAt - p.since)} · </Text>
338              {p.name}
339            </Text>
340          ))}
341      </Box>
342    )
343  })
344
345  // Optional side pane (/sessions-pane): the same timeline with a header.
346  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
347    const { value: snap } = await $.state.get(SNAPSHOT)
348
349    if (e.surface !== 'terminal') {
350      const { Svg, Text } = $.ui.resolve(e)
351      if (!snap) return <Text>Scanning…</Text>
352      const pane = paneSvg(snap)
353
354      return <Svg source={pane.source} alt={`${snap.peers.length} sessions in this repo`} />
355    }
356
357    const { Box, Text } = $.ui.resolve(e)
358    if (!snap) return <Text dimColor>Scanning…</Text>
359
360    return (
361      <Box flexDirection="column">
362        {snap.peers.map(p => (
363          <Box key={`${p.agent}-${p.id}`} flexDirection="column" marginTop={1}>
364            <Text wrap="truncate-end">
365              <Text color={p.status === 'busy' ? '#0ca30c' : 'gray'}>{p.status === 'busy' ? '●' : '○'} </Text>
366              <Text bold={p.status === 'busy'}>{p.name}</Text>
367            </Text>
368            <Text wrap="truncate-end">
369              {'  '}<Text color={p.agent === 'codex' ? '#3987e5' : '#d95926'}>{stripText(p.history)}</Text>
370              <Text dimColor>
371                {' '}{p.isSelf ? 'You · ' : ''}{p.status} {ago(snap.checkedAt - p.since)}
372                {p.branch ? ` · ${p.branch}` : ''}
373                {relativeCwd(p.cwd, snap.root) ? ` · ${relativeCwd(p.cwd, snap.root)}` : ''}
374              </Text>
375            </Text>
376          </Box>
377        ))}
378      </Box>
379    )
380  })
381}
382
hooks/draw.ts 307 lines
1import type { Peer, Snapshot } from '../types'
2
3// Pure drawing helpers: no `$`, so they can be rendered outside Claude Code.
4// Pixel style: labels in a 5x7 bitmap font, a pixel sprite per agent, and
5// one block per 15 s on the timeline, all drawn with crisp edges.
6
7export const HISTORY_BUCKETS = 40
8export const BUCKET_MS = 15_000
9
10const PAD = 10
11
12// Agent identity: categorical slots 1 and 2, validated for both modes.
13const AGENT = {
14  claude: { label: 'Claude', light: '#eb6834', dark: '#d95926' },
15  codex: { label: 'Codex', light: '#2a78d6', dark: '#3987e5' },
16} as const
17
18const STYLE = `
19  .t1 { fill: #0b0b0b; } .t2 { fill: #52514e; } .t3 { fill: #8a8880; }
20  .edge { stroke: #0b0b0b; stroke-opacity: .16; fill: none; } .grid { stroke: #0b0b0b; stroke-opacity: .12; }
21  .off { fill: #0b0b0b; fill-opacity: .13; } .ring { stroke: #8a8880; fill: none; }
22  .self, .chip { fill: #0b0b0b; } .self { fill-opacity: .04; } .chip { fill-opacity: .045; }
23  .claude { fill: ${AGENT.claude.light}; } .codex { fill: ${AGENT.codex.light}; } .eye { fill: #1a1a19; }
24  @media (prefers-color-scheme: dark) {
25    .t1 { fill: #f2f1ec; } .t2 { fill: #c3c2b7; } .t3 { fill: #8f8d85; }
26    .edge, .grid { stroke: #ffffff; } .edge { stroke-opacity: .16; } .grid { stroke-opacity: .1; }
27    .off { fill: #ffffff; fill-opacity: .14; } .ring { stroke: #8f8d85; }
28    .self, .chip { fill: #ffffff; } .self { fill-opacity: .045; } .chip { fill-opacity: .06; }
29    .claude { fill: ${AGENT.claude.dark}; } .codex { fill: ${AGENT.codex.dark}; }
30  }
31  text { font-family: "SF Mono", ui-monospace, Menlo, Monaco, monospace; }
32`
33
34// 5x7 bitmap glyphs, one string of five bits per row.
35const GLYPHS: Record<string, string> = {
36  A: '01110100011000111111100011000110001', B: '11110100011000111110100011000111110',
37  C: '01110100011000010000100001000101110', D: '11110100011000110001100011000111110',
38  E: '11111100001000011110100001000011111', F: '11111100001000011110100001000010000',
39  G: '01110100011000010111100011000101111', H: '10001100011000111111100011000110001',
40  I: '01110001000010000100001000010001110', J: '00111000100001000010000101001001100',
41  K: '10001100101010011000101001001010001', L: '10000100001000010000100001000011111',
42  M: '10001110111010110101100011000110001', N: '10001100011100110101100111000110001',
43  O: '01110100011000110001100011000101110', P: '11110100011000111110100001000010000',
44  Q: '01110100011000110001101011001001101', R: '11110100011000111110101001001010001',
45  S: '01111100001000001110000010000111110', T: '11111001000010000100001000010000100',
46  U: '10001100011000110001100011000101110', V: '10001100011000110001100010101000100',
47  W: '10001100011000110101101011010101010', X: '10001100010101000100010101000110001',
48  Y: '10001100010101000100001000010000100', Z: '11111000010001000100010001000011111',
49  '0': '01110100011001110101110011000101110', '1': '00100011000010000100001000010001110',
50  '2': '01110100010000100010001000100011111', '3': '11111000100010000010000011000101110',
51  '4': '00010001100101010010111110001000010', '5': '11111100001111000001000011000101110',
52  '6': '00110010001000011110100011000101110', '7': '11111000010001000100010000100001000',
53  '8': '01110100011000101110100011000101110', '9': '01110100011000101111000010001001100',
54  '-': '00000000000000011111000000000000000', '/': '00001000010001000100010001000010000',
55  '.': '00000000000000000000000000110001100', ':': '00000011000110000000011000110000000',
56  '+': '00000001000010011111001000010000000', '_': '00000000000000000000000000000011111',
57  '·': '00000000000000000100000000000000000', '?': '01110100010000100010001000000000100',
58  ' ': '00000000000000000000000000000000000',
59}
60
61// Draws `text` in the bitmap font with its top-left at (x, y); `px` is the
62// size of one font pixel. Lit runs in each row become one path.
63export function pixelText(x: number, y: number, text: string, px: number, cls: string) {
64  const d: string[] = []
65  const chars = [...text.toUpperCase()]
66  chars.forEach((ch, i) => {
67    const bits = GLYPHS[ch] ?? GLYPHS['?']!
68    const gx = x + i * 6 * px
69    for (let row = 0; row < 7; row++) {
70      let col = 0
71      while (col < 5) {
72        if (bits[row * 5 + col] !== '1') {
73          col++
74          continue
75        }
76        const start = col
77        while (col < 5 && bits[row * 5 + col] === '1') col++
78        d.push(`M${+(gx + start * px).toFixed(2)} ${+(y + row * px).toFixed(2)}h${+((col - start) * px).toFixed(2)}v${px}h${-((col - start) * px).toFixed(2)}z`)
79      }
80    }
81  })
82  return { svg: `<path d="${d.join('')}" class="${cls}"/>`, width: chars.length * 6 * px - px }
83}
84
85export const pixelWidth = (text: string, px: number) => [...text].length * 6 * px - px
86
87export const esc = (s: string) =>
88  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
89
90// Fits monospace text to a pixel width.
91export const fit = (s: string, px: number, size: number) => {
92  const max = Math.max(1, Math.floor(px / (size * 0.61)))
93  return s.length <= max ? s : `${s.slice(0, max - 1)}…`
94}
95
96export const ago = (ms: number) => {
97  const s = Math.max(0, Math.round(ms / 1000))
98  if (s < 60) return `${s}s`
99  if (s < 3600) return `${Math.round(s / 60)}m`
100  if (s < 86400) return `${Math.round(s / 3600)}h`
101  return `${Math.round(s / 86400)}d`
102}
103
104export const relativeCwd = (cwd: string, root: string) =>
105  cwd === root ? '' : cwd.startsWith(`${root}/`) ? `./${cwd.slice(root.length + 1)}` : cwd.split('/').pop() ?? cwd
106
107const svg = (width: number, height: number, body: string, isFixed: boolean) =>
108  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges"${
109    isFixed ? ` width="${width}" height="${height}"` : ''
110  }><style>${STYLE}</style>${body}</svg>`
111
112// Pixel sprites, one per agent: rows of `x` (body), `e` (eye), `.` (empty).
113// Busy sprites walk (two leg frames and a bob); idle ones nap with closed
114// eyes and a rising "z".
115const SPRITES = {
116  claude: {
117    awake: ['..xxxxx..', '.xxxxxxx.', '.xexxxex.', 'xxxxxxxxx', '.xxxxxxx.'],
118    asleep: ['..xxxxx..', '.xxxxxxx.', '.xeexeex.', 'xxxxxxxxx', '.xxxxxxx.'],
119    legs: ['.x.x.x.x.', 'x.x.x.x.x'],
120  },
121  codex: {
122    awake: ['....x....', '.xxxxxxx.', '.xexxxex.', '.xxxxxxx.', '.xxeeexx.'],
123    asleep: ['....x....', '.xxxxxxx.', '.xeexeex.', '.xxxxxxx.', '.xxxxxxx.'],
124    legs: ['..x...x..', '...x.x...'],
125  },
126} as const
127const SPRITE_W = 9
128const SPRITE_H = 6
129
130const pixels = (x: number, y: number, rows: readonly string[], px: number, ch: string, cls: string) => {
131  const d: string[] = []
132  rows.forEach((row, r) => {
133    ;[...row].forEach((c, i) => {
134      if (c === ch) d.push(`M${x + i * px} ${y + r * px}h${px}v${px}h${-px}z`)
135    })
136  })
137  return d.length ? `<path d="${d.join('')}" class="${cls}"/>` : ''
138}
139
140// A sprite with its top-left at (x, y); `px` is one sprite pixel.
141export function sprite(x: number, y: number, agent: Peer['agent'], isBusy: boolean, px = 1.5) {
142  const s = SPRITES[agent]
143  const body = isBusy ? s.awake : s.asleep
144  const legsY = y + body.length * px
145  const still = pixels(x, y, body, px, 'x', agent) + pixels(x, y, body, px, 'e', 'eye')
146  if (!isBusy) {
147    const zx = x + SPRITE_W * px + 1
148    return `${still}${pixels(x, legsY, [s.legs[0]], px, 'x', agent)}
149      <g class="t3">
150        <path d="M0 0h3v1h-1v1h-1v1h2v1h-3v-1h1v-1h1v-1h-2z" transform="translate(${zx} ${y})">
151          <animateTransform attributeName="transform" type="translate" values="${zx} ${y + 3};${zx + 2} ${y - 3}" dur="2.4s" repeatCount="indefinite"/>
152          <animate attributeName="opacity" values="0;1;0" dur="2.4s" repeatCount="indefinite"/>
153        </path>
154      </g>`
155  }
156  const frame = (legs: string, isFirst: boolean) =>
157    `<g>${pixels(x, legsY, [legs], px, 'x', agent)}
158      <animate attributeName="opacity" values="${isFirst ? '1;0' : '0;1'}" calcMode="discrete" dur=".5s" repeatCount="indefinite"/></g>`
159  return `<g>
160      <animateTransform attributeName="transform" type="translate" values="0 0;0 -1;0 0" calcMode="discrete" dur=".5s" repeatCount="indefinite"/>
161      ${still}${frame(s.legs[0], true)}${frame(s.legs[1], false)}
162    </g>`
163}
164
165const agentMark = (x: number, y: number, agent: Peer['agent']) =>
166  `<rect x="${x}" y="${y}" width="5" height="5" class="${agent}"/>`
167
168// Geometry for one drawing width: the shared time lane (last 10 minutes,
169// left to right) takes the right-hand part, one cell per bucket.
170type Geo = { W: number; LX: number; RX: number; CELL: number }
171const geo = (W: number): Geo => {
172  const RX = W - PAD
173  const CELL = Math.max(3, Math.floor(Math.min(240, W * 0.4) / HISTORY_BUCKETS))
174  return { W, LX: RX - CELL * HISTORY_BUCKETS, RX, CELL }
175}
176
177const lane = (g: Geo, y: number, history: readonly number[], agent: Peer['agent']) =>
178  history
179    .map((v, i) => {
180      const x = g.LX + i * g.CELL
181      return v
182        ? `<rect x="${x}" y="${y - 3}" width="${g.CELL - 1}" height="6" class="${agent}"/>`
183        : `<rect x="${x + Math.floor((g.CELL - 1) / 2)}" y="${y}" width="1" height="1" class="off"/>`
184    })
185    .join('')
186
187const ROW = 24
188
189const sessionRow = (g: Geo, p: Peer, y: number, snap: Snapshot, selfBranch: string | null) => {
190  const isBusy = p.status === 'busy'
191  const mid = y + ROW / 2
192  const textX = PAD + 26
193  const meta = [
194    p.isSelf ? 'you' : '',
195    ago(snap.checkedAt - p.since),
196    p.branch && p.branch !== selfBranch ? p.branch : '',
197    p.isWorktree ? 'worktree' : '',
198    relativeCwd(p.cwd, snap.root),
199  ].filter(Boolean).join(' · ')
200  const nameW = Math.min(p.name.length * 11 * 0.61, (g.LX - 12 - textX) * 0.6)
201  const name = fit(p.name, nameW, 11)
202  const metaX = textX + name.length * 11 * 0.61 + 8
203
204  return `
205    ${p.isSelf ? `<rect x="2" y="${y + 1}" width="${g.W - 4}" height="${ROW - 2}" class="self"/>` : ''}
206    ${sprite(PAD, mid - 6, p.agent, isBusy, 2)}
207    <text x="${textX}" y="${mid + 4}" font-size="11" font-weight="${isBusy ? 700 : 500}" class="${isBusy ? 't1' : 't2'}">${esc(name)}</text>
208    <text x="${metaX}" y="${mid + 4}" font-size="10" class="t3">${esc(fit(meta, g.LX - 12 - metaX, 10))}</text>
209    ${lane(g, mid, p.history, p.agent)}`
210}
211
212// The timeline: one line per session on a shared 10-minute axis. `width` is
213// the drawing's width in CSS pixels; `isFixed` pins it rather than scaling.
214export function paneSvg(snap: Snapshot, width = 360, options: { withHeader?: boolean; isFixed?: boolean } = {}) {
215  const { withHeader = true, isFixed = false } = options
216  const g = geo(width)
217  const { W, LX, RX } = g
218  const busy = snap.peers.filter(p => p.status === 'busy').length
219  const selfBranch = snap.peers.find(p => p.isSelf)?.branch ?? null
220  const parts: string[] = []
221
222  let axisY = 4
223  if (withHeader) {
224    const repo = pixelText(PAD, 8, snap.root.split('/').pop() ?? snap.root, 1.5, 't1')
225    parts.push(repo.svg)
226    parts.push(pixelText(PAD + repo.width + 10, 10, `${busy} busy · ${snap.peers.length - busy} idle`, 1, 't3').svg)
227    axisY = 30
228  }
229
230  const mid = LX + (RX - LX) / 2
231  parts.push(pixelText(LX, axisY, '10m', 1, 't3').svg)
232  parts.push(pixelText(mid - pixelWidth('5m', 1) / 2, axisY, '5m', 1, 't3').svg)
233  parts.push(pixelText(RX - pixelWidth('now', 1), axisY, 'now', 1, 't3').svg)
234
235  let y = axisY + 9
236  const rowsTop = y
237  const rows: string[] = []
238  for (const p of snap.peers) {
239    rows.push(sessionRow(g, p, y, snap, selfBranch))
240    y += ROW
241  }
242  for (const gx of [LX, Math.round(mid), RX]) {
243    parts.push(`<line x1="${gx - 0.5}" x2="${gx - 0.5}" y1="${rowsTop + 3}" y2="${y - 3}" class="grid" stroke-dasharray="1 3"/>`)
244  }
245  parts.push(...rows)
246
247  if (snap.subagents.length > 0) {
248    y += 6
249    parts.push(pixelText(PAD, y, 'subagents', 1, 't3').svg)
250    y += 8
251    for (const a of snap.subagents) {
252      parts.push(`${sprite(PAD, y + 6, 'claude', true, 2)}
253        <text x="${PAD + 26}" y="${y + 16}" font-size="11" class="t1">${esc(fit(a.label, W - 120, 11))}</text>
254        <text x="${RX}" y="${y + 16}" font-size="10" text-anchor="end" class="t3">${esc(fit(a.type, 80, 10))}</text>`)
255      y += ROW
256    }
257  }
258
259  const h = y + 4
260  return { width: W, height: h, source: svg(W, h, parts.join(''), isFixed) }
261}
262
263// The band's header: repository and counts of the other sessions.
264export function summarySvg(snap: Snapshot) {
265  const others = snap.peers.filter(p => !p.isSelf)
266  const busy = others.filter(p => p.status === 'busy').length
267  const H = 18
268  const repo = pixelText(1, 5, (snap.root.split('/').pop() ?? snap.root).slice(0, 24), 1.15, 't1')
269  const counts = pixelText(1 + repo.width + 8, 6, `${busy} busy · ${others.length - busy} idle`, 1, 't3')
270  const width = Math.ceil(1 + repo.width + 8 + counts.width + 2)
271  return { width, height: H, source: svg(width, H, repo.svg + counts.svg, true) }
272}
273
274// One sprite as its own drawing, for a chip.
275export function spriteSvg(agent: Peer['agent'], isBusy: boolean) {
276  const width = SPRITE_W * 2 + (isBusy ? 0 : 7)
277  const height = 16
278  return { width, height, source: svg(width, height, sprite(0, 3, agent, isBusy, 2), true) }
279}
280
281// A short label in the bitmap font as its own drawing.
282export function pixelTextSvg(text: string, cls = 't3') {
283  const t = pixelText(0, 4, text, 1, cls)
284  const width = Math.ceil(t.width) + 1
285  return { text, width, height: 14, source: svg(width, 14, t.svg, true) }
286}
287
288// A session name in the monospace face as its own drawing, for a chip that
289// has no link to open.
290export function nameSvg(name: string, isBusy: boolean) {
291  const text = fit(name, 160, 11)
292  const width = Math.ceil(text.length * 11 * 0.61) + 2
293  return {
294    text,
295    width,
296    height: 16,
297    source: svg(width, 16, `<text x="0" y="12" font-size="11" font-weight="${isBusy ? 700 : 500}" class="${isBusy ? 't1' : 't2'}">${esc(text)}</text>`, true),
298  }
299}
300
301// Estimated width of one chip in the band: sprite, name, time and gaps.
302export const chipWidth = (name: string, time: string, isButton: boolean) =>
303  SPRITE_W * 2 + 7 + 8 + (isButton ? name.length * 7.2 + 12 : Math.min(name.length, 26) * 11 * 0.61) + 8 + pixelWidth(time, 1) + 16
304
305// Terminal version of the lane.
306export const stripText = (history: readonly number[]) => history.map(v => (v ? '█' : '·')).join('')
307
types/index.d.ts 31 lines
1export type Peer = {
2  id: string
3  agent: 'claude' | 'codex'
4  name: string
5  cwd: string
6  branch: string | null
7  status: 'busy' | 'idle'
8  since: number
9  isSelf: boolean
10  isWorktree: boolean
11  /** Deep link that opens the session in its app; absent when none exists (a terminal session). */
12  openUrl?: string
13  /** Busy (1) or idle (0) per 15 s bucket, oldest first, last 10 minutes. */
14  history: number[]
15}
16
17export type Subagent = { id: string; label: string; type: string }
18
19export type Snapshot = {
20  root: string
21  peers: Peer[]
22  subagents: Subagent[]
23  checkedAt: number
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'repo-sessions': { snapshot: Snapshot | null; isExpanded: boolean }
29  }
30}
31