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

ai-link (AI Link, formerly 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, Qwen Code and the Antigravity CLI (agy) sessions can message each other and hand out work (see link-bridge/README.md). It is the cross-CLI half of AI Link; the ai-link mod is the Claude Code half. One-shot helpers there let Claude use agy (agy-worker.sh, agy-image.sh) and Codex (codex-agent.sh, read-only by default) as agents; link-grid.sh and big-grid.sh open many workers in one tmux window, and messages are typed straight into their panes; lan.mjs links sessions on other machines on the same Wi-Fi.Not on a marketplace. Clone the repo and load the plugin folder:
git clone https://github.com/Creeperprol/basic-mod
claude --plugin-dir basic-mod/ai-link
To load it in every session, add the folder's absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json.
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|auto|all> <task> hands out a task (auto picks the least-loaded worker; the host's model is also told how to run workers: contract first, even sizing, verify reports); /board shows the open tasks (finished ones clear themselves), /board clear empties the board and messages. /setname <name> <new name> renames a worker. The pane has a box for typing a task.report tool. While a worker holds a host task it is told to write no chat text and only report; a host's rename is applied by the plugin and never shown to the worker's model./run <name|all> /cost and the command tool let the host run compact, clear or cost in a worker (commands that open a dialog, like /model, are refused). A result only clears a task when it comes from the worker it was assigned to, tasks open over 15 minutes show as stale, renames refuse a name another session already uses, and after a report the worker is told it is done so it writes no closing summary.~/.claude.Tests: claude plugin test ai-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"]
link-bridge/listen.mjs: node listen.mjs install adds a Claude Code hook so sessions read link messages by themselves where nothing can type into the window (Windows Terminal, VS Code, iTerm2).link-bridge/LAN-PROMPT.md: a prompt to paste into Claude Code on the second computer; it installs and starts the Wi-Fi link there.link-bridge/lan.mjs links sessions on two or more machines on the same Wi-Fi: run node lan.mjs key on one, node lan.mjs join <key> on the others, then node lan.mjs on each (see link-bridge/README.md).claude-mods marketplace; install from a clone (see Install). If you installed it from the marketplace: /plugin uninstall ai-link@claude-mods and /plugin marketplace remove claude-mods.session-link to ai-link (AI Link). If you installed the old name: /plugin uninstall session-link@claude-mods, then /plugin install ai-link@claude-mods./clear no longer drops the session name or the host role: the new session picks them up from the one it replaced./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 1115 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 = 'ai-link'
8const log = atom({ plugin: 'ai-link', key: 'log' } as const, [])
9const me = atom({ plugin: 'ai-link', key: 'me' } as const, null)
10const role = atom({ plugin: 'ai-link', key: 'role' } as const, 'solo')
11const tasks = atom({ plugin: 'ai-link', key: 'tasks' } as const, [])
12const inbox = atom({ plugin: 'ai-link', key: 'inbox' } as const, [])
13const target = atom({ plugin: 'ai-link', key: 'target' } as const, 'all')
14
15const T_ASSIGN = 'mcp__ai-link__assign'
16const T_REPORT = 'mcp__ai-link__report'
17const T_PEERS = 'mcp__ai-link__peers'
18// What the host's model is told about running workers (added to its system prompt while it is the host).
19const HOST_WORKFLOW = `You are the host of a multi-window setup: other Claude sessions (workers) take tasks you give them with the assign tool and answer with a short report. Run it like this. 1) Contract first: before assigning, write the shared contract (file ownership per worker, data shapes, function signatures, constants in one place). 2) Self-contained tasks: name the files the worker may edit, the contract to read, and the exact done-when check (a command that must pass). Workers already work silently, so do not ask them to chat. 3) Share work evenly: size each task (S one small file, M one module, L several modules), give it to the least-loaded idle worker (assign with worker "auto" does this), never queue two tasks on one worker while another is idle, split a lopsided big task by file ownership, and tell the person the split in one table (worker, task, size). Use worker "all" only for tasks that are truly identical. 4) Never trust a report alone: re-run the tests yourself, run an end-to-end check, look at real output, and say what was verified for real and what was not. 5) Diagnose first: reproduce a problem, find the cause, then send the owning worker a precise task with the repro; do not edit a worker's files while it may be editing them. 6) Watch worker context: a report saying NEEDS COMPACT or ctx ~100k or more means send that worker only small tasks and tell the person to run /compact in its window. 7) Use worker names exactly as peers lists them; if assign says no session matches, call peers again. Deploy or publish only when the person asked, after the checks pass.`
20
21const T_RENAME = 'mcp__ai-link__rename'
22const T_COMMAND = 'mcp__ai-link__command'
23
24type Peer = { id: string; name: string; cwd: string; seen: number }
25
26const FRESH_MS = 6 * 3600 * 1000
27// A task still open after this long is shown as stale and no longer counts against its worker.
28const STALE_TASK_MS = 15 * 60 * 1000
29const isStale = (t: Task, now: number) => t.status === 'sent' && now - t.at >= STALE_TASK_MS
30// Commands that would wipe a worker's context while it still holds an unfinished task.
31const DESTRUCTIVE_COMMANDS = new Set(['clear', 'compact'])
32
33const ago = (ms: number) => {
34 const m = Math.max(0, Math.floor(ms / 60000))
35 return m < 1 ? 'now' : m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ago`
36}
37
38// Folder name of a path, whether it uses / (macOS, Linux) or \\ (Windows).
39const base = (cwd: string) => cwd.split(/[\\/]+/).filter(Boolean).pop() ?? 'session'
40
41async function listPeers($: any, selfId: string): Promise<Peer[]> {
42 const now = await $.clock.now()
43 const out: Peer[] = []
44 for (const k of await $.store.keys()) {
45 if (!k.startsWith('peer:')) continue
46 const p = (await $.store.get(k)) as Peer | undefined
47 if (p && p.id !== selfId && now - p.seen < FRESH_MS) out.push(p)
48 }
49 return out.sort((a, z) => z.seen - a.seen)
50}
51
52// The one worker to give the next task: fewest open tasks, then fewest finished, so work stays even.
53async function leastLoaded($: any, list: Peer[]): Promise<Peer[]> {
54 const board = (await read($, tasks)) as Task[]
55 const now = await $.clock.now()
56 const ranked: { p: Peer; open: number; done: number }[] = []
57 for (const p of list) {
58 const done = ((await $.store.get(`done:${p.id}`)) as number | undefined) ?? 0
59 ranked.push({ p, open: board.filter(t => t.toId === p.id && t.status === 'sent' && !isStale(t, now)).length, done })
60 }
61 ranked.sort((a, z) => a.open - z.open || a.done - z.done || z.p.seen - a.p.seen)
62 return ranked.length ? [ranked[0].p] : []
63}
64
65const pick = (list: Peer[], q: string) => {
66 const w = q.toLowerCase()
67 const exact = list.filter(p => p.name.toLowerCase() === w || p.id === q)
68 if (exact.length) return exact
69 return list.filter(p => p.name.toLowerCase().startsWith(w) || p.id.startsWith(q))
70}
71
72async function announce($: any, who: Me, cwd: string) {
73 // A name chosen for this session (rename or auto-rename) survives a name rebuilt from the folder.
74 const saved = (await $.store.get(`name:${who.id}`)) as string | undefined
75 const name = saved || who.name
76 const peer: Peer = { id: who.id, name, cwd, seen: await $.clock.now() }
77 await $.store.set(`peer:${who.id}`, peer)
78}
79
80async function remember($: any, m: Msg) {
81 await update($, log, l => [...l, m].slice(-50))
82 $.ui.invalidate('ui.render')
83}
84
85async function deliver($: any, who: Me, p: Peer, text: string): Promise<string | null> {
86 const r = await $.session.send({ to: { sessionId: p.id }, text: `[from session "${who.name}"] ${text}` })
87 if (r.isDelivered) {
88 await remember($, { at: await $.clock.now(), dir: 'out', who: p.name, text })
89 return null
90 }
91 if (/not running|gone/i.test(r.reason)) await $.store.delete(`peer:${p.id}`)
92 return `${p.name}: ${r.reason}`
93}
94
95const TASK_RE = /\[TASK (\S+) host=(\S+) name="([^"]*)"\]\n([\s\S]*)$/
96const RENAME_RE = /\[RENAME host=(\S+)\]\n([\s\S]*)$/
97const COMMAND_RE = /\[COMMAND host=(\S+)\]\n([\s\S]*)$/
98// Slash commands a host may run in a worker. Only ones that run without opening a picker or dialog (/model, /theme, /status... need a person at the keyboard and would leave the worker stuck). Nothing that runs code, edits files or changes permissions.
99const WORKER_COMMANDS = new Set(['compact', 'clear', 'cost'])
100// Splits "/cost" into its command and arguments; null unless the command is on the allowlist.
101function parseWorkerCommand(raw: string): { command: string; args: string } | null {
102 const m = /^\/?([a-z][a-z-]*)(?:\s+([\s\S]*))?$/.exec(raw.replace(/<[^>]*>/g, ' ').trim().split('\n')[0].trim().slice(0, 200))
103 return m && WORKER_COMMANDS.has(m[1]) ? { command: m[1], args: (m[2] ?? '').trim() } : null
104}
105const REPORT_DONE = 'Result sent to the host. DONE: do not call report again. Your whole turn is finished. Write no text, no summary and no status line; if you must output something, output only a single period (.)'
106// Task ids whose report is being or has been sent, so a repeated report call is not sent twice.
107const reporting = new Set<string>()
108const RESULT_RE = /\[RESULT (\S+) from="([^"]*)"\]\n([\s\S]*)$/
109
110async function hostRecord($: any): Promise<{ id: string; name: string } | null> {
111 const h = (await $.store.get('host')) as { id: string; name: string } | undefined
112 return h ?? null
113}
114
115// Host-ness lives in the shared host record, so it survives a reload of this module.
116async function isHost($: any, who: Me | null): Promise<boolean> {
117 if (!who) return false
118 const h = await hostRecord($)
119 return h !== null && h.id === who.id
120}
121
122const HANDOFF_MS = 60 * 1000
123
124// Takes the note a just-ended session left for its /clear successor, if it is recent and from this folder.
125async function takeHandoff($: any, cwd: string): Promise<{ name: string; host: boolean } | null> {
126 const h = (await $.store.get('handoff')) as { fromId: string; name: string; cwd: string; host: boolean; at: number } | undefined
127 if (!h) return null
128 await $.store.delete('handoff')
129 if (h.cwd !== cwd || (await $.clock.now()) - h.at > HANDOFF_MS) return null
130 return { name: h.name, host: h.host }
131}
132
133// Turns host mode on or off; shared by the /host command and the pane (a plugin's own
134// $.command.run does not reach its own hooks).
135async function setHost($: any, who: Me, isOn: boolean) {
136 await update($, role, () => (isOn ? 'host' : 'solo') as Role)
137 if (isOn) {
138 await $.store.set('host', { id: who.id, name: who.name })
139 } else {
140 const h = await hostRecord($)
141 if (h && h.id === who.id) await $.store.delete('host')
142 }
143 $.ui.invalidate('ui.render')
144}
145
146// Names are plain text: drop any markup, such as the closing tag of the message envelope.
147const cleanName = (raw: string) => raw.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60)
148
149// Asks one worker to take a new session name.
150async function renameWorker($: any, who: Me, p: Peer, name: string): Promise<string | null> {
151 // Names stay unique among live sessions (case-insensitive); the worker's own name is a no-op.
152 if (p.name.toLowerCase() === name.toLowerCase()) return null
153 const clash = (await listPeers($, who.id)).find(q => q.id !== p.id && q.name.toLowerCase() === name.toLowerCase())
154 if (clash || who.name.toLowerCase() === name.toLowerCase()) return `name already used by ${clash ? clash.name : who.name}`
155 const failed = await deliver($, who, p, `[RENAME host=${who.id}]\n${name}`)
156 // Remember a worker the host has named, so auto-rename never overrides it (the peer list can lag behind a rename).
157 if (!failed) await $.store.set(`named:${p.id}`, true)
158 return failed
159}
160
161// Asks one worker to run a slash command such as /cost.
162async function commandWorker($: any, who: Me, p: Peer, line: string): Promise<string | null> {
163 return deliver($, who, p, `[COMMAND host=${who.id}]\n${line}`)
164}
165
166// Runs a slash command line on one worker, or on every worker for "all". Returns a one-line outcome.
167async function runOnWorkers($: any, who: Me, target: string, line: string): Promise<string> {
168 const parsed = parseWorkerCommand(line)
169 if (!parsed) return `Not allowed. Allowed commands: ${[...WORKER_COMMANDS].map(c => '/' + c).join(', ')}.`
170 const peers = await listPeers($, who.id)
171 const matches = target === 'all' ? peers : pick(peers, target)
172 if (matches.length === 0) return `No session matches "${target}". Try /sessions.`
173 if (target !== 'all' && matches.length > 1) return `"${target}" is ambiguous: ${matches.map(p => p.name).join(', ')}`
174 const out: string[] = []
175 const board = (await read($, tasks)) as Task[]
176 const now = await $.clock.now()
177 for (const p of matches) {
178 const busy = board.find(t => t.toId === p.id && t.status === 'sent' && !isStale(t, now))
179 if (DESTRUCTIVE_COMMANDS.has(parsed.command) && busy) {
180 out.push(`${p.name}: refused (still working on task ${busy.id}; /${parsed.command} would lose it)`)
181 continue
182 }
183 const failed = await commandWorker($, who, p, `/${parsed.command} ${parsed.args}`.trim())
184 out.push(failed ? `${p.name}: failed (${failed})` : `${p.name}: asked`)
185 }
186 return `/${parsed.command} ${parsed.args}`.trim() + ' -> ' + out.join('; ')
187}
188
189// Empties the whole board (the pane's Clear board button).
190async function clearBoard($: any) {
191 await update($, tasks, () => [])
192 $.ui.invalidate('ui.render')
193}
194
195// Empties the message log (the pane's Clear messages button).
196async function clearLog($: any) {
197 await update($, log, () => [])
198 $.ui.invalidate('ui.render')
199}
200
201// Hands one task to one worker and logs it on the board.
202async function assign($: any, who: Me, p: Peer, text: string): Promise<string | null> {
203 const used = new Set([...(await read($, tasks)).map(t => t.id), ...(await read($, inbox)).map(i => i.id)])
204 let id = ''
205 do id = `t${Math.random().toString(36).slice(2, 8).padEnd(6, '0')}`
206 while (used.has(id))
207 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.`
208 const failed = await deliver($, who, p, body)
209 // A worker still on its random default name is renamed after its first task.
210 if (!failed && /-[0-9a-f]{4}$/.test(p.name) && !(await $.store.get(`named:${p.id}`))) {
211 const short = cleanName(text).split(' ').slice(0, 3).join('-').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 20)
212 const names = new Set([...(await listPeers($, who.id)).filter(q => q.id !== p.id).map(q => q.name.toLowerCase()), who.name.toLowerCase()])
213 let pickName = short
214 for (let n = 2; names.has(pickName.toLowerCase()); n++) pickName = `${short}-${n === 2 ? p.id.slice(0, 2) : p.id.slice(0, 2) + n}`
215 if (short) await renameWorker($, who, p, pickName)
216 }
217 const t: Task = { id, toId: p.id, toName: p.name, text, status: failed ? 'failed' : 'sent', at: await $.clock.now() }
218 await update($, tasks, l => [...l.filter(x => x.status !== 'failed' || t.at - x.at < 600000), t].slice(-30))
219 $.ui.invalidate('ui.render')
220 return failed
221}
222
223// ---- usage pane: helpers ----
224const USAGE_PANE = 'usage-bar'
225const snap = atom({ plugin: 'ai-link', key: 'snap' } as const, null)
226const showTools = atom({ plugin: 'ai-link', key: 'showTools' } as const, true)
227const showWhere = atom({ plugin: 'ai-link', key: 'showWhere' } as const, true)
228const title = atom({ plugin: 'ai-link', key: 'title' } as const, '')
229const effort = atom({ plugin: 'ai-link', key: 'effort' } as const, '')
230
231const EFFORT_COLOR: Record<string, string> = { low: 'blue', medium: 'cyan', high: 'green', xhigh: 'magenta', max: 'red' }
232
233const human = (n: number) =>
234 n >= 1e6 ? `${(n / 1e6).toFixed(1)}m` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
235
236const tone = (p: number) => (p < 50 ? 'cyan' : p < 75 ? 'yellow' : p < 90 ? 'magenta' : 'red')
237
238const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
239
240// Smooth bar: full cells, one partial eighth, then a dim track.
241const bar = (p: number, width: number) => {
242 const cells = (Math.min(100, Math.max(0, p)) / 100) * width
243 const full = Math.floor(cells)
244 const part = EIGHTHS[Math.floor((cells - full) * 8)]
245 return { fill: '█'.repeat(full) + part, track: '░'.repeat(Math.max(0, width - full - (part ? 1 : 0))) }
246}
247
248const LABEL: Record<string, string> = { five_hour: '5-HOUR', seven_day: 'WEEKLY', spend_limit: 'SPEND' }
249const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
250
251const reset = (iso?: string) => {
252 if (!iso) return ''
253 const d = new Date(iso)
254 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
255 return d.toDateString() === new Date().toDateString() ? `resets ${hm}` : `resets ${DAYS[d.getDay()]} ${hm}`
256}
257
258const span = (ms: number) => {
259 const m = Math.max(0, Math.floor(ms / 60000))
260 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
261}
262
263type Usage = {
264 startedAt: number
265 context: { percent?: number; tokens?: number; window: number; breakdown?: any }
266 rateLimits: { kind: string; percentUsed: number; resetsAt?: string }[]
267 cost?: { usd: number }
268}
269
270const toSnap = (u: Usage, prev: Snap | null): Snap => {
271 const b = u.context.breakdown
272 return {
273 percent: u.context.percent ?? 0,
274 tokens: u.context.tokens ?? 0,
275 window: u.context.window,
276 limits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
277 usd: u.cost ? u.cost.usd : null,
278 startedAt: u.startedAt,
279 model: b ? b.model : (prev ? prev.model : ''),
280 cats: b
281 ? b.categories
282 .filter((c: any) => c.kind === 'used' && c.tokens > 0)
283 .map((c: any) => ({ name: c.name, tokens: c.tokens }))
284 .sort((a: any, z: any) => z.tokens - a.tokens)
285 : (prev ? prev.cats : []),
286 compactAt: b ? (b.autoCompactThreshold ?? null) : (prev ? prev.compactAt : null),
287 }
288}
289
290// detail: also itemise the window (local estimate, free); the light call keeps the last itemising.
291async function refresh($: any, detail: boolean) {
292 const prev = await read($, snap)
293 const u = await $.session.usage(detail ? { breakdown: 'summary' } : undefined)
294 await update($, snap, () => toSnap(u, prev))
295 $.ui.invalidate('ui.render')
296}
297
298type Call = { id: string; tool: string; hint: string; isDone: boolean }
299
300const hintOf = (input: any): string => {
301 if (!input || typeof input !== 'object') return ''
302 const v = input.command ?? input.file_path ?? input.path ?? input.pattern ?? input.url ?? input.query ?? input.description ?? ''
303 return String(v).replace(/\s+/g, ' ').slice(0, 60)
304}
305
306// Called from the shared session.start hook (a module may register each event once).
307export async function usageStart($: any, autoOpen: boolean) {
308 await $.command.register({ name: 'usage-bar', description: 'Open the usage meters in a side pane' })
309 if (autoOpen) void $.ui.open({ id: USAGE_PANE, title: 'Usage' })
310 await refresh($, true)
311}
312
313// Called from the shared prompt hook: the reasoning effort this turn runs at.
314export async function usageEffort($: any, level: string) {
315 try {
316 if (level) await update($, effort, () => level)
317 } catch {
318 // cosmetic only
319 }
320}
321
322// Called from the shared /rename and prompt hooks: the pane header follows the session's name.
323export async function usageNamed($: any, name: string) {
324 try {
325 if (name) await update($, title, () => name)
326 } catch {
327 // the name is cosmetic; never block anything over it
328 }
329}
330
331
332// ---- session link ----
333export const register: Register = (on, options) => {
334 on('session.start', async ($, e, next) => {
335 const id = await $.session.id()
336 const cwd = await $.session.cwd()
337 let name = options.name ? String(options.name) : ((await $.store.get(`name:${id}`)) as string | undefined) || `${base(cwd)}-${id.slice(0, 4)}`
338 // /clear starts a new session id: carry the name and the host role over from the one it replaced.
339 const carried = (e as { source?: string }).source === 'clear' ? await takeHandoff($, cwd) : null
340 if (carried && !options.name) name = carried.name
341 const who: Me = { id, name }
342 await update($, me, () => who)
343 await announce($, who, cwd)
344 if (carried?.host) await setHost($, who, true)
345
346 const refused: string[] = []
347 for (const [name, description] of [
348 ['sessions', 'List your other running Claude Code sessions'],
349 ['tell', 'Message another session: /tell <name> <message>'],
350 ['tellall', 'Message every other session: /tellall <message>'],
351 ['link', 'Open the session link pane'],
352 ['host', 'Make this session the host that hands out work: /host or /host off'],
353 ['assign', 'Host: give a task to a session: /assign <name|all> <task>'],
354 ['board', 'Host: show the task board, or /board clear to empty the board and messages'],
355 ['setname', 'Host: rename another session: /setname <session> <new name>'],
356 ['run', 'Host: run a slash command in a worker: /run <session|all> /cost'],
357 ]) {
358 try {
359 await $.command.register({ name, description })
360 } catch (err) {
361 refused.push(`/${name}`)
362 }
363 }
364 if (refused.length) $.ui.toast(`ai-link: could not register ${refused.join(', ')} (name taken)`)
365 try {
366 await $.tool.register({
367 name: 'assign',
368 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, or "auto" for the least-loaded one.',
369 inputSchema: {
370 type: 'object',
371 properties: { worker: { type: 'string', description: 'Worker session name, "auto" (least-loaded worker) or "all"' }, task: { type: 'string', description: 'What the worker should do, self-contained' } },
372 required: ['worker', 'task'],
373 },
374 })
375 await $.tool.register({
376 name: 'report',
377 description: 'Worker only. Send the result of a task the host assigned back to the host.',
378 inputSchema: {
379 type: 'object',
380 properties: { task_id: { type: 'string' }, result: { type: 'string', description: 'Short result or summary' } },
381 required: ['task_id', 'result'],
382 },
383 })
384 await $.tool.register({
385 name: 'command',
386 description: `Host only. Run a slash command in a worker session, e.g. "/cost" or "/effort high". Allowed: ${[...WORKER_COMMANDS].join(', ')}. Use worker "all" for every worker.`,
387 inputSchema: {
388 type: 'object',
389 properties: { worker: { type: 'string', description: 'Worker session name, or "all"' }, command: { type: 'string', description: 'The slash command with its arguments, e.g. "/cost"' } },
390 required: ['worker', 'command'],
391 },
392 })
393 await $.tool.register({
394 name: 'rename',
395 description: 'Host only. Rename another Claude Code session (a worker).',
396 inputSchema: {
397 type: 'object',
398 properties: { worker: { type: 'string', description: 'Worker session name' }, name: { type: 'string', description: 'The new session name' } },
399 required: ['worker', 'name'],
400 },
401 })
402 await $.tool.register({ name: 'peers', description: 'List the other running Claude Code sessions you can message.' })
403 } catch (err) {
404 $.ui.toast('ai-link: could not register its tools')
405 }
406 void $.ui.open({ id: PANE, title: 'Sessions' })
407 await usageStart($, options.autoOpen !== false)
408 return next(e)
409 })
410
411 on('command.run', { command: 'host' }, async ($, e) => {
412 const who = await read($, me)
413 if (!who) return { text: 'Session link is not ready yet.' }
414 if (e.args.trim() === 'off') {
415 await setHost($, who, false)
416 return { text: 'No longer hosting.' }
417 }
418 await setHost($, who, true)
419 return { text: `${who.name} is the host. Give work with /assign <session|all> <task>, or ask me to delegate.` }
420 })
421
422 on('command.run', { command: 'assign' }, async ($, e) => {
423 const who = await read($, me)
424 if (!who) return { text: 'Session link is not ready yet.' }
425 if (!(await isHost($, who))) return { text: 'Run /host first to make this session the host.' }
426 const [target, ...rest] = e.args.trim().split(/\s+/)
427 const text = rest.join(' ')
428 if (!target || !text) return { text: 'Usage: /assign <session name|auto|all> <task> (auto = the least-loaded worker)' }
429 const list = await listPeers($, who.id)
430 const chosen = target === 'all' ? list : target === 'auto' ? await leastLoaded($, list) : pick(list, target)
431 if (chosen.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
432 if (target !== 'all' && chosen.length > 1) return { text: `"${target}" is ambiguous: ${chosen.map(p => p.name).join(', ')}` }
433 const out: string[] = []
434 for (const p of chosen) {
435 const f = await assign($, who, p, text)
436 out.push(f ? `✗ ${f}` : `→ ${p.name}`)
437 }
438 return { text: out.join('\n') }
439 })
440
441 on('command.run', { command: 'setname' }, async ($, e) => {
442 const who = await read($, me)
443 if (!who) return { text: 'Session link is not ready yet.' }
444 if (!(await isHost($, who))) return { text: 'Run /host first to make this session the host.' }
445 const [target, ...rest] = e.args.trim().split(/\s+/)
446 const name = cleanName(rest.join(' '))
447 if (!target || !name) return { text: 'Usage: /setname <session> <new name>' }
448 const matches = pick(await listPeers($, who.id), target)
449 if (matches.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
450 if (matches.length > 1) return { text: `"${target}" is ambiguous: ${matches.map(p => p.name).join(', ')}` }
451 const failed = await renameWorker($, who, matches[0], name)
452 return { text: failed ? `Not renamed. ${failed}` : `Asked ${matches[0].name} to rename itself to "${name}".` }
453 })
454
455 on('command.run', { command: 'run' }, async ($, e) => {
456 const who = await read($, me)
457 if (!who) return { text: 'Session link is not ready yet.' }
458 if (!(await isHost($, who))) return { text: 'Run /host first to make this session the host.' }
459 const [target, ...rest] = e.args.trim().split(/\s+/)
460 const line = rest.join(' ')
461 if (!target || !line) return { text: 'Usage: /run <session|all> /cost' }
462 return { text: await runOnWorkers($, who, target, line) }
463 })
464
465 on('command.run', { command: 'board' }, async ($, e) => {
466 if (e.args.trim() === 'clear') {
467 const before = (await read($, tasks)).length
468 await clearBoard($)
469 await clearLog($)
470 return { text: `Cleared ${before} task${before === 1 ? '' : 's'} and the messages.` }
471 }
472 const list = await read($, tasks)
473 if (list.length === 0) return { text: 'No tasks yet.' }
474 const icon = { sent: '…', done: '✓', failed: '✗' }
475 const now = await $.clock.now()
476 return {
477 text: list
478 .map(t => `${isStale(t, now) ? '⚠ stale' : icon[t.status]} ${t.id} ${t.toName}: ${t.text.slice(0, 60)}${t.result ? `\n ⇒ ${t.result.slice(0, 200)}` : ''}`)
479 .join('\n'),
480 }
481 })
482
483 // The tools the model calls.
484 on('tool.call', { tool: T_PEERS }, async $ => {
485 const who = await read($, me)
486 const list = await listPeers($, who ? who.id : '')
487 return { result: list.length ? list.map(p => `${p.name} (${base(p.cwd)})`).join('\n') : 'No other sessions running.' }
488 })
489
490 on('tool.call', { tool: T_ASSIGN }, async ($, e) => {
491 const who = await read($, me)
492 if (!(await isHost($, who))) return { result: 'This session is not the host. The person must run /host first.' }
493 const input = e as unknown as { worker?: string; task?: string }
494 const worker = String(input.worker ?? '')
495 const task = String(input.task ?? '')
496 if (!worker || !task) return { result: 'Both worker and task are required.' }
497 const list = await listPeers($, who.id)
498 const chosen = worker === 'all' ? list : worker === 'auto' ? await leastLoaded($, list) : pick(list, worker)
499 if (chosen.length === 0) return { result: `No session matches "${worker}".` }
500 if (worker !== 'all' && chosen.length > 1) return { result: `"${worker}" is ambiguous: ${chosen.map(p => p.name).join(', ')}` }
501 const out: string[] = []
502 for (const p of chosen) {
503 const f = await assign($, who, p, task)
504 out.push(f ? `failed: ${f}` : `sent to ${p.name}`)
505 }
506 return { result: out.join('\n') }
507 })
508
509 on('tool.call', { tool: T_COMMAND }, async ($, e) => {
510 const who = await read($, me)
511 if (!who || !(await isHost($, who))) return { result: 'This session is not the host. The person must run /host first.' }
512 const input = e as unknown as { worker?: string; command?: string }
513 const worker = String(input.worker ?? '')
514 const line = String(input.command ?? '')
515 if (!worker || !line) return { result: 'Both worker and command are required.' }
516 return { result: await runOnWorkers($, who, worker, line) }
517 })
518
519 on('tool.call', { tool: T_RENAME }, async ($, e) => {
520 const who = await read($, me)
521 if (!who || !(await isHost($, who))) return { result: 'This session is not the host. The person must run /host first.' }
522 const input = e as unknown as { worker?: string; name?: string }
523 const worker = String(input.worker ?? '')
524 const name = cleanName(String(input.name ?? ''))
525 if (!worker || !name) return { result: 'Both worker and name are required.' }
526 const matches = pick(await listPeers($, who.id), worker)
527 if (matches.length !== 1) return { result: matches.length ? `"${worker}" is ambiguous.` : `No session matches "${worker}".` }
528 const failed = await renameWorker($, who, matches[0], name)
529 return { result: failed ? `Not renamed: ${failed}` : `Asked ${matches[0].name} to rename itself to "${name}".` }
530 })
531
532 on('tool.call', { tool: T_REPORT }, async ($, e) => {
533 const who = await read($, me)
534 const input = e as unknown as { task_id?: string; result?: string }
535 const id = String(input.task_id ?? '')
536 const found = (await read($, inbox)).find(i => i.id === id)
537 if (!who || !found) return { result: `No task ${id} was assigned to this session.` }
538 // A second report for the same task (even one fired in the same batch) is not sent again.
539 if (reporting.has(id)) return { result: REPORT_DONE }
540 reporting.add(id)
541 const r = await $.session.send({
542 to: { sessionId: found.hostId },
543 text: `[RESULT ${id} from="${who.name}"]\n${String(input.result ?? '')}`,
544 })
545 // The task is done: drop it so the worker goes back to normal once nothing is left.
546 if (r.isDelivered) {
547 // Stay quiet until the person next types here: a stop hook's feedback can arrive after the report.
548 await $.store.set(`quiet:${await $.session.id()}`, true)
549 await update($, inbox, l => l.filter(i => i.id !== id))
550 $.ui.invalidate('ui.render')
551 }
552 if (!r.isDelivered) reporting.delete(id) // a failed send may be retried
553 return { result: r.isDelivered ? REPORT_DONE : `Could not reach the host: ${r.reason}` }
554 })
555
556 on('command.run', { command: 'link' }, async $ => {
557 await $.ui.open({ id: PANE, title: 'Sessions' })
558 return { text: 'Session pane opened.' }
559 })
560
561 on('command.run', { command: 'sessions' }, async $ => {
562 const who = await read($, me)
563 const list = await listPeers($, who ? who.id : '')
564 const now = await $.clock.now()
565 if (list.length === 0) return { text: 'No other sessions found. Start Claude Code in another terminal with this mod loaded.' }
566 return { text: list.map(p => `${p.name} ${base(p.cwd)} ${ago(now - p.seen)} (${p.id.slice(0, 8)})`).join('\n') }
567 })
568
569 on('command.run', { command: 'tell' }, async ($, e) => {
570 const who = await read($, me)
571 if (!who) return { text: 'Session link is not ready yet.' }
572 const [target, ...rest] = e.args.trim().split(/\s+/)
573 const text = rest.join(' ')
574 if (!target || !text) return { text: 'Usage: /tell <session name or id> <message>' }
575 const matches = pick(await listPeers($, who.id), target)
576 if (matches.length === 0) return { text: `No session matches "${target}". Try /sessions.` }
577 if (matches.length > 1) return { text: `"${target}" is ambiguous: ${matches.map(p => p.name).join(', ')}` }
578 const failed = await deliver($, who, matches[0], text)
579 return { text: failed ? `Not delivered. ${failed}` : `Sent to ${matches[0].name}.` }
580 })
581
582 on('command.run', { command: 'tellall' }, async ($, e) => {
583 const who = await read($, me)
584 if (!who) return { text: 'Session link is not ready yet.' }
585 const text = e.args.trim()
586 if (!text) return { text: 'Usage: /tellall <message>' }
587 const list = await listPeers($, who.id)
588 if (list.length === 0) return { text: 'No other sessions to tell.' }
589 const failures: string[] = []
590 for (const p of list) {
591 const f = await deliver($, who, p, text)
592 if (f) failures.push(f)
593 }
594 return { text: `Sent to ${list.length - failures.length}/${list.length} sessions.${failures.length ? '\n' + failures.join('\n') : ''}` }
595 })
596
597 // Multi-window: while this session holds a host task, the model only does the work and reports.
598 on('prompt.compose', async ($, e, next) => {
599 const r = await next(e)
600 try {
601 const quiet = Boolean(await $.store.get(`quiet:${await $.session.id()}`))
602 if (await isHost($, await read($, me))) return { sections: [...r.sections, { id: 'ai-link:host-workflow', scope: 'session' as const, text: HOST_WORKFLOW }] }
603 if ((await read($, inbox)).length === 0 && !quiet) return r
604 } catch {
605 return r
606 }
607 return {
608 sections: [
609 ...r.sections,
610 {
611 id: 'ai-link:silent-worker',
612 scope: 'session' as const,
613 text: `You are a worker in a multi-window setup and a host session has given you a task. Do only that task. Write no chat text between tool calls and no summary or thanks at the end: your only output is tool calls. Do not ask the user questions or offer next steps. When the work is done, call ${T_REPORT} once with the task_id and a short result (one or two sentences), then stop. Hook feedback that arrives after your report (a stop hook, a design or lint finding) is not a reason to write text: act on it with tool calls only, or ignore it, and never explain, triage or summarise it in chat. Put ctx ~NNk (your best estimate of your context size) in the report and add NEEDS COMPACT at about 100k or more. Read files in parts (grep first, then offset and limit), do not re-read what you just edited, and edit only the files your task names. Never write a closing line such as "Task X is done", "Nothing is left to do", "Idle" or a restatement of your report: the report call already said it. If the harness forces you to write something after the report, write exactly one period (.) and nothing else. This holds until the person next types in this session.`,
614 },
615 ],
616 }
617 })
618
619 // Record what other sessions say to this one; the model still reads it as usual.
620 on('session.receive', async ($, e, next) => {
621 if (e.origin.kind === 'peer' || e.origin.kind === 'peer-send-message') {
622 try {
623 const now = await $.clock.now()
624 const task = TASK_RE.exec(e.text)
625 const result = RESULT_RE.exec(e.text)
626 const rename = RENAME_RE.exec(e.text)
627 const cmd = COMMAND_RE.exec(e.text)
628 if (task) {
629 // A task is only taken from the session registered as the host.
630 const h = await hostRecord($)
631 if (h && h.id === task[2]) {
632 const item: Inbox = { id: task[1], hostId: task[2], hostName: task[3], text: task[4].split('\n\nWhen you are finished')[0] }
633 await update($, inbox, l => [...l, item].slice(-30))
634 await remember($, { at: now, dir: 'in', who: `task ${item.id}`, text: item.text.slice(0, 300) })
635 $.ui.toast(`Task ${item.id} from ${item.hostName}`)
636 if (options.autoRun === true) {
637 void $.prompt
638 .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.` })
639 .catch(() => {})
640 }
641 }
642 } else if (cmd) {
643 // A command is only taken from the session registered as the host, and only from the allowlist.
644 const h = await hostRecord($)
645 const parsed = parseWorkerCommand(cmd[2])
646 if (h && h.id === cmd[1] && parsed && DESTRUCTIVE_COMMANDS.has(parsed.command) && (await read($, inbox)).length > 0) {
647 await remember($, { at: now, dir: 'in', who: h.name, text: `refused /${parsed.command}: an unfinished task is still open` })
648 $.ui.toast(`Refused /${parsed.command} from ${h.name}: finish the open task first`)
649 return { consumed: 'command refused' }
650 }
651 if (h && h.id === cmd[1] && parsed) {
652 await $.command.run({ command: parsed.command, args: parsed.args })
653 await remember($, { at: now, dir: 'in', who: h.name, text: `ran /${parsed.command} ${parsed.args}`.trim() })
654 $.ui.toast(`${h.name} ran /${parsed.command} ${parsed.args}`.trim())
655 return { consumed: 'command applied' }
656 }
657 } else if (rename) {
658 // A rename is only taken from the session registered as the host.
659 const h = await hostRecord($)
660 const name = cleanName(rename[2])
661 const who = await read($, me)
662 if (h && h.id === rename[1] && name && who) {
663 await $.command.run({ command: 'rename', args: name })
664 const renamed: Me = { id: who.id, name }
665 await $.store.set(`name:${who.id}`, renamed.name)
666 await update($, me, () => renamed)
667 await announce($, renamed, await $.session.cwd())
668 await remember($, { at: now, dir: 'in', who: h.name, text: `renamed this session to "${name}"` })
669 $.ui.toast(`${h.name} renamed this session to "${name}"`)
670 // The rename is handled here: the model never reads it, so the worker does not answer it.
671 return { consumed: 'rename applied' }
672 }
673 } else if (result) {
674 const id = result[1]
675 const found = (await read($, tasks)).find(t => t.id === id)
676 // Only the worker the task went to may finish it; anyone else's result is logged and ignored.
677 const peer = found ? ((await $.store.get(`peer:${found.toId}`)) as Peer | undefined) : undefined
678 const fromOwner = !!found && (result[2] === found.toName || result[2] === peer?.name)
679 if (found && !fromOwner) {
680 await remember($, { at: now, dir: 'in', who: `${result[2]} ✗ ignored ${id}`, text: result[3].slice(0, 300) })
681 } else if (found) {
682 await update($, tasks, l => l.filter(t => t.id !== id)) // finished tasks clear themselves; the result still lands in the log
683 await $.store.set(`done:${found.toId}`, (((await $.store.get(`done:${found.toId}`)) as number | undefined) ?? 0) + 1)
684 await remember($, { at: now, dir: 'in', who: `${result[2]} ✓ ${id}`, text: result[3].slice(0, 300) })
685 $.ui.toast(/NEEDS COMPACT/i.test(result[3]) ? `${result[2]} finished ${id} and needs /compact` : `${result[2]} finished ${id}`)
686 }
687 } else {
688 await remember($, { at: now, dir: 'in', who: 'peer', text: e.text.slice(0, 300) })
689 }
690 } catch {
691 // logging must never block delivery
692 }
693 }
694 return next(e)
695 })
696
697 // /rename carries the new name as its argument: follow it at once, unless the option fixes one.
698 on('command.run', { command: 'rename' }, async ($, e, next) => {
699 const r = await next(e)
700 await usageNamed($, e.args.trim())
701 try {
702 const who = await read($, me)
703 const t = e.args.trim()
704 if (who && t && !options.name && who.name !== t) {
705 const renamed: Me = { id: who.id, name: t }
706 await $.store.set(`name:${who.id}`, renamed.name)
707 await update($, me, () => renamed)
708 await announce($, renamed, await $.session.cwd())
709 $.ui.invalidate('ui.render')
710 }
711 } catch {
712 // the name is cosmetic; never block /rename over it
713 }
714 return r
715 })
716
717 // Follow the name set with /rename, unless the option fixes one.
718 on('classic.UserPromptSubmit', async ($, e, next) => {
719 await usageNamed($, e.session_title ? String(e.session_title) : '')
720 await usageEffort($, e.effort ? String(e.effort.level) : '')
721 try {
722 const who = await read($, me)
723 const t = e.session_title ? String(e.session_title) : ''
724 if (who && t && !options.name && who.name !== t) {
725 const renamed: Me = { id: who.id, name: t }
726 await $.store.set(`name:${who.id}`, renamed.name)
727 await update($, me, () => renamed)
728 await announce($, renamed, await $.session.cwd())
729 $.ui.invalidate('ui.render')
730 }
731 } catch {
732 // the name is cosmetic; never block a prompt over it
733 }
734 return next(e)
735 })
736
737 // Keep this session's listing fresh while it is in use.
738 on('turn.complete', async ($, e, next) => {
739 try {
740 const who = await read($, me)
741 if (who) await announce($, who, await $.session.cwd())
742 } catch {
743 // a failed heartbeat must never affect the turn
744 }
745 return next(e)
746 })
747
748 on('session.end', async ($, e, next) => {
749 try {
750 const who = await read($, me)
751 if (who) {
752 // Leave a note so a /clear successor in this folder can pick up the name and the host role.
753 const host = await isHost($, who)
754 await $.store.set('handoff', { fromId: who.id, name: who.name, cwd: await $.session.cwd(), host, at: await $.clock.now() })
755 await $.store.delete(`peer:${who.id}`)
756 }
757 } catch {
758 // leaving quietly
759 }
760 return next(e)
761 })
762
763 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
764 const { Box, Text, Button, Input, Select } = $.ui.resolve(e)
765 const who = await read($, me)
766 const msgs = await read($, log)
767 const myRole: Role = (await isHost($, who)) ? 'host' : 'solo'
768 const board = await read($, tasks)
769 const waiting = await read($, inbox)
770 const pickedId = await read($, target)
771 let list: Peer[] = []
772 let now = 0
773 try {
774 now = await $.clock.now()
775 list = await listPeers($, who ? who.id : '')
776 } catch (err) {
777 return <Text color="red">ai-link error: {String(err)}</Text>
778 }
779
780 return (
781 <Box flexDirection="column" paddingX={1}>
782 <Text bold color="cyan">{who ? who.name : '…'}</Text>
783 <Text>
784 <Text dimColor>{who ? who.id.slice(0, 8) : ''} </Text>
785 <Text bold color={myRole === 'host' ? 'yellow' : 'green'}>{myRole === 'host' ? '★ HOST' : 'worker'}</Text>
786 </Text>
787 <Text> </Text>
788
789 <Text bold>OTHER SESSIONS</Text>
790 {list.length === 0 ? <Text dimColor>none running</Text> : null}
791 {list.map(p => {
792 // Only the host knows what it handed out, so only it can say who is working.
793 const open = myRole === 'host' ? board.filter(t => t.toId === p.id && t.status === 'sent' && !isStale(t, now)).slice(-1)[0] : undefined
794 return (
795 <Box key={p.id} flexDirection="column">
796 <Text>
797 <Text color={open ? 'yellow' : 'green'}>● </Text>
798 <Text bold>{p.name}</Text>
799 <Text dimColor> {ago(now - p.seen)}</Text>
800 </Text>
801 {myRole === 'host' ? (
802 <Text key={`state-${p.id}`} wrap="truncate" color={open ? 'yellow' : undefined} dimColor={!open}>
803 {open ? ` working · ${open.text.slice(0, 24)}` : ' idle'}
804 </Text>
805 ) : null}
806 </Box>
807 )
808 })}
809 <Text> </Text>
810
811 {myRole === 'host' ? (
812 <Box flexDirection="column" marginBottom={1}>
813 <Text bold>TASKS</Text>
814 {board.length === 0 ? <Text dimColor>none yet · /assign name task</Text> : null}
815 {board.length ? <Button key="clear" label="Clear board" onPress={() => clearBoard($)} /> : null}
816 {board.slice(-4).map(t => (
817 <Text key={t.id} wrap="truncate">
818 <Text color={t.status === 'failed' ? 'red' : 'yellow'}>{t.status === 'failed' ? '✗ ' : isStale(t, now) ? '⚠ stale ' : '… '}</Text>
819 <Text bold>{t.toName.slice(0, 14)} </Text>
820 <Text dimColor>{t.text.slice(0, 28)}</Text>
821 </Text>
822 ))}
823 </Box>
824 ) : waiting.length ? (
825 <Box flexDirection="column" marginBottom={1}>
826 <Text bold>FROM THE HOST</Text>
827 {waiting.slice(-4).map(i => (
828 <Text key={i.id} wrap="truncate">
829 <Text color="yellow">● </Text>
830 <Text bold>{i.id} </Text>
831 <Text dimColor>{i.text}</Text>
832 </Text>
833 ))}
834 </Box>
835 ) : null}
836
837 <Box flexDirection="column" marginBottom={1}>
838 <Text bold>PROMPT A WORKER</Text>
839 {list.length === 0 ? (
840 <Text dimColor>no other sessions yet</Text>
841 ) : (
842 <Box flexDirection="column">
843 <Select
844 key="target"
845 label="to "
846 value={pickedId}
847 options={[{ value: 'all', label: 'all workers' }, ...list.map(p => ({ value: p.id, label: p.name }))]}
848 onSelect={v => update($, target, () => v)}
849 />
850 <Input
851 key="prompt"
852 placeholder="type a task, Enter to send"
853 submitLabel="send"
854 onSubmit={async text => {
855 const task = text.trim()
856 if (!task || !who) return
857 if (myRole !== 'host') await setHost($, who, true)
858 const chosen = pickedId === 'all' ? list : list.filter(p => p.id === pickedId)
859 let ok = 0
860 for (const p of chosen) if (!(await assign($, who, p, task))) ok += 1
861 $.ui.toast(ok ? `Sent to ${ok} session${ok === 1 ? '' : 's'}` : 'Nothing was delivered')
862 }}
863 />
864 </Box>
865 )}
866 </Box>
867
868 <Text bold>MESSAGES</Text>
869 {msgs.length ? <Button key="clear-msgs" label="Clear messages" onPress={() => clearLog($)} /> : null}
870 {msgs.length === 0 ? <Text dimColor>nothing yet</Text> : null}
871 {msgs.slice(-8).map((m, i) => (
872 <Text key={`${m.at}-${i}`} wrap="truncate">
873 <Text color={m.dir === 'in' ? 'yellow' : 'cyan'}>{m.dir === 'in' ? '← ' : '→ '}</Text>
874 <Text bold>{m.who} </Text>
875 <Text dimColor>{m.text}</Text>
876 </Text>
877 ))}
878 <Text> </Text>
879 <Box>
880 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => $.ui.invalidate('ui.render')} />
881 <Text> </Text>
882 <Button
883 key="host"
884 label={myRole === 'host' ? 'Stop hosting' : 'Become host'}
885 onPress={() => (who ? setHost($, who, myRole !== 'host') : undefined)}
886 />
887 </Box>
888 </Box>
889 )
890 })
891
892 // ---- usage pane: hooks ----
893 let turns = 0
894 let askedOnce = false
895 let tools = 0
896 let calls: Call[] = []
897 const counts: Record<string, number> = {}
898
899 on('command.run', { command: 'usage-bar' }, async $ => {
900 await $.ui.open({ id: USAGE_PANE, title: 'Usage' })
901 return { text: 'Usage pane opened.' }
902 })
903
904 // A resumed session already has its name when it starts.
905 on('classic.SessionStart', async ($, e, next) => {
906 try {
907 if (e.session_title) await update($, title, () => String(e.session_title))
908 } catch {
909 // cosmetic only
910 }
911 return next(e)
912 })
913
914 on('prompt.submit', async ($, e, next) => {
915 turns += 1
916 // A prompt the person types ends a worker's quiet spell.
917 if ((e as { origin?: { kind?: string } }).origin?.kind === 'composer') {
918 try {
919 await $.store.delete(`quiet:${await $.session.id()}`)
920 } catch {
921 // the store is best effort
922 }
923 }
924 // A pane the mod opens on its own waits below 144 columns; one opened in answer to the
925 // person's prompt is seated from 110. So ask once, on their first prompt.
926 if (!askedOnce && options.autoOpen !== false) {
927 askedOnce = true
928 try {
929 await $.ui.open({ id: USAGE_PANE, title: 'Usage' })
930 } catch {
931 // the pane is a convenience; never block a prompt over it
932 }
933 }
934 return next(e)
935 })
936
937 on('session.measure', async ($, e, next) => {
938 await refresh($, true)
939 return next(e)
940 })
941
942 // Keep the meters moving between turns: refresh after every tool call.
943 on('tool.call', async ($, e, next) => {
944 tools += 1
945 counts[e.tool] = (counts[e.tool] ?? 0) + 1
946 const call: Call = { id: e.tool_use_id ?? `${e.tool}-${tools}`, tool: e.tool, hint: hintOf(e), isDone: false }
947 calls = [...calls, call].slice(-12)
948 $.ui.invalidate('ui.render')
949 const r = await next(e)
950 calls = calls.map(c => (c.id === call.id ? { ...c, isDone: true } : c))
951 try {
952 await refresh($, false)
953 } catch {
954 // a failed refresh must never affect the tool call
955 }
956 return r
957 })
958
959 on('ui.render', { component: 'Pane', requestId: USAGE_PANE }, async ($, e) => {
960 const { Box, Text, Button } = $.ui.resolve(e)
961 let s: Snap
962 let now = 0
963 let isTools = true
964 let isWhere = true
965 let name = ''
966 let level = ''
967 let sid = ''
968 try {
969 s = (await read($, snap)) ?? toSnap(await $.session.usage({ breakdown: 'summary' }), null)
970 now = await $.clock.now()
971 isTools = await read($, showTools)
972 isWhere = await read($, showWhere)
973 name = await read($, title)
974 level = await read($, effort)
975 sid = (await $.session.id()).slice(0, 8)
976 } catch (err) {
977 return <Text color="red">usage error: {String(err)}</Text>
978 }
979
980 const width = Math.max(8, (e.props.bodyColumns ?? 30) - 2)
981 const age = Math.max(1, now - s.startedAt)
982 const perHour = s.usd !== null ? (s.usd / age) * 3600000 : null
983
984 const Meter = ({ label, p, note }: { label: string; p: number; note?: string }) => {
985 const b = bar(p, width)
986 return (
987 <Box flexDirection="column" marginBottom={1}>
988 <Box>
989 <Text bold>{label}</Text>
990 <Text bold color={tone(p)}> {Math.round(p)}%</Text>
991 </Box>
992 <Text>
993 <Text color={tone(p)}>{b.fill}</Text>
994 <Text dimColor>{b.track}</Text>
995 </Text>
996 {note ? <Text dimColor>{note}</Text> : null}
997 </Box>
998 )
999 }
1000
1001 const Row = ({ k, v, color }: { k: string; v: string; color?: string }) => (
1002 <Box>
1003 <Text dimColor>{k.padEnd(10)}</Text>
1004 <Text bold color={color}>{v}</Text>
1005 </Box>
1006 )
1007
1008 const top = s.cats.slice(0, 6)
1009 const biggest = top.length ? top[0].tokens : 1
1010
1011 return (
1012 <Box flexDirection="column" paddingX={1}>
1013 <Text bold color="white">{name || 'untitled session'}</Text>
1014 <Text dimColor>{sid}</Text>
1015 <Text>
1016 {s.model ? <Text bold color="cyan">{s.model}</Text> : null}
1017 <Text dimColor> · effort </Text>
1018 <Text bold color={EFFORT_COLOR[level] ?? 'white'}>{level || '—'}</Text>
1019 </Text>
1020 <Text dimColor>session {span(age)}</Text>
1021 <Text> </Text>
1022
1023 <Meter
1024 label="CONTEXT"
1025 p={s.percent}
1026 note={`${human(s.tokens)} / ${human(s.window)} tokens${
1027 s.compactAt ? ` · compacts at ${human(s.compactAt)}` : ''
1028 }`}
1029 />
1030 {s.limits.map(l => (
1031 <Meter key={l.kind} label={LABEL[l.kind] ?? l.kind} p={l.percentUsed} note={reset(l.resetsAt)} />
1032 ))}
1033
1034 {top.length ? (
1035 <Box flexDirection="column" marginBottom={1}>
1036 <Text bold>WHERE CONTEXT GOES</Text>
1037 {(isWhere ? top : []).map(c => {
1038 const b = bar((c.tokens / biggest) * 100, 6)
1039 return (
1040 <Text key={c.name}>
1041 <Text color="cyan">{b.fill}</Text>
1042 <Text dimColor>{b.track} </Text>
1043 <Text>{c.name} </Text>
1044 <Text dimColor>{human(c.tokens)}</Text>
1045 </Text>
1046 )
1047 })}
1048 {isWhere && s.cats.length > top.length ? (
1049 <Text dimColor>+{s.cats.length - top.length} more</Text>
1050 ) : null}
1051 </Box>
1052 ) : null}
1053
1054 <Box flexDirection="column" marginBottom={1}>
1055 <Text bold>TOOLS</Text>
1056 {calls.length === 0 ? <Text dimColor>none yet</Text> : null}
1057 {(isTools ? calls.slice(-5) : []).map(c => (
1058 <Text key={c.id} wrap="truncate">
1059 <Text color={c.isDone ? 'green' : 'yellow'}>{c.isDone ? '✓ ' : '● '}</Text>
1060 <Text bold={!c.isDone}>{c.tool}</Text>
1061 <Text dimColor> {c.hint}</Text>
1062 </Text>
1063 ))}
1064 {Object.keys(counts).length ? (
1065 <Text dimColor>
1066 {Object.entries(counts)
1067 .sort((a, z) => z[1] - a[1])
1068 .slice(0, 4)
1069 .map(([k, n]) => `${k}×${n}`)
1070 .join(' ')}
1071 </Text>
1072 ) : null}
1073 </Box>
1074
1075 <Text bold>SESSION</Text>
1076 <Row k="turns" v={String(turns)} />
1077 <Row k="tool calls" v={String(tools)} />
1078 {s.usd !== null ? <Row k="cost" v={`$${s.usd.toFixed(2)}`} color="green" /> : null}
1079 {perHour !== null && age > 120000 ? <Row k="burn" v={`$${perHour.toFixed(2)}/h`} /> : null}
1080
1081 <Box flexDirection="column" marginTop={1}>
1082 <Box>
1083 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($, true)} />
1084 <Text> </Text>
1085 <Button
1086 key="reset"
1087 label="Reset counts"
1088 onPress={() => {
1089 turns = 0
1090 tools = 0
1091 calls = []
1092 for (const k of Object.keys(counts)) delete counts[k]
1093 $.ui.invalidate('ui.render')
1094 }}
1095 />
1096 </Box>
1097 <Box>
1098 <Button
1099 key="tools"
1100 label={isTools ? 'Hide tools' : 'Show tools'}
1101 onPress={() => update($, showTools, v => !v)}
1102 />
1103 <Text> </Text>
1104 <Button
1105 key="where"
1106 label={isWhere ? 'Hide breakdown' : 'Show breakdown'}
1107 onPress={() => update($, showWhere, v => !v)}
1108 />
1109 </Box>
1110 </Box>
1111 </Box>
1112 )
1113 })
1114}
1115types/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 'ai-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