SLOPSHOPPER

cmdc-worker

Claude orchestrates, Command Code (cmdc) implements: a tool that hands a task to cmdc and returns its diff for review, with a live pane of the run.

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-04ashafizullah/cmdc-worker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cmdc-worker
│ ┃ cmdc worker ✕ › fix the failing auth test and add an audit log call │ ┃ idle │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ No runs yet. Claude hands tasks to cmdc ⏺ Update(src/auth.ts) │ ┃ here. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Clear log ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cmdc │ ⎿ cmdc-worker: cmdc worker pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · cmdc worker
idle No runs yet. Claude hands tasks to cmdc here. [ Clear log ]
README

cmdc-worker

CI License: MIT

A Claude Code mod in which Claude plans and reviews while Command Code (cmdc) writes the code.

  • Tool mcp__cmdc-worker__implement (task, cwd?, resume?, verify?): runs cmdc -p <task> --yolo --output-format json in cwd, then the verify command (e.g. npm test) if given; when verify fails it sends the failure back to cmdc's session and retries (autoFix rounds) before returning cmdc's summary, time and token figures, the verify exit code and output tail, git status, and the diff of what this call changed (shortened per file when verify passed and the diff is large; new files in full, taken from snapshots of the working tree through a private index) for Claude to review. resume: true continues cmdc's previous session through --session <id>, so fix rounds keep cmdc's context.
  • Pane "cmdc worker" (/cmdc): shows the run live with a spinner and what cmdc is doing, the run's turn, time, tokens and model, the job's totals for cmdc and for Claude (its model steps' time, tokens and cost in the turns that handed work to cmdc, in the loop that called it; the cost shows as $? when other agents ran alongside), and one line per run. Stop button (s), Clear log button (c).

Loop

The loop is built to spend as few Claude requests as possible: Claude reads no code up front and runs no checks itself.

  1. Claude writes one task from what it already knows, a whole feature rather than one small step, with a verify command.
  2. cmdc explores the code and implements it; the plugin then runs verify, and on failure hands the output back to cmdc to fix, up to autoFix times.
  3. Claude reviews the diff and the verify result in one step.
  4. If something is wrong, Claude calls again with resume: true and a precise list of fixes. This repeats until the change is right.

Settings

model (passed as --model), maxTurns (default 60) and autoFix (automatic fix rounds when verify fails, default 3; 0 turns it off), in /config or under pluginConfigs["cmdc-worker"].

Requirements

  • Claude Code with a Claude subscription (or API access).
  • Command Code with a subscription, and cmdc installed and signed in.

Load

claude --plugin-dir ~/Projects/cmdc-worker

Or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json to load it in every session.

Develop

claude plugin validate .
claude plugin test .

cmdc runs with --yolo: it can edit any file and run any command in cwd without asking.

License

MIT

Source 3 files
hooks/register.tsx 456 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Job, Run, RunKind, RunStatus, Tokens } from '../types'
5import { NO_TOKENS, addTokens, clipDiff, formatMs, formatTokens, parseLine, splitLines } from './events'
6
7const PANE = 'cmdc-worker'
8const TOOL = 'implement'
9const LOG_LIMIT = 400
10const DIFF_LIMIT = 60_000
11// The diff budget once verify passed: the review is a skim, and the report stays in Claude's context.
12const PASSED_DIFF_LIMIT = 16_000
13const VERIFY_LIMIT = 6_000
14const VERIFY_TIMEOUT_MS = 10 * 60_000
15const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
16
17const IDLE: Run = { status: 'idle', task: '', cwd: '', turns: 0 }
18const run = atom({ plugin: 'cmdc-worker', key: 'run' } as const, IDLE)
19const log = atom({ plugin: 'cmdc-worker', key: 'log' } as const, [] as string[])
20const job = atom({ plugin: 'cmdc-worker', key: 'job' } as const, null as Job | null)
21const frame = atom({ plugin: 'cmdc-worker', key: 'frame' } as const, 0)
22
23const DESCRIPTION = `Hand an implementation task to Command Code (cmdc), a separate coding agent, and get back what it changed and whether it passes.
24
25You are the planner and reviewer; cmdc is the implementer. The point is to spend as few of your own requests as possible, so:
261. Do not explore the code first: cmdc reads the codebase itself. Write ONE task per call from what you already know: the goal, the expected behaviour, the constraints (what not to touch, patterns to follow), and any files or functions you already know matter.
272. Make each call count: every call costs you at least two requests whatever its size, so hand over a whole feature, or several related changes, in one task rather than one small step at a time.
283. Call this tool with \`verify\` set to the command that proves the change (tests, typecheck, lint). cmdc runs headless with all permissions (--yolo) in \`cwd\`; the person watches it live in the "cmdc worker" pane. After cmdc finishes the plugin runs \`verify\`; when it fails, the plugin sends the failure back to cmdc and retries on its own a few times before reporting to you.
294. Review the report in one go: the diff (only what this call changed, new files in full; shortened per file when verify passed and the diff is large) and the \`verify\` result. Check that automatic fix rounds did not weaken or delete tests. Do not rerun the tests or reread the files unless the report leaves a real doubt. Do not trust cmdc's own summary.
305. If anything is wrong or missing, call again with \`resume: true\` and a precise list of fixes (file, line, what is wrong, what you expect). Once the change is right, report to the person, including the time and token figures from the report.
31
32For a small edit you can make in one or two steps yourself, make it yourself instead. Do not make cmdc's edits yourself unless cmdc fails repeatedly on the same point. One run at a time.`
33
34type Input = { task?: unknown; cwd?: unknown; resume?: unknown; verify?: unknown }
35
36const clip = (text: string, max: number) =>
37  text.length > max ? `${text.slice(0, max)}\n… (${text.length - max} more characters cut; read the files directly)` : text
38
39async function git($: EngineInterface, cwd: string, args: string[], env?: Record<string, string>): Promise<string> {
40  try {
41    const { exitCode, stdout, stderr } = await $.process.run(['git', ...args], { cwd, env, timeoutMs: 30_000 })
42    return exitCode === 0 ? stdout : `(git ${args[0]} failed: ${stderr.trim()})`
43  } catch (error) {
44    return `(git ${args[0]} failed: ${String(error)})`
45  }
46}
47
48/** Runs the verify command and gives its exit code and output, the tail kept when long. */
49async function verify($: EngineInterface, cwd: string, command: string): Promise<{ code: number | null; output: string }> {
50  try {
51    const { exitCode, stdout, stderr } = await $.process.run(['sh', '-c', command], { cwd, timeoutMs: VERIFY_TIMEOUT_MS })
52    const output = [stdout, stderr].filter(text => text.trim()).join('\n').trim()
53    return { code: exitCode, output: output.length > VERIFY_LIMIT ? `… (${output.length - VERIFY_LIMIT} earlier characters cut)\n${output.slice(-VERIFY_LIMIT)}` : output }
54  } catch (error) {
55    return { code: null, output: `(could not run: ${String(error)})` }
56  }
57}
58
59const fixTask = (command: string, check: { code: number | null; output: string }) =>
60  `The verify command \`${command}\` failed (exit ${check.code ?? 'none'}). Fix the code so it passes. Do not weaken, skip or delete tests to make them pass. Its output:\n\n${check.output || '(no output)'}`
61
62/**
63 * The working tree as a git tree object, untracked files included (ignored ones not),
64 * written through a private index so the person's staging area is left alone.
65 */
66async function snapshot($: EngineInterface, cwd: string): Promise<string | undefined> {
67  const gitDir = (await git($, cwd, ['rev-parse', '--absolute-git-dir'])).trim()
68  if (!gitDir || gitDir.startsWith('(')) return undefined
69  const env = { GIT_INDEX_FILE: `${gitDir}/cmdc-worker.index` }
70  const head = await git($, cwd, ['read-tree', 'HEAD'], env)
71  if (head.startsWith('(')) await git($, cwd, ['read-tree', '--empty'], env)
72  if ((await git($, cwd, ['add', '-A'], env)).startsWith('(')) return undefined
73  const tree = (await git($, cwd, ['write-tree'], env)).trim()
74  return /^[0-9a-f]{40,64}$/.test(tree) ? tree : undefined
75}
76
77const elapsed = (from?: number, to?: number) => (from === undefined ? '' : formatMs((to ?? Date.now()) - from))
78
79const tokensOf = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Tokens => {
80  const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
81  return { input, output: u.output_tokens, cacheRead: u.cache_read_input_tokens, context: input }
82}
83
84async function sessionUsd($: EngineInterface): Promise<number> {
85  try {
86    return (await $.session.usage()).cost?.usd ?? 0
87  } catch {
88    return 0
89  }
90}
91
92// The running child's stream, so Stop can end it. Lost on reload, as is the child.
93let current: AsyncIterator<unknown> | undefined
94// Spins the pane's spinner while a run is going.
95let ticker: Timer | undefined
96type Spent = { turnId: string; ms: number; tokens: Tokens; usd: number; usdShared?: boolean }
97// Claude's model steps in each loop's current turn (main, or a subagent's run), so a job
98// started mid-turn counts the planning before it. Keyed by the loop's agent id, '' for main.
99const loops = new Map<string, Spent>()
100// The session cost after the last model step of any loop. The session's ledger is the only
101// source of cost and is shared by every loop, so a cost read while another loop was stepping
102// may hold that loop's spend too; such a figure is flagged rather than shown.
103let lastUsd = 0
104
105const loopOf = (agentId?: string) => agentId ?? ''
106
107const usdOf = (j: Job) => (j.usdShared ? '$? (other agents ran alongside, so the session cost cannot be split)' : `$${j.claudeUsd.toFixed(2)}`)
108
109async function append($: EngineInterface, lines: string[]) {
110  if (lines.length === 0) return
111  await update($, log, list => [...(list ?? []), ...lines].slice(-LOG_LIMIT))
112}
113
114function stopTicker() {
115  ticker?.cancel()
116  ticker = undefined
117}
118
119async function stop($: EngineInterface) {
120  if (!current) return
121  const stream = current
122  current = undefined
123  stopTicker()
124  await stream.return?.()
125  await update($, run, r => ({ ...(r ?? IDLE), status: 'stopped' as RunStatus, endedAt: Date.now() }))
126  await append($, ['■ stopped'])
127}
128
129/** Starts a job for a new task, or brings back the current one for a fix round in a later turn. */
130async function joinJob($: EngineInterface, isFix: boolean, loop: string) {
131  const spent = loops.get(loop)
132  const since = { claudeMs: spent?.ms ?? 0, claude: spent?.tokens ?? NO_TOKENS, claudeUsd: spent?.usd ?? 0, usdShared: !!spent?.usdShared }
133  const existing = await read($, job)
134  if (isFix && existing?.active) return
135  if (isFix && existing) {
136    await update($, job, j =>
137      j && {
138        ...j,
139        active: true,
140        loop,
141        claudeMs: j.claudeMs + since.claudeMs,
142        claude: addTokens(j.claude, since.claude),
143        claudeUsd: j.claudeUsd + since.claudeUsd,
144        usdShared: j.usdShared || since.usdShared,
145      },
146    )
147    return
148  }
149  await update($, job, () => ({ startedAt: Date.now(), active: true, loop, cmdcMs: 0, cmdc: NO_TOKENS, runs: [], ...since }))
150}
151
152/** One cmdc process: streams its events into the pane and the job, and gives what it ended with. */
153async function runCmdc(
154  $: EngineInterface,
155  args: { task: string; cwd: string; resumeId?: string; kind: RunKind; signal?: AbortSignal; model: string; maxTurns: number },
156) {
157  const { task, cwd, resumeId, kind, signal, model, maxTurns } = args
158  const argv = [
159    'cmdc', '-p', task,
160    '--output-format', 'json',
161    '--yolo', '-t', '--skip-onboarding', '--no-auto-update',
162    '--max-turns', String(maxTurns),
163    ...(model ? ['--model', model] : []),
164    ...(resumeId ? ['--session', resumeId] : []),
165  ]
166
167  const startedAt = Date.now()
168  await update($, run, () => ({ status: 'running' as RunStatus, task, cwd, sessionId: resumeId, startedAt, turns: 0, activity: 'starting', tokens: NO_TOKENS }))
169  await append($, ['', `━━ ${kind} · ${new Date(startedAt).toLocaleTimeString()} ━━`, ...task.split('\n').slice(0, 6).map(row => `» ${row}`)])
170  void $.ui.open({ id: PANE, title: 'cmdc worker' })
171  $.ui.status('cmdc: running')
172  stopTicker()
173  ticker = $.clock.every(120, () => void update($, frame, f => ((f ?? 0) + 1) % SPINNER.length))
174
175  let sessionId = resumeId
176  let finalText: string | undefined
177  let stopReason: string | undefined
178  let stderr = ''
179  let buffer = ''
180  let tokens = NO_TOKENS
181  let turns = 0
182  let exit: { code: number | null; signal: string | null } | undefined
183
184  const iterator = $.process.spawn({ argv, cwd })[Symbol.asyncIterator]()
185  current = iterator
186  const onAbort = () => void stop($)
187  signal?.addEventListener('abort', onAbort)
188
189  try {
190    while (true) {
191      const step = await iterator.next()
192      if (step.done) {
193        exit = step.value as typeof exit
194        break
195      }
196      const chunk = step.value
197      if (chunk.stream === 'stderr') {
198        stderr = (stderr + chunk.text).slice(-4000)
199        continue
200      }
201      const { complete, rest } = splitLines(buffer + chunk.text)
202      buffer = rest
203      const lines: string[] = []
204      let activity: string | undefined
205      let usedModel: string | undefined
206      for (const raw of complete) {
207        const parsed = parseLine(raw)
208        lines.push(...parsed.lines)
209        sessionId = parsed.sessionId ?? sessionId
210        finalText = parsed.finalText ?? finalText
211        stopReason = parsed.stopReason ?? stopReason
212        turns = parsed.turn ?? turns
213        activity = parsed.activity ?? activity
214        usedModel = parsed.model ?? usedModel
215        if (parsed.usage) tokens = addTokens(tokens, parsed.usage)
216      }
217      await update($, run, r => ({
218        ...(r ?? IDLE),
219        turns,
220        sessionId,
221        tokens,
222        activity: activity ?? r?.activity,
223        model: usedModel ?? r?.model,
224      }))
225      await append($, lines)
226    }
227  } catch (error) {
228    stderr += `\n${String(error)}`
229  } finally {
230    signal?.removeEventListener('abort', onAbort)
231  }
232
233  stopTicker()
234  const wasStopped = current === undefined
235  current = undefined
236  const status: RunStatus = wasStopped ? 'stopped' : exit?.code === 0 ? 'done' : 'failed'
237  const endedAt = Date.now()
238  const ms = endedAt - startedAt
239  await update($, run, r => ({ ...(r ?? IDLE), status, sessionId, finalText, endedAt, activity: undefined }))
240  await update($, job, j => j && { ...j, cmdcMs: j.cmdcMs + ms, cmdc: addTokens(j.cmdc, tokens), runs: [...j.runs, { kind, status, ms, turns, tokens }] })
241  await append($, [`${status === 'done' ? '✓' : '✗'} ${status} in ${formatMs(ms)} · ${formatTokens(tokens)}${exit?.code ? ` (exit ${exit.code})` : ''}`])
242  $.ui.status(undefined)
243  return { status, sessionId, finalText, stopReason, stderr, tokens, turns, exit, ms }
244}
245
246async function runVerify($: EngineInterface, cwd: string, command: string) {
247  $.ui.status('cmdc: verifying')
248  const check = await verify($, cwd, command)
249  $.ui.status(undefined)
250  await append($, [`${check.code === 0 ? '✓' : '✗'} verify ${command} (exit ${check.code ?? 'none'})`])
251  return check
252}
253
254export const register: Register = (on, options) => {
255  const model = typeof options.model === 'string' ? options.model.trim() : ''
256  const maxTurns = typeof options.maxTurns === 'number' && options.maxTurns > 0 ? options.maxTurns : 60
257  const autoFix = typeof options.autoFix === 'number' && options.autoFix >= 0 ? Math.floor(options.autoFix) : 3
258
259  on('session.start', async ($, e, next) => {
260    await $.command.register({ name: 'cmdc', description: 'Open the cmdc worker pane' })
261    await $.tool.register({
262      name: TOOL,
263      description: DESCRIPTION,
264      inputSchema: {
265        type: 'object',
266        properties: {
267          task: { type: 'string', description: 'The full, self-contained instructions for cmdc.' },
268          cwd: { type: 'string', description: 'Directory cmdc works in; default the session directory.' },
269          resume: {
270            type: 'boolean',
271            description: "Continue cmdc's previous session (it keeps its context) — use for review fixes.",
272          },
273          verify: {
274            type: 'string',
275            description: 'Shell command run in `cwd` after cmdc finishes (e.g. `npm test`); its exit code and output tail go in the report.',
276          },
277        },
278        required: ['task'],
279      },
280    })
281    return next(e)
282  })
283
284  on('command.run', { command: 'cmdc' }, async $ => {
285    await $.ui.open({ id: PANE, title: 'cmdc worker' })
286    return { text: 'cmdc worker pane opened.' }
287  })
288
289  // Claude's side: each model step's own time (tools, cmdc included, run outside it) and usage,
290  // counted for the loop that called cmdc only; other loops step past the job.
291  on('turn.step', async function* ($, e, next) {
292    const started = Date.now()
293    const before = await sessionUsd($)
294    const result = yield* next(e)
295    const ms = Date.now() - started
296    const tokens = result?.usage ? tokensOf(result.usage) : NO_TOKENS
297    const usd = await sessionUsd($)
298    const cost = Math.max(0, usd - Math.max(before, lastUsd))
299    lastUsd = Math.max(lastUsd, usd)
300    const loop = loopOf(e.agentId)
301    const prior = loops.get(loop)
302    const spent = prior?.turnId === e.turnId ? prior : { turnId: e.turnId, ms: 0, tokens: NO_TOKENS, usd: 0 }
303    for (const [other, s] of loops) if (other !== loop) s.usdShared = true
304    loops.set(loop, { ...spent, ms: spent.ms + ms, tokens: addTokens(spent.tokens, tokens), usd: spent.usd + cost })
305    const j = await read($, job)
306    if (j?.active && (j.loop ?? '') === loop) {
307      await update($, job, j => j && { ...j, claudeMs: j.claudeMs + ms, claude: addTokens(j.claude, tokens), claudeUsd: j.claudeUsd + cost })
308    } else if (j?.active && !j.usdShared) {
309      await update($, job, j => j && { ...j, usdShared: true })
310    }
311    return result
312  })
313
314  on('turn.complete', async ($, e, next) => {
315    const loop = loopOf(e.agentId)
316    if (loops.get(loop)?.turnId === e.turnId) loops.delete(loop)
317    const j = await read($, job)
318    if (j?.active && (j.loop ?? '') === loop) await update($, job, j => j && { ...j, active: false })
319    return next(e)
320  })
321
322  on('tool.call', { tool: 'mcp__cmdc-worker__implement' }, async ($, e, next) => {
323    const input = e as unknown as Input
324    const task = typeof input.task === 'string' ? input.task.trim() : ''
325    if (!task) return { deny: 'task is required.' }
326    const verifyCommand = typeof input.verify === 'string' ? input.verify.trim() : ''
327    if (current) return { deny: 'A cmdc run is already in progress; wait for it or stop it in the pane.' }
328
329    const cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : await $.session.cwd()
330    const previous = await read($, run)
331    const resumeId = input.resume === true ? previous.sessionId : undefined
332    if (input.resume === true && !resumeId) return { deny: 'No previous cmdc session to resume; call without resume.' }
333    await joinJob($, !!resumeId, loopOf(e.agentId))
334
335    const before = await snapshot($, cwd)
336    const first = await runCmdc($, { task, cwd, resumeId, kind: resumeId ? 'fix' : 'task', signal: next.signal, model, maxTurns })
337    const attempts = [first]
338    let check = verifyCommand && first.status !== 'stopped' ? await runVerify($, cwd, verifyCommand) : undefined
339    while (check && check.code !== 0 && attempts.length <= autoFix) {
340      const last = attempts[attempts.length - 1]
341      if (last.status === 'stopped' || !last.sessionId) break
342      await append($, [`↻ verify failed: auto-fix ${attempts.length}/${autoFix}`])
343      const fix = await runCmdc($, { task: fixTask(verifyCommand, check), cwd, resumeId: last.sessionId, kind: 'auto-fix', signal: next.signal, model, maxTurns })
344      attempts.push(fix)
345      if (fix.status === 'stopped') break
346      check = await runVerify($, cwd, verifyCommand)
347    }
348    const last = attempts[attempts.length - 1]
349    const ms = attempts.reduce((sum, a) => sum + a.ms, 0)
350    const turns = attempts.reduce((sum, a) => sum + a.turns, 0)
351    const tokens = attempts.reduce((sum, a) => addTokens(sum, a.tokens), NO_TOKENS)
352    const autoFixes = attempts.length - 1
353    $.ui.toast(`cmdc ${last.status}${check ? `, verify ${check.code === 0 ? 'passed' : 'failed'}` : ''}`)
354
355    const after = await snapshot($, cwd)
356    const range = before && after ? [before, after] : []
357    const [statusShort, stat, diff] = await Promise.all([
358      git($, cwd, ['status', '--short']),
359      git($, cwd, ['diff', '--stat', ...range]),
360      git($, cwd, ['diff', ...range]),
361    ])
362    const scope = range.length ? 'what this call changed, new files in full' : 'tracked changes against the index; read untracked (??) files directly'
363    const passed = check?.code === 0
364    const shown = clipDiff(diff, passed ? PASSED_DIFF_LIMIT : DIFF_LIMIT)
365
366    const j = await read($, job)
367    const report = [
368      `cmdc ${last.status} after ${formatMs(ms)} (exit ${last.exit?.code ?? 'none'}${last.stopReason ? `, ${last.stopReason}` : ''})${autoFixes ? `, ${autoFixes} automatic fix round${autoFixes === 1 ? '' : 's'}` : ''}. Session ${last.sessionId ?? 'unknown'}.`,
369      `This call: ${turns} turns, ${formatTokens(tokens)} tokens.`,
370      ...(j
371        ? [`Job so far (${j.runs.length} run${j.runs.length === 1 ? '' : 's'}): cmdc ${formatMs(j.cmdcMs)}, ${formatTokens(j.cmdc)}; Claude ${formatMs(j.claudeMs)}, ${formatTokens(j.claude)}, ${usdOf(j)}.`]
372        : []),
373      '',
374      "## cmdc's own summary (verify, don't trust)",
375      last.finalText?.trim() || '(none)',
376      ...(last.status === 'failed' ? ['', '## stderr (tail)', last.stderr.trim() || '(empty)'] : []),
377      ...(check
378        ? ['', `## verify: ${verifyCommand} (${passed ? 'passed' : `failed, exit ${check.code ?? 'none'}`}${autoFixes ? ` after ${autoFixes} automatic fix round${autoFixes === 1 ? '' : 's'}` : ''})`, check.output || '(no output)']
379        : ['', '## verify', "(none given: run the checks yourself, and pass `verify` next time)"]),
380      '',
381      '## git status --short',
382      statusShort.trim() || '(clean)',
383      '',
384      `## git diff --stat (${scope})`,
385      stat.trim() || '(no changes)',
386      '',
387      `## git diff${shown.length < diff.length ? ' (shortened per file)' : ''}`,
388      shown || '(empty)',
389      '',
390      'Now review this against your instructions in one step. If anything is off, call again with resume: true and a precise fix list.',
391    ].join('\n')
392
393    return { result: report }
394  })
395
396  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
397    const { Box, Text, Button } = $.ui.resolve(e)
398    const r = await read($, run)
399    const lines = await read($, log)
400    const j = await read($, job)
401    const f = await read($, frame)
402    const running = r.status === 'running'
403    const runs = j?.runs ?? []
404    const room = Math.max(3, (e.viewport?.rows ?? 24) - 9 - Math.min(runs.length, 5))
405    const color = running ? 'yellow' : r.status === 'done' ? 'green' : r.status === 'idle' ? undefined : 'red'
406    const firstLine = r.task.split('\n')[0] ?? ''
407    const liveMs = running && r.startedAt !== undefined ? Date.now() - r.startedAt : 0
408    const liveTokens = running ? r.tokens ?? NO_TOKENS : NO_TOKENS
409
410    return (
411      <Box flexDirection="column">
412        <Box flexDirection="row" gap={1}>
413          {running && <Text color="yellow">{SPINNER[f % SPINNER.length]}</Text>}
414          <Text bold color={color}>{r.status}</Text>
415          {running && r.activity && <Text wrap="truncate-end">{r.activity}</Text>}
416        </Box>
417        {r.status !== 'idle' && (
418          <Text dimColor wrap="truncate-end">
419            run · turn {r.turns} · {elapsed(r.startedAt, running ? undefined : r.endedAt)} · {formatTokens(r.tokens ?? NO_TOKENS)}
420            {r.model ? ` · ${r.model}` : ''}
421          </Text>
422        )}
423        {j && (
424          <Box flexDirection="row" gap={2}>
425            <Text wrap="truncate-end">
426              <Text color="cyan">cmdc</Text> {formatMs(j.cmdcMs + liveMs)} · {formatTokens(addTokens(j.cmdc, liveTokens))}
427            </Text>
428            <Text wrap="truncate-end">
429              <Text color="magenta">Claude</Text> {formatMs(j.claudeMs)} · {formatTokens(j.claude)} · {j.usdShared ? '$?' : `$${j.claudeUsd.toFixed(2)}`}
430            </Text>
431            <Text dimColor>total {formatMs(j.cmdcMs + liveMs + j.claudeMs)}</Text>
432          </Box>
433        )}
434        {runs.slice(-5).map((s, i) => (
435          <Text dimColor wrap="truncate-end">
436            {runs.length - Math.min(runs.length, 5) + i + 1}. {s.kind} {s.status === 'done' ? '✓' : '✗'} {formatMs(s.ms)} · {s.turns} turns · {formatTokens(s.tokens)}
437          </Text>
438        ))}
439        {firstLine && <Text dimColor wrap="truncate-end">» {firstLine}</Text>}
440        <Box flexDirection="column" marginTop={1}>
441          {lines.length === 0 && <Text dimColor>No runs yet. Claude hands tasks to cmdc here.</Text>}
442          {lines.slice(-room).map(line => (
443            <Text dimColor={line.startsWith('  ')} color={line.startsWith('✗') || line.startsWith('  ✗') ? 'red' : undefined} wrap="truncate-end">
444              {line || ' '}
445            </Text>
446          ))}
447        </Box>
448        <Box flexDirection="row" gap={1} marginTop={1}>
449          {running && <Button key="stop" hotkey="s" variant="primary" onPress={() => stop($)}>Stop</Button>}
450          <Button key="clear" hotkey="c" onPress={() => update($, log, () => [])}>Clear log</Button>
451        </Box>
452      </Box>
453    )
454  })
455}
456
hooks/events.ts 131 lines
1// Turns cmdc's `--output-format json` NDJSON stream into readable log lines.
2
3export type CmdcLine =
4  | { type: 'event'; event: { type: string; [key: string]: unknown } }
5  | { type: 'result'; subtype?: string; sessionId?: string; finalText?: string; stopReason?: string }
6
7import type { Tokens } from '../types'
8
9export type Parsed = {
10  lines: string[]
11  sessionId?: string
12  finalText?: string
13  stopReason?: string
14  turn?: number
15  activity?: string
16  /** One model request's usage; counted once per request, never from turn or run totals. */
17  usage?: Tokens
18  model?: string
19}
20
21export const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, context: 0 }
22
23/** Sums the counts; `context` is the later one's, as each request resends the whole context. */
24export const addTokens = (a: Tokens, b: Tokens): Tokens => ({
25  input: a.input + b.input,
26  output: a.output + b.output,
27  cacheRead: a.cacheRead + b.cacheRead,
28  context: b.context || a.context || 0,
29})
30
31const count = (n: number) =>
32  n < 1000 ? String(n) : n < 1_000_000 ? `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k` : `${(n / 1_000_000).toFixed(1)}M`
33
34/**
35 * `↑176k new + 4.2M cached · ctx 120k ↓37k`: input split into uncached and cache reads
36 * (summed over requests), then the last request's context size.
37 */
38export function formatTokens(t: Tokens): string {
39  const cached = t.cacheRead > 0 ? ` + ${count(t.cacheRead)} cached` : ''
40  const context = t.context ? ` · ctx ${count(t.context)}` : ''
41  return `↑${count(Math.max(0, t.input - t.cacheRead))} new${cached}${context} ↓${count(t.output)}`
42}
43
44export function formatMs(ms: number): string {
45  const seconds = Math.round(ms / 1000)
46  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
47}
48
49const clip = (text: string, max: number) =>
50  text.length > max ? `${text.slice(0, max - 1)}…` : text
51
52const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
53
54// The argument most worth showing for a tool call: a path, a command, a pattern.
55function toolArg(input: unknown): string {
56  if (input === null || typeof input !== 'object') return ''
57  const fields = input as Record<string, unknown>
58  for (const key of ['file_path', 'path', 'command', 'pattern', 'query', 'url']) {
59    const value = fields[key]
60    if (typeof value === 'string') return clip(oneLine(value), 90)
61  }
62  return ''
63}
64
65/** Splits buffered stdout into complete lines, returning the unfinished tail. */
66export function splitLines(buffer: string): { complete: string[]; rest: string } {
67  const parts = buffer.split('\n')
68  const rest = parts.pop() ?? ''
69  return { complete: parts.filter(line => line.trim() !== ''), rest }
70}
71
72export function parseLine(raw: string): Parsed {
73  let line: CmdcLine
74  try {
75    line = JSON.parse(raw) as CmdcLine
76  } catch {
77    return { lines: [clip(oneLine(raw), 160)] }
78  }
79
80  if (line.type === 'result') {
81    return { lines: [], sessionId: line.sessionId, finalText: line.finalText, stopReason: line.stopReason }
82  }
83
84  const ev = line.event
85  switch (ev.type) {
86    case 'run_start':
87      return { lines: ['● run started'], sessionId: ev.sessionId as string | undefined }
88    case 'turn_start':
89      return { lines: [], turn: ev.turnNumber as number, activity: 'thinking' }
90    case 'tool_queued': {
91      const arg = toolArg(ev.input)
92      const call = `${String(ev.toolName)}${arg ? ` ${arg}` : ''}`
93      return { lines: [`▸ ${call}`], activity: call }
94    }
95    case 'model_request_end': {
96      const u = ev.usage as Partial<Record<'inputTokens' | 'outputTokens' | 'cacheReadTokens', number>> | undefined
97      const usage = u && { input: u.inputTokens ?? 0, output: u.outputTokens ?? 0, cacheRead: u.cacheReadTokens ?? 0, context: u.inputTokens ?? 0 }
98      return { lines: [], usage, model: typeof ev.model === 'string' ? ev.model : undefined }
99    }
100    case 'tool_completed': {
101      const result = ev.result as { type: string; text?: string }[] | undefined
102      const text = result?.find(block => block.type === 'text')?.text ?? ''
103      const isError = ev.isError === true || /^error/i.test(text)
104      return { lines: isError ? [`  ✗ ${clip(oneLine(text), 140)}`] : [] }
105    }
106    case 'message_end': {
107      const content = ev.content as { type: string; text?: string }[] | undefined
108      const text = content?.filter(block => block.type === 'text').map(block => block.text ?? '').join('') ?? ''
109      return { lines: text.trim() ? text.trim().split('\n').map(row => `  ${clip(row, 160)}`) : [] }
110    }
111    case 'error':
112      return { lines: [`✗ ${clip(oneLine(String(ev.message ?? JSON.stringify(ev))), 160)}`] }
113    case 'run_end': {
114      const result = ev.result as { finalText?: string; stopReason?: string } | undefined
115      return { lines: [], finalText: result?.finalText, stopReason: result?.stopReason }
116    }
117    default:
118      return { lines: [] }
119  }
120}
121
122/** The diff cut to `limit` characters, shared out per file so a large file cannot hide the rest. */
123export function clipDiff(diff: string, limit: number): string {
124  if (diff.length <= limit) return diff
125  const files = diff.split(/(?=^diff --git )/m)
126  const share = Math.max(1_500, Math.floor(limit / files.length))
127  return files
128    .map(file => (file.length > share ? `${file.slice(0, share)}\n… (${file.length - share} more characters of this file cut; read it directly)\n` : file))
129    .join('')
130}
131
types/index.d.ts 61 lines
1export type RunStatus = 'idle' | 'running' | 'done' | 'failed' | 'stopped'
2
3/**
4 * Token counts summed over model requests; `input` includes the cached part, `cacheRead`.
5 * `context` is the last request's input, the size of the context it sent.
6 */
7export type Tokens = { input: number; output: number; cacheRead: number; context?: number }
8
9export type Run = {
10  status: RunStatus
11  task: string
12  cwd: string
13  sessionId?: string
14  startedAt?: number
15  endedAt?: number
16  turns: number
17  finalText?: string
18  /** What cmdc is doing now: "thinking", or the tool call it queued last. */
19  activity?: string
20  model?: string
21  tokens?: Tokens
22}
23
24/** What started a run: Claude's task, Claude's fix list, or the plugin after verify failed. */
25export type RunKind = 'task' | 'fix' | 'auto-fix'
26
27/** One cmdc run of a job. */
28export type RunSummary = {
29  kind: RunKind
30  status: RunStatus
31  ms: number
32  turns: number
33  tokens: Tokens
34}
35
36/**
37 * A task and its fix rounds: cmdc's runs and Claude's planning and reviews. Claude's
38 * side counts its model steps in the turns that handed work to cmdc, in the loop that did.
39 */
40export type Job = {
41  startedAt: number
42  /** Whether Claude's steps count toward this job right now. */
43  active: boolean
44  /** The loop whose steps count: the agent id of the subagent that called cmdc, '' for main. */
45  loop?: string
46  cmdcMs: number
47  cmdc: Tokens
48  claudeMs: number
49  claude: Tokens
50  claudeUsd: number
51  /** Another loop stepped during the job, so `claudeUsd` may hold its spend too. */
52  usdShared?: boolean
53  runs: RunSummary[]
54}
55
56declare module 'claude-code' {
57  interface PluginState {
58    'cmdc-worker': { run: Run; log: string[]; job: Job | null; frame: number }
59  }
60}
61