Side panes for Claude Code: a usage pane (context, limits, cost, tools) and session link (sessions message each other, host mode)

session-link: a Claude Code mod (a plugin of function hooks) with two side panes.link-bridge: a small MCP server so Claude Code, Codex and Qwen Code sessions can message each other (see link-bridge/README.md)./plugin marketplace add Creeperprol/claude-mods
/plugin install session-link@claude-mods
Shows the session name, model and reasoning effort, context fill (and where it compacts), the 5-hour and weekly limits with reset times, where the context goes by category, the tools being run, and turns, tool calls, cost and burn rate. Buttons: Refresh, Reset counts, Hide/Show tools, Hide/Show breakdown.
/usage-bar opens the pane. The "Auto open" option (on by default) opens it at session start, and again on your first prompt so it also seats on terminals 110-143 columns wide. Below 110 columns it opens inline above the prompt or via the command.Lets your sessions on one machine find and message each other, and lets one act as the host.
/sessions lists the other running sessions. /tell <name> <message> and /tellall <message> send messages./host makes this session the host. /assign <name|all> <task> hands out a task; /board shows results, /board clear drops finished ones. /setname <name> <new name> renames a worker. The pane has a box for typing a task.report tool; the result lands on the host's board.~/.claude.Tests: claude plugin test session-link.
Codex can't load Claude Code mods or draw side panes. Two things work there:
link-bridge (above) gives Codex sessions the link_* tools to message other sessions and take tasks.~/.codex/config.toml:[tui]
status_line = ["model-with-reasoning", "thread-name", "context-used", "five-hour-limit", "weekly-limit", "estimated-thread-cost", "task-progress"]
/host, /assign, /board, /setname, a prompt box); usage pane shows effort, session name and tools; opens on your first prompt so it seats from 110 columns; Windows paths handled.hooks/register.tsx 906 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Inbox, Me, Msg, Role, Snap, Task } from '../types'
5
6
7const PANE = 'session-link'
8const log = atom({ plugin: 'session-link', key: 'log' } as const, [])
9const me = atom({ plugin: 'session-link', key: 'me' } as const, null)
10const role = atom({ plugin: 'session-link', key: 'role' } as const, 'solo')
11const tasks = atom({ plugin: 'session-link', key: 'tasks' } as const, [])
12const inbox = atom({ plugin: 'session-link', key: 'inbox' } as const, [])
13const target = atom({ plugin: 'session-link', key: 'target' } as const, 'all')
14
15const T_ASSIGN = 'mcp__session-link__assign'
16const T_REPORT = 'mcp__session-link__report'
17const T_PEERS = 'mcp__session-link__peers'
18const T_RENAME = 'mcp__session-link__rename'
19
20type Peer = { id: string; name: string; cwd: string; seen: number }
21
22const FRESH_MS = 6 * 3600 * 1000
23
24const ago = (ms: number) => {
25 const m = Math.max(0, Math.floor(ms / 60000))
26 return m < 1 ? 'now' : m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ago`
27}
28
29// Folder name of a path, whether it uses / (macOS, Linux) or \\ (Windows).
30const base = (cwd: string) => cwd.split(/[\\/]+/).filter(Boolean).pop() ?? 'session'
31
32async function listPeers($: any, selfId: string): Promise<Peer[]> {
33 const now = await $.clock.now()
34 const out: Peer[] = []
35 for (const k of await $.store.keys()) {
36 if (!k.startsWith('peer:')) continue
37 const p = (await $.store.get(k)) as Peer | undefined
38 if (p && p.id !== selfId && now - p.seen < FRESH_MS) out.push(p)
39 }
40 return out.sort((a, z) => z.seen - a.seen)
41}
42
43const pick = (list: Peer[], q: string) => {
44 const w = q.toLowerCase()
45 const exact = list.filter(p => p.name.toLowerCase() === w || p.id === q)
46 if (exact.length) return exact
47 return list.filter(p => p.name.toLowerCase().startsWith(w) || p.id.startsWith(q))
48}
49
50async function announce($: any, who: Me, cwd: string) {
51 const peer: Peer = { id: who.id, name: who.name, cwd, seen: await $.clock.now() }
52 await $.store.set(`peer:${who.id}`, peer)
53}
54
55async function remember($: any, m: Msg) {
56 await update($, log, l => [...l, m].slice(-50))
57 $.ui.invalidate('ui.render')
58}
59
60async function deliver($: any, who: Me, p: Peer, text: string): Promise<string | null> {
61 const r = await $.session.send({ to: { sessionId: p.id }, text: `[from session "${who.name}"] ${text}` })
62 if (r.isDelivered) {
63 await remember($, { at: await $.clock.now(), dir: 'out', who: p.name, text })
64 return null
65 }
66 if (/not running|gone/i.test(r.reason)) await $.store.delete(`peer:${p.id}`)
67 return `${p.name}: ${r.reason}`
68}
69
70const TASK_RE = /\[TASK (\S+) host=(\S+) name="([^"]*)"\]\n([\s\S]*)$/
71const RENAME_RE = /\[RENAME host=(\S+)\]\n([\s\S]*)$/
72const RESULT_RE = /\[RESULT (\S+) from="([^"]*)"\]\n([\s\S]*)$/
73
74async function hostRecord($: any): Promise<{ id: string; name: string } | null> {
75 const h = (await $.store.get('host')) as { id: string; name: string } | undefined
76 return h ?? null
77}
78
79// Host-ness lives in the shared host record, so it survives a reload of this module.
80async function isHost($: any, who: Me | null): Promise<boolean> {
81 if (!who) return false
82 const h = await hostRecord($)
83 return h !== null && h.id === who.id
84}
85
86// Turns host mode on or off; shared by the /host command and the pane (a plugin's own
87// $.command.run does not reach its own hooks).
88async function setHost($: any, who: Me, isOn: boolean) {
89 await update($, role, () => (isOn ? 'host' : 'solo') as Role)
90 if (isOn) {
91 await $.store.set('host', { id: who.id, name: who.name })
92 } else {
93 const h = await hostRecord($)
94 if (h && h.id === who.id) await $.store.delete('host')
95 }
96 $.ui.invalidate('ui.render')
97}
98
99// Names are plain text: drop any markup, such as the closing tag of the message envelope.
100const cleanName = (raw: string) => raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
101
102// Asks one worker to take a new session name.
103async function renameWorker($: any, who: Me, p: Peer, name: string): Promise<string | null> {
104 return deliver($, who, p, `[RENAME host=${who.id}]\n${name}`)
105}
106
107// Drops finished tasks from the board; pending and failed ones stay.
108async function clearDone($: any) {
109 await update($, tasks, l => l.filter(t => t.status !== 'done'))
110 $.ui.invalidate('ui.render')
111}
112
113// Hands one task to one worker and logs it on the board.
114async function assign($: any, who: Me, p: Peer, text: string): Promise<string | null> {
115 const id = `t${((await $.clock.now()) % 46656).toString(36)}${(await read($, tasks)).length}`
116 const body = `[TASK ${id} host=${who.id} name="${who.name}"]\n${text}\n\nWhen you are finished, call the tool ${T_REPORT} with task_id "${id}" and a short result.`
117 const failed = await deliver($, who, p, body)
118 const t: Task = { id, toId: p.id, toName: p.name, text, status: failed ? 'failed' : 'sent', at: await $.clock.now() }
119 await update($, tasks, l => [...l, t].slice(-30))
120 $.ui.invalidate('ui.render')
121 return failed
122}
123
124// ---- usage pane: helpers ----
125const USAGE_PANE = 'usage-bar'
126const snap = atom({ plugin: 'session-link', key: 'snap' } as const, null)
127const showTools = atom({ plugin: 'session-link', key: 'showTools' } as const, true)
128const showWhere = atom({ plugin: 'session-link', key: 'showWhere' } as const, true)
129const title = atom({ plugin: 'session-link', key: 'title' } as const, '')
130const effort = atom({ plugin: 'session-link', key: 'effort' } as const, '')
131
132const EFFORT_COLOR: Record<string, string> = { low: 'blue', medium: 'cyan', high: 'green', xhigh: 'magenta', max: 'red' }
133
134const human = (n: number) =>
135 n >= 1e6 ? `${(n / 1e6).toFixed(1)}m` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
136
137const tone = (p: number) => (p < 50 ? 'cyan' : p < 75 ? 'yellow' : p < 90 ? 'magenta' : 'red')
138
139const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
140
141// Smooth bar: full cells, one partial eighth, then a dim track.
142const bar = (p: number, width: number) => {
143 const cells = (Math.min(100, Math.max(0, p)) / 100) * width
144 const full = Math.floor(cells)
145 const part = EIGHTHS[Math.floor((cells - full) * 8)]
146 return { fill: '█'.repeat(full) + part, track: '░'.repeat(Math.max(0, width - full - (part ? 1 : 0))) }
147}
148
149const LABEL: Record<string, string> = { five_hour: '5-HOUR', seven_day: 'WEEKLY', spend_limit: 'SPEND' }
150const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
151
152const reset = (iso?: string) => {
153 if (!iso) return ''
154 const d = new Date(iso)
155 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
156 return d.toDateString() === new Date().toDateString() ? `resets ${hm}` : `resets ${DAYS[d.getDay()]} ${hm}`
157}
158
159const span = (ms: number) => {
160 const m = Math.max(0, Math.floor(ms / 60000))
161 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
162}
163
164type Usage = {
165 startedAt: number
166 context: { percent?: number; tokens?: number; window: number; breakdown?: any }
167 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
168 cost?: { usd: number }
169}
170
171const toSnap = (u: Usage, prev: Snap | null): Snap => {
172 const b = u.context.breakdown
173 return {
174 percent: u.context.percent ?? 0,
175 tokens: u.context.tokens ?? 0,
176 window: u.context.window,
177 limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
178 usd: u.cost ? u.cost.usd : null,
179 startedAt: u.startedAt,
180 model: b ? b.model : (prev ? prev.model : ''),
181 cats: b
182 ? b.categories
183 .filter((c: any) => c.kind === 'used' && c.tokens > 0)
184 .map((c: any) => ({ name: c.name, tokens: c.tokens }))
185 .sort((a: any, z: any) => z.tokens - a.tokens)
186 : (prev ? prev.cats : []),
187 compactAt: b ? (b.autoCompactThreshold ?? null) : (prev ? prev.compactAt : null),
188 }
189}
190
191// detail: also itemise the window (local estimate, free); the light call keeps the last itemising.
192async function refresh($: any, detail: boolean) {
193 const prev = await read($, snap)
194 const u = await $.session.usage(detail ? { breakdown: 'summary' } : undefined)
195 await update($, snap, () => toSnap(u, prev))
196 $.ui.invalidate('ui.render')
197}
198
199type Call = { id: string; tool: string; hint: string; isDone: boolean }
200
201const hintOf = (input: any): string => {
202 if (!input || typeof input !== 'object') return ''
203 const v = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.query ?? input.description ?? ''
204 return String(v).replace(/\s+/g, ' ').slice(0, 60)
205}
206
207// Called from the shared session.start hook (a module may register each event once).
208export async function usageStart($: any, autoOpen: boolean) {
209 await $.command.register({ name: 'usage-bar', description: 'Open the usage meters in a side pane' })
210 if (autoOpen) void $.ui.open({ id: USAGE_PANE, title: 'Usage' })
211 await refresh($, true)
212}
213
214// Called from the shared prompt hook: the reasoning effort this turn runs at.
215export async function usageEffort($: any, level: string) {
216 try {
217 if (level) await update($, effort, () => level)
218 } catch {
219 // cosmetic only
220 }
221}
222
223// Called from the shared /rename and prompt hooks: the pane header follows the session's name.
224export async function usageNamed($: any, name: string) {
225 try {
226 if (name) await update($, title, () => name)
227 } catch {
228 // the name is cosmetic; never block anything over it
229 }
230}
231
232
233// ---- session link ----
234export const register: Register = (on, options) => {
235 on('session.start', async ($, e, next) => {
236 const id = await $.session.id()
237 const cwd = await $.session.cwd()
238 const name = options.name ? String(options.name) : `${base(cwd)}-${id.slice(0, 4)}`
239 const who: Me = { id, name }
240 await update($, me, () => who)
241 await announce($, who, cwd)
242
243 const refused: string[] = []
244 for (const [name, description] of [
245 ['sessions', 'List your other running Claude Code sessions'],
246 ['tell', 'Message another session: /tell <name> <message>'],
247 ['tellall', 'Message every other session: /tellall <message>'],
248 ['link', 'Open the session link pane'],
249 ['host', 'Make this session the host that hands out work: /host or /host off'],
250 ['assign', 'Host: give a task to a session: /assign <name|all> <task>'],
251 ['board', 'Host: show the task board, or /board clear to drop finished tasks'],
252 ['setname', 'Host: rename another session: /setname <session> <new name>'],
253 ]) {
254 try {
255 await $.command.register({ name, description })
256 } catch (err) {
257 refused.push(`/${name}`)
258 }
259 }
260 if (refused.length) $.ui.toast(`session-link: could not register ${refused.join(', ')} (name taken)`)
261 try {
262 await $.tool.register({
263 name: 'assign',
264 description: 'Host only. Give a task to another Claude Code session (a worker) and carry on; its result comes back as a message. Use worker "all" for every worker.',
265 inputSchema: {
266 type: 'object',
267 properties: { worker: { type: 'string', description: 'Worker session name, or "all"' }, task: { type: 'string', description: 'What the worker should do, self-contained' } },
268 required: ['worker', 'task'],
269 },
270 })
271 await $.tool.register({
272 name: 'report',
273 description: 'Worker only. Send the result of a task the host assigned back to the host.',
274 inputSchema: {
275 type: 'object',
276 properties: { task_id: { type: 'string' }, result: { type: 'string', description: 'Short result or summary' } },
277 required: ['task_id', 'result'],
278 },
279 })
280 await $.tool.register({
281 name: 'rename',
282 description: 'Host only. Rename another Claude Code session (a worker).',
283 inputSchema: {
284 type: 'object',
285 properties: { worker: { type: 'string', description: 'Worker session name' }, name: { type: 'string', description: 'The new session name' } },
286 required: ['worker', 'name'],
287 },
288 })
289 await $.tool.register({ name: 'peers', description: 'List the other running Claude Code sessions you can message.' })
290 } catch (err) {
291 $.ui.toast('session-link: could not register its tools')
292 }
293 void $.ui.open({ id: PANE, title: 'Sessions' })
294 await usageStart($, options.autoOpen !== false)
295 return next(e)
296 })
297
298 on('command.run', { command: 'host' }, async ($, e) => {
299 const who = await read($, me)
300 if (!who) return { text: 'Session link is not ready yet.' }
301 if (e.args.trim() === 'off') {
302 await setHost($, who, false)
303 return { text: 'No longer hosting.' }
304 }
305 await setHost($, who, true)
306 return { text: `${who.name} is the host. Give work with /assign <session|all> <task>, or ask me to delegate.` }
307 })
308
309 on('command.run', { command: 'assign' }, async ($, e) => {
310 const who = await read($, me)
311 if (!who) return { text: 'Session link is not ready yet.' }
312 if (!(await isHost($, who))) return { text: 'Run /host first to make this session the host.' }
313 const [target, ...rest] = e.args.trim().split(/\s+/)
314 const text = rest.join(' ')
315 if (!target || !text) return { text: 'Usage: /assign <session name|all> <task>' }
316 const list = await listPeers($, who.id)
317 const chosen = target === 'all' ? list : pick(list, target)
318 if (chosen.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
319 if (target !== 'all' && chosen.length > 1) return { text: `"${target}" is ambiguous: ${chosen.map(p => p.name).join(', ')}` }
320 const out: string[] = []
321 for (const p of chosen) {
322 const f = await assign($, who, p, text)
323 out.push(f ? `✗ ${f}` : `→ ${p.name}`)
324 }
325 return { text: out.join('\n') }
326 })
327
328 on('command.run', { command: 'setname' }, async ($, e) => {
329 const who = await read($, me)
330 if (!who) return { text: 'Session link is not ready yet.' }
331 if (!(await isHost($, who))) return { text: 'Run /host first to make this session the host.' }
332 const [target, ...rest] = e.args.trim().split(/\s+/)
333 const name = cleanName(rest.join(' '))
334 if (!target || !name) return { text: 'Usage: /setname <session> <new name>' }
335 const matches = pick(await listPeers($, who.id), target)
336 if (matches.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
337 if (matches.length > 1) return { text: `"${target}" is ambiguous: ${matches.map(p => p.name).join(', ')}` }
338 const failed = await renameWorker($, who, matches[0], name)
339 return { text: failed ? `Not renamed. ${failed}` : `Asked ${matches[0].name} to rename itself to "${name}".` }
340 })
341
342 on('command.run', { command: 'board' }, async ($, e) => {
343 if (e.args.trim() === 'clear') {
344 const before = (await read($, tasks)).length
345 await clearDone($)
346 const after = (await read($, tasks)).length
347 return { text: `Cleared ${before - after} finished task${before - after === 1 ? '' : 's'}.` }
348 }
349 const list = await read($, tasks)
350 if (list.length === 0) return { text: 'No tasks yet.' }
351 const icon = { sent: '…', done: '✓', failed: '✗' }
352 return {
353 text: list
354 .map(t => `${icon[t.status]} ${t.id} ${t.toName}: ${t.text.slice(0, 60)}${t.result ? `\n ⇒ ${t.result.slice(0, 200)}` : ''}`)
355 .join('\n'),
356 }
357 })
358
359 // The tools the model calls.
360 on('tool.call', { tool: T_PEERS }, async $ => {
361 const who = await read($, me)
362 const list = await listPeers($, who ? who.id : '')
363 return { result: list.length ? list.map(p => `${p.name} (${base(p.cwd)})`).join('\n') : 'No other sessions running.' }
364 })
365
366 on('tool.call', { tool: T_ASSIGN }, async ($, e) => {
367 const who = await read($, me)
368 if (!(await isHost($, who))) return { result: 'This session is not the host. The person must run /host first.' }
369 const input = e as unknown as { worker?: string; task?: string }
370 const worker = String(input.worker ?? '')
371 const task = String(input.task ?? '')
372 if (!worker || !task) return { result: 'Both worker and task are required.' }
373 const list = await listPeers($, who.id)
374 const chosen = worker === 'all' ? list : pick(list, worker)
375 if (chosen.length === 0) return { result: `No session matches "${worker}".` }
376 if (worker !== 'all' && chosen.length > 1) return { result: `"${worker}" is ambiguous: ${chosen.map(p => p.name).join(', ')}` }
377 const out: string[] = []
378 for (const p of chosen) {
379 const f = await assign($, who, p, task)
380 out.push(f ? `failed: ${f}` : `sent to ${p.name}`)
381 }
382 return { result: out.join('\n') }
383 })
384
385 on('tool.call', { tool: T_RENAME }, async ($, e) => {
386 const who = await read($, me)
387 if (!who || !(await isHost($, who))) return { result: 'This session is not the host. The person must run /host first.' }
388 const input = e as unknown as { worker?: string; name?: string }
389 const worker = String(input.worker ?? '')
390 const name = cleanName(String(input.name ?? ''))
391 if (!worker || !name) return { result: 'Both worker and name are required.' }
392 const matches = pick(await listPeers($, who.id), worker)
393 if (matches.length !== 1) return { result: matches.length ? `"${worker}" is ambiguous.` : `No session matches "${worker}".` }
394 const failed = await renameWorker($, who, matches[0], name)
395 return { result: failed ? `Not renamed: ${failed}` : `Asked ${matches[0].name} to rename itself to "${name}".` }
396 })
397
398 on('tool.call', { tool: T_REPORT }, async ($, e) => {
399 const who = await read($, me)
400 const input = e as unknown as { task_id?: string; result?: string }
401 const id = String(input.task_id ?? '')
402 const found = (await read($, inbox)).find(i => i.id === id)
403 if (!who || !found) return { result: `No task ${id} was assigned to this session.` }
404 const r = await $.session.send({
405 to: { sessionId: found.hostId },
406 text: `[RESULT ${id} from="${who.name}"]\n${String(input.result ?? '')}`,
407 })
408 return { result: r.isDelivered ? 'Result sent to the host.' : `Could not reach the host: ${r.reason}` }
409 })
410
411 on('command.run', { command: 'link' }, async $ => {
412 await $.ui.open({ id: PANE, title: 'Sessions' })
413 return { text: 'Session pane opened.' }
414 })
415
416 on('command.run', { command: 'sessions' }, async $ => {
417 const who = await read($, me)
418 const list = await listPeers($, who ? who.id : '')
419 const now = await $.clock.now()
420 if (list.length === 0) return { text: 'No other sessions found. Start Claude Code in another terminal with this mod loaded.' }
421 return { text: list.map(p => `${p.name} ${base(p.cwd)} ${ago(now - p.seen)} (${p.id.slice(0, 8)})`).join('\n') }
422 })
423
424 on('command.run', { command: 'tell' }, async ($, e) => {
425 const who = await read($, me)
426 if (!who) return { text: 'Session link is not ready yet.' }
427 const [target, ...rest] = e.args.trim().split(/\s+/)
428 const text = rest.join(' ')
429 if (!target || !text) return { text: 'Usage: /tell <session name or id> <message>' }
430 const matches = pick(await listPeers($, who.id), target)
431 if (matches.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
432 if (matches.length > 1) return { text: `"${target}" is ambiguous: ${matches.map(p => p.name).join(', ')}` }
433 const failed = await deliver($, who, matches[0], text)
434 return { text: failed ? `Not delivered. ${failed}` : `Sent to ${matches[0].name}.` }
435 })
436
437 on('command.run', { command: 'tellall' }, async ($, e) => {
438 const who = await read($, me)
439 if (!who) return { text: 'Session link is not ready yet.' }
440 const text = e.args.trim()
441 if (!text) return { text: 'Usage: /tellall <message>' }
442 const list = await listPeers($, who.id)
443 if (list.length === 0) return { text: 'No other sessions to tell.' }
444 const failures: string[] = []
445 for (const p of list) {
446 const f = await deliver($, who, p, text)
447 if (f) failures.push(f)
448 }
449 return { text: `Sent to ${list.length - failures.length}/${list.length} sessions.${failures.length ? '\n' + failures.join('\n') : ''}` }
450 })
451
452 // Record what other sessions say to this one; the model still reads it as usual.
453 on('session.receive', async ($, e, next) => {
454 if (e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message') {
455 try {
456 const now = await $.clock.now()
457 const task = TASK_RE.exec(e.text)
458 const result = RESULT_RE.exec(e.text)
459 const rename = RENAME_RE.exec(e.text)
460 if (task) {
461 // A task is only taken from the session registered as the host.
462 const h = await hostRecord($)
463 if (h && h.id === task[2]) {
464 const item: Inbox = { id: task[1], hostId: task[2], hostName: task[3], text: task[4].split('\n\nWhen you are finished')[0] }
465 await update($, inbox, l => [...l, item].slice(-30))
466 await remember($, { at: now, dir: 'in', who: `task ${item.id}`, text: item.text.slice(0, 300) })
467 $.ui.toast(`Task ${item.id} from ${item.hostName}`)
468 if (options.autoRun === true) {
469 void $.prompt
470 .submit({ text: `Task ${item.id} from host "${item.hostName}": ${item.text}\nWhen finished, call ${T_REPORT} with task_id "${item.id}" and a short result.` })
471 .catch(() => {})
472 }
473 }
474 } else if (rename) {
475 // A rename is only taken from the session registered as the host.
476 const h = await hostRecord($)
477 const name = cleanName(rename[2])
478 const who = await read($, me)
479 if (h && h.id === rename[1] && name && who) {
480 await $.command.run({ command: 'rename', args: name })
481 const renamed: Me = { id: who.id, name }
482 await update($, me, () => renamed)
483 await announce($, renamed, await $.session.cwd())
484 await remember($, { at: now, dir: 'in', who: h.name, text: `renamed this session to "${name}"` })
485 $.ui.toast(`${h.name} renamed this session to "${name}"`)
486 }
487 } else if (result) {
488 const id = result[1]
489 const found = (await read($, tasks)).find(t => t.id === id)
490 if (found) {
491 await update($, tasks, l => l.map(t => (t.id === id ? { ...t, status: 'done' as const, result: result[3].slice(0, 2000) } : t)))
492 await remember($, { at: now, dir: 'in', who: `${result[2]} ✓ ${id}`, text: result[3].slice(0, 300) })
493 $.ui.toast(`${result[2]} finished ${id}`)
494 }
495 } else {
496 await remember($, { at: now, dir: 'in', who: 'peer', text: e.text.slice(0, 300) })
497 }
498 } catch {
499 // logging must never block delivery
500 }
501 }
502 return next(e)
503 })
504
505 // /rename carries the new name as its argument: follow it at once, unless the option fixes one.
506 on('command.run', { command: 'rename' }, async ($, e, next) => {
507 const r = await next(e)
508 await usageNamed($, e.args.trim())
509 try {
510 const who = await read($, me)
511 const t = e.args.trim()
512 if (who && t && !options.name && who.name !== t) {
513 const renamed: Me = { id: who.id, name: t }
514 await update($, me, () => renamed)
515 await announce($, renamed, await $.session.cwd())
516 $.ui.invalidate('ui.render')
517 }
518 } catch {
519 // the name is cosmetic; never block /rename over it
520 }
521 return r
522 })
523
524 // Follow the name set with /rename, unless the option fixes one.
525 on('classic.UserPromptSubmit', async ($, e, next) => {
526 await usageNamed($, e.session_title ? String(e.session_title) : '')
527 await usageEffort($, e.effort ? String(e.effort.level) : '')
528 try {
529 const who = await read($, me)
530 const t = e.session_title ? String(e.session_title) : ''
531 if (who && t && !options.name && who.name !== t) {
532 const renamed: Me = { id: who.id, name: t }
533 await update($, me, () => renamed)
534 await announce($, renamed, await $.session.cwd())
535 $.ui.invalidate('ui.render')
536 }
537 } catch {
538 // the name is cosmetic; never block a prompt over it
539 }
540 return next(e)
541 })
542
543 // Keep this session's listing fresh while it is in use.
544 on('turn.complete', async ($, e, next) => {
545 try {
546 const who = await read($, me)
547 if (who) await announce($, who, await $.session.cwd())
548 } catch {
549 // a failed heartbeat must never affect the turn
550 }
551 return next(e)
552 })
553
554 on('session.end', async ($, e, next) => {
555 try {
556 const who = await read($, me)
557 if (who) await $.store.delete(`peer:${who.id}`)
558 } catch {
559 // leaving quietly
560 }
561 return next(e)
562 })
563
564 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
565 const { Box, Text, Button, Input, Select } = $.ui.resolve(e)
566 const who = await read($, me)
567 const msgs = await read($, log)
568 const myRole: Role = (await isHost($, who)) ? 'host' : 'solo'
569 const board = await read($, tasks)
570 const waiting = await read($, inbox)
571 const pickedId = await read($, target)
572 let list: Peer[] = []
573 let now = 0
574 try {
575 now = await $.clock.now()
576 list = await listPeers($, who ? who.id : '')
577 } catch (err) {
578 return <Text color="red">session-link error: {String(err)}</Text>
579 }
580
581 return (
582 <Box flexDirection="column" paddingX={1}>
583 <Text bold color="cyan">{who ? who.name : '…'}</Text>
584 <Text>
585 <Text dimColor>{who ? who.id.slice(0, 8) : ''} </Text>
586 <Text bold color={myRole === 'host' ? 'yellow' : 'green'}>{myRole === 'host' ? '★ HOST' : 'worker'}</Text>
587 </Text>
588 <Text> </Text>
589
590 <Text bold>OTHER SESSIONS</Text>
591 {list.length === 0 ? <Text dimColor>none running</Text> : null}
592 {list.map(p => (
593 <Box key={p.id} flexDirection="column">
594 <Text>
595 <Text color="green">● </Text>
596 <Text bold>{p.name}</Text>
597 <Text dimColor> {ago(now - p.seen)}</Text>
598 </Text>
599 <Text dimColor> {base(p.cwd)} /tell {p.name} …</Text>
600 </Box>
601 ))}
602 <Text> </Text>
603
604 {myRole === 'host' ? (
605 <Box flexDirection="column" marginBottom={1}>
606 <Text bold>TASKS</Text>
607 {board.length === 0 ? <Text dimColor>none yet · /assign name task</Text> : null}
608 {board.some(t => t.status === 'done') ? (
609 <Button key="clear" label="Clear done" onPress={() => clearDone($)} />
610 ) : null}
611 {board.slice(-6).map(t => (
612 <Box key={t.id} flexDirection="column">
613 <Text wrap="truncate">
614 <Text color={t.status === 'done' ? 'green' : t.status === 'failed' ? 'red' : 'yellow'}>
615 {t.status === 'done' ? '✓ ' : t.status === 'failed' ? '✗ ' : '… '}
616 </Text>
617 <Text bold>{t.toName} </Text>
618 <Text dimColor>{t.text}</Text>
619 </Text>
620 {t.result ? <Text wrap="truncate" color="green"> ⇒ {t.result.replace(/\s+/g, ' ')}</Text> : null}
621 </Box>
622 ))}
623 </Box>
624 ) : waiting.length ? (
625 <Box flexDirection="column" marginBottom={1}>
626 <Text bold>FROM THE HOST</Text>
627 {waiting.slice(-4).map(i => (
628 <Text key={i.id} wrap="truncate">
629 <Text color="yellow">● </Text>
630 <Text bold>{i.id} </Text>
631 <Text dimColor>{i.text}</Text>
632 </Text>
633 ))}
634 </Box>
635 ) : null}
636
637 <Box flexDirection="column" marginBottom={1}>
638 <Text bold>PROMPT A WORKER</Text>
639 {list.length === 0 ? (
640 <Text dimColor>no other sessions yet</Text>
641 ) : (
642 <Box flexDirection="column">
643 <Select
644 key="target"
645 label="to "
646 value={pickedId}
647 options={[{ value: 'all', label: 'all workers' }, ...list.map(p => ({ value: p.id, label: p.name }))]}
648 onSelect={v => update($, target, () => v)}
649 />
650 <Input
651 key="prompt"
652 placeholder="type a task, Enter to send"
653 submitLabel="send"
654 onSubmit={async text => {
655 const task = text.trim()
656 if (!task || !who) return
657 if (myRole !== 'host') await setHost($, who, true)
658 const chosen = pickedId === 'all' ? list : list.filter(p => p.id === pickedId)
659 let ok = 0
660 for (const p of chosen) if (!(await assign($, who, p, task))) ok += 1
661 $.ui.toast(ok ? `Sent to ${ok} session${ok === 1 ? '' : 's'}` : 'Nothing was delivered')
662 }}
663 />
664 </Box>
665 )}
666 </Box>
667
668 <Text bold>MESSAGES</Text>
669 {msgs.length === 0 ? <Text dimColor>nothing yet</Text> : null}
670 {msgs.slice(-8).map((m, i) => (
671 <Text key={`${m.at}-${i}`} wrap="truncate">
672 <Text color={m.dir === 'in' ? 'yellow' : 'cyan'}>{m.dir === 'in' ? '← ' : '→ '}</Text>
673 <Text bold>{m.who} </Text>
674 <Text dimColor>{m.text}</Text>
675 </Text>
676 ))}
677 <Text> </Text>
678 <Box>
679 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => $.ui.invalidate('ui.render')} />
680 <Text> </Text>
681 <Button
682 key="host"
683 label={myRole === 'host' ? 'Stop hosting' : 'Become host'}
684 onPress={() => (who ? setHost($, who, myRole !== 'host') : undefined)}
685 />
686 </Box>
687 </Box>
688 )
689 })
690
691 // ---- usage pane: hooks ----
692 let turns = 0
693 let askedOnce = false
694 let tools = 0
695 let calls: Call[] = []
696 const counts: Record<string, number> = {}
697
698 on('command.run', { command: 'usage-bar' }, async $ => {
699 await $.ui.open({ id: USAGE_PANE, title: 'Usage' })
700 return { text: 'Usage pane opened.' }
701 })
702
703 // A resumed session already has its name when it starts.
704 on('classic.SessionStart', async ($, e, next) => {
705 try {
706 if (e.session_title) await update($, title, () => String(e.session_title))
707 } catch {
708 // cosmetic only
709 }
710 return next(e)
711 })
712
713 on('prompt.submit', async ($, e, next) => {
714 turns += 1
715 // A pane the mod opens on its own waits below 144 columns; one opened in answer to the
716 // person's prompt is seated from 110. So ask once, on their first prompt.
717 if (!askedOnce && options.autoOpen !== false) {
718 askedOnce = true
719 try {
720 await $.ui.open({ id: USAGE_PANE, title: 'Usage' })
721 } catch {
722 // the pane is a convenience; never block a prompt over it
723 }
724 }
725 return next(e)
726 })
727
728 on('session.measure', async ($, e, next) => {
729 await refresh($, true)
730 return next(e)
731 })
732
733 // Keep the meters moving between turns: refresh after every tool call.
734 on('tool.call', async ($, e, next) => {
735 tools += 1
736 counts[e.tool] = (counts[e.tool] ?? 0) + 1
737 const call: Call = { id: e.tool_use_id ?? `${e.tool}-${tools}`, tool: e.tool, hint: hintOf(e), isDone: false }
738 calls = [...calls, call].slice(-12)
739 $.ui.invalidate('ui.render')
740 const r = await next(e)
741 calls = calls.map(c => (c.id === call.id ? { ...c, isDone: true } : c))
742 try {
743 await refresh($, false)
744 } catch {
745 // a failed refresh must never affect the tool call
746 }
747 return r
748 })
749
750 on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e) => {
751 const { Box, Text, Button } = $.ui.resolve(e)
752 let s: Snap
753 let now = 0
754 let isTools = true
755 let isWhere = true
756 let name = ''
757 let level = ''
758 let sid = ''
759 try {
760 s = (await read($, snap)) ?? toSnap(await $.session.usage({ breakdown: 'summary' }), null)
761 now = await $.clock.now()
762 isTools = await read($, showTools)
763 isWhere = await read($, showWhere)
764 name = await read($, title)
765 level = await read($, effort)
766 sid = (await $.session.id()).slice(0, 8)
767 } catch (err) {
768 return <Text color="red">usage error: {String(err)}</Text>
769 }
770
771 const width = Math.max(8, (e.props.bodyColumns ?? 30) - 2)
772 const age = Math.max(1, now - s.startedAt)
773 const perHour = s.usd !== null ? (s.usd / age) * 3600000 : null
774
775 const Meter = ({ label, p, note }: { label: string; p: number; note?: string }) => {
776 const b = bar(p, width)
777 return (
778 <Box flexDirection="column" marginBottom={1}>
779 <Box>
780 <Text bold>{label}</Text>
781 <Text bold color={tone(p)}> {Math.round(p)}%</Text>
782 </Box>
783 <Text>
784 <Text color={tone(p)}>{b.fill}</Text>
785 <Text dimColor>{b.track}</Text>
786 </Text>
787 {note ? <Text dimColor>{note}</Text> : null}
788 </Box>
789 )
790 }
791
792 const Row = ({ k, v, color }: { k: string; v: string; color?: string }) => (
793 <Box>
794 <Text dimColor>{k.padEnd(10)}</Text>
795 <Text bold color={color}>{v}</Text>
796 </Box>
797 )
798
799 const top = s.cats.slice(0, 6)
800 const biggest = top.length ? top[0].tokens : 1
801
802 return (
803 <Box flexDirection="column" paddingX={1}>
804 <Text bold color="white">{name || 'untitled session'}</Text>
805 <Text dimColor>{sid}</Text>
806 <Text>
807 {s.model ? <Text bold color="cyan">{s.model}</Text> : null}
808 <Text dimColor> · effort </Text>
809 <Text bold color={EFFORT_COLOR[level] ?? 'white'}>{level || '—'}</Text>
810 </Text>
811 <Text dimColor>session {span(age)}</Text>
812 <Text> </Text>
813
814 <Meter
815 label="CONTEXT"
816 p={s.percent}
817 note={`${human(s.tokens)} / ${human(s.window)} tokens${
818 s.compactAt ? ` · compacts at ${human(s.compactAt)}` : ''
819 }`}
820 />
821 {s.limits.map(l => (
822 <Meter key={l.kind} label={LABEL[l.kind] ?? l.kind} p={l.percentUsed} note={reset(l.resetsAt)} />
823 ))}
824
825 {top.length ? (
826 <Box flexDirection="column" marginBottom={1}>
827 <Text bold>WHERE CONTEXT GOES</Text>
828 {(isWhere ? top : []).map(c => {
829 const b = bar((c.tokens / biggest) * 100, 6)
830 return (
831 <Text key={c.name}>
832 <Text color="cyan">{b.fill}</Text>
833 <Text dimColor>{b.track} </Text>
834 <Text>{c.name} </Text>
835 <Text dimColor>{human(c.tokens)}</Text>
836 </Text>
837 )
838 })}
839 {isWhere && s.cats.length > top.length ? (
840 <Text dimColor>+{s.cats.length - top.length} more</Text>
841 ) : null}
842 </Box>
843 ) : null}
844
845 <Box flexDirection="column" marginBottom={1}>
846 <Text bold>TOOLS</Text>
847 {calls.length === 0 ? <Text dimColor>none yet</Text> : null}
848 {(isTools ? calls.slice(-5) : []).map(c => (
849 <Text key={c.id} wrap="truncate">
850 <Text color={c.isDone ? 'green' : 'yellow'}>{c.isDone ? '✓ ' : '● '}</Text>
851 <Text bold={!c.isDone}>{c.tool}</Text>
852 <Text dimColor> {c.hint}</Text>
853 </Text>
854 ))}
855 {Object.keys(counts).length ? (
856 <Text dimColor>
857 {Object.entries(counts)
858 .sort((a, z) => z[1] - a[1])
859 .slice(0, 4)
860 .map(([k, n]) => `${k}×${n}`)
861 .join(' ')}
862 </Text>
863 ) : null}
864 </Box>
865
866 <Text bold>SESSION</Text>
867 <Row k="turns" v={String(turns)} />
868 <Row k="tool calls" v={String(tools)} />
869 {s.usd !== null ? <Row k="cost" v={`$${s.usd.toFixed(2)}`} color="green" /> : null}
870 {perHour !== null && age > 120000 ? <Row k="burn" v={`$${perHour.toFixed(2)}/h`} /> : null}
871
872 <Box flexDirection="column" marginTop={1}>
873 <Box>
874 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
875 <Text> </Text>
876 <Button
877 key="reset"
878 label="Reset counts"
879 onPress={() => {
880 turns = 0
881 tools = 0
882 calls = []
883 for (const k of Object.keys(counts)) delete counts[k]
884 $.ui.invalidate('ui.render')
885 }}
886 />
887 </Box>
888 <Box>
889 <Button
890 key="tools"
891 label={isTools ? 'Hide tools' : 'Show tools'}
892 onPress={() => update($, showTools, v => !v)}
893 />
894 <Text> </Text>
895 <Button
896 key="where"
897 label={isWhere ? 'Hide breakdown' : 'Show breakdown'}
898 onPress={() => update($, showWhere, v => !v)}
899 />
900 </Box>
901 </Box>
902 </Box>
903 )
904 })
905}
906types/index.d.ts 48 lines1export type Msg = { at: number; dir: 'in' | 'out'; who: string; text: string }
2export type Me = { id: string; name: string }
3export type Role = 'solo' | 'host'
4export type Task = {
5 id: string
6 toId: string
7 toName: string
8 text: string
9 status: 'sent' | 'done' | 'failed'
10 result?: string
11 at: number
12}
13export type Inbox = { id: string; hostId: string; hostName: string; text: string }
14
15export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
16export type Cat = { name: string; tokens: number }
17export type Snap = {
18 percent: number
19 tokens: number
20 window: number
21 limits: Limit[]
22 usd: number | null
23 startedAt: number
24 model: string
25 cats: Cat[]
26 compactAt: number | null
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'session-link': {
32 // session messaging and host mode
33 log: Msg[]
34 me: Me | null
35 role: Role
36 tasks: Task[]
37 inbox: Inbox[]
38 target: string
39 // usage pane
40 snap: Snap | null
41 showTools: boolean
42 showWhere: boolean
43 title: string
44 effort: string
45 }
46 }
47}
48