SLOPSHOPPER

session-link

Session Link: a side panel of the live Claude sessions on this machine, the messages between them, and a guard that stops one session overwriting another's…

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-link
│ ┃ Link ✕ › fix the failing auth test and add an audit log call │ ┃ ⇄ SESSION LINK 0 live 0 overlap │ ┃ ⏺ Read(src/auth.ts) │ ┃ ◎ SESSIONS checked 0s ago ⎿ Read 6 lines │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Update(src/auth.ts) │ ┃ ━━━━━━━━━━ ⎿ Added 2 lines, removed 1 line │ ┃ Looking for sessions on this machine. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ⚠ OVERLAPS files two sessions both edited │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ━━━━━━━━━━ │ ┃ ✓ No file edited by two sessions. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ✉ MESSAGES between sessions › /link │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⎿ session-link: Session Link open. │ ┃ ━━━━━━━━━━ │ ┃ No messages between sessions yet. │ ┃ │ ┃ ▸ CONTROLS │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ┃ ━━━━━━━━━━ │ ┃ [ Refresh ] [ Release my files ] [ Mission ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Link
⇄ SESSION LINK 0 live 0 overlap ◎ SESSIONS checked 0s ago ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Looking for sessions on this machine. ⚠ OVERLAPS files two sessions both edited ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ✓ No file edited by two sessions. ✉ MESSAGES between sessions ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ No messages between sessions yet. ▸ CONTROLS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [ Refresh ] [ Release my files ] [ Mission ]
README

Session dashboard for Claude Code

Five side-panel mods for Claude Code (function hooks, early access API):

ModPanelCommand
agent-progressCrew: each sub-agent as a little Claude crab dressed for its job (builder with a hammer, inspector with a monocle, scout with a compass, architect with a set square, librarian with a book), model, live activity, steps, elapsed time, cache hit bar, tokens, and a warning when a worker goes quiet/crew
cache-taxCache: prompt cache countdown gauge, hit-rate sparkline, cold-start warnings, keep-warm pings, and what caching cost: spent, saved by reads, the cold-start tax, what pings cost and what going cold now would add (estimated from /cost with the published cache multipliers)/keepwarm, /keepwarm now, `/keepwarm ttl 5\60, /keepwarm panel`
spend-ledgerSpend: what each prompt and each sub-agent cost, a STATS dashboard modelled on /usage stats: views Overview, Tokens, Cost, Cache and In/Out; ranges This session, 7 days, 30 days and All time; a model filter; an activity heatmap, charts in four styles (line, area, bars, dots) with axes, stat tiles (favorite model, total tokens, sessions, active days, longest session, streaks) and per-model cards. Past days come from Claude Code's own history (~/.claude/stats-cache.json, the file /usage reads), with their in/out/cache split and cost estimated from each model's totals; today is exact, from the mod's own tracking. An active-hours chart shows when you work, a by-model breakdown (cost, share, requests, tokens, cache hit rate), burn rate, budget, running insights. On an API key it budgets in dollars (/spend budget 50, warnings at 80% and 100%). On a Claude subscription it shows the plan's 5-hour and weekly windows with reset times and how long the 5-hour window lasts at your pace, and budgets as a share of it (/spend budget 80%); the dollar figures are then the API-price value of the work, not a charge/spend, /spend budget 50, /spend budget 80%, add hard to refuse new prompts once reached (/spend budget 50 hard), `/spend hard on\off, /spend budget off`
session-linkSession Link: the live Claude sessions on this machine, each with its own label (the session sets it itself early in its first turn; until then its first prompt stands in; /link name overrides) and its own Claude crab badge (a pose and colour no two live sessions share), its directory, branch and the files it edited; the messages the sessions send each other; files two sessions both edited. It also stops one session overwriting another: an edit to a file another live session edited in the last six hours, or a broad git command (add -A, commit -a, reset --hard, checkout, switch, stash, rebase, merge, pull) in a repository another session works in, is refused once with the other session's name so the model messages it first; the same call again goes through/link, /link name A: finish UA, /link release; the model's set_session_label tool
mission-controlMission Control: all four stacked in one scrollable panel, with buttons for every command/mission

Every panel has buttons with hotkeys for its commands.

Install

At the prompt of a terminal session:

/plugin install mission-control --marketplace kimathinjoki/claude-session-dashboard

Answer y to add the marketplace, choose the user scope, then install the others the same way (agent-progress, cache-tax, spend-ledger, session-link). Mission Control reads their state, so it needs all four.

Best viewed

Side panels dock beside the transcript in fullscreen mode ("tui": "fullscreen" in ~/.claude/settings.json, or /config) from 110 columns. They open on their own at session start from 144 columns; at any width the commands above open them. With several open they share the dock as tabs.

The Cache panel follows your promptCacheTtl setting ("1h" keeps the cache for an hour; 1-hour cache writes cost more than 5-minute ones).

Source 3 files
hooks/register.tsx 415 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LinkView } from '../types'
5import {
6  ACK_WINDOW_MS,
7  type Beacon,
8  type Peer,
9  ago,
10  assignBadges,
11  cleanLabel,
12  editDeny,
13  freshClaims,
14  gitDeny,
15  gitDirOf,
16  holderOf,
17  keepNotes,
18  overlaps,
19  parseRegistry,
20  riskyGit,
21  senderOf,
22  sharersOf,
23  shortPath,
24  timeline,
25} from './link'
26
27const view = atom({ plugin: 'session-link', key: 'view' } as const, null as LinkView | null)
28
29const PANE = 'session-link'
30const REFRESH_MS = 15_000
31const PROTOCOL = {
32  id: 'session-link:protocol',
33  scope: 'session' as const,
34  text:
35    'Other Claude Code sessions may be working on this machine, in the same repositories. Session Link refuses an edit to a file ' +
36    'another live session edited recently, and a broad git command (add -A, commit -a, reset --hard, checkout, switch, stash, ' +
37    'rebase, merge, pull) in a repository another session is working in, and its refusal names the session. When that happens, ' +
38    'message that session with SendMessage before going on, commit only your own files with `git commit -- <paths>`, and never ' +
39    'switch branches in a shared working tree. Repeating the same call after agreeing, or on the user\'s say-so, is allowed. ' +
40    'Early in your first turn, and again if your task changes, call the set_session_label tool (Session Link) with a short label ' +
41    'naming what this session is doing, under 40 characters, with the letter or name the user gave the session if any ' +
42    '(for example "A: finish UA sub-project 1"), so the other sessions and the user can tell the sessions apart.',
43}
44
45// Module state: rebuilt on reload from this session's own beacon file.
46let home = ''
47let me: Beacon | null = null
48let peers: Array<Peer & { repo: string | null }> = []
49let refreshedAt = 0
50const repoCache = new Map<string, string | null>()
51const acks = new Map<string, number>()
52
53const linkDir = () => `${home}/.claude/session-link`
54const beaconPath = (id: string) => `${linkDir()}/${id}.json`
55
56async function repoOf($: EngineInterface, dir: string) {
57  if (repoCache.has(dir)) return repoCache.get(dir)!
58  const out = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 }).catch(() => null)
59  const repo = out && out.exitCode === 0 ? out.stdout.trim() : null
60  repoCache.set(dir, repo)
61  return repo
62}
63
64async function branchOf($: EngineInterface, dir: string) {
65  const out = await $.process.run(['git', '-C', dir, 'branch', '--show-current'], { timeoutMs: 5000 }).catch(() => null)
66  return out && out.exitCode === 0 ? out.stdout.trim() || null : null
67}
68
69async function saveBeacon($: EngineInterface) {
70  if (!me) return
71  me.updatedAt = await $.clock.now()
72  me.claims = freshClaims(me.claims, me.updatedAt)
73  await $.fs.write(beaconPath(me.sessionId), JSON.stringify(me)).catch(() => undefined)
74}
75
76async function readJson($: EngineInterface, path: string) {
77  try {
78    return JSON.parse(String(await $.fs.read(path)))
79  } catch {
80    return null
81  }
82}
83
84// Reads the registry, keeps the sessions whose process is alive, and joins each to its beacon.
85async function refresh($: EngineInterface) {
86  const now = await $.clock.now()
87  const registryDir = `${home}/.claude/sessions`
88  const entries = await $.fs.list(registryDir).catch(() => [])
89  const rows = (
90    await Promise.all(
91      entries
92        .filter(entry => entry.name.endsWith('.json'))
93        .map(async entry => parseRegistry(String(await $.fs.read(`${registryDir}/${entry.name}`).catch(() => ''))))
94    )
95  ).filter((row): row is NonNullable<ReturnType<typeof parseRegistry>> => row !== null)
96
97  const alive = new Set<number>()
98  if (rows.length > 0) {
99    const out = await $.process
100      .run(['sh', '-c', 'for p in "$@"; do kill -0 "$p" 2>/dev/null && echo "$p"; done', 'sh', ...rows.map(row => String(row.pid))], { timeoutMs: 5000 })
101      .catch(() => null)
102    for (const line of out?.stdout.split('\n') ?? []) if (line.trim()) alive.add(Number(line.trim()))
103  }
104
105  const live = rows.filter(row => alive.has(row.pid))
106  const myId = await $.session.id()
107  const self = live.find(row => row.sessionId === myId)
108  if (me && self) me.name = self.name
109  if (me) me.branch = await branchOf($, me.cwd)
110  await saveBeacon($)
111
112  peers = await Promise.all(
113    live.map(async row => {
114      const beacon: Beacon | null = row.sessionId === myId ? me : await readJson($, beaconPath(row.sessionId))
115      return {
116        sessionId: row.sessionId,
117        name: row.name,
118        label: beacon?.label ?? null,
119        cwd: row.cwd,
120        branch: beacon?.branch ?? null,
121        status: row.status,
122        isSelf: row.sessionId === myId,
123        claims: freshClaims(beacon?.claims ?? {}, now),
124        sent: beacon?.sent ?? [],
125        repo: await repoOf($, row.cwd),
126      }
127    })
128  )
129  refreshedAt = now
130  const badges = assignBadges(peers.map(peer => peer.sessionId))
131  const badgeOfName = new Map(peers.map(peer => [peer.name, badges.get(peer.sessionId)!]))
132  const labelOfName = new Map(peers.map(peer => [peer.name, peer.label]))
133
134  const next: LinkView = {
135    updatedAt: now,
136    sessions: peers
137      .map(peer => ({
138        name: peer.name,
139        label: peer.label,
140        glyph: badges.get(peer.sessionId)!.glyph,
141        hue: badges.get(peer.sessionId)!.hue,
142        status: peer.status,
143        isSelf: peer.isSelf,
144        cwd: shortPath(peer.cwd, home),
145        branch: peer.branch,
146        files: Object.keys(peer.claims).length,
147        recent: Object.entries(peer.claims)
148          .sort((a, b) => b[1].at - a[1].at)
149          .slice(0, 3)
150          .map(([path]) => shortPath(path, home)),
151      }))
152      .sort((a, b) => Number(b.isSelf) - Number(a.isSelf) || a.name.localeCompare(b.name)),
153    overlaps: overlaps(peers, now).map(hit => ({ path: shortPath(hit.path, home), names: hit.names })),
154    messages: timeline(peers, now)
155      .slice(-12)
156      .map(m => ({
157        ...m,
158        fromLabel: labelOfName.get(m.from) ?? null,
159        toLabel: labelOfName.get(m.to) ?? null,
160        fromGlyph: badgeOfName.get(m.from)?.glyph ?? '▐▛█▜▌',
161        fromHue: badgeOfName.get(m.from)?.hue ?? '#94a3b8',
162        toGlyph: badgeOfName.get(m.to)?.glyph ?? '▐▛█▜▌',
163        toHue: badgeOfName.get(m.to)?.hue ?? '#94a3b8',
164      })),
165  }
166  await update($, view, () => next)
167}
168
169async function freshPeers($: EngineInterface) {
170  if ((await $.clock.now()) - refreshedAt > REFRESH_MS) await refresh($)
171  return peers
172}
173
174// The first refusal stands; the same call again inside the window is the model saying it agreed.
175function acknowledged(key: string, now: number) {
176  const first = acks.get(key)
177  if (first !== undefined && now - first <= ACK_WINDOW_MS) {
178    acks.delete(key)
179    return true
180  }
181  acks.set(key, now)
182  return false
183}
184
185async function onEdit($: EngineInterface, e: any, next: (e: any) => Promise<any>) {
186  const raw = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
187  if (!me || typeof raw !== 'string' || !raw) return next(e)
188  const path = raw.startsWith('/') ? raw : `${me.cwd.replace(/\/$/, '')}/${raw}`
189  const now = await $.clock.now()
190  const holder = holderOf(path, await freshPeers($), now)
191  if (holder && !acknowledged(`edit:${path}`, now)) {
192    void $.ui.toast(`Session Link: ${holder.peer.name} edited this file ${ago(now - holder.claim.at)} ago`)
193    return { deny: editDeny(path, holder, now, home) }
194  }
195  const result = await next(e)
196  if (!result?.deny && !result?.isError) {
197    me.claims[path] = { at: await $.clock.now(), tool: e.tool }
198    await saveBeacon($)
199    void refresh($)
200  }
201  return result
202}
203
204export const register: Register = on => {
205  on('session.start', async ($, e, next) => {
206    home = (await $.env.get('HOME')) ?? ''
207    const id = await $.session.id()
208    const cwd = await $.session.cwd()
209    const saved: Beacon | null = await readJson($, beaconPath(id))
210    me = {
211      sessionId: id,
212      name: saved?.name ?? id.slice(0, 8),
213      label: saved?.label ?? null,
214      cwd,
215      branch: null,
216      updatedAt: 0,
217      claims: saved?.claims ?? {},
218      sent: saved?.sent ?? [],
219      received: saved?.received ?? [],
220    }
221    await $.command.register({ name: 'link', description: 'Open Session Link: live sessions, their messages and overlaps. /link name <label> labels this session; /link release clears your file claims' })
222    await $.tool.register({
223      name: 'set_session_label',
224      description:
225        'Labels this Claude session in Session Link, the panel that shows every live session on this machine. Call it early in your ' +
226        'first turn with a short label for what this session is doing (under 40 characters), and again if the task changes.',
227      inputSchema: {
228        type: 'object',
229        properties: { label: { type: 'string', description: 'Short label, for example "A: finish UA sub-project 1"' } },
230        required: ['label'],
231      },
232      isDeferred: false,
233    })
234    await refresh($)
235    $.clock.every(REFRESH_MS, () => void refresh($))
236    void $.ui.open({ id: PANE, title: 'Link' })
237    return next(e)
238  })
239
240  on('tool.call', { tool: 'mcp__session-link__set_session_label' }, async ($, e) => {
241    const label = cleanLabel(String((e as any).label ?? ''))
242    if (!me) return { result: 'Session Link is still starting; try again in a moment.' }
243    if (!label) return { result: 'Give a label of a few words, for example "A: finish UA sub-project 1".' }
244    me.label = label
245    await saveBeacon($)
246    await refresh($)
247    return { result: `This session is now labelled "${label}" in Session Link.` }
248  })
249
250  // Until the model labels itself, the first thing the user asked stands in, so no session is nameless.
251  on('prompt.submit', async ($, e, next) => {
252    if (me && !me.label && (!(e as any).origin || (e as any).origin.kind === 'composer')) {
253      const words = String((e as any).text ?? '').replace(/\s+/g, ' ').trim().split(' ').slice(0, 6).join(' ')
254      const label = cleanLabel(words)
255      if (label && !label.startsWith('/')) {
256        me.label = label
257        void saveBeacon($).then(() => refresh($))
258      }
259    }
260    return next(e)
261  })
262
263  on('prompt.compose', async ($, e, next) => {
264    const out = await next(e)
265    return { sections: [...out.sections.filter(section => section.id !== PROTOCOL.id), PROTOCOL] }
266  })
267
268  on('tool.call', { tool: 'Edit' }, onEdit)
269  on('tool.call', { tool: 'Write' }, onEdit)
270  on('tool.call', { tool: 'NotebookEdit' }, onEdit)
271
272  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
273    const command = String((e as any).command ?? '')
274    if (!me || !riskyGit(command)) return next(e)
275    const repo = await repoOf($, gitDirOf(command, me.cwd))
276    if (!repo) return next(e)
277    const now = await $.clock.now()
278    const list = await freshPeers($)
279    const sharers = sharersOf(repo, list, now, cwd => list.find(peer => peer.cwd === cwd)?.repo ?? null)
280    if (sharers.length > 0 && !acknowledged(`git:${repo}:${command}`, now)) {
281      void $.ui.toast(`Session Link: ${sharers[0]!.name} shares this repository`)
282      return { deny: gitDeny(command, repo, sharers, home) }
283    }
284    return next(e)
285  })
286
287  // Record what this session sends to another session (not to its own sub-agents).
288  on('session.send', async ($, e, next) => {
289    const result = await next(e)
290    const target = peers.find(peer => !peer.isSelf && (peer.name === e.to || peer.sessionId === e.to))
291    if (me && result.isDelivered && target) {
292      me.sent = keepNotes(me.sent, { at: await $.clock.now(), peer: target.name, text: e.text.slice(0, 160) })
293      await saveBeacon($)
294      void refresh($)
295    }
296    return result
297  })
298
299  on('session.receive', async ($, e, next) => {
300    const from = senderOf(e.text)
301    if (me && peers.some(peer => !peer.isSelf && peer.name === from)) {
302      const body = e.text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
303      me.received = keepNotes(me.received, { at: await $.clock.now(), peer: from, text: body.slice(0, 160) })
304      void saveBeacon($)
305    }
306    return next(e)
307  })
308
309  on('command.run', { command: 'link' }, async ($, e) => {
310    const args = String(e.args ?? '').trim()
311    const named = args.match(/^name\s+(.+)$/i)
312    if (named || /^name$/i.test(args)) {
313      if (me) me.label = named ? cleanLabel(named[1]!) : null
314      await saveBeacon($)
315      await refresh($)
316      return { text: me?.label ? `Session Link: this session is now labelled "${me.label}".` : 'Session Link: label cleared.' }
317    }
318    if (args === 'release') {
319      if (me) me.claims = {}
320      await saveBeacon($)
321      await refresh($)
322      return { text: 'Session Link: your file claims are cleared; other sessions can edit those files without a warning.' }
323    }
324    await refresh($)
325    await $.ui.open({ id: PANE, title: 'Link', focus: true })
326    return { text: 'Session Link open.' }
327  })
328
329  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
330    const { Box, Text, Button } = $.ui.resolve(e)
331    const width = Math.max(26, (e.props.bodyColumns ?? 44) - 2)
332    const data = await read($, view)
333    const now = await $.clock.now()
334    const sessions = data?.sessions ?? []
335    const others = sessions.filter(s => !s.isSelf)
336    const Rule = ({ title, hue, right }: { title: string; hue: string; right?: string }) => (
337      <Box flexDirection="column" marginTop={1}>
338        <Box justifyContent="space-between">
339          <Text bold color={hue}>{title}</Text>
340          {right ? <Text dimColor>{right}</Text> : null}
341        </Box>
342        <Text color={hue} dimColor>{'━'.repeat(width)}</Text>
343      </Box>
344    )
345    const hueOf = (status: string) => (status === 'busy' ? '#4ade80' : status === 'idle' ? '#94a3b8' : '#f97316')
346
347    return (
348      <Box flexDirection="column">
349        <Box justifyContent="space-between">
350          <Text bold color="#e2e8f0">⇄ SESSION LINK</Text>
351          <Text>
352            <Text color="#22d3ee" bold>{sessions.length}</Text>
353            <Text dimColor> live  </Text>
354            <Text color={(data?.overlaps.length ?? 0) > 0 ? '#f97316' : '#22c55e'} bold>{data?.overlaps.length ?? 0}</Text>
355            <Text dimColor> overlap</Text>
356          </Text>
357        </Box>
358
359        <Rule title="◎ SESSIONS" hue="#22d3ee" right={data ? `checked ${ago(now - data.updatedAt)} ago` : 'checking'} />
360        {sessions.length === 0 && <Text dimColor>Looking for sessions on this machine.</Text>}
361        {sessions.map(s => (
362          <Box key={s.name} flexDirection="column">
363            <Text wrap="truncate-end">
364              <Text color={s.hue} bold>{s.glyph} </Text>
365              <Text bold color={s.hue}>{s.label ?? s.name}</Text>
366              {s.label ? <Text dimColor> {s.name}</Text> : null}
367              <Text dimColor>{s.isSelf ? ' (this one)' : ''} </Text>
368              <Text color={hueOf(s.status)}>● {s.status}</Text>
369            </Text>
370            <Text wrap="truncate-end" dimColor>
371              {'  '}{s.cwd}{s.branch ? `  ⎇ ${s.branch}` : ''}{s.files ? `  ${s.files} file${s.files === 1 ? '' : 's'}` : ''}
372            </Text>
373            {s.recent.map(path => (
374              <Text key={path} wrap="truncate-start" color="#a78bfa">{'  ✎ '}{path}</Text>
375            ))}
376          </Box>
377        ))}
378        {others.length === 0 && sessions.length > 0 && <Text dimColor>No other session is running. Nothing to coordinate.</Text>}
379        {sessions.some(s => s.isSelf && !s.label) && <Text dimColor>This session labels itself on its first prompt; /link name changes it.</Text>}
380
381        <Rule title="⚠ OVERLAPS" hue="#f97316" right="files two sessions both edited" />
382        {(data?.overlaps ?? []).length === 0 && <Text color="#22c55e">✓ No file edited by two sessions.</Text>}
383        {(data?.overlaps ?? []).map(hit => (
384          <Text key={hit.path} wrap="truncate-start">
385            <Text color="#f97316">⚠ </Text>
386            <Text>{hit.path}</Text>
387            <Text dimColor>  {hit.names.join(' + ')}</Text>
388          </Text>
389        ))}
390
391        <Rule title="✉ MESSAGES" hue="#f472b6" right="between sessions" />
392        {(data?.messages ?? []).length === 0 && <Text dimColor>No messages between sessions yet.</Text>}
393        {(data?.messages ?? []).map(m => (
394          <Box key={`${m.from}-${m.at}`} flexDirection="column">
395            <Text wrap="truncate-end">
396              <Text color={m.fromHue} bold>{m.fromGlyph} {m.fromLabel ?? m.from}</Text>
397              <Text dimColor> → </Text>
398              <Text color={m.toHue} bold>{m.toGlyph} {m.toLabel ?? m.to}</Text>
399              <Text dimColor>  {ago(now - m.at)} ago</Text>
400            </Text>
401            <Text wrap="truncate-end">{'  '}{m.text}</Text>
402          </Box>
403        ))}
404
405        <Rule title="▸ CONTROLS" hue="#e2e8f0" />
406        <Box gap={1} flexWrap="wrap">
407          <Button key="refresh" hotkey="r" label="Refresh" onPress={() => void refresh($)} />
408          <Button key="release" hotkey="x" label="Release my files" onPress={() => void $.command.run({ command: 'link', args: 'release' })} />
409          <Button key="mission" hotkey="m" label="Mission" onPress={() => void $.command.run({ command: 'mission' })} />
410        </Box>
411      </Box>
412    )
413  })
414}
415
hooks/link.ts 180 lines
1// Pure helpers for Session Link: no `$`, so the tests can run them directly.
2
3export type Claim = { at: number; tool: string }
4export type Note = { at: number; peer: string; text: string }
5
6// What each session writes about itself, one file per session so no two ever write the same file.
7export type Beacon = {
8  sessionId: string
9  name: string
10  label: string | null
11  cwd: string
12  branch: string | null
13  updatedAt: number
14  claims: Record<string, Claim>
15  sent: Note[]
16  received: Note[]
17}
18
19// One live session as the pane shows it: the registry's facts plus the session's own beacon.
20export type Peer = {
21  sessionId: string
22  name: string
23  label: string | null
24  cwd: string
25  branch: string | null
26  status: string
27  isSelf: boolean
28  claims: Record<string, Claim>
29  sent: Note[]
30}
31
32export const CLAIM_TTL_MS = 6 * 60 * 60_000
33export const ACK_WINDOW_MS = 10 * 60_000
34export const NOTE_LIMIT = 30
35
36export const shortPath = (path: string, home: string) => (home && path.startsWith(home) ? `~${path.slice(home.length)}` : path)
37
38export const ago = (ms: number) => {
39  const s = Math.max(0, Math.round(ms / 1000))
40  if (s < 60) return `${s}s`
41  const m = Math.round(s / 60)
42  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
43}
44
45// The registry line `~/.claude/sessions/<pid>.json` writes for every Claude Code session.
46export const parseRegistry = (text: string) => {
47  try {
48    const row = JSON.parse(text)
49    if (!row?.sessionId || !row?.pid) return null
50    return {
51      pid: Number(row.pid),
52      sessionId: String(row.sessionId),
53      name: String(row.name ?? row.sessionId.slice(0, 8)),
54      cwd: String(row.cwd ?? ''),
55      status: String(row.status ?? 'unknown'),
56    }
57  } catch {
58    return null
59  }
60}
61
62export const freshClaims = (claims: Record<string, Claim>, now: number) =>
63  Object.fromEntries(Object.entries(claims ?? {}).filter(([, claim]) => now - claim.at <= CLAIM_TTL_MS))
64
65// Another live session that has edited this exact file recently, newest first.
66export const holderOf = (path: string, peers: Peer[], now: number) =>
67  peers
68    .filter(peer => !peer.isSelf)
69    .map(peer => ({ peer, claim: freshClaims(peer.claims, now)[path] }))
70    .filter((hit): hit is { peer: Peer; claim: Claim } => Boolean(hit.claim))
71    .sort((a, b) => b.claim.at - a.claim.at)[0] ?? null
72
73// Git commands that can sweep up, discard or move another session's uncommitted work when two
74// sessions share one working tree. Committing named paths (`git commit -- a b`) is not on it.
75const RISKY: RegExp[] = [
76  /\bgit\b[^|;&\n]*\badd\s+(-A|--all|-u|--update|\.)(\s|$)/,
77  /\bgit\b[^|;&\n]*\bcommit\b[^|;&\n]*\s-(?!-)[a-zA-Z]*a[a-zA-Z]*(\s|$)/,
78  /\bgit\b[^|;&\n]*\bcommit\b[^|;&\n]*\s--all\b/,
79  /\bgit\b[^|;&\n]*\breset\b[^|;&\n]*--hard\b/,
80  /\bgit\b[^|;&\n]*\b(checkout|switch|restore)\b/,
81  /\bgit\b[^|;&\n]*\bclean\s+-[a-zA-Z]*f/,
82  /\bgit\b[^|;&\n]*\bstash\b(?!\s+(list|show))/,
83  /\bgit\b[^|;&\n]*\b(rebase|merge|pull|cherry-pick|revert)\b/,
84]
85
86export const riskyGit = (command: string) => RISKY.some(rule => rule.test(command))
87
88// The directory a git command acts on: `git -C dir`, else the last `cd dir` before it, else the
89// session's own directory.
90export const gitDirOf = (command: string, cwd: string) => {
91  const flag = command.match(/\bgit\s+-C\s+("([^"]+)"|'([^']+)'|(\S+))/)
92  const cds = [...command.matchAll(/\bcd\s+("([^"]+)"|'([^']+)'|([^\s;&|]+))/g)]
93  const pick = (m: RegExpMatchArray | undefined | null) => (m ? m[2] ?? m[3] ?? m[4] ?? null : null)
94  const dir = pick(flag) ?? pick(cds.at(-1)) ?? cwd
95  return dir.startsWith('/') ? dir : `${cwd.replace(/\/$/, '')}/${dir}`
96}
97
98export const within = (path: string, root: string) => path === root || path.startsWith(`${root.replace(/\/$/, '')}/`)
99
100// The live sessions also working in this git repository: their directory is in it, or they have
101// edited a file in it recently.
102export const sharersOf = (repo: string, peers: Peer[], now: number, repoOfCwd: (cwd: string) => string | null) =>
103  peers.filter(peer => {
104    if (peer.isSelf) return false
105    if (repoOfCwd(peer.cwd) === repo) return true
106    return Object.keys(freshClaims(peer.claims, now)).some(path => within(path, repo))
107  })
108
109export const editDeny = (path: string, holder: { peer: Peer; claim: Claim }, now: number, home: string) =>
110  `Session Link: ${titleOf(holder.peer)}, another Claude session on this machine (${shortPath(holder.peer.cwd, home)}` +
111  `${holder.peer.branch ? `, branch ${holder.peer.branch}` : ''}), edited ${shortPath(path, home)} ${ago(now - holder.claim.at)} ago. ` +
112  `To avoid overwriting its work, message it first with SendMessage (to: "${holder.peer.name}") and agree who edits this file, ` +
113  'or ask the user. If you have agreed, or the user told you to go ahead, make the same edit again and it will be allowed.'
114
115export const gitDeny = (command: string, repo: string, sharers: Peer[], home: string) =>
116  `Session Link: ${sharers.map(peer => `${titleOf(peer)}${peer.branch ? ` (branch ${peer.branch})` : ''}`).join(', ')} ` +
117  `${sharers.length > 1 ? 'are' : 'is'} also working in the git repository ${shortPath(repo, home)}. ` +
118  `\`${command.trim().slice(0, 80)}\` can sweep up, discard or move that session's uncommitted work. ` +
119  'Stage only your own files and commit them with `git commit -- <paths>`, and do not switch branches in a shared working tree. ' +
120  `Message ${sharers[0]!.name} with SendMessage first if you need to. If the user told you to run this exact command, run it again and it will be allowed.`
121
122// A received message's sender, from the envelope the engine wraps it in.
123export const senderOf = (text: string) => text.match(/from="([^"]+)"/)?.[1] ?? 'another session'
124
125export const keepNotes = (notes: Note[], note: Note) => [...notes, note].slice(-NOTE_LIMIT)
126
127// Every message between the live sessions, oldest first: each session reports only what it sent,
128// so nothing is counted twice.
129export const timeline = (peers: Peer[], now: number, windowMs = 6 * 60 * 60_000) =>
130  peers
131    .flatMap(peer => peer.sent.map(note => ({ from: peer.name, to: note.peer, text: note.text, at: note.at })))
132    .filter(row => now - row.at <= windowMs)
133    .sort((a, b) => a.at - b.at)
134
135// Files two or more live sessions have both edited recently: where an overwrite is likeliest.
136export const overlaps = (peers: Peer[], now: number) => {
137  const byPath = new Map<string, string[]>()
138  for (const peer of peers) {
139    for (const path of Object.keys(freshClaims(peer.claims, now))) byPath.set(path, [...(byPath.get(path) ?? []), peer.name])
140  }
141  return [...byPath.entries()].filter(([, names]) => names.length > 1).map(([path, names]) => ({ path, names }))
142}
143
144// A badge per session: a crab and a hue, picked from its id so a session keeps its badge, and
145// never shared by two live sessions (a clash moves the later one to the next free badge).
146// Little Claude crabs (Clawd, as the Claude Code banner draws him), each pose with its own colour.
147export const BADGES = [
148  { glyph: '▐▛█▜▌', hue: '#f97316' },
149  { glyph: '▐▙█▟▌', hue: '#22d3ee' },
150  { glyph: '▝▜█▛▘', hue: '#f472b6' },
151  { glyph: '▗▟█▙▖', hue: '#a78bfa' },
152  { glyph: '▐▀█▀▌', hue: '#34d399' },
153  { glyph: '▐▄█▄▌', hue: '#38bdf8' },
154  { glyph: '▛▜█▛▜', hue: '#e879f9' },
155  { glyph: '▙▟█▙▟', hue: '#f87171' },
156  { glyph: '▝▛█▜▘', hue: '#4ade80' },
157  { glyph: '▗▙█▟▖', hue: '#fb7185' },
158  { glyph: '▐▛▀▜▌', hue: '#818cf8' },
159  { glyph: '▐▙▄▟▌', hue: '#2dd4bf' },
160] as const
161
162const hash = (text: string) => [...text].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) >>> 0, 7)
163
164export const assignBadges = (ids: string[]) => {
165  const taken = new Set<number>()
166  const out = new Map<string, (typeof BADGES)[number]>()
167  for (const id of [...ids].sort()) {
168    let at = hash(id) % BADGES.length
169    for (let tries = 0; taken.has(at) && tries < BADGES.length; tries++) at = (at + 1) % BADGES.length
170    taken.add(at)
171    out.set(id, BADGES[at]!)
172  }
173  return out
174}
175
176// What a session is called on screen and in a refusal: its label, with the name to message.
177export const titleOf = (peer: { name: string; label: string | null }) => (peer.label ? `${peer.label} (${peer.name})` : peer.name)
178
179export const cleanLabel = (text: string) => text.replace(/[\r\n\t]+/g, ' ').trim().slice(0, 40) || null
180
types/index.d.ts 35 lines
1export type LinkView = {
2  updatedAt: number
3  sessions: Array<{
4    name: string
5    label: string | null
6    glyph: string
7    hue: string
8    status: string
9    isSelf: boolean
10    cwd: string
11    branch: string | null
12    files: number
13    recent: string[]
14  }>
15  overlaps: Array<{ path: string; names: string[] }>
16  messages: Array<{
17    from: string
18    to: string
19    text: string
20    at: number
21    fromLabel: string | null
22    toLabel: string | null
23    fromGlyph: string
24    fromHue: string
25    toGlyph: string
26    toHue: string
27  }>
28}
29
30declare module 'claude-code' {
31  interface PluginState {
32    'session-link': { view: LinkView | null }
33  }
34}
35