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…

Five side-panel mods for Claude Code (function hooks, early access API):
| Mod | Panel | Command | |
|---|---|---|---|
| agent-progress | Crew: 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-tax | Cache: 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-ledger | Spend: 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-link | Session 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-control | Mission Control: all four stacked in one scrollable panel, with buttons for every command | /mission |
Every panel has buttons with hotkeys for its commands.
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.
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).
hooks/register.tsx 415 lines1import { 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}
415hooks/link.ts 180 lines1// 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
180types/index.d.ts 35 lines1export 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