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.

A Claude Code mod in which Claude plans and reviews while Command Code (cmdc) writes the code.
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./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).The loop is built to spend as few Claude requests as possible: Claude reads no code up front and runs no checks itself.
verify command.verify, and on failure hands the output back to cmdc to fix, up to autoFix times.verify result in one step.resume: true and a precise list of fixes. This repeats until the change is right.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"].
cmdc installed and signed in.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.
claude plugin validate .
claude plugin test .
cmdc runs with
--yolo: it can edit any file and run any command incwdwithout asking.
hooks/register.tsx 456 lines1import { 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}
456hooks/events.ts 131 lines1// 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}
131types/index.d.ts 61 lines1export 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