Spin up Claude Code workers from a picker: OpenRouter (any model) or Amazon Bedrock (Claude), as tmux panes or terminal tabs; team messaging between them

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What it does |
|---|---|
cheap-worker | /worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage). |
agent-progress | /progress: animated progress bar per agent (main, subagents, workers) from their task lists. |
agent-map | /map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review. |
context-bar | Context-window breakdown above the prompt. |
usage-meter | 5-hour / weekly usage in the footer. |
quick-settings | ⚙ footer button for model and effort. |
skins | /skins: colour themes (Tokyo Night, Dracula, Gruvbox, …). |
cw-pane, driver-seat-guard, evidence-band | For the driver-seat workflow; skip them elsewhere. |
/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods
On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were). |
Work laptop: provider = bedrock, launch = tabs.
crewThe mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).
crew # list running workers, pick one by number
crew ab12c # attach to cw-ab12c
If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.
Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 744 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Active, Launch, Mode, Msg, Picker, WStats } from '../types'
5import { BEDROCK_MODELS, DEFAULT_MODEL, META_FORMAT, attachLine, defaultModelFor, ghosttyTabScript, surfaceRef, bannerModel, parseMeta, choicesFor, clockDur, costOf, isRetired, isWorking, kfmt, leadFrom, parseModels, parsePanes, parsePeer, peekLines, price, projectDir, search, sq, teamPreamble, toPins, tokensOf, usageFrom, vendorOf, workerScript } from './worker'
6import type { LaunchMode, Provider } from './worker'
7
8const PANE = 'cheap-worker'
9const VIEW = 'cheap-workers'
10const FORCE_AFTER_MS = 6000
11const OR_LIME = '#c8f031'
12const ACCENT = '#9fd17a'
13// Permission mode as the card's colored word, like the screenshot's tier word.
14const MODE_WORD: Record<Mode, string> = { acceptEdits: 'edits', auto: 'auto', default: 'ask' }
15const MODE_COLOR: Record<Mode, string> = { acceptEdits: '#57ab5a', auto: '#539bf5', default: '#e0823d' }
16// Provider logos as 16×16 pixel-art SVG traces (assets/providers-svg), for the remote surfaces' Svg; '' = none.
17const iconsSvg = new Map<string, string>()
18// 8×8 colour grids (assets/providers-px.json) drawn as half-block text: works in every
19// terminal, including ones that can't show images (the desktop app's terminal prints alt text).
20let pixels: Record<string, (string | null)[][]> | null = null
21const MODES: readonly { value: Mode; label: string; note: string }[] = [
22 { value: 'acceptEdits', label: 'edits', note: 'auto-approves file edits, asks for the rest' },
23 { value: 'auto', label: 'auto', note: 'auto mode (under OpenRouter it may show a billing dialog once)' },
24 { value: 'default', label: 'ask', note: 'asks before every edit and command' },
25]
26
27const DEFAULTS: Picker = {
28 model: DEFAULT_MODEL,
29 mode: 'acceptEdits',
30 query: '',
31 models: [],
32 recent: [DEFAULT_MODEL],
33 pins: [],
34 launched: [],
35 flash: null,
36 active: [],
37 closing: [],
38 team: false,
39 messages: [],
40 stats: {},
41 showFinished: true,
42}
43const MAX_MESSAGES = 40
44// The plugin's userConfig (set per machine in /config): set by register, which reruns when it changes.
45const cfg: { provider: Provider; launch: LaunchMode } = { provider: 'openrouter', launch: 'panes' }
46const CREW_MARK = '# crew — cheap-worker'
47// State outlives a reload, and the default above only seeds an empty slot: a
48// value saved by an older version lacks newer fields until session.start fills them.
49const picker = atom({ plugin: 'cheap-worker', key: 'picker' } as const, DEFAULTS)
50
51async function loadModels($: EngineInterface): Promise<boolean> {
52 if (cfg.provider === 'bedrock') {
53 await update($, picker, p => ({ ...p, models: BEDROCK_MODELS }))
54 return true
55 }
56 const res = await $.http.fetch('https://openrouter.ai/api/v1/models').catch(() => null)
57 if (!res?.ok) return false
58 const models = parseModels(res.text)
59 await update($, picker, p => ({ ...p, models }))
60 return true
61}
62
63async function openPane($: EngineInterface) {
64 await $.ui.open({ id: PANE, title: 'Cheap worker' })
65 if ((await read($, picker)).models.length === 0) await loadModels($)
66}
67
68async function togglePin($: EngineInterface, id: string) {
69 await update($, picker, p => ({ ...p, pins: p.pins.includes(id) ? p.pins.filter(x => x !== id) : [...p.pins, id] }))
70 await $.store.set('pins', (await read($, picker)).pins)
71}
72
73async function tmux($: EngineInterface, args: string[]) {
74 return $.process.run(['tmux', ...args])
75}
76
77async function launch($: EngineInterface, task: string): Promise<{ l: Launch; isTabOpen: boolean | null }> {
78 const saved = await read($, picker)
79 // a model the provider can't run (e.g. an OpenRouter pick, then switched to bedrock) → its default
80 const isRunnable = cfg.provider === 'bedrock' ? BEDROCK_MODELS.some(m => m.id === saved.model) : !BEDROCK_MODELS.some(m => m.id === saved.model)
81 const p = isRunnable ? saved : { ...saved, model: defaultModelFor(cfg.provider) }
82 if (cfg.provider === 'openrouter') {
83 const key = await $.process.run(['zsh', '-c', '[[ -n $CW_OPENROUTER_API_KEY ]]'])
84 if (key.exitCode !== 0) throw new Error('CW_OPENROUTER_API_KEY is not set in ~/.zshenv')
85 }
86
87 const name = `cw-${(await $.clock.now()).toString(36).slice(-5)}`
88 const prompt = p.team
89 ? `${teamPreamble({ name, teammates: (p.active ?? []).map(a => a.name), lead: await leadName($) })}\n\nTask: ${task || 'wait for messages from teammates or the lead.'}`
90 : task
91 // Single-quoted whole, so only the pane's own zsh expands the key reference.
92 const contextTokens = p.models.find(m => m.id === p.model)?.contextTokens
93 const run = `zsh -c ${sq(workerScript({ name, model: p.model, mode: p.mode, task: prompt, contextTokens, provider: cfg.provider }))}`
94 const cwd = await $.session.cwd()
95 const fmt = ['-P', '-F', '#{pane_id}']
96
97 const startedAt = await $.clock.now()
98 const label = async (pane: string) => {
99 await tmux($, ['set', '-p', '-t', pane, '@cw', name])
100 // on the pane, so every session (not just this one) knows what this worker is
101 for (const [k, v] of [['@cw_model', p.model], ['@cw_mode', p.mode], ['@cw_start', String(startedAt)], ['@cw_cwd', cwd], ['@cw_task', task.replace(/[\t\n]+/g, ' ').slice(0, 200)]] as const)
102 await tmux($, ['set', '-p', '-t', pane, k, v])
103 }
104 const l: Launch = { name, model: p.model, mode: p.mode, pane: '', task, isLive: true, startedAt, cwd }
105
106 if (cfg.launch === 'tabs') {
107 // A tmux session of its own (it outlives a closed tab), shown in a new terminal tab.
108 const made = await tmux($, ['new-session', '-d', '-s', name, '-x', '200', '-y', '50', '-c', cwd, ...fmt, run])
109 if (made.exitCode !== 0) throw new Error(made.stderr.trim() || 'tmux could not start a session')
110 const pane = made.stdout.trim()
111 await label(pane)
112 const tmuxPath = (await $.process.run(['zsh', '-c', 'command -v tmux']).catch(() => null))?.stdout.trim() ?? ''
113 return { l: { ...l, pane }, isTabOpen: tmuxPath.startsWith('/') && (await openTab($, name, tmuxPath)) }
114 }
115
116 // The same layout as cw: session `cw`, window `workers`, tiled panes.
117 const hasSession = (await tmux($, ['has-session', '-t', 'cw'])).exitCode === 0
118 const hasWindow = hasSession && (await tmux($, ['list-windows', '-t', 'cw', '-F', '#W'])).stdout.split('\n').includes('workers')
119 const made = !hasSession
120 ? await tmux($, ['new-session', '-d', '-s', 'cw', '-n', 'workers', '-x', '200', '-y', '50', '-c', cwd, ...fmt, run])
121 : !hasWindow
122 ? await tmux($, ['new-window', '-t', 'cw:', '-n', 'workers', '-c', cwd, ...fmt, run])
123 : await tmux($, ['split-window', '-d', '-t', 'cw:workers', '-c', cwd, ...fmt, run])
124 if (made.exitCode !== 0) throw new Error(made.stderr.trim() || 'tmux could not open a pane')
125 const pane = made.stdout.trim()
126 await tmux($, ['select-layout', '-t', 'cw:workers', 'tiled'])
127 await label(pane)
128 await tmux($, ['set', '-t', 'cw', 'pane-border-status', 'top'])
129 await tmux($, ['set', '-t', 'cw', 'pane-border-format', ' #{@cw} '])
130 return { l: { ...l, pane }, isTabOpen: null }
131}
132
133/**
134 * Opens a background tab attached to the worker's session: in cmux (when this session runs
135 * inside it) through its CLI, else in Ghostty through AppleScript. False → the caller says CMD+T + crew.
136 */
137async function openTab($: EngineInterface, name: string, tmuxPath: string): Promise<boolean> {
138 if (await $.env.get('CMUX_WORKSPACE_ID').catch(() => undefined)) {
139 const cmux = (await $.env.get('CMUX_BUNDLED_CLI_PATH').catch(() => undefined)) || 'cmux'
140 const made = await $.process.run([cmux, 'new-surface', '--type', 'terminal', '--focus', 'false'], { timeoutMs: 10_000 }).catch(() => null)
141 const ref = made?.exitCode === 0 ? surfaceRef(made.stdout) : null
142 if (!ref) return false
143 await $.process.run([cmux, 'rename-tab', '--surface', ref, name], { timeoutMs: 10_000 }).catch(() => null)
144 const sent = await $.process.run([cmux, 'send', '--surface', ref, attachLine(name, tmuxPath)], { timeoutMs: 10_000 }).catch(() => null)
145 return sent?.exitCode === 0
146 }
147 // The first run waits on macOS's "allow control of Ghostty?" dialog; past 15s (or refused) → manual CMD+T + crew.
148 const tab = await $.process.run(['osascript', '-e', ghosttyTabScript(name, tmuxPath)], { timeoutMs: 15_000 }).catch(() => null)
149 return tab?.exitCode === 0
150}
151
152/** Puts the `crew` picker on PATH (~/.local/bin), never over someone else's `crew`. */
153async function installCrew($: EngineInterface) {
154 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
155 const src = await $.fs.read(`${$.plugin.root}/bin/crew`).catch(() => null)
156 if (!home || typeof src !== 'string') return
157 const dest = `${home}/.local/bin/crew`
158 const cur = await $.fs.read(dest).catch(() => null)
159 if (cur === src || (typeof cur === 'string' && !cur.includes(CREW_MARK))) return
160 await $.process.run(['mkdir', '-p', `${home}/.local/bin`])
161 await $.fs.write(dest, src)
162 await $.process.run(['chmod', '+x', dest])
163}
164
165// This session's name as other sessions address it, from the ListAgents tool.
166async function leadName($: EngineInterface): Promise<string | null> {
167 const res = await $.tool.call({ tool: 'ListAgents' } as never).catch(() => null)
168 const r = res as { result?: unknown } | null
169 const text = typeof r?.result === 'string' ? r.result : JSON.stringify(r?.result ?? '')
170 return leadFrom(text)
171}
172
173async function logMessage($: EngineInterface, m: Msg) {
174 await update($, picker, x => ({ ...x, messages: [...(x.messages ?? []), m].slice(-MAX_MESSAGES) }))
175}
176
177async function sendTo($: EngineInterface, to: string, text: string) {
178 const res = await $.session.send({ to, text }).catch(err => ({ isDelivered: false as const, reason: String(err) }))
179 await logMessage($, { dir: 'out', peer: to, text, at: await $.clock.now(), ...(res.isDelivered ? {} : { isFailed: true }) })
180 if (!res.isDelivered) $.ui.toast(`Not delivered to ${to}: ${res.reason}`)
181}
182
183// tmux is the source of truth: every labelled pane on the server (session `cw`'s
184// tiles and tabs-mode sessions `cw-*` alike), this mod's
185// workers and driver-seat's alike, each with a peek at its last output lines.
186async function refreshLive($: EngineInterface) {
187 const res = await tmux($, ['list-panes', '-a', '-F', META_FORMAT]).catch(() => null)
188 const panes = res?.exitCode === 0 ? parseMeta(res.stdout) : []
189 // Workers another session launched (or launched before pane metadata existed): learn the
190 // model from the startup banner once, save it on the pane, and adopt them here.
191 const known = new Set((await read($, picker)).launched.map(l => l.pane))
192 const adopted: Launch[] = []
193 for (const m of panes) {
194 if (known.has(m.pane)) continue
195 let model = m.model
196 if (!model) {
197 const full = (await tmux($, ['capture-pane', '-p', '-J', '-S', '-', '-t', m.pane]).catch(() => null))?.stdout ?? ''
198 model = bannerModel(full) ?? ''
199 if (model) await tmux($, ['set', '-p', '-t', m.pane, '@cw_model', model])
200 }
201 if (!model) continue // not a cheap worker we can describe (e.g. driver-seat's): stays external
202 const mode = (['acceptEdits', 'auto', 'default'] as const).find(x => x === m.mode) ?? 'acceptEdits'
203 adopted.push({ name: m.name, model, mode, pane: m.pane, task: m.task, isLive: true, ...(m.start ? { startedAt: m.start } : {}), ...(m.cwd ? { cwd: m.cwd } : {}) })
204 }
205 if (adopted.length > 0) await update($, picker, x => ({ ...x, launched: [...adopted, ...x.launched].slice(0, 24) }))
206 const p = await read($, picker)
207 const active: Active[] = []
208 for (const { pane, name } of panes) {
209 const cap = (await tmux($, ['capture-pane', '-p', '-J', '-t', pane]).catch(() => null))?.stdout ?? ''
210 active.push({
211 pane,
212 name,
213 launch: p.launched.find(l => l.pane === pane) ?? null,
214 peek: peekLines(cap),
215 isClosing: p.closing.includes(pane),
216 isBusy: isWorking(cap),
217 })
218 }
219 const live = new Set(panes.map(x => x.pane))
220 const now = await $.clock.now()
221 await update($, picker, x => ({
222 ...x,
223 active,
224 closing: x.closing.filter(c => live.has(c)),
225 launched: x.launched.map(l => {
226 const isLive = live.has(l.pane)
227 return { ...l, isLive, ...(!isLive && l.endedAt === undefined ? { endedAt: now } : {}) }
228 }),
229 }))
230 await refreshStats($)
231 $.ui.status(active.length > 0 ? `workers: ${active.length} running` : undefined)
232}
233
234// Tokens, ≈cost and context fill, read from each worker's own transcript: live
235// workers every refresh, a finished one once more after it ends.
236async function refreshStats($: EngineInterface) {
237 const p = await read($, picker)
238 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
239 const now = await $.clock.now()
240 const next: Record<string, WStats> = { ...(p.stats ?? {}) }
241 const found: Record<string, string> = {}
242 for (const l of p.launched) {
243 // a finished worker gets 15s more of reads, so its last reply is counted
244 const isSettled = !l.isLive && now - (l.endedAt ?? now) > 15_000 && next[l.name] !== undefined
245 if (isSettled || !l.cwd || !home) continue
246 let file = l.transcript
247 if (!file) {
248 const dir = projectDir(home, l.cwd)
249 const hit = await $.process.run(['grep', '-rl', '--include=*.jsonl', '-e', `"agentName":"${l.name}"`, dir]).catch(() => null)
250 file = hit?.stdout.split('\n').find(Boolean)
251 if (!file) continue
252 found[l.name] = file
253 }
254 const text = await $.fs.read(file).catch(() => null)
255 if (typeof text !== 'string') continue
256 const u = usageFrom(text)
257 const model = p.models.find(m => m.id === l.model)
258 next[l.name] = {
259 tokens: tokensOf(u),
260 cost: costOf(u, model),
261 ctxPct: model?.contextTokens ? Math.round((100 * u.lastPrompt) / model.contextTokens) : null,
262 }
263 }
264 await update($, picker, x => ({
265 ...x,
266 stats: next,
267 launched: x.launched.map(l => (found[l.name] ? { ...l, transcript: found[l.name] } : l)),
268 }))
269}
270
271// Asks the worker to /exit (its transcript closes cleanly), then kills the pane
272// if it's still there: a worker mid-turn only reads /exit once the turn ends.
273async function closeWorker($: EngineInterface, pane: string) {
274 await update($, picker, x => ({ ...x, closing: [...x.closing.filter(c => c !== pane), pane] }))
275 await tmux($, ['send-keys', '-t', pane, 'C-u'])
276 await tmux($, ['send-keys', '-t', pane, '-l', '/exit'])
277 await tmux($, ['send-keys', '-t', pane, 'Enter'])
278 await refreshLive($)
279 $.clock.after(FORCE_AFTER_MS, async () => {
280 const still = (await tmux($, ['list-panes', '-a', '-F', '#{pane_id}'])).stdout.split('\n').includes(pane)
281 if (still) {
282 // claude survives the hangup kill-pane sends, so stop its process first
283 // (the pane exec'd into claude: the pane's pid is the worker's).
284 const pid = (await tmux($, ['display-message', '-p', '-t', pane, '#{pane_pid}'])).stdout.trim()
285 if (/^\d+$/.test(pid)) await $.process.run(['kill', pid])
286 await tmux($, ['kill-pane', '-t', pane])
287 }
288 await refreshLive($)
289 })
290}
291
292async function openView($: EngineInterface) {
293 await refreshLive($)
294 await $.ui.open({ id: VIEW, title: 'Workers' })
295}
296
297export const register: Register = (on, options) => {
298 const o = (options ?? {}) as { provider?: unknown; launch?: unknown }
299 cfg.provider = o.provider === 'bedrock' ? 'bedrock' : 'openrouter'
300 cfg.launch = o.launch === 'tabs' ? 'tabs' : 'panes'
301
302 on('session.start', async ($, e, next) => {
303 const via = cfg.provider === 'bedrock' ? 'Bedrock Claude' : 'cheap OpenRouter'
304 await $.command.register({ name: 'worker', description: `Spin up a ${via} worker (${cfg.launch === 'tabs' ? 'opens a terminal tab' : 'tmux pane'})` })
305 await $.command.register({ name: 'workers', description: 'See and close the active workers' })
306 const pins = toPins(await $.store.get('pins').catch(() => null))
307 await update($, picker, p => {
308 const base = { ...DEFAULTS, ...p, pins, model: pins[0] ?? p.model ?? DEFAULTS.model }
309 // switching provider: a model the other provider can't run falls back to its default
310 if (cfg.provider === 'bedrock')
311 return { ...base, models: BEDROCK_MODELS, model: BEDROCK_MODELS.some(m => m.id === base.model) ? base.model : defaultModelFor('bedrock') }
312 return BEDROCK_MODELS.some(m => m.id === base.model) ? { ...base, models: [], model: DEFAULT_MODEL } : base
313 })
314 void installCrew($)
315 // ponytail: polls tmux; one list-panes plus a capture per worker, fine for a handful
316 $.clock.every(5000, () => refreshLive($))
317 return next(e)
318 })
319
320 on('command.run', { command: 'worker' }, async $ => {
321 await openPane($)
322 return { text: 'Cheap worker pane open.' }
323 })
324
325 // Messages other sessions send here: logged for the Workers view and toasted,
326 // then passed on untouched so the model still reads them.
327 on('session.receive', async ($, e, next) => {
328 const kind = (e.origin as { kind: string }).kind
329 if (e.agentId === undefined && (kind === 'peer' || kind === 'peer-send-message')) {
330 const { from, body } = parsePeer(e.text)
331 await logMessage($, { dir: 'in', peer: from, text: body, at: await $.clock.now() })
332 $.ui.toast(`✉ ${from}: ${body.length > 60 ? `${body.slice(0, 59)}…` : body}`)
333 }
334 return next(e)
335 })
336
337 on('command.run', { command: 'workers' }, async $ => {
338 await openView($)
339 return { text: 'Workers view open.' }
340 })
341
342 // Footer button, bottom right beside quick-settings' ⚙: the OpenRouter mark
343 // (Ghostty/kitty draw it; elsewhere a lime dot) and a live worker count.
344 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
345 const below = await next(e)
346 const els = $.ui.resolve(e)
347 const { Box, Button, Text } = els
348 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
349 const running = (await read($, picker)).active?.length ?? 0
350 if (Svg && !iconsSvg.has('openrouter')) {
351 const svg = await $.fs.read(`${$.plugin.root}/assets/providers-svg/openrouter.svg`).catch(() => null)
352 iconsSvg.set('openrouter', typeof svg === 'string' ? svg : '')
353 }
354 const orSvg = iconsSvg.get('openrouter')
355 const mark =
356 cfg.provider === 'bedrock' ? (
357 <Text color="#d97757">✻</Text>
358 ) : Svg && orSvg ? (
359 <Svg key="or-mark" source={orSvg} width={14} height={14} alt="OpenRouter" />
360 ) : (
361 <Text color={OR_LIME}>◉</Text>
362 )
363 return (
364 <Box justifyContent="space-between">
365 <Box flexGrow={1}>{below}</Box>
366 <Box key="worker-chip" columnGap={1} marginLeft={2}>
367 {mark}
368 <Button key="worker-open" plain label="worker" hover={{ color: OR_LIME, bold: true }} onPress={() => openPane($)} />
369 {running > 0 && (
370 <Button key="workers-view" plain label={`· ${running} live`} hover={{ color: OR_LIME, bold: true }} onPress={() => openView($)} />
371 )}
372 </Box>
373 </Box>
374 )
375 })
376
377 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
378 const els = $.ui.resolve(e)
379 const { Box, Button, Text } = els
380 // mobile draws no Input: text boxes are left out there
381 const Input = 'Input' in els ? els.Input : null
382 const p = await read($, picker)
383 const info = (id: string) => p.models.find(m => m.id === id)
384 const isBedrock = cfg.provider === 'bedrock'
385 const priceOf = (id: string) => (isBedrock ? 'via Amazon Bedrock' : price(info(id)))
386 const matches = search(p.models, p.query)
387 const choices =
388 cfg.provider === 'bedrock' ? BEDROCK_MODELS.map(m => m.id) : choicesFor(p.models, p.query, p.pins, p.recent)
389 const choose = (id: string) =>
390 update($, picker, x => ({ ...x, model: id, recent: [id, ...x.recent.filter(r => r !== id)].slice(0, 4) }))
391
392 return (
393 <Box flexDirection="column" gap={1} paddingX={1}>
394 <Box flexDirection="column">
395 <Text bold color={ACCENT}>Model</Text>
396 <Box justifyContent="space-between">
397 <Text>
398 <Text bold>{p.model}</Text>
399 <Text dimColor> {priceOf(p.model)}</Text>
400 {isRetired(p.models, p.model) && <Text color="#e5534b"> not in catalog: retired?</Text>}
401 </Text>
402 {!isBedrock && <Button
403 key="refresh"
404 plain
405 dimColor
406 label={`↻ refresh${p.models.length > 0 ? ` (${p.models.length})` : ''}`}
407 onPress={async () => {
408 const ok = await loadModels($)
409 const n = (await read($, picker)).models.length
410 await update($, picker, x => ({
411 ...x,
412 flash: ok ? { text: `Catalog refreshed: ${n} tool-capable models.`, isError: false } : { text: 'OpenRouter unreachable; kept the old list.', isError: true },
413 }))
414 }}
415 />}
416 </Box>
417 {Input && !isBedrock && (
418 <Input
419 key="query"
420 placeholder={p.models.length > 0 ? `search ${p.models.length} tool-capable models…` : 'loading models… (or type a slug, Enter)'}
421 value={p.query}
422 onInput={(value: string) => update($, picker, x => ({ ...x, query: value }))}
423 onSubmit={(value: string) => (matches[0] ? choose(matches[0].id) : choose(value.trim()))}
424 />
425 )}
426 <Box flexDirection="column">
427 {choices.map(id => (
428 <Box key={`row-${id}`} columnGap={1}>
429 <Button key={`pin-${id}`} plain label={p.pins.includes(id) ? '★' : '☆'} onPress={() => togglePin($, id)} />
430 <Button
431 key={`m-${id}`}
432 plain
433 dimColor={id !== p.model}
434 label={`${id === p.model ? '●' : '○'} ${id} ${isRetired(p.models, id) ? '✗ retired?' : priceOf(id)}`}
435 onPress={() => choose(id)}
436 />
437 </Box>
438 ))}
439 </Box>
440 </Box>
441
442 <Box flexDirection="column">
443 <Text bold color={ACCENT}>Permissions</Text>
444 <Box columnGap={1}>
445 {MODES.map(m => (
446 <Button
447 key={`mode-${m.value}`}
448 label={`${m.value === p.mode ? '●' : '○'} ${m.label}`}
449 variant={m.value === p.mode ? 'primary' : 'secondary'}
450 onPress={() => update($, picker, x => ({ ...x, mode: m.value }))}
451 />
452 ))}
453 </Box>
454 <Text dimColor>{MODES.find(m => m.value === p.mode)?.note}</Text>
455 </Box>
456
457 <Box flexDirection="column">
458 <Text bold color={ACCENT}>Team</Text>
459 <Box columnGap={1}>
460 <Button
461 key="team-toggle"
462 label={p.team ? '● team' : '○ solo'}
463 variant={p.team ? 'primary' : 'secondary'}
464 onPress={() => update($, picker, x => ({ ...x, team: !x.team }))}
465 />
466 </Box>
467 <Text dimColor>
468 {p.team
469 ? `knows its teammates${(p.active ?? []).length > 0 ? ` (${p.active.map(a => a.name).join(', ')})` : ''} and reports to this session via SendMessage`
470 : 'works alone; you can still message it from the Workers view'}
471 </Text>
472 </Box>
473
474 <Box flexDirection="column">
475 <Text bold color={ACCENT}>Task</Text>
476 {Input && (
477 <Input
478 key="task"
479 placeholder="what should the worker do? (Enter launches; empty opens it idle)"
480 submitLabel="Launch"
481 onSubmit={async (value: string) => {
482 try {
483 const { l, isTabOpen } = await launch($, value.trim())
484 const where =
485 isTabOpen === null
486 ? `${l.name} started in tmux cw:workers. Watch: tmux new -A -s cw`
487 : isTabOpen
488 ? `${l.name} opened in a new tab (or: crew ${l.name.slice(3)})`
489 : `${l.name} is running. Open it: CMD+T, then crew ${l.name.slice(3)}`
490 await update($, picker, x => ({ ...x, launched: [l, ...x.launched].slice(0, 12), flash: { text: where, isError: false } }))
491 if (isTabOpen === false) $.ui.toast(`${l.name}: CMD+T, then crew ${l.name.slice(3)}`)
492 } catch (err) {
493 const text = err instanceof Error ? err.message : String(err)
494 await update($, picker, x => ({ ...x, flash: { text, isError: true } }))
495 }
496 }}
497 />
498 )}
499 </Box>
500
501 {p.flash && <Text color={p.flash.isError ? '#e5534b' : '#57ab5a'}>{p.flash.isError ? '✗' : '✓'} {p.flash.text}</Text>}
502
503 <Box key="to-view">
504 <Button
505 key="open-view"
506 plain
507 dimColor={p.active.length === 0}
508 label={p.active.length > 0 ? `◉ ${p.active.length} live worker${p.active.length > 1 ? 's' : ''} · view & close →` : '○ no live workers · view →'}
509 hover={{ color: OR_LIME, bold: true }}
510 onPress={() => openView($)}
511 />
512 </Box>
513 </Box>
514 )
515 })
516
517 on('ui.render', { component: 'Pane', requestId: VIEW }, async ($, e) => {
518 const els = $.ui.resolve(e)
519 const { Box, Button, Text } = els
520 const Input = 'Input' in els ? els.Input : null
521 const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : null
522 const p = await read($, picker)
523 const now = await $.clock.now()
524 const stats = p.stats ?? {}
525 if (pixels === null) {
526 const raw = await $.fs.read(`${$.plugin.root}/assets/providers-px.json`).catch(() => null)
527 try {
528 pixels = typeof raw === 'string' ? JSON.parse(raw) : {}
529 } catch {
530 pixels = {}
531 }
532 }
533 const hhmm = (t: number) => {
534 const d = new Date(t)
535 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
536 }
537
538 // Provider marks, read once per load ('' = no logo shipped: letter badge).
539 const vendors = new Set([...(p.active ?? []), ...p.launched].map(w => vendorOf(('launch' in w ? w.launch?.model : w.model) ?? '')))
540 for (const v of vendors) {
541 if (v && Svg && !iconsSvg.has(v)) {
542 const svg = await $.fs.read(`${$.plugin.root}/assets/providers-svg/${v}.svg`).catch(() => null)
543 iconsSvg.set(v, typeof svg === 'string' ? svg : '')
544 }
545 }
546
547 const running = p.active ?? []
548 const finished = p.launched.filter(l => !l.isLive)
549 const all = [...running.map(w => w.launch).filter((l): l is Launch => l !== null), ...finished]
550 const totals = all.reduce(
551 (t, l) => {
552 const s = stats[l.name]
553 return { cost: t.cost + (s?.cost ?? 0), tokens: t.tokens + (s?.tokens ?? 0) }
554 },
555 { cost: 0, tokens: 0 },
556 )
557 const firstStart = Math.min(...all.map(l => l.startedAt ?? now), now)
558 const lastEnd = running.length > 0 ? now : Math.max(...finished.map(l => l.endedAt ?? now), firstStart)
559
560 const avatar = (model: string, key: string) => {
561 const v = vendorOf(model)
562 const svg = iconsSvg.get(v)
563 const grid = e.surface === 'terminal' ? pixels?.[v] : undefined
564 return grid ? (
565 <Box key={`av-${key}`} flexDirection="column" width={8}>
566 {[0, 2, 4, 6].map(y => (
567 <Text key={`av-${key}-${y}`}>
568 {grid[y]!.map((top, x) => {
569 const bot = grid[y + 1]![x] ?? null
570 return top && bot ? (
571 <Text key={`px-${x}`} color={top} backgroundColor={bot}>▀</Text>
572 ) : top ? (
573 <Text key={`px-${x}`} color={top}>▀</Text>
574 ) : bot ? (
575 <Text key={`px-${x}`} color={bot}>▄</Text>
576 ) : (
577 ' '
578 )
579 })}
580 </Text>
581 ))}
582 </Box>
583 ) : Svg && svg ? (
584 <Svg key={`av-${key}`} source={svg} width={32} height={32} alt={`${v} logo`} />
585 ) : (
586 <Box key={`av-${key}`} width={8} height={4} justifyContent="center" alignItems="center" backgroundColor="#2d333b">
587 <Text bold color={OR_LIME}>{(v || '?').slice(0, 1).toUpperCase()}</Text>
588 </Box>
589 )
590 }
591 const tile = (label: string, value: string) => (
592 <Box key={`tile-${label}`} flexDirection="column" flexGrow={1} borderStyle="round" borderColor="#3b3f4a" paddingX={1}>
593 <Text dimColor>{label}</Text>
594 <Text bold>{value}</Text>
595 </Box>
596 )
597 const bar = (pct: number, color: string, width = 34) => {
598 const fill = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
599 return (
600 <Text>
601 <Text color={color}>{'━'.repeat(fill)}</Text>
602 <Text color="#3b3f4a">{'━'.repeat(width - fill)}</Text>
603 </Text>
604 )
605 }
606 const statLine = (l: Launch | null, end: number) => {
607 const s = l ? stats[l.name] : undefined
608 const parts = [
609 s?.ctxPct != null ? `ctx ${s.ctxPct}%` : null,
610 s ? kfmt(s.tokens) : null,
611 s?.cost != null ? `≈$${s.cost.toFixed(2)}` : null,
612 l?.startedAt ? clockDur(end - l.startedAt) : null,
613 ].filter(Boolean)
614 return parts.join(' · ')
615 }
616 const title = (l: Launch | null, name: string) => {
617 const t = l?.task?.split('\n')[0]?.trim()
618 return t ? (t.length > 48 ? `${t.slice(0, 47)}…` : t) : name
619 }
620 const subtitle = (l: Launch | null, name: string) =>
621 l ? (
622 <Text>
623 <Text color={MODE_COLOR[l.mode]}>{MODE_WORD[l.mode]}</Text>
624 <Text dimColor> {l.model.split('/')[1] ?? l.model} · {name}</Text>
625 </Text>
626 ) : (
627 <Text dimColor>{name} · driver-seat / external</Text>
628 )
629
630 return (
631 <Box flexDirection="column" gap={1} paddingX={1}>
632 <Box justifyContent="space-between">
633 <Text bold>Workers</Text>
634 <Button key="view-refresh" plain dimColor label="↻" onPress={() => refreshLive($)} />
635 </Box>
636
637 <Box columnGap={1}>
638 {tile('Cost', all.length > 0 ? `≈$${totals.cost.toFixed(2)}` : '—')}
639 {tile('Tokens', all.length > 0 ? kfmt(totals.tokens) : '—')}
640 {tile('Time', all.length > 0 ? clockDur(lastEnd - firstStart) : '—')}
641 </Box>
642
643 <Box flexDirection="column">
644 <Text dimColor>Running · {running.length}</Text>
645 {running.length === 0 && <Text dimColor> none. Launch one from the worker button.</Text>}
646 {running.map(w => {
647 const color = w.launch ? MODE_COLOR[w.launch.mode] : OR_LIME
648 const s = w.launch ? stats[w.launch.name] : undefined
649 return (
650 <Box key={`card-${w.pane}`} flexDirection="column" paddingY={0} marginTop={1}>
651 <Box columnGap={2}>
652 {avatar(w.launch?.model ?? '', w.pane)}
653 <Box flexDirection="column" flexGrow={1}>
654 <Box justifyContent="space-between">
655 <Text bold>{title(w.launch, w.name)}</Text>
656 <Text color={w.isClosing ? '#e8c46a' : w.isBusy ? '#57ab5a' : '#768390'}>
657 {w.isClosing ? '◌ closing' : w.isBusy ? '●' : '◌ idle'}
658 </Text>
659 </Box>
660 {subtitle(w.launch, w.name)}
661 <Box justifyContent="space-between">
662 <Text dimColor>{w.peek.at(-1)?.slice(0, 40) ?? 'starting…'}</Text>
663 <Text dimColor>{statLine(w.launch, now)}</Text>
664 </Box>
665 {bar(s?.ctxPct ?? 0, color)}
666 </Box>
667 </Box>
668 <Box columnGap={2} marginLeft={6}>
669 {Input && !w.isClosing && (
670 <Input
671 key={`msg-${w.pane}`}
672 placeholder={`message ${w.name}…`}
673 submitLabel="Send"
674 onSubmit={(value: string) => (value.trim() ? sendTo($, w.name, value.trim()) : undefined)}
675 />
676 )}
677 {!w.isClosing && (
678 <Button
679 key={`close-${w.pane}`}
680 plain
681 dimColor
682 label="✕ close"
683 hover={{ color: '#e5534b', bold: true }}
684 onPress={() => closeWorker($, w.pane)}
685 />
686 )}
687 </Box>
688 </Box>
689 )
690 })}
691 </Box>
692
693 {finished.length > 0 && (
694 <Box flexDirection="column">
695 <Box key="finished-head">
696 <Button
697 key="toggle-finished"
698 plain
699 dimColor
700 label={`${p.showFinished ? '▾' : '▸'} Finished · ${finished.length}`}
701 onPress={() => update($, picker, x => ({ ...x, showFinished: !x.showFinished }))}
702 />
703 </Box>
704 {p.showFinished &&
705 finished.map(l => (
706 <Box key={`done-${l.name}`} columnGap={2} marginTop={1}>
707 {avatar(l.model, `done-${l.name}`)}
708 <Box flexDirection="column" flexGrow={1}>
709 <Box justifyContent="space-between">
710 <Text bold>{title(l, l.name)}</Text>
711 <Text color="#57ab5a">✓</Text>
712 </Box>
713 {subtitle(l, l.name)}
714 <Box justifyContent="flex-end">
715 <Text dimColor>{statLine(l, l.endedAt ?? now)}</Text>
716 </Box>
717 {bar(100, MODE_COLOR[l.mode])}
718 </Box>
719 </Box>
720 ))}
721 </Box>
722 )}
723
724 {(p.messages ?? []).length > 0 && (
725 <Box flexDirection="column">
726 <Box justifyContent="space-between">
727 <Text dimColor>Messages · {(p.messages ?? []).length}</Text>
728 <Button key="msgs-clear" plain dimColor label="clear" onPress={() => update($, picker, x => ({ ...x, messages: [] }))} />
729 </Box>
730 {(p.messages ?? []).slice(-8).map((m, i) => (
731 <Text key={`m-${i}-${m.at}`} dimColor={m.dir === 'out'}>
732 <Text color={m.isFailed ? '#e5534b' : m.dir === 'in' ? OR_LIME : undefined}>{m.dir === 'in' ? '←' : m.isFailed ? '✗' : '→'}</Text>
733 {` ${hhmm(m.at)} ${m.peer}: ${m.text.length > 90 ? `${m.text.slice(0, 89)}…` : m.text}`}
734 </Text>
735 ))}
736 </Box>
737 )}
738
739 <Text dimColor>watch live: tmux new -A -s cw · ✕ sends /exit, force-closes after {FORCE_AFTER_MS / 1000}s</Text>
740 </Box>
741 )
742 })
743}
744hooks/worker.ts 251 lines1import type { Mode, OrModel } from '../types'
2
3export const DEFAULT_MODEL = 'deepseek/deepseek-v4.1-flash'
4
5/** Where workers' model calls go: OpenRouter (any model, your key) or Amazon Bedrock (Claude only). */
6export type Provider = 'openrouter' | 'bedrock'
7/** How workers are shown: panes tiled in tmux session `cw`, or one tmux session each, opened as a terminal tab. */
8export type LaunchMode = 'panes' | 'tabs'
9
10// Bedrock's choices: Claude Code's own aliases, written as slugs so the picker,
11// pins and the Anthropic avatar work unchanged. No catalog fetch, no prices.
12export const BEDROCK_MODELS: OrModel[] = ['sonnet', 'opus', 'haiku'].map(a => ({ id: `anthropic/${a}`, inPerM: 0, outPerM: 0 }))
13export const defaultModelFor = (p: Provider) => (p === 'bedrock' ? 'anthropic/sonnet' : DEFAULT_MODEL)
14
15/** `cmux new-surface` prints `OK surface:110 pane:38 workspace:14`: the new tab's ref. */
16export const surfaceRef = (stdout: string) => stdout.match(/\bsurface:\d+\b/)?.[0] ?? null
17
18/** What cmux types into the new tab: the attach, then Enter (a real newline; cmux sends text as typed). */
19export function attachLine(session: string, tmuxPath: string): string {
20 if (!/^cw-[a-z0-9]+$/.test(session)) throw new Error(`not a worker session: ${session}`)
21 if (!/^\/[\w./+-]+$/.test(tmuxPath)) throw new Error(`not an absolute tmux path: ${tmuxPath}`)
22 return `${tmuxPath} attach -t '=${session}'\n`
23}
24
25/**
26 * AppleScript for Ghostty 1.3+: a new tab in the front window whose process is the
27 * attach itself, then re-selects the tab you were on so focus doesn't jump. tmux by
28 * absolute path: a tab's command gets no Homebrew PATH (bare `tmux` isn't found there).
29 */
30export function ghosttyTabScript(session: string, tmuxPath: string): string {
31 if (!/^cw-[a-z0-9]+$/.test(session)) throw new Error(`not a worker session: ${session}`)
32 if (!/^\/[\w./+-]+$/.test(tmuxPath)) throw new Error(`not an absolute tmux path: ${tmuxPath}`)
33 return [
34 'tell application "Ghostty"',
35 ' set cfg to new surface configuration',
36 ` set command of cfg to "${tmuxPath} attach -t =${session}"`,
37 ' if (count of windows) is 0 then',
38 ' new window with configuration cfg',
39 ' else',
40 ' set w to front window',
41 ' set here to selected tab of w',
42 ' new tab in w with configuration cfg',
43 ' select tab here',
44 ' end if',
45 'end tell',
46 ].join('\n')
47}
48
49// An OpenRouter slug, `vendor/name` with an optional `:variant`; anything else
50// never reaches the shell.
51export const isSlug = (s: string) => /^[a-z0-9][\w.-]*\/[\w.-]+(:[\w.-]+)?$/i.test(s)
52
53/** Single-quotes `s` for a POSIX shell. */
54export const sq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
55
56/**
57 * The zsh script a worker pane runs. The key is referenced, never inlined:
58 * the pane's zsh expands $CW_OPENROUTER_API_KEY from ~/.zshenv, so it never
59 * appears in tmux's or ps's argument lists.
60 */
61export function workerScript(opts: { name: string; model: string; mode: Mode; task: string; contextTokens?: number; provider?: Provider }): string {
62 if (!isSlug(opts.model)) throw new Error(`not an OpenRouter model id: ${opts.model}`)
63 const m = opts.model
64 if (opts.provider === 'bedrock') {
65 const alias = m.split('/')[1]
66 if (!BEDROCK_MODELS.some(b => b.id === m) || !alias) throw new Error(`not a Bedrock model choice: ${m}`)
67 // Bedrock config (CLAUDE_CODE_USE_BEDROCK, AWS_*) lives in user settings, so they
68 // must load: no --setting-sources here. Claude Code maps the alias to the Bedrock id.
69 return `exec command claude -n ${opts.name} --model ${alias} --strict-mcp-config --permission-mode ${opts.mode}${opts.task ? ` ${sq(opts.task)}` : ''}`
70 }
71 return [
72 'export ANTHROPIC_BASE_URL=https://openrouter.ai/api ANTHROPIC_AUTH_TOKEN=$CW_OPENROUTER_API_KEY ANTHROPIC_API_KEY=',
73 `export ANTHROPIC_DEFAULT_OPUS_MODEL=${m} ANTHROPIC_DEFAULT_SONNET_MODEL=${m} ANTHROPIC_DEFAULT_HAIKU_MODEL=${m} CLAUDE_CODE_SUBAGENT_MODEL=${m}`,
74 // Claude Code doesn't know OpenRouter models and assumes 200k; give it the real window.
75 ...(opts.contextTokens && Number.isInteger(opts.contextTokens) ? [`export CLAUDE_CODE_MAX_CONTEXT_TOKENS=${opts.contextTokens}`] : []),
76 // Your own skills without the rest of user settings: a folder holding only .claude/skills
77 // (a link to ~/.claude/skills), added with --add-dir. Edits there are denied: acceptEdits would
78 // auto-approve them, and the link leads to the real skills.
79 'S=$HOME/.claude/cw-skills; mkdir -p $S/.claude && ln -sfn $HOME/.claude/skills $S/.claude/skills',
80 // Same flags cw proved in Phase 0: no user hooks/plugins or MCP servers ride along.
81 // --add-dir and --disallowedTools take several values: --permission-mode stays between them and the task.
82 `exec command claude -n ${opts.name} --model sonnet --setting-sources project,local --strict-mcp-config --add-dir $S --disallowedTools "Edit(/$S/**)" "Edit(/$HOME/.claude/skills/**)" "Edit(/$HOME/.agents/skills/**)" --permission-mode ${opts.mode}${opts.task ? ` ${sq(opts.task)}` : ''}`,
83 ].join('\n')
84}
85
86/** Compacts OpenRouter's /models payload to the tool-capable models, cheapest input first. */
87export function parseModels(json: string): OrModel[] {
88 const data = (JSON.parse(json) as { data?: unknown[] }).data ?? []
89 return data
90 .flatMap(raw => {
91 const m = raw as {
92 id?: string
93 context_length?: number
94 pricing?: { prompt?: string; completion?: string; input_cache_read?: string }
95 supported_parameters?: string[]
96 }
97 if (!m.id || !isSlug(m.id) || !m.supported_parameters?.includes('tools')) return []
98 return [
99 {
100 id: m.id,
101 inPerM: Number(m.pricing?.prompt ?? 0) * 1e6,
102 outPerM: Number(m.pricing?.completion ?? 0) * 1e6,
103 ...(m.pricing?.input_cache_read !== undefined && { cachePerM: Number(m.pricing.input_cache_read) * 1e6 }),
104 ...(Number.isInteger(m.context_length) && { contextTokens: m.context_length }),
105 },
106 ]
107 })
108 .sort((a, b) => a.inPerM - b.inPerM)
109}
110
111/** Every space-separated word of `query` in the id; the first `limit`, cheapest first. */
112export function search(models: readonly OrModel[], query: string, limit = 6): OrModel[] {
113 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
114 if (words.length === 0) return []
115 return models.filter(m => words.every(w => m.id.toLowerCase().includes(w))).slice(0, limit)
116}
117
118export const price = (m: OrModel | undefined) =>
119 m ? `$${+m.inPerM.toFixed(2)}/$${+m.outPerM.toFixed(2)} per M` : 'price unknown'
120
121/** What the list shows: search hits while searching, else pins then recent picks. */
122export function choicesFor(models: readonly OrModel[], query: string, pins: readonly string[], recent: readonly string[]): string[] {
123 const hits = search(models, query)
124 if (hits.length > 0) return hits.map(m => m.id)
125 return [...pins, ...recent.filter(r => !pins.includes(r))]
126}
127
128/** Gone from a loaded catalog; never claimed while the catalog is empty (offline, loading). */
129export const isRetired = (models: readonly OrModel[], id: string) => models.length > 0 && !models.some(m => m.id === id)
130
131/** Reads the stored pin list, dropping anything that isn't a slug. */
132export const toPins = (raw: unknown): string[] =>
133 Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string' && isSlug(x)) : []
134
135/** `tmux list-panes -F '#{pane_id}|#{@cw}'` → labelled worker panes (unlabelled panes aren't workers). */
136export function parsePanes(stdout: string): { pane: string; name: string }[] {
137 return stdout
138 .split('\n')
139 .map(line => line.split('|'))
140 .filter(([pane, name]) => pane?.startsWith('%') && name)
141 .map(([pane, name]) => ({ pane: pane as string, name: name as string }))
142}
143
144// Claude Code's frame: rules, the empty prompt, the footer hints. Not what the worker is doing.
145const CHROME = /^[─━│┃╭╮╰╯▐▛▜▝▘\s]*$|^❯\s*$|shift\+tab|esc to interrupt|\? for shortcuts|auto mode on|accept edits on/
146
147/** The last `n` meaningful lines of a captured pane, each cut to `width`. */
148export function peekLines(capture: string, n = 2, width = 72): string[] {
149 return capture
150 .split('\n')
151 .map(l => l.trimEnd())
152 .filter(l => l.trim() !== '' && !CHROME.test(l))
153 .slice(-n)
154 .map(l => (l.trim().length > width ? `${l.trim().slice(0, width - 1)}…` : l.trim()))
155}
156
157/** A cross-session delivery's sender and body: `<cross-session-message … from-name="cw-ping" …>body</…>`. */
158export function parsePeer(text: string): { from: string; body: string } {
159 const from = text.match(/from-name="([^"]+)"/)?.[1] ?? text.match(/from="([^"]+)"/)?.[1] ?? 'peer'
160 const body = text.match(/<cross-session-message[^>]*>([\s\S]*?)<\/cross-session-message>/)?.[1] ?? text
161 return { from, body: body.trim() }
162}
163
164/** This session's own name, from the ListAgents tool's first line ("This session is <name> [ref] …"). */
165export const leadFrom = (listAgents: string) => listAgents.match(/This session is (.+?) \[[0-9a-f]+\]/)?.[1] ?? null
166
167/** The team note a worker launched with "team" on reads before its task. */
168export function teamPreamble(opts: { name: string; teammates: readonly string[]; lead: string | null }): string {
169 const mates = opts.teammates.filter(t => t !== opts.name)
170 return [
171 `You are ${opts.name}, a worker on a team of Claude Code sessions on this machine.`,
172 mates.length > 0
173 ? `Teammates right now: ${mates.join(', ')}. More may join; the ListAgents tool lists every session.`
174 : 'No teammates yet; the ListAgents tool lists sessions that join later.',
175 'Coordinate with the SendMessage tool: hand off work, ask a teammate for a result, or tell them what you changed. Keep messages short and specific.',
176 opts.lead
177 ? `Your lead is the session named "${opts.lead}". SendMessage it when you finish (one-line summary and where the result is) or when you are blocked. Copy the lead on decisions that affect other workers.`
178 : 'When you finish or are blocked, say so in your own output.',
179 'Messages from teammates arrive as new prompts; answer them, then continue your task.',
180 ].join('\n')
181}
182
183/** Claude Code's transcript folder for a working directory: every / and . becomes -. */
184export const projectDir = (home: string, cwd: string) => `${home}/.claude/projects/${cwd.replace(/[/.]/g, '-')}`
185
186export type Usage = { input: number; cacheRead: number; cacheWrite: number; output: number; lastPrompt: number }
187
188/** Token totals of one transcript; a streamed reply repeats its message id, so each id counts once. */
189export function usageFrom(jsonl: string): Usage {
190 const seen = new Set<string>()
191 const u: Usage = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, lastPrompt: 0 }
192 for (const line of jsonl.split('\n')) {
193 if (!line.includes('"usage"')) continue
194 let d: { type?: string; message?: { id?: string; usage?: Record<string, number> } }
195 try {
196 d = JSON.parse(line)
197 } catch {
198 continue
199 }
200 const m = d.message
201 if (d.type !== 'assistant' || !m?.usage || !m.id || seen.has(m.id)) continue
202 seen.add(m.id)
203 const x = m.usage
204 u.input += x.input_tokens ?? 0
205 u.cacheRead += x.cache_read_input_tokens ?? 0
206 u.cacheWrite += x.cache_creation_input_tokens ?? 0
207 u.output += x.output_tokens ?? 0
208 u.lastPrompt = (x.input_tokens ?? 0) + (x.cache_read_input_tokens ?? 0) + (x.cache_creation_input_tokens ?? 0)
209 }
210 return u
211}
212
213/** ≈ USD at the model's listed rates (cache reads at the cache rate when listed, else input rate). */
214export const costOf = (u: Usage, m: OrModel | undefined) =>
215 m ? ((u.input + u.cacheWrite) * m.inPerM + u.cacheRead * (m.cachePerM ?? m.inPerM) + u.output * m.outPerM) / 1e6 : null
216
217export const tokensOf = (u: Usage) => u.input + u.cacheRead + u.cacheWrite + u.output
218
219export const vendorOf = (model: string) => model.split('/')[0]?.replace(/^~/, '') ?? ''
220
221export const kfmt = (n: number) => (n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
222
223/** 0:27, 12:05, 1:02:09 */
224export function clockDur(ms: number): string {
225 const s = Math.max(0, Math.floor(ms / 1000))
226 const h = Math.floor(s / 3600)
227 const mm = Math.floor((s % 3600) / 60)
228 const ss = String(s % 60).padStart(2, '0')
229 return h > 0 ? `${h}:${String(mm).padStart(2, '0')}:${ss}` : `${mm}:${ss}`
230}
231
232/** Claude Code shows "esc to interrupt" only while a turn runs. */
233export const isWorking = (capture: string) => /esc to interrupt/.test(capture)
234
235/** The full model id from a worker's startup banner ("deepseek/deepseek-v4.1-flash · API Usage Billing"). */
236export const bannerModel = (capture: string) => capture.match(/([a-z0-9][\w.-]*\/[\w.:-]+) · API Usage Billing/i)?.[1] ?? null
237
238/** Pane metadata every session can read: tmux options set at launch, tab-separated by list-panes. */
239export const META_FORMAT = '#{pane_id}\t#{@cw}\t#{@cw_model}\t#{@cw_mode}\t#{@cw_start}\t#{@cw_cwd}\t#{@cw_task}'
240export type PaneMeta = { pane: string; name: string; model: string; mode: string; start: number | null; cwd: string; task: string }
241export function parseMeta(stdout: string): PaneMeta[] {
242 return stdout
243 .split('\n')
244 .map(l => l.split('\t'))
245 .filter(f => f[0]?.startsWith('%') && f[1])
246 .map(([pane, name, model, mode, start, cwd, ...task]) => ({
247 pane: pane!, name: name!, model: model ?? '', mode: mode ?? '',
248 start: start && /^\d+$/.test(start) ? Number(start) : null, cwd: cwd ?? '', task: task.join('\t'),
249 }))
250}
251types/index.d.ts 58 lines1/** One OpenRouter model that takes tools, prices in USD per million tokens. */
2export type OrModel = { id: string; inPerM: number; outPerM: number; cachePerM?: number; contextTokens?: number }
3export type Mode = 'acceptEdits' | 'auto' | 'default'
4export type Launch = {
5 name: string
6 model: string
7 mode: Mode
8 pane: string
9 task: string
10 isLive: boolean
11 startedAt?: number
12 /** Where it ran, to find its transcript. */
13 cwd?: string
14 /** Its transcript, once found. */
15 transcript?: string
16 /** When the pane was first seen gone. */
17 endedAt?: number
18}
19
20/** Read from a worker's transcript. */
21export type WStats = { tokens: number; cost: number | null; ctxPct: number | null }
22
23/** A live worker pane in tmux session `cw`, joined with what this mod launched there (if it did). */
24export type Active = { pane: string; name: string; launch: Launch | null; peek: string[]; isClosing: boolean; isBusy: boolean }
25
26/** One message to or from a worker, as this session saw it. */
27export type Msg = { dir: 'in' | 'out'; peer: string; text: string; at: number; isFailed?: boolean }
28
29export type Picker = {
30 model: string
31 mode: Mode
32 query: string
33 /** Fetched when the pane opens; empty until then or when offline. */
34 models: OrModel[]
35 recent: string[]
36 /** Starred models, kept across sessions in $.store; the first is the default. */
37 pins: string[]
38 launched: Launch[]
39 flash: { text: string; isError: boolean } | null
40 /** Refreshed from tmux every few seconds. */
41 active: Active[]
42 /** Panes sent /exit, awaiting the force-kill check. */
43 closing: string[]
44 /** Launch the next worker as a team member (teammates + lead in its prompt). */
45 team: boolean
46 /** Newest last, capped. */
47 messages: Msg[]
48 /** Per worker name. */
49 stats: Record<string, WStats>
50 showFinished: boolean
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'cheap-worker': { picker: Picker }
56 }
57}
58