SLOPSHOPPER

cheap-worker

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

newpanespinnercommandtoaststatus
v0.1.0no licenseupdated 2026-10-10ianwilson97/claude-mods/cheap-worker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cheap-worker
│ ┃ Cheap worker ✕ › fix the failing auth test and add an audit log call │ ┃ Model │ ┃ deepseek/deepseek-v4.1-flash price ↻ refre ⏺ Read(src/auth.ts) │ ┃ unknown ⎿ Read 6 lines │ ┃ loading models… (or type a slug, Enter) ⏺ Update(src/auth.ts) │ ┃ ☆ ● deepseek/deepseek-v4.1-flash price unk ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Permissions ⎿ 3 pass, 1 fail │ ┃ [ ● edits ] [ ○ auto ] [ ○ ask ] │ ┃ auto-approves file edits, asks for the ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ rest │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Team │ ┃ [ ○ solo ] › /worker │ ┃ works alone; you can still message it from ⎿ cheap-worker: Cheap worker pane open. │ ┃ the Workers view │ ┃ │ ┃ Task │ ┃ what should the worker do? (Enter launches; │ ┃ │ ┃ ○ no live workers · view → │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ◉ worker

Draws

Pane · Cheap worker
Model deepseek/deepseek-v4.1-flash price unknown ↻ refresh loading models… (or type a slug, Enter) ☆ ● deepseek/deepseek-v4.1-flash price unknown Permissions [ ● edits ] [ ○ auto ] [ ○ ask ] auto-approves file edits, asks for the rest Team [ ○ solo ] works alone; you can still message it from the Workers view Task what should the worker do? (Enter launches; empty opens it ○ no live workers · view →
Pane · Workers
Workers ↻ ╭────────────────────────────────────────────────────────╮ │ Cost │ │ — │ ╰────────────────────────────────────────────────────────╯ Running · 0 none. Launch one from the worker button. watch live: tmux new -A -s cw · ✕ sends /exit, force-closes after 6s
Prompt hint
⟨Claude Code's own drawing⟩ ◉ worker
README

ianwilson-mods

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.

ModWhat 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-barContext-window breakdown above the prompt.
usage-meter5-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-bandFor the driver-seat workflow; skip them elsewhere.

Install

/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.

cheap-worker: two switches

Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):

OptionValues
provideropenrouter (default)Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins.
bedrockClaude 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.
launchpanes (default)All workers tiled in tmux session cw: tmux new -A -s cw.
tabsEach 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.

Reaching a worker: crew

The 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+.

First run on a new machine: checks

  1. /plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads.
  2. /worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …).
  3. Turn on team, launch two workers, then from /workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.
Source 3 files
hooks/register.tsx 744 lines
1import { 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}
744
hooks/worker.ts 251 lines
1import 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}
251
types/index.d.ts 58 lines
1/** 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