Token-light team companion: capped agent teams, risk-based review, disciplined debugging.

<img src="docs/assets/logo-light.svg" alt="Claude Team Kit: Native agent teams. Under control." width="520">
<h2 align="center">Build with a team. Stay in control.</h2>
Turn complex tasks into coordinated Claude Code agent teams.<br> Set hard limits, watch teammates work, and follow task dependencies live.
<a href="#install"><b>Install CTK</b></a> · <a href="#mission-control"><b>Watch Mission Control</b></a> · <a href="#how-ctk-works"><b>How it works</b></a>
<a href="https://github.com/tc3oliver/claude-team-kit/actions/workflows/ci.yml"><img src="https://github.com/tc3oliver/claude-team-kit/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
<img src="docs/assets/mission-control-f.svg" alt="Recording of a real Claude Code session: one plain sentence starts a team, three teammates work, and a click on the CTK line above the prompt opens Mission Control beside the transcript with workers, a task graph whose nodes turn from ready to running to done, and usage" width="900">
One goal, several teammates, clear tasks and dependencies. Claude Code's own Agent Teams do the work; CTK's skill guides the lead to split the goal into verifiable tasks and to check the result before calling it done.
Choose how many native teammates may be live at once (default 5, from 1 to 12). A spawn above the limit is refused and its task stays pending. Claude Code itself documents no such limit.
See workers, tasks, dependencies and usage without leaving Claude Code. It is read-only: it never starts, stops or changes anything.
Two ways, both through Claude Code's own Plugin Manager.
Paste this into Claude Code (or another coding agent that can run shell commands):
Install Claude Team Kit (CTK), a Claude Code plugin, by following the checklist at
https://raw.githubusercontent.com/tc3oliver/claude-team-kit/main/INSTALL.md
Rules: use only Claude Code's native Plugin Manager (`claude plugin ...`); no other installer, no `curl | bash`,
no `npm install`. Check `claude --version` and any existing CTK install first. Show me the exact change to
settings.json and wait for my yes before editing it; merge only, and keep my other plugins, MCP servers, hooks and
settings. Do not uninstall or disable anything else, including OMC. Tell me which steps only I can run
(`/reload-plugins` or a restart, then `/ctk-doctor`) and what the result should look like.
The agent follows INSTALL.md: it installs with the two commands below, adds the Agent Teams setting only after you agree, and asks you to reload and run /ctk-doctor.
In Claude Code:
/plugin marketplace add tc3oliver/claude-team-kit
/plugin install ctk@ctk-kit
Then run /reload-plugins (or restart). CTK needs Claude Code 2.1.287 or newer.
Turn on Agent Teams. They are experimental and off by default, and a plugin cannot switch them on. Add this to the env object in ~/.claude/settings.json (keep your other settings), then restart:
"env": { "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" }
Run /ctk-doctor to check the setup: it changes nothing and prints the exact fix for anything missing. Details, Claude 5.x notes and options: Installation.
Ask in plain words:
Use a team to implement this feature, write tests, and review the result.
Or be explicit with /ctk:team <goal>. The explicit command always loads the team skill. A plain sentence usually does too, but the model decides, so use the command when you want to be sure (how it works). Then click CTK ▸ above the prompt to open Mission Control.
<img src="docs/assets/how-it-works.svg" alt="Illustration: one goal becomes eight tasks with dependencies; only ready tasks start; three native teammates work at once and a fourth ready task waits; the lead hands the next ready task to an idle teammate; the lead runs the final verification" width="900">
Plan → Coordinate → Execute → Verify
Claude Code provides the agent team and its shared task list. CTK's skill guides the lead through the four steps, and CTK's mod enforces the limit on native teammates. CTK has no scheduler of its own. Full division of work: How CTK works.
Click the CTK ▸ line above the prompt, or run /ctk-mission. Seven views: Overview, Workers, Tasks, Usage, Config, Stats and Doctor; keys 1 to 7 switch, Esc closes. Figures come from Claude Code's own events and API, and anything CTK could not observe reads unavailable, never a made-up zero.
<a href="docs/assets/mission-control-f-workers.svg"><img src="docs/assets/mission-control-f-workers.svg" alt="Mission Control Workers view beside the transcript: three workers on Sonnet 5.5, each with its task, tool-call count, elapsed time and last activity" width="640"></a><br> <b>Live workers</b><br> <sub>Who is running what, on which model, and how recently it did something.</sub>
<a href="docs/assets/mission-control-f-poster.svg"><img src="docs/assets/mission-control-f-poster.svg" alt="Mission Control Tasks view beside the transcript: five finished tasks fan in to a running npm test task, which leads to the report task" width="640"></a><br> <b>Task dependency graph</b><br> <sub>Drawn only from the dependencies the lead declared; waiting, ready, running and done are distinct.</sub>
<a href="docs/assets/mission-control-f-usage.svg"><img src="docs/assets/mission-control-f-usage.svg" alt="Mission Control Usage view beside the transcript: 5-hour and weekly limits with reset times, context use, session cost and tool calls" width="640"></a><br> <b>Usage</b><br> <sub>5-hour and weekly limits, context and session cost, as Claude Code reports them.</sub>
designer on Opus writes UI/UX briefs; it is new on main and has not yet been part of a recorded run./ctk:review scales reviewer depth to the risk of the change./ctk:debug asks for a failing reproduction before a fix./plugin configure ctk@ctk-kit. The optional ctk CLI syncs a profile between machines through a git repository you own, with a secrets scan before every publish (Configuration)./ctk:team says the limit and the team line are off.v0.1.1. The earlier v0.1.0 predates the redesigned Mission Control, the designer agent and the default limit of 5. The install commands above follow main.More: Limitations · Architecture · Threat model
Installation · How CTK works · Mission Control · Natural language · Configuration · Architecture · Limitations · Recordings · Verification record · Comparison with OMC, superpowers and claude-hud · Coming from OMC · Rollback
Contributing · Code of Conduct · Security · Changelog · MIT License · Third-party notices
hooks/register.tsx 753 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { readOptions } from '../shared/policy.ts'
4import type { PolicyOptions } from '../shared/policy.ts'
5import { emptyStats, parseStats, safeSessionId } from '../shared/stats.ts'
6import type { StatsRecord } from '../shared/stats.ts'
7import { BAND_MARGIN, formatBand, formatSummary } from './band.ts'
8import { factsFrom, formatDoctor, isCtkStatusLine } from './doctor.ts'
9import type { Facts } from './doctor.ts'
10import { capacityDeny, CONFIG_TOOL_NAME, effectiveLive, emptySnapshot, guardDeny, settingChangeDeny, isNewToolCall, measuredOf, routed, snapshotOf, startsOutsideCap, STATUS_TOOL_NAME, teamHintFor } from './team.ts'
11import type { Snapshot } from './team.ts'
12import { cancel, confirm, describeOptions, emptyPending, OPTION_NAMES, propose, sweep, validateChange } from './config.ts'
13import type { PendingState } from './config.ts'
14import { displayWidth } from '../shared/hudline.ts'
15import { readBranch } from './branch.ts'
16import {
17 buildMission,
18 forcedTierColumns,
19 guardOf,
20 MC_PANE_ID,
21 missionJson,
22 missionText,
23 newMcState,
24 newMissionState,
25 noteSpawn,
26 noteTaskCall,
27 noteTeammateIdle,
28 noteWorkerToolCall,
29 toolLabel,
30 noteWorkerTurnEnd,
31 OPEN_KEY,
32 pressMc,
33} from './mission.ts'
34import type { McState, MissionState } from './mission.ts'
35import { renderMission } from './missionui.tsx'
36import { delayFor, motionOf, newMotionState, observe, reducedFrom } from './ui/motion.ts'
37import type { MotionState } from './ui/motion.ts'
38
39// Every host call is here because `$` may only be handed to top-level functions of this
40// file. Anything pure (cap text, routing, snapshot math, band text) is in team.ts / band.ts.
41// Each host call degrades on failure: a stats or HUD problem never touches a spawn, and
42// the cap itself fails closed.
43
44const STATS_WRITE_GAP_MS = 2000
45/** The band is redrawn for a tool call at most this often. */
46const DRAW_GAP_MS = 1000
47
48type Ctx = {
49 opts: PolicyOptions
50 stats: StatsRecord
51 snap: Snapshot
52 /** Spawns that passed the cap and have not returned yet; bumped before the first await. */
53 inflight: number
54 /** Accepted teammates (id -> accepted-at ms) the roster has not listed yet; see effectiveLive. */
55 pending: Map<string, number>
56 /** Last clock reading, the fallback timestamp if the clock fails. */
57 lastNow: number
58 /** False until the first refresh: the band draws nothing before it has figures. */
59 ready: boolean
60 dirty: boolean
61 lastWrite: number
62 lastDraw: number
63 /** tool_use_ids already counted, so one call is never counted twice. */
64 toolSeen: Set<string>
65 /** True while the CTK status line is configured: the band then leaves out what it shows. */
66 coordinated: boolean
67 /** The git branch of the session's directory, read from .git/HEAD; null when not in a repository. */
68 branch: string | null
69 /** 2 when CTK_AMBIGUOUS_WIDTH=2 (a terminal that draws │ and … two cells wide). */
70 ambiguous: 1 | 2
71 /** What Mission Control shows: workers, tasks and tool calls observed this session (memory only). */
72 mission: MissionState
73 /** Which view the pane shows and the HUD form; session-local, never saved. */
74 mc: McState
75 /** Whether the Agent Teams flag is set; null until read. */
76 teamsEnabled: boolean | null
77 /** An option change the model proposed and the person has not yet confirmed (memory only). */
78 cfg: PendingState
79 /** The last result of a confirmed or cancelled change, shown in Config. */
80 notice: string | null
81 /** True from just before a confirmed change is written until it is refused: teammate spawns wait, because the reload that follows forgets spawns in flight. */
82 applying: boolean
83 /** Mission Control's motion: highlights and the slow frame. Memory only; reduced motion keeps it static. */
84 mo: MotionState
85 /** The one pending redraw timer, armed only while the pane draws and something moves. */
86 moTimer: { cancel: () => void } | null
87 /** True from a pane draw until the pane is closed by its own button; the timer does nothing otherwise. */
88 paneOpen: boolean
89 /** True from session end: a late pane draw must not arm another timer. */
90 ended: boolean
91}
92
93async function statsPath($: EngineInterface, sessionId: string): Promise<string | null> {
94 const explicit = await $.env.get('CLAUDE_CONFIG_DIR')
95 const home = explicit ? null : (await $.env.get('HOME')) || (await $.env.get('USERPROFILE'))
96 const dir = explicit || (home ? `${home}/.claude` : null)
97 return dir === null ? null : `${dir.replace(/[\\/]+$/, '')}/ctk/stats/${safeSessionId(sessionId)}.json`
98}
99
100// Throttled to one write per STATS_WRITE_GAP_MS; a change inside the gap is written by the
101// next state change, turn end or (forced) session end. `dirty` is cleared before the write
102// so a change made during it is kept, and set again if the write fails, so the forced write
103// at session end retries it.
104async function persist($: EngineInterface, c: Ctx, force = false) {
105 if (!c.opts.recordStats || !c.dirty) return
106 try {
107 const now = await $.clock.now()
108 if (!force && now - c.lastWrite < STATS_WRITE_GAP_MS) return
109 const path = await statsPath($, c.stats.sessionId)
110 if (path === null) return
111 c.lastWrite = now
112 c.dirty = false
113 c.stats.updatedAt = now
114 try {
115 await $.fs.write(path, `${JSON.stringify(c.stats)}\n`)
116 } catch (err) {
117 c.dirty = true
118 throw err
119 }
120 } catch {
121 // A stats failure must never affect a spawn or the HUD.
122 }
123}
124
125// Re-reads .git/HEAD (two small files at most) and redraws the band when the branch changed. Never throws.
126async function refreshBranch($: EngineInterface, c: Ctx) {
127 try {
128 const branch = await readBranch(path => $.fs.read(path), await $.session.cwd())
129 if (branch === c.branch) return
130 c.branch = branch
131 c.dirty = true
132 $.ui.invalidate('ui.render')
133 } catch {
134 // No branch shown; the band is otherwise unchanged.
135 }
136}
137
138// Re-reads the roster and usage, then redraws the band and writes stats. Never throws.
139async function refresh($: EngineInterface, c: Ctx, write = true) {
140 try {
141 const [agents, usage, now, model] = await Promise.all([
142 $.agent.list().catch(() => null),
143 $.session.usage().catch(() => null),
144 $.clock.now().catch(() => 0),
145 $.session.model().catch(() => null),
146 ])
147 if (now > 0) c.lastNow = now
148 c.snap = snapshotOf(agents, usage, now)
149 if (usage !== null || model !== null) c.stats.measured = measuredOf(usage, model)
150 if (c.snap.live !== null) c.stats.peakLive = Math.max(c.stats.peakLive, c.snap.live)
151 c.ready = true
152 c.dirty = true
153 $.ui.invalidate('ui.render')
154 } catch {
155 // Band and stats are best effort.
156 }
157 await refreshBranch($, c)
158 if (write) await persist($, c)
159}
160
161// Whether the CTK status line is the configured one (any settings level) and whether the Agent Teams
162// flag is set. Best effort: unreadable settings leave the last answers, which start as "no" and
163// "unknown", so the band then shows everything and the guard reads unavailable.
164async function detectEnvironment($: EngineInterface, c: Ctx) {
165 try {
166 const [settings, flag] = await Promise.all([
167 $.settings.read().catch(() => null),
168 $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS').then(value => ({ value }), () => null),
169 ])
170 if (settings !== null) c.coordinated = isCtkStatusLine(settings)
171 c.teamsEnabled = factsFrom({ opts: c.opts, envFlag: flag, settings, toolNames: null }).teamsEnabled
172 } catch {
173 // Keep the previous answers.
174 }
175}
176
177// A tool call changed the count: redraw the band (at most once per DRAW_GAP_MS) and let the
178// throttled stats write pick it up. Never throws, never touches the call.
179async function touch($: EngineInterface, c: Ctx) {
180 try {
181 const now = await $.clock.now()
182 c.lastNow = now
183 c.dirty = true
184 if (now - c.lastDraw >= DRAW_GAP_MS) {
185 c.lastDraw = now
186 $.ui.invalidate('ui.render')
187 }
188 } catch {
189 // The count is kept; the next refresh draws it.
190 }
191 await persist($, c)
192}
193
194// Rejects when the roster or the clock cannot be read: the caller fails closed.
195async function liveTeammates($: EngineInterface, c: Ctx): Promise<number> {
196 const roster = await $.agent.list()
197 const now = await $.clock.now()
198 c.lastNow = now
199 return effectiveLive(roster, c.pending, now)
200}
201
202// What the readiness report and the status tool's preflight fields rest on. Each read
203// degrades to null on failure; nothing is written.
204async function gatherFacts($: EngineInterface, c: Ctx): Promise<Facts> {
205 const [value, settings, tools] = await Promise.all([
206 $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS').then(value => ({ value }), () => null),
207 $.settings.read().catch(() => null),
208 $.tool.list().catch(() => null),
209 ])
210 const base = factsFrom({ opts: c.opts, envFlag: value, settings, toolNames: tools === null ? null : tools.map(t => t.name) })
211 // The guard is judged from what this session saw, with the flag read just now; callers refresh the roster first.
212 const guard = guardOf(c.stats, c.snap, base.teamsEnabled, c.ready)
213 return { ...base, guard: { state: guard.state, why: guard.why }, outsideCap: c.stats.spawnsOutsideCap }
214}
215
216// A session.start that fires again (enable, worker respawn, reload) continues this
217// session's counters from its stats file instead of resetting them.
218async function boot($: EngineInterface, c: Ctx) {
219 try {
220 const now = await $.clock.now()
221 const sessionId = await $.session.id()
222 c.lastNow = now
223 c.stats = emptyStats(sessionId, c.opts.maxWorkers, now)
224 c.lastWrite = 0
225 try {
226 const path = await statsPath($, sessionId)
227 const prior = path === null ? null : parseStats(JSON.parse(await $.fs.read(path)))
228 if (prior !== null && prior.sessionId === sessionId) {
229 c.stats = {
230 ...c.stats,
231 ...prior,
232 maxWorkers: c.opts.maxWorkers,
233 workerModels: prior.workerModels ?? {},
234 measured: { ...c.stats.measured, ...prior.measured },
235 }
236 }
237 } catch {
238 // No readable prior file: start from zero.
239 }
240 } catch {
241 // Keep the placeholder session id; counters still work.
242 }
243 try {
244 c.ambiguous = (await $.env.get('CTK_AMBIGUOUS_WIDTH')) === '2' ? 2 : 1
245 } catch {
246 // Keep one-cell ambiguous characters.
247 }
248 try {
249 c.mo = newMotionState(reducedFrom(await $.env.get('CTK_REDUCED_MOTION'), await $.env.get('NO_COLOR')))
250 } catch {
251 // Keep motion on its default.
252 }
253 await detectEnvironment($, c)
254 await refresh($, c)
255}
256
257// Stops the redraw timer. Called when the pane closes and when the session ends.
258function stopMotion(c: Ctx, ended = false) {
259 if (ended) c.ended = true
260 c.moTimer?.cancel()
261 c.moTimer = null
262 c.paneOpen = false
263}
264
265// One timer at most, and only while the pane is drawing and something moves (a running worker, or a highlight
266// still to end). Each fire redraws the pane, which arms the next; a pane that is gone draws nothing, so the
267// chain ends by itself.
268function armMotion($: EngineInterface, c: Ctx, now: number) {
269 if (c.moTimer !== null || c.ended) return
270 const ms = delayFor(c.mo, missionOf(c), now)
271 if (ms === null) return
272 c.moTimer = $.clock.after(ms, () => {
273 c.moTimer = null
274 if (!c.paneOpen || c.ended) return
275 try {
276 c.mo = { ...c.mo, frame: c.mo.frame + 1 }
277 $.ui.invalidate('ui.render')
278 } catch {
279 // A redraw that cannot be asked for ends the chain; motion is decoration.
280 }
281 })
282}
283
284const noop = () => {}
285
286const missionOf = (c: Ctx) =>
287 buildMission({ stats: c.stats, snap: c.snap, state: c.mission, nowMs: c.lastNow, teamsEnabled: c.teamsEnabled, ready: c.ready })
288
289// Reads the readiness report for the Doctor view; a failure leaves a one-line reason.
290async function loadDoctor($: EngineInterface, c: Ctx) {
291 try {
292 await refresh($, c, false)
293 c.mc = { ...c.mc, doctorText: formatDoctor(await gatherFacts($, c)) }
294 } catch {
295 c.mc = { ...c.mc, doctorText: 'CTK readiness could not be read.' }
296 }
297}
298
299// Opens Mission Control. It is always called from the person's own press or command, which is what lets
300// the pane be placed at any terminal width. Resolves false when no pane could be drawn.
301async function openMission($: EngineInterface, c: Ctx): Promise<boolean> {
302 try {
303 await refresh($, c)
304 if (c.mc.view === 'doctor') await loadDoctor($, c)
305 const r = await $.ui.open({ id: MC_PANE_ID, title: 'CTK Mission Control', focus: true, closeOnEscape: true, rows: 16 })
306 return r.isPlaced
307 } catch {
308 return false
309 }
310}
311
312// A press on the band or on one of the pane's buttons. Only the band's opens something; the pane's
313// buttons change what the pane shows. A failure here is swallowed: the team is never affected.
314async function handlePress($: EngineInterface, c: Ctx, key: string) {
315 try {
316 if (key === OPEN_KEY) {
317 await openMission($, c)
318 return
319 }
320 const r = pressMc(c.mc, key)
321 c.mc = r.mc
322 if (r.effect === 'close') {
323 stopMotion(c)
324 await $.ui.close({ id: MC_PANE_ID })
325 }
326 else if (r.effect === 'doctor') await loadDoctor($, c)
327 else if (r.effect === 'refresh') await refresh($, c)
328 else if (r.effect === 'confirm' || r.effect === 'cancel') await decideChange($, c, r.effect, r.id ?? '')
329 $.ui.invalidate('ui.render')
330 } catch {
331 // Mission Control is a view; its failure changes nothing else.
332 }
333}
334
335// The person's answer to a proposed option change. Confirm is the only way a change is applied: the model
336// can only propose. Before the one write (`$.config.set`) the change is checked again against the options
337// as they are now, the setting must not be locked by managed settings, and no teammate may be starting
338// (a successful set reloads this module, which forgets spawns still in flight).
339async function decideChange($: EngineInterface, c: Ctx, answer: 'confirm' | 'cancel', id: string) {
340 const waiting = c.cfg.pending
341 if (waiting === null || waiting.id !== id) {
342 // The press was drawn for a proposal that is gone or has been replaced: it answers nothing.
343 c.notice = waiting === null ? 'Nothing is waiting for your confirmation.' : 'That proposal was replaced. Check the one now waiting; nothing changed.'
344 return
345 }
346 if (answer === 'cancel') {
347 c.cfg = cancel(c.cfg, id).state
348 c.notice = `Cancelled: ${waiting.change.text}. Nothing changed.`
349 return
350 }
351 let now: number
352 let live: number
353 try {
354 now = await $.clock.now()
355 // Spawns the roster has not listed yet are pruned here, so a settled team does not block the change.
356 live = effectiveLive(await $.agent.list(), c.pending, now)
357 } catch {
358 c.notice = 'Not applied: the clock or the roster could not be read, so CTK cannot tell the team is settled.'
359 return
360 }
361 void live
362 if (c.inflight > 0 || c.pending.size > 0) {
363 c.notice = 'Teammates are starting. Confirm again in a moment; nothing changed.'
364 return
365 }
366 const decided = confirm(c.cfg, id, now)
367 c.cfg = decided.state
368 if (decided.outcome.kind === 'expired') {
369 c.notice = 'That proposal is older than 10 minutes and was dropped. Ask again; nothing changed.'
370 return
371 }
372 if (decided.outcome.kind !== 'confirmed') return
373 const change = decided.outcome.change
374 const again = validateChange(change.name, change.value, c.opts)
375 if (!again.ok) {
376 c.notice = `Not applied: ${again.reason}`
377 return
378 }
379 try {
380 const row = (await $.config.list()).find(r => r.key === again.key)
381 if (row === undefined) {
382 c.notice = 'Not applied: this Claude Code has no such setting.'
383 return
384 }
385 if (row.isLocked) {
386 c.notice = 'Not applied: a managed setting controls this option.'
387 return
388 }
389 // The checks above awaited: look again, and from here until the write is refused no teammate may start.
390 if (c.inflight > 0 || c.pending.size > 0) {
391 c.notice = 'Teammates are starting. Confirm again in a moment; nothing changed.'
392 return
393 }
394 c.applying = true
395 // A successful set reloads the module a moment later: write the counters now and the notice first.
396 await persist($, c, true)
397 c.notice = `Applied: ${again.text}.`
398 const res = await $.config.set({ key: again.key, value: again.value })
399 c.applying = false
400 if (res.deny !== undefined) {
401 c.notice = `Not applied: ${res.deny}`
402 return
403 }
404 c.opts = { ...c.opts, [again.name]: again.value }
405 if (again.name === 'maxWorkers') c.stats.maxWorkers = c.opts.maxWorkers
406 } catch {
407 c.applying = false
408 c.notice = 'Not applied: the setting could not be written.'
409 }
410}
411
412// The model's tool for options: `show` lists them, `propose` stores one change and opens Mission Control on it.
413// Nothing is applied here.
414async function configTool($: EngineInterface, c: Ctx, input: Record<string, unknown>): Promise<string> {
415 if (input.action === 'show') {
416 return JSON.stringify({
417 options: describeOptions(c.opts).map(r => ({ option: r.name, value: r.shown, allowed: r.allowed, default: r.defaultShown })),
418 waiting: c.cfg.pending === null ? null : c.cfg.pending.change.text,
419 })
420 }
421 if (input.action !== 'propose') return JSON.stringify({ error: 'action must be "show" or "propose"' })
422 const v = validateChange(String(input.option), input.value, c.opts)
423 if (!v.ok) return JSON.stringify({ status: 'rejected', reason: v.reason, validOptions: OPTION_NAMES })
424 const now = await $.clock.now().catch(() => c.lastNow)
425 c.cfg = propose(c.cfg, v, now).state
426 c.notice = null
427 c.mc = { ...c.mc, view: 'config', selected: null }
428 $.ui.invalidate('ui.render')
429 const shown = await openMission($, c)
430 return JSON.stringify({
431 status: 'pending_user_confirmation',
432 change: v.text,
433 applied: false,
434 next: shown
435 ? 'Nothing has changed yet. Mission Control is open on its Config page with a Confirm button, and the band above the prompt says "Confirm setting" until it is answered. Tell the user, in their language: "I opened CTK Mission Control; press Confirm there to apply it, or Cancel. Nothing changes until you do." Do not say it is done.'
436 : 'Nothing has changed. Mission Control could not be drawn here. Tell the user to run /ctk-mission and press Confirm on its Config page (the band above the prompt also says "Confirm setting"), or to change the option with /plugin configure ctk@ctk-kit. Do not say it is done.',
437 })
438}
439
440export const register: Register = (on, options) => {
441 const opts = readOptions(options)
442 const c: Ctx = {
443 opts,
444 stats: emptyStats('unknown', opts.maxWorkers, 0),
445 snap: emptySnapshot(),
446 inflight: 0,
447 pending: new Map(),
448 lastNow: 0,
449 ready: false,
450 dirty: false,
451 lastWrite: 0,
452 lastDraw: 0,
453 toolSeen: new Set(),
454 coordinated: false,
455 branch: null,
456 ambiguous: 1,
457 mission: newMissionState(),
458 mc: newMcState(),
459 teamsEnabled: null,
460 cfg: emptyPending(),
461 notice: null,
462 applying: false,
463 mo: newMotionState(),
464 moTimer: null,
465 paneOpen: false,
466 ended: false,
467 }
468
469 // Hard cap on live teammates. `inflight` is bumped synchronously before the first await,
470 // so concurrent spawns from one assistant message each see the others' reservations. A
471 // spawn that has started but not yet released its reservation may be counted twice: the
472 // cap errs toward refusing, never over. Only teammate spawns are gated.
473 on('agent.spawn', async ($, e, next) => {
474 // Every spawn event counts: that this hook is reached at all is what "Guard ON" rests on.
475 c.stats.spawnsSeen += 1
476 c.dirty = true
477 if (startsOutsideCap(e, c.teamsEnabled)) c.stats.spawnsOutsideCap += 1
478 if (e.isTeammate !== true) {
479 const r = await next(routed(e, c.opts))
480 // Let the band and Mission Control list this subagent now. A failed read never touches the spawn (and must not throw: the spawn already ran).
481 try {
482 await refresh($, c)
483 } catch {
484 // the next refresh will catch up
485 }
486 return r
487 }
488
489 // A confirmed option change is being written; the reload after it forgets reservations, so a
490 // teammate waits (retryable) rather than starting across it. Counted as neither accepted nor failed.
491 if (c.applying) return { deny: settingChangeDeny() }
492
493 c.inflight += 1
494 let held = true
495 const release = () => {
496 if (held) {
497 held = false
498 c.inflight -= 1
499 }
500 }
501
502 try {
503 const live = await liveTeammates($, c)
504 if (live + c.inflight > c.opts.maxWorkers) {
505 const starting = c.inflight - 1
506 release()
507 c.stats.spawnsRejected += 1
508 await refresh($, c)
509 return { deny: capacityDeny(live, starting, c.opts.maxWorkers) }
510 }
511 const started = await next(routed(e, c.opts))
512 // Accepted is verified, not assumed: a result without both ids did not start a teammate.
513 if (started.deny === undefined && started.agentId !== undefined && started.teammateId !== undefined) {
514 c.stats.spawnsAccepted += 1
515 c.stats.workerModels[started.model] = (c.stats.workerModels[started.model] ?? 0) + 1
516 // Keep it counted until the roster lists it, and only then release the reservation,
517 // so a roster that lags behind next() cannot let a spawn past the cap.
518 const acceptedAt = await $.clock.now().catch(() => c.lastNow)
519 c.pending.set(started.teammateId, acceptedAt)
520 noteSpawn(c.mission, started.agentId, e.name ?? started.teammateId.split('@')[0] ?? started.teammateId, started.model, acceptedAt)
521 }
522 release()
523 await refresh($, c)
524 return started
525 } finally {
526 release()
527 }
528 }).catch(($, e, next) => {
529 if (next.called) return next(e)
530 if (e.isTeammate !== true) return next(e)
531 c.stats.spawnsFailedClosed += 1
532 return { deny: guardDeny() }
533 })
534
535 // A prompt that asks for several agents, a team or parallel work, or to use CTK, gets one hint line beside it
536 // (never shown, no model call): the model then loads the team skill instead of starting plain subagents. The
537 // prompt's own text is untouched, a prompt that is not the person's own is left alone, and any failure here
538 // lets the prompt through as it was.
539 on('prompt.submit', async ($, e, next) => {
540 await refreshBranch($, c) // a `git checkout` the person ran themselves shows up with their next prompt
541 const hint = c.opts.teamHint ? teamHintFor(e) : null
542 return next(hint === null ? e : { ...e, context: [...(e.context ?? []), hint] })
543 }).catch(($, e, next) => next(e))
544
545 on('session.start', async ($, e, next) => {
546 c.ended = false
547 try {
548 await $.tool.register({
549 name: STATUS_TOOL_NAME,
550 description: 'Live CTK team state: workers, cap, accepted and refused spawns.',
551 })
552 } catch {
553 // Without the tool the Agent result's teammate_id still confirms a spawn.
554 }
555 try {
556 await $.tool.register({
557 name: CONFIG_TOOL_NAME,
558 description: 'Show CTK options, or propose one change. A proposal applies nothing: the user confirms it in CTK Mission Control.',
559 inputSchema: {
560 type: 'object',
561 properties: {
562 action: { type: 'string', enum: ['show', 'propose'] },
563 option: { type: 'string', enum: [...OPTION_NAMES] },
564 value: { description: 'maxWorkers: whole number 1-12; models: alias or id; hudBand, recordStats and teamHint: true or false' },
565 },
566 required: ['action'],
567 },
568 isDeferred: true,
569 })
570 } catch {
571 // Without the tool, options are changed with /plugin configure.
572 }
573 try {
574 await $.command.register({ name: 'ctk-stats', description: 'Show CTK team stats for this session.' })
575 } catch {
576 // Without the command `ctk stats` still reads the stats file.
577 }
578 try {
579 await $.command.register({ name: 'ctk-doctor', description: 'Check CTK readiness (read-only).' })
580 } catch {
581 // Without the command the status tool still reports the same facts.
582 }
583 try {
584 await $.command.register({ name: 'ctk-mission', description: 'Open CTK Mission Control (read-only team view).' })
585 } catch {
586 // Without the command the CTK band is the way in.
587 }
588 const r = await next(e)
589 await boot($, c)
590 return r
591 })
592
593 on('session.measure', async ($, e, next) => {
594 const r = await next(e)
595 await refresh($, c)
596 return r
597 })
598
599 // Teammate status changes raise no session.measure; turn ends do.
600 on('turn.complete', async ($, e, next) => {
601 const r = await next(e)
602 await detectEnvironment($, c)
603 await refresh($, c)
604 noteWorkerTurnEnd(c.mission, (e as { agentId?: string }).agentId, c.lastNow)
605 return r
606 })
607
608 on('session.end', async ($, e, next) => {
609 stopMotion(c, true)
610 await persist($, c, true)
611 return next(e)
612 })
613
614 // Read-only counters: these never block a task.
615 on('classic.TaskCreated', async ($, e, next) => {
616 c.stats.tasks = { created: (c.stats.tasks?.created ?? 0) + 1, completed: c.stats.tasks?.completed ?? 0 }
617 await refresh($, c)
618 return next(e)
619 })
620
621 on('classic.TaskCompleted', async ($, e, next) => {
622 c.stats.tasks = { created: c.stats.tasks?.created ?? 0, completed: (c.stats.tasks?.completed ?? 0) + 1 }
623 await refresh($, c)
624 return next(e)
625 })
626
627 // Every tool call the model makes, in the lead and in subagents and teammates, counts once by
628 // its tool_use_id. The count happens before the call runs and the call is passed on untouched;
629 // a call a hook later denies still counts, because the model made it.
630 on('tool.call', async ($, e, next) => {
631 const isNew = isNewToolCall(c.toolSeen, e.tool_use_id)
632 if (isNew) c.stats.toolCalls += 1
633 const r = await next(e)
634 await touch($, c)
635 if (isNew) {
636 noteWorkerToolCall(c.mission, e.agentId, c.lastNow, toolLabel(e.tool, e))
637 // The task board is built from the Task tools' named fields only (see noteTaskCall), and only
638 // from a call that ran: a denied or failed one changes nothing.
639 if ((e.tool === 'TaskCreate' || e.tool === 'TaskUpdate') && r.deny === undefined) {
640 noteTaskCall(c.mission, e.tool, e as unknown as Record<string, unknown>, (r as { result?: unknown }).result)
641 }
642 }
643 return r
644 }).catch(($, e, next) => next(e)) // whatever fails here, the model's tool call still goes through
645
646 // A teammate going idle is the one moment its idle time starts; nothing else reads this event.
647 on('classic.TeammateIdle', async ($, e, next) => {
648 noteTeammateIdle(c.mission, e.teammate_name, await $.clock.now().catch(() => c.lastNow))
649 await refresh($, c)
650 return next(e)
651 }).catch(($, e, next) => next(e))
652
653 // The matcher must be a literal for `claude plugin validate`; policy.test.ts ties it to STATUS_TOOL.
654 on('tool.call', { tool: 'mcp__ctk__ctk_team_status' }, async ($, e, next) => {
655 await refresh($, c)
656 const facts = await gatherFacts($, c)
657 const status = {
658 live: c.snap.live,
659 max: c.opts.maxWorkers,
660 workers: c.snap.workers,
661 rejected: c.stats.spawnsRejected,
662 accepted: c.stats.spawnsAccepted,
663 cap: c.opts.maxWorkers,
664 teamsEnabled: facts.teamsEnabled,
665 taskTools: facts.taskTools,
666 ...missionJson(missionOf(c)),
667 }
668 // Claude Code validates a registered tool's result as a string or an array of content
669 // blocks; an MCP-style { content, isError } object is rejected (verified on 2.1.294).
670 return { result: JSON.stringify(status) }
671 })
672
673 on('tool.call', { tool: 'mcp__ctk__ctk_config' }, async ($, e, next) => ({ result: await configTool($, c, e as unknown as Record<string, unknown>) })).catch(
674 () => ({ result: JSON.stringify({ error: 'CTK could not read the request; nothing changed.' }) }),
675 )
676
677 on('command.run', { command: 'ctk-stats' }, async ($, e, next) => {
678 await refresh($, c)
679 return { text: formatSummary(c.stats, c.snap, c.lastNow) }
680 })
681
682 // The command is the way in where the band is hidden, and the answer where no pane can be drawn.
683 on('command.run', { command: 'ctk-mission' }, async ($, e, next) => {
684 if (await openMission($, c)) return { text: 'ctk: Mission Control is open. Esc returns to the prompt; /ctk-stats and /ctk-doctor still work.' }
685 return { text: missionText(missionOf(c)) }
686 })
687
688 // Presses on the band and on Mission Control's buttons. The buttons' own handlers do nothing; the
689 // work is done here, after them.
690 on('ui.press', { plugin: 'ctk' }, async ($, e, next) => {
691 // The button's own handler runs first: handling the press redraws, and a redraw releases the handler.
692 const r = await next(e)
693 await handlePress($, c, e.element)
694 return r
695 }).catch(($, e, next) => next(e))
696
697 on('command.run', { command: 'ctk-doctor' }, async ($, e, next) => {
698 await refresh($, c, false)
699 return { text: formatDoctor(await gatherFacts($, c)) }
700 })
701
702 // The whole band is one button: a click anywhere on it, or Enter once it has the focus (ctrl+x then Tab),
703 // opens Mission Control. The line itself is laid out for the room left after the `CTK ▸` entry.
704 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
705 if (!c.opts.hudBand || !c.ready || e.props.hasSurvey) return next(e)
706 const { Text, Button } = $.ui.resolve(e)
707 const entry = 'CTK ▸ '
708 const guard = guardOf(c.stats, c.snap, c.teamsEnabled, c.ready).label
709 // Until a teammate has started, the band may show less: only the entry and the guard, or nothing
710 // (then /ctk-mission is the way in). Once a team has run, the band always shows in full.
711 const waiting = sweep(c.cfg, c.lastNow).pending !== null
712 const idle = c.mission.workers.size === 0 && (c.snap.live ?? 0) === 0 && c.stats.spawnsAccepted === 0 && !waiting
713 if (idle && c.opts.hudIdle === 'hidden') return next(e)
714 const room = e.props.bodyColumns - displayWidth(entry, c.ambiguous)
715 const line = idle && c.opts.hudIdle === 'minimal' ? `Guard ${guard}` : formatBand(c.stats, c.snap, room, {
716 nowMs: c.lastNow,
717 ambiguous: c.ambiguous,
718 coordinated: c.coordinated,
719 branch: c.branch,
720 guard,
721 subagentsLive: c.snap.subagents.filter(a => !['completed', 'failed', 'killed'].includes(a.status)).length,
722 pendingChange: waiting,
723 tierColumns: forcedTierColumns(c.mc.hudMode) ?? e.viewport?.columns ?? e.props.bodyColumns + BAND_MARGIN,
724 })
725 return (
726 <Button key={OPEN_KEY} plain label="Open CTK Mission Control" onPress={noop}>
727 <Text bold>{entry}</Text>
728 <Text dimColor>{line}</Text>
729 </Button>
730 )
731 })
732
733 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
734 if (e.requestId !== MC_PANE_ID) return next(e)
735 const now = await $.clock.now().catch(() => c.lastNow)
736 c.paneOpen = true
737 c.mo = observe(c.mo, missionOf(c), now)
738 armMotion($, c, now)
739 return renderMission(
740 $.ui.resolve(e),
741 missionOf(c),
742 c.mc,
743 {
744 statsText: formatSummary(c.stats, c.snap, c.lastNow),
745 options: describeOptions(c.opts).map(r => ({ label: r.label, value: r.shown })),
746 pending: sweep(c.cfg, c.lastNow).pending === null ? null : { id: sweep(c.cfg, c.lastNow).pending!.id, text: sweep(c.cfg, c.lastNow).pending!.change.text },
747 notice: c.notice,
748 },
749 { ...e.props, ambiguous: c.ambiguous, motion: motionOf(c.mo) },
750 )
751 })
752}
753shared/policy.ts 100 lines1// Single source of truth for the plugin's runtime options (the flat `userConfig`
2// values Claude Code hands to `register(on, options)`).
3//
4// Plain TypeScript with no imports: the plugin loads this file directly (mods may
5// only import their own files) and the CLI imports it too, so defaults can never
6// drift. `test/contract.test.ts` checks plugin.json `userConfig` against this file.
7
8export const ROLES = ['explorer', 'implementer', 'reviewer', 'highRisk', 'designer'] as const
9export type Role = (typeof ROLES)[number]
10
11export const HUD_IDLE = ['full', 'minimal', 'hidden'] as const
12export type HudIdle = (typeof HUD_IDLE)[number]
13
14export type PolicyOptions = {
15 /** Most teammates alive at once. Further spawns are refused with TEAM_CAPACITY_REACHED. */
16 maxWorkers: number
17 /** Model for each CTK agent role: an alias (`haiku`), a full id, or `inherit`. */
18 explorerModel: string
19 implementerModel: string
20 reviewerModel: string
21 highRiskModel: string
22 designerModel: string
23 /** Draw the team band above the prompt. */
24 hudBand: boolean
25 /** What the band shows while no teammate has started: everything, only the CTK entry and guard, or nothing. */
26 hudIdle: HudIdle
27 /** Write small per-session counters to <config>/ctk/stats/ for `ctk stats`. */
28 recordStats: boolean
29 /** Add one hidden hint line to a prompt that asks for several agents or a team, so the model loads the team skill. */
30 teamHint: boolean
31}
32
33export const DEFAULT_OPTIONS: PolicyOptions = {
34 maxWorkers: 5,
35 explorerModel: 'haiku',
36 implementerModel: 'sonnet',
37 reviewerModel: 'sonnet',
38 highRiskModel: 'opus',
39 designerModel: 'opus',
40 hudBand: true,
41 hudIdle: 'full',
42 recordStats: true,
43 teamHint: true,
44}
45
46/** Default reasoning effort per role. Mirrors the `effort:` frontmatter in plugins/ctk/agents/. */
47export const DEFAULT_EFFORT: Record<Role, string> = {
48 explorer: 'medium',
49 implementer: 'medium',
50 reviewer: 'medium',
51 highRisk: 'high',
52 designer: 'high',
53}
54
55/** Agent type names as Claude Code reports them for plugin agents (`<plugin>:<name>`). */
56export const AGENT_TYPES: Record<Role, string> = {
57 explorer: 'ctk:explorer',
58 implementer: 'ctk:implementer',
59 reviewer: 'ctk:reviewer',
60 highRisk: 'ctk:high-risk-reviewer',
61 designer: 'ctk:designer',
62}
63
64/** Error code carried in every capacity refusal. The team skill keys off this exact token. */
65export const CAPACITY_CODE = 'TEAM_CAPACITY_REACHED'
66
67const str = (v: unknown, d: string) => (typeof v === 'string' && v.trim() !== '' ? v.trim() : d)
68const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
69
70export const clampMax = (v: unknown): number => {
71 const n = Number(v ?? DEFAULT_OPTIONS.maxWorkers)
72 return Number.isFinite(n) && n >= 1 ? Math.min(Math.floor(n), 12) : DEFAULT_OPTIONS.maxWorkers
73}
74
75/** Normalize whatever `register` received into a complete, valid PolicyOptions. */
76export const readOptions = (raw: unknown): PolicyOptions => {
77 const o = (raw ?? {}) as Record<string, unknown>
78 return {
79 maxWorkers: clampMax(o.maxWorkers),
80 explorerModel: str(o.explorerModel, DEFAULT_OPTIONS.explorerModel),
81 implementerModel: str(o.implementerModel, DEFAULT_OPTIONS.implementerModel),
82 reviewerModel: str(o.reviewerModel, DEFAULT_OPTIONS.reviewerModel),
83 highRiskModel: str(o.highRiskModel, DEFAULT_OPTIONS.highRiskModel),
84 designerModel: str(o.designerModel, DEFAULT_OPTIONS.designerModel),
85 hudBand: bool(o.hudBand, DEFAULT_OPTIONS.hudBand),
86 hudIdle: (HUD_IDLE as readonly unknown[]).includes(o.hudIdle) ? (o.hudIdle as HudIdle) : DEFAULT_OPTIONS.hudIdle,
87 recordStats: bool(o.recordStats, DEFAULT_OPTIONS.recordStats),
88 teamHint: bool(o.teamHint, DEFAULT_OPTIONS.teamHint),
89 }
90}
91
92export const modelFor = (opts: PolicyOptions, role: Role): string =>
93 ({
94 explorer: opts.explorerModel,
95 implementer: opts.implementerModel,
96 reviewer: opts.reviewerModel,
97 highRisk: opts.highRiskModel,
98 designer: opts.designerModel,
99 })[role]
100shared/stats.ts 99 lines1// Per-session counters written by the mod and read by `ctk stats`.
2// Only figures Claude Code reports through its public mod API. Nothing here is a
3// per-worker cost: Claude Code exposes none, so none is invented.
4
5export const STATS_SCHEMA_VERSION = 1
6
7export type StatsRecord = {
8 schemaVersion: typeof STATS_SCHEMA_VERSION
9 sessionId: string
10 /** Epoch ms of the first write for this session. */
11 startedAt: number
12 /** Epoch ms of the latest write. */
13 updatedAt: number
14 /** Configured cap. */
15 maxWorkers: number
16 /** Teammate spawns that Claude Code accepted. */
17 spawnsAccepted: number
18 /** Teammate spawns CTK refused with TEAM_CAPACITY_REACHED. */
19 spawnsRejected: number
20 /** Teammate spawns refused for any other CTK reason (guard failure). */
21 spawnsFailedClosed: number
22 /** Every `agent.spawn` event the guard saw, teammate or not: proof that Claude Code reaches it. */
23 spawnsSeen: number
24 /** Named agents Claude Code started as ordinary subagents while Agent Teams were on (a call with `isolation` is one): outside the cap. */
25 spawnsOutsideCap: number
26 /** Highest number of simultaneously live teammates seen at a spawn decision. */
27 peakLive: number
28 /** Resolved model per accepted spawn, as reported by Claude Code: counts by model id/alias. */
29 workerModels: Record<string, number>
30 /** Counts from TaskCreated / TaskCompleted events; null when no such event ever fired. */
31 tasks: { created: number; completed: number } | null
32 /**
33 * Tool calls the model made this session, counted from `tool.call` events: the lead, its
34 * subagents and its teammates together, each tool_use_id once, including calls a hook then denied.
35 */
36 toolCalls: number
37 /** Latest measured figures; null when Claude Code did not report them. */
38 measured: {
39 costUsd: number | null
40 contextPct: number | null
41 fiveHourPct: number | null
42 sevenDayPct: number | null
43 /** ISO 8601 reset time of each window, as Claude Code reported it; null when it did not. */
44 fiveHourResetsAt: string | null
45 sevenDayResetsAt: string | null
46 /** The session's model id, as `$.session.model()` returns it. */
47 model: string | null
48 }
49}
50
51const EMPTY_MEASURED = (): StatsRecord['measured'] => ({
52 costUsd: null,
53 contextPct: null,
54 fiveHourPct: null,
55 sevenDayPct: null,
56 fiveHourResetsAt: null,
57 sevenDayResetsAt: null,
58 model: null,
59})
60
61export const emptyStats = (sessionId: string, maxWorkers: number, now: number): StatsRecord => ({
62 schemaVersion: STATS_SCHEMA_VERSION,
63 sessionId,
64 startedAt: now,
65 updatedAt: now,
66 maxWorkers,
67 spawnsAccepted: 0,
68 spawnsRejected: 0,
69 spawnsFailedClosed: 0,
70 spawnsSeen: 0,
71 spawnsOutsideCap: 0,
72 peakLive: 0,
73 workerModels: {},
74 tasks: null,
75 toolCalls: 0,
76 measured: EMPTY_MEASURED(),
77})
78
79/** Session ids become file names; keep them to a safe charset. */
80export const safeSessionId = (id: string): string => id.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64) || 'unknown'
81
82const count = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0)
83
84/** Narrow unknown JSON to a StatsRecord, or null. Used by the CLI on files it did not just write. */
85export const parseStats = (raw: unknown): StatsRecord | null => {
86 if (typeof raw !== 'object' || raw === null) return null
87 const r = raw as Partial<StatsRecord>
88 if (r.schemaVersion !== STATS_SCHEMA_VERSION || typeof r.sessionId !== 'string') return null
89 if (typeof r.spawnsAccepted !== 'number' || typeof r.spawnsRejected !== 'number') return null
90 // Files written by an earlier version lack the newer fields: fill them, never guess them.
91 return {
92 ...(r as StatsRecord),
93 toolCalls: typeof r.toolCalls === 'number' && r.toolCalls >= 0 ? r.toolCalls : 0,
94 spawnsSeen: count(r.spawnsSeen),
95 spawnsOutsideCap: count(r.spawnsOutsideCap),
96 measured: { ...EMPTY_MEASURED(), ...(typeof r.measured === 'object' && r.measured !== null ? r.measured : {}) },
97 }
98}
99hooks/band.ts 178 lines1import { DASH, fmtModels, pct, usd } from '../shared/format.ts'
2import { fmtCountdown, fmtPct, layoutLine, resetMs, truncateToWidth, usageSegment } from '../shared/hudline.ts'
3import type { Segment } from '../shared/hudline.ts'
4import type { StatsRecord } from '../shared/stats.ts'
5import type { Snapshot } from './team.ts'
6
7// Pure text for the AbovePrompt band and the /ctk-stats summary (figure formatting is in
8// shared/format.ts, which `ctk stats` uses too; the width-aware layout is in shared/hudline.ts).
9
10const num = (v: number | null): string => (v === null ? DASH : String(v))
11
12export const fmtElapsed = (ms: number | null): string => {
13 if (ms === null) return DASH
14 const s = Math.floor(ms / 1000)
15 if (s < 60) return `${s}s`
16 const m = Math.floor(s / 60)
17 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
18}
19
20/**
21 * `claude-sonnet-5-5` -> `Sonnet 5.5`; `claude-opus-4-1-20250805` -> `Opus 4.1`; an alias such
22 * as `sonnet` -> `Sonnet`. Anything unrecognised is shown as given, minus a `claude-` prefix.
23 */
24export const modelLabel = (id: string | null): string | null => {
25 if (id === null) return null
26 const raw = id.trim().replace(/^claude-/i, '')
27 if (raw === '') return null
28 const long = /\[1m\]$/i.test(raw) ? ' 1M' : ''
29 const parts = raw.replace(/\[.*\]$/, '').split('-')
30 const family = parts[0] ?? ''
31 if (!/^(opus|sonnet|haiku|fable)$/i.test(family)) return raw
32 const nums = parts.slice(1).filter(p => /^\d{1,2}$/.test(p))
33 return `${family[0]?.toUpperCase()}${family.slice(1).toLowerCase()}${nums.length > 0 ? ` ${nums.slice(0, 2).join('.')}` : ''}${long}`
34}
35
36export type BandOptions = {
37 /** Epoch ms the reset countdowns count from: the clock reading of the last refresh. */
38 nowMs?: number
39 /** 2 on terminals that draw `│` and `…` two cells wide (CTK_AMBIGUOUS_WIDTH=2). */
40 ambiguous?: 1 | 2
41 /**
42 * True when the CTK status line is configured. It then shows model, usage, context, cost and
43 * branch under the prompt, so the band keeps to what only the mod can know and nothing is
44 * drawn twice.
45 */
46 coordinated?: boolean
47 /**
48 * The terminal width that picks the form (full, abbreviated, essentials). Default: the width the
49 * engine reports for the band plus its margin; a forced HUD form passes its own.
50 */
51 tierColumns?: number
52 /** The guard's one-word state (`ON`, `ERR`, `–`), shown as `Guard ON`; left out when not given. */
53 guard?: string
54 /** Ordinary subagents live now (not teammates); shown as `Sub 2` only when above zero. */
55 subagentsLive?: number
56 /** An option change waits for the person's Confirm in Mission Control; the band says so until it is answered. */
57 pendingChange?: boolean
58 /** The git branch of the session's directory (a short commit id when detached); left out when null or the status line shows it. */
59 branch?: string | null
60}
61
62// Rank when the line is too wide (higher stays longer). Model, usage, context and cost are
63// the status line's too; the rest is the team's.
64/** Above this many tasks the band points at Mission Control for the whole list. */
65const MORE_TASKS = 6
66
67const RANK = { pending: 95, fiveHour: 100, sevenDay: 90, tools: 80, context: 70, model: 60, branch: 55, agents: 50, guard: 45, tasks: 40, subagents: 35, cost: 30, workerModels: 10 }
68
69export const bandSegments = (s: StatsRecord, snap: Snapshot, opts: BandOptions = {}): Segment[] => {
70 const now = opts.nowMs ?? 0
71 const out: Segment[] = []
72 const mine = opts.coordinated !== true
73
74 if (mine) {
75 out.push({ id: 'model', prio: RANK.model, bold: true, ...modelSegment(s.measured.model) })
76 out.push(
77 usageSegment('5h', '5h', RANK.fiveHour, { pct: s.measured.fiveHourPct, resetsAtMs: resetMs(s.measured.fiveHourResetsAt) }, now),
78 usageSegment('wk', 'Wk', RANK.sevenDay, { pct: s.measured.sevenDayPct, resetsAtMs: resetMs(s.measured.sevenDayResetsAt) }, now),
79 )
80 }
81 if (mine && opts.branch) {
82 const full = `git:${opts.branch}`
83 out.push({ id: 'branch', prio: RANK.branch, forms: [truncateToWidth(full, 28, opts.ambiguous), truncateToWidth(full, 20, opts.ambiguous), ''] })
84 }
85 out.push({ id: 'tools', prio: RANK.tools, forms: [`Tools ${s.toolCalls}`, `T${s.toolCalls}`, `T${s.toolCalls}`] })
86 out.push(agentsSegment(s, snap))
87 if (opts.guard !== undefined) {
88 const text = `Guard ${opts.guard}`
89 out.push({ id: 'guard', prio: RANK.guard, missing: opts.guard === DASH, forms: [text, text, ''] })
90 }
91 if (mine) {
92 const ctx = s.measured.contextPct
93 const text = `Ctx ${fmtPct(ctx)}`
94 out.push({ id: 'ctx', prio: RANK.context, missing: fmtPct(ctx) === DASH, forms: [text, text, text] })
95 }
96 if (opts.subagentsLive !== undefined && opts.subagentsLive > 0) {
97 const n = opts.subagentsLive
98 out.push({ id: 'subagents', prio: RANK.subagents, forms: [`Sub ${n}`, `S${n}`, ''] })
99 }
100 if (opts.pendingChange === true) out.push({ id: 'pending', prio: RANK.pending, bold: true, forms: ['Confirm setting: click here', 'Confirm: click', '!'] })
101 if (s.tasks !== null) {
102 const text = `Tasks ${s.tasks.completed}/${s.tasks.created}`
103 // Past a handful of tasks the pane lists only some of them, so say where the whole board is.
104 const more = s.tasks.created > MORE_TASKS
105 out.push({ id: 'tasks', prio: RANK.tasks, forms: [more ? `${text} (full list: Mission Control)` : text, more ? `${text} (list: click here)` : text, ''] })
106 }
107 if (mine) {
108 const cost = usd(s.measured.costUsd)
109 const elapsed = snap.elapsedMs === null || s.measured.costUsd === null ? null : fmtElapsed(snap.elapsedMs)
110 out.push({
111 id: 'cost',
112 prio: RANK.cost,
113 missing: s.measured.costUsd === null,
114 forms: [elapsed === null ? cost : `${cost} (${elapsed})`, cost, ''],
115 })
116 }
117 if (Object.keys(s.workerModels).length > 0) {
118 out.push({ id: 'workers', prio: RANK.workerModels, forms: [`models ${fmtModels(s.workerModels)}`, '', ''] })
119 }
120 return out
121}
122
123const modelSegment = (id: string | null): Pick<Segment, 'forms' | 'missing'> => {
124 const label = modelLabel(id)
125 return label === null ? { missing: true, forms: [DASH, DASH, DASH] } : { forms: [label, label, ''] }
126}
127
128// `Agents 2/3`: live teammates (busy and idle, the figure the cap counts) over the cap. A refused
129// spawn is the cap doing its job, so it travels with the segment and is dropped with it.
130const agentsSegment = (s: StatsRecord, snap: Snapshot): Segment => {
131 const live = num(snap.live)
132 const refused = s.spawnsRejected
133 const busy = snap.busy !== null && snap.live !== null && snap.live > 0 ? ` (${snap.busy} busy)` : ''
134 return {
135 id: 'agents',
136 prio: RANK.agents,
137 missing: snap.live === null,
138 forms: [
139 `Agents ${live}/${s.maxWorkers}${refused > 0 ? ` (refused ${refused})` : busy}`,
140 `A${live}/${s.maxWorkers}${refused > 0 ? ` !${refused}` : ''}`,
141 `A${live}/${s.maxWorkers}${refused > 0 ? ` !${refused}` : ''}`,
142 ],
143 }
144}
145
146/**
147 * The band's body is 5 columns narrower than the terminal (Claude Code keeps the right edge for its
148 * own `[-]` control; measured on 2.1.295: bodyColumns 125, 115, 95, 75, 55 at terminal widths 130,
149 * 120, 100, 80, 60). The tier follows the terminal, so 120+ columns is the full form, as documented.
150 */
151export const BAND_MARGIN = 5
152
153export const formatBand = (s: StatsRecord, snap: Snapshot, bodyColumns: number, opts: BandOptions = {}): string =>
154 layoutLine(bandSegments(s, snap, opts), { columns: bodyColumns, tierColumns: opts.tierColumns ?? bodyColumns + BAND_MARGIN, ambiguous: opts.ambiguous })
155
156const limitLine = (label: string, pctValue: number | null, resetsAt: string | null, now: number): string => {
157 const left = fmtCountdown(resetMs(resetsAt), now)
158 const stale = resetMs(resetsAt) !== null && left === null
159 return `${label}: ${stale ? DASH : pct(pctValue)}${left === null ? '' : ` (resets in ${left})`}`
160}
161
162export const formatSummary = (s: StatsRecord, snap: Snapshot, nowMs = 0): string =>
163 [
164 `CTK session ${s.sessionId}`,
165 'counted by CTK:',
166 ` teammate spawns: ${s.spawnsAccepted} accepted, ${s.spawnsRejected} refused at capacity, ${s.spawnsFailedClosed} failed closed`,
167 ` guard reached by ${s.spawnsSeen} spawn event(s); named agents started outside the cap (with isolation): ${s.spawnsOutsideCap}`,
168 ` peak live teammates: ${s.peakLive} (cap ${s.maxWorkers}); now ${num(snap.live)}`,
169 ` worker models: ${fmtModels(s.workerModels) || DASH}`,
170 ` tasks created/completed: ${s.tasks === null ? `${DASH} (no task event seen)` : `${s.tasks.created}/${s.tasks.completed}`}`,
171 ` tool calls: ${s.toolCalls} (lead, subagents and teammates; each call once)`,
172 'measured (reported by Claude Code):',
173 ` model: ${modelLabel(s.measured.model) ?? DASH} cost: ${usd(s.measured.costUsd)} context: ${pct(s.measured.contextPct)}`,
174 ` ${limitLine('5h limit', s.measured.fiveHourPct, s.measured.fiveHourResetsAt, nowMs)} ${limitLine('7d limit', s.measured.sevenDayPct, s.measured.sevenDayResetsAt, nowMs)}`,
175 ` elapsed: ${fmtElapsed(snap.elapsedMs)}`,
176 ' per-worker cost: not available from Claude Code',
177 ].join('\n')
178hooks/doctor.ts 145 lines1import type { PolicyOptions } from '../shared/policy.ts'
2
3// Pure readiness logic for /ctk-doctor and the status tool's preflight fields. The host
4// reads (env, settings, tool list) are in register.tsx; a state that could not be read is
5// null here and is reported as unknown, never guessed.
6
7export const TEAMS_FLAG = 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'
8export const TEAMS_FIX = `Add {"env":{"${TEAMS_FLAG}":"1"}} to ~/.claude/settings.json, then restart Claude Code.`
9
10export type Facts = {
11 maxWorkers: number
12 /** Whether settings hold /pluginConfigs/ctk@…/options/maxWorkers; null when settings could not be read. */
13 capFromSettings: boolean | null
14 /** Null when neither the process environment nor settings could be read. */
15 teamsEnabled: boolean | null
16 /** True only when TaskCreate is in the tool list: a deferred tool is not listed, so absence proves nothing. */
17 taskTools: boolean | null
18 /** False when the tool list itself could not be read. */
19 toolListRead: boolean
20 statusLine: boolean | null
21 /** True when the configured status line is CTK's own script; null when settings could not be read. */
22 ctkStatusLine: boolean | null
23 hudBand: boolean
24 recordStats: boolean
25 teamHint: boolean
26 /** The guard's state and the reason for it (see guardOf); null when it was not computed. */
27 guard: { state: 'active' | 'available' | 'unavailable' | 'error'; why: string } | null
28 /** Named agents started as ordinary subagents this session (outside the cap). */
29 outsideCap: number
30}
31
32export type Inputs = {
33 opts: PolicyOptions
34 /** `$.env.get(TEAMS_FLAG)`: null when the read failed. It also sees values from settings.json `env`. */
35 envFlag: { value: string | undefined } | null
36 settings: Readonly<Record<string, unknown>> | null
37 toolNames: string[] | null
38 guard?: Facts['guard']
39 outsideCap?: number
40}
41
42const record = (v: unknown): Record<string, unknown> | null =>
43 typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
44
45const truthy = (v: string): boolean => ['1', 'true', 'yes', 'on'].includes(v.trim().toLowerCase())
46
47/** Whether the merged settings point the status line at CTK's script (the command itself is never shown). */
48export const isCtkStatusLine = (settings: Readonly<Record<string, unknown>> | null): boolean => {
49 const command = record(settings?.statusLine)?.command
50 return typeof command === 'string' && command.includes('ctk-statusline')
51}
52
53export const factsFrom = ({ opts, envFlag, settings, toolNames, guard = null, outsideCap = 0 }: Inputs): Facts => {
54 const settingsFlag = record(settings?.env)?.[TEAMS_FLAG]
55 const raw = envFlag?.value ?? (typeof settingsFlag === 'string' ? settingsFlag : undefined)
56 const pluginConfigs = record(settings?.pluginConfigs)
57 return {
58 maxWorkers: opts.maxWorkers,
59 capFromSettings:
60 settings === null
61 ? null
62 : Object.entries(pluginConfigs ?? {}).some(
63 ([k, v]) => (k === 'ctk' || k.startsWith('ctk@')) && record(record(v)?.options)?.maxWorkers !== undefined,
64 ),
65 teamsEnabled: raw !== undefined ? truthy(raw) : envFlag !== null ? false : null,
66 taskTools: toolNames?.includes('TaskCreate') ? true : null,
67 toolListRead: toolNames !== null,
68 statusLine: settings === null ? null : settings.statusLine !== undefined,
69 ctkStatusLine: settings === null ? null : isCtkStatusLine(settings),
70 hudBand: opts.hudBand,
71 recordStats: opts.recordStats,
72 teamHint: opts.teamHint,
73 guard,
74 outsideCap,
75 }
76}
77
78type Row = { level: 'ok' | 'info' | 'action'; text: string; fix?: string }
79
80const guardRow = (f: Facts): Row => {
81 if (f.guard === null) return { level: 'info', text: 'guard: state not computed' }
82 const text = `guard: ${f.guard.state === 'active' ? 'ON' : f.guard.state} (${f.guard.why})`
83 if (f.guard.state === 'error') return { level: 'action', text, fix: 'Do not rely on the cap until this clears; /ctk-stats lists the counters, and a restart of Claude Code reloads the guard.' }
84 return { level: f.guard.state === 'active' ? 'ok' : 'info', text }
85}
86
87export const doctorRows = (f: Facts): Row[] => [
88 { level: 'ok', text: 'mod: loaded (it answered this command)' },
89 guardRow(f),
90 {
91 level: 'ok',
92 text: `cap: ${f.maxWorkers} live teammates (${f.capFromSettings === null ? 'source not checked' : f.capFromSettings ? 'set in plugin options' : 'default'})`,
93 },
94 f.teamsEnabled === true
95 ? { level: 'ok', text: `agent teams: enabled (${TEAMS_FLAG})` }
96 : f.teamsEnabled === false
97 ? { level: 'action', text: `agent teams: not enabled (${TEAMS_FLAG} is not set to 1)`, fix: TEAMS_FIX }
98 : { level: 'info', text: `agent teams: unknown (${TEAMS_FLAG} could not be read)` },
99 f.outsideCap > 0
100 ? { level: 'info', text: `outside the cap: ${f.outsideCap} named agent(s) started as ordinary subagents this session (a call with isolation does that)` }
101 : { level: 'info', text: 'the cap counts teammates only: ordinary subagents, and named agents started with isolation, are not limited' },
102 f.taskTools === true
103 ? { level: 'ok', text: 'task tools: TaskCreate is available' }
104 : {
105 level: 'info',
106 text: f.toolListRead
107 ? 'task tools: unknown (TaskCreate is not in the listed tools; deferred tools are not listed)'
108 : 'task tools: unknown (tool list not readable)',
109 },
110 f.statusLine === null
111 ? { level: 'info', text: 'statusLine: not checked (settings not readable)' }
112 : f.ctkStatusLine === true
113 ? { level: 'info', text: 'statusLine: CTK’s, under the prompt (model, usage, context, cost); the band above it shows only tools, agents and tasks' }
114 : f.statusLine
115 ? { level: 'info', text: 'statusLine: yours is configured and left untouched; the band above the prompt shows model, usage and team figures' }
116 : { level: 'info', text: 'statusLine: none configured (optional); the band above the prompt shows model, usage and team figures' },
117 f.hudBand
118 ? { level: 'ok', text: 'team band: on' }
119 : { level: 'info', text: 'team band: off (plugin option hudBand)' },
120 f.recordStats
121 ? { level: 'ok', text: 'stats recording: on' }
122 : { level: 'info', text: 'stats recording: off (plugin option recordStats)' },
123 f.teamHint
124 ? { level: 'ok', text: 'team hint: on (a prompt that asks for several agents or a team gets one hidden hint line; plugin option teamHint)' }
125 : { level: 'info', text: 'team hint: off (plugin option teamHint)' },
126]
127
128export const formatDoctor = (f: Facts): string => {
129 const rows = doctorRows(f)
130 const actions = rows.filter(r => r.level === 'action').length
131 const lines = rows.flatMap(r => [`[${r.level}]`.padEnd(9) + r.text, ...(r.fix === undefined ? [] : [` fix: ${r.fix}`])])
132 return ['CTK readiness (read-only; nothing is changed):', ...lines, actions === 0 ? 'ready' : `${actions} action(s) needed`].join('\n')
133}
134
135/** Reads a formatDoctor report back into rows for the Mission Control view; the report stays the one source. */
136export const parseDoctor = (text: string): Row[] => {
137 const rows: Row[] = []
138 for (const l of text.split('\n')) {
139 const m = /^\[(ok|info|action)\]\s+(.*)$/.exec(l)
140 if (m !== null) rows.push({ level: m[1] as Row['level'], text: m[2]! })
141 else if (/^\s+fix: /.test(l) && rows.length > 0) rows[rows.length - 1]!.fix = l.replace(/^\s+fix: /, '')
142 }
143 return rows
144}
145hooks/team.ts 194 lines1import type { AgentInfo, AgentSpawnInput, SessionUsage } from 'claude-code'
2
3import { AGENT_TYPES, CAPACITY_CODE, modelFor, ROLES } from '../shared/policy.ts'
4import type { PolicyOptions, Role } from '../shared/policy.ts'
5import type { StatsRecord } from '../shared/stats.ts'
6
7// Pure team logic: no host calls here. Everything that needs `$` lives in register.tsx.
8
9export const GUARD_CODE = 'TEAM_GUARD_FAILED'
10
11/** Statuses that hold a teammate slot. A finished (completed, failed, killed) teammate frees it. */
12const LIVE = new Set(['pending', 'running', 'waiting', 'idle'])
13/** Working or blocked mid-task (`waiting` is stuck on an approval, so not free to reuse). */
14const BUSY = new Set(['pending', 'running', 'waiting'])
15
16export const isLiveTeammate = (a: AgentInfo): boolean => a.teammateId !== undefined && LIVE.has(a.status)
17
18export type Worker = { name: string; teammateId: string; agentId: string; status: string }
19
20/** An agent the roster lists without a teammate address: an ordinary subagent, which the cap neither counts nor limits. */
21export type Subagent = { agentId: string; type: string; description: string; status: string }
22
23/** What the last refresh saw. A null figure was not reported; it renders as a dash. */
24export type Snapshot = {
25 /** Null when the roster could not be read. */
26 busy: number | null
27 idle: number | null
28 done: number | null
29 /** Teammates that failed or were killed; a subset of `done`. */
30 failed: number | null
31 live: number | null
32 workers: Worker[]
33 /** Ordinary subagents the roster lists (not teammates); empty when the roster could not be read. */
34 subagents: Subagent[]
35 elapsedMs: number | null
36}
37
38export const emptySnapshot = (): Snapshot => ({ busy: null, idle: null, done: null, failed: null, live: null, workers: [], subagents: [], elapsedMs: null })
39
40export const snapshotOf = (agents: AgentInfo[] | null, usage: SessionUsage | null, now: number): Snapshot => {
41 const elapsedMs = usage !== null && usage.startedAt > 0 && now >= usage.startedAt ? now - usage.startedAt : null
42 if (agents === null) return { ...emptySnapshot(), elapsedMs }
43 const team = agents.filter(a => a.teammateId !== undefined)
44 const count = (statuses: Set<string>) => team.filter(a => statuses.has(a.status)).length
45 return {
46 busy: count(BUSY),
47 idle: team.filter(a => a.status === 'idle').length,
48 done: team.length - count(LIVE),
49 failed: team.filter(a => a.status === 'failed' || a.status === 'killed').length,
50 live: team.filter(isLiveTeammate).length,
51 workers: team.map(a => ({
52 name: (a.teammateId as string).split('@')[0] as string,
53 teammateId: a.teammateId as string,
54 agentId: a.id,
55 status: a.status,
56 })),
57 subagents: agents.filter(a => a.teammateId === undefined).map(a => ({ agentId: a.id, type: a.type, description: a.description, status: a.status })),
58 elapsedMs,
59 }
60}
61
62export const measuredOf = (usage: SessionUsage | null, model: string | null = null): StatsRecord['measured'] => {
63 const limit = (kind: string) => usage?.rateLimits.find(l => l.kind === kind)
64 return {
65 costUsd: usage?.cost?.usd ?? null,
66 contextPct: usage?.context.percent ?? null,
67 fiveHourPct: limit('five_hour')?.percentUsed ?? null,
68 sevenDayPct: limit('seven_day')?.percentUsed ?? null,
69 fiveHourResetsAt: limit('five_hour')?.resetsAt ?? null,
70 sevenDayResetsAt: limit('seven_day')?.resetsAt ?? null,
71 model,
72 }
73}
74
75/** Most tool_use_ids remembered for de-duplication; the oldest half is forgotten past this. */
76export const TOOL_SEEN_LIMIT = 4096
77
78/**
79 * Whether a `tool.call` event is a call not yet counted. A call is identified by its
80 * tool_use_id, so an event that fires twice for one call (a retried hook, a replay) counts
81 * once; an event without an id cannot be recognised again and counts every time.
82 */
83export const isNewToolCall = (seen: Set<string>, toolUseId: unknown): boolean => {
84 if (typeof toolUseId !== 'string' || toolUseId === '') return true
85 if (seen.has(toolUseId)) return false
86 seen.add(toolUseId)
87 if (seen.size > TOOL_SEEN_LIMIT) {
88 let drop = seen.size - TOOL_SEEN_LIMIT / 2
89 for (const id of seen) {
90 if (drop-- <= 0) break
91 seen.delete(id)
92 }
93 }
94 return true
95}
96
97/** A teammate spawn that arrives while a confirmed option change is being written. The skill treats this code like any guard refusal: leave the task pending and retry. */
98export const settingChangeDeny = (): string =>
99 `${GUARD_CODE}: CTK is applying a setting change you confirmed. Do not treat this worker as started; leave its task pending and retry in a moment.`
100
101export const capacityDeny = (live: number, starting: number, max: number): string =>
102 `${CAPACITY_CODE}: live=${live} starting=${starting} max=${max}. Do not treat this worker as started; leave its task pending; reuse an idle teammate via SendMessage or wait for one to finish.`
103
104export const guardDeny = (): string =>
105 `${GUARD_CODE}: the teammate cap could not be checked. Do not treat this worker as started; leave its task pending and retry later.`
106
107/**
108 * A named agent that Claude Code started as an ordinary subagent while Agent Teams were on. Claude Code's
109 * documentation says a named call becomes a teammate unless it is a fork or passes `isolation`, and a
110 * live probe on 2.1.295 showed exactly that: the spawn carried no `isTeammate`. The cap cannot gate it
111 * (the event does not say why), so the guard only counts it, to keep "outside the cap" visible.
112 */
113export const startsOutsideCap = (e: Pick<AgentSpawnInput, 'isTeammate' | 'name' | 'fork' | 'workflow'>, teamsEnabled: boolean | null): boolean =>
114 teamsEnabled === true && e.isTeammate !== true && e.name !== undefined && e.fork !== true && e.workflow === undefined
115
116export const roleOf = (subagentType: string): Role | undefined => ROLES.find(r => AGENT_TYPES[r] === subagentType)
117
118/**
119 * Model routing by role. An explicit `model` always wins and `inherit` passes through.
120 * Effort is deliberately not overridden here: the options carry models only, so effort
121 * comes from each agent's frontmatter (`effort:` in plugins/ctk/agents/).
122 */
123export const routed = (e: AgentSpawnInput, opts: PolicyOptions): AgentSpawnInput => {
124 if (e.model !== undefined) return e
125 const role = roleOf(e.subagentType)
126 if (role === undefined) return e
127 const model = modelFor(opts, role)
128 return model === 'inherit' ? e : { ...e, model }
129}
130
131/** Full name Claude Code gives the status tool (`mcp__<plugin>__<name>`); the tool.call hook matches it. */
132export const STATUS_TOOL_NAME = 'ctk_team_status'
133export const STATUS_TOOL = `mcp__ctk__${STATUS_TOOL_NAME}`
134export const CONFIG_TOOL_NAME = 'ctk_config'
135export const CONFIG_TOOL = `mcp__ctk__${CONFIG_TOOL_NAME}`
136
137/** How long an accepted teammate stays counted while the roster has not listed it yet. */
138export const PENDING_TTL_MS = 10_000
139
140/**
141 * Live teammates for the cap: the roster's live ones plus accepted ones the roster has not
142 * listed yet (teammateId -> accepted-at ms). A listed one is dropped from `pending` and the
143 * roster's status governs from then on; an unlisted one is dropped after the TTL, so a
144 * teammate that finished instantly cannot hold a slot forever. Mutates `pending`.
145 */
146export const effectiveLive = (roster: AgentInfo[], pending: Map<string, number>, now: number): number => {
147 const listed = new Set(roster.flatMap(a => (a.teammateId === undefined ? [] : [a.teammateId])))
148 for (const [id, at] of pending) {
149 if (listed.has(id) || now - at >= PENDING_TTL_MS) pending.delete(id)
150 }
151 return roster.filter(isLiveTeammate).length + pending.size
152}
153
154// --- Natural-language entry ----------------------------------------------------------------
155
156/**
157 * Whether a prompt asks for several agents, a team or parallel work, or to use CTK. A small, fixed set of
158 * phrases in English and Chinese: it is not a classifier. All it can do is attach a one-line hint (see
159 * TEAM_HINT) that the model reads beside the prompt; the model still decides, and the team skill's own
160 * intent gate still applies. A prompt that is a command, or already names the team skill, is left alone.
161 */
162const TEAM_ASK = [
163 // Several agents, by name: "multi-agent", "multiple subagents", "3 teammates", "多agent", "多個 Agent"
164 /多\s*(個|个|一個)?\s*(sub-?)?(agents?|代理)/i,
165 /\b(multi|multiple|several|parallel)[\s-]*(sub-?)?agents?\b/i,
166 /\b(\d+|two|three|four|five|several)\s+(sub-?agents?|teammates?|agents)\b/i,
167 // A team of agents: "agent team", "use a team", "spin up a team", "team of agents"
168 /\bagents?\s+team\b/i,
169 /\bteam\s+of\s+(sub-?)?(agents?|teammates?)\b/i,
170 /\b(use|using|spin up|assemble)\s+(a|an|the|your)?\s*team\b/i,
171 // Chinese: an action verb must follow, so "使用團隊功能" or "開團隊頁面" do not match
172 /(開|組|啟動|用|使用)\s*(一個|個)?\s*(agent\s*)?(team|團隊)\s*(來|去|做|處理|完成|幫|執行|跑)/i,
173 /(平行|並行)\s*(的)?\s*(agents?|代理|subagent)/i,
174 // CTK itself: "use ctk", "ctk 流程"
175 /\bctk\s*(的)?\s*(流程|workflow|team)/i,
176 /(?<![A-Za-z])(use|using)\s+ctk\b/i,
177 /(用|使用)\s*ctk/i,
178]
179
180export const teamIntent = (text: string): boolean => {
181 const t = text.slice(0, 2000).trim()
182 // A slash command ("/ctk:team ...", "/ctk-doctor") is not a request in words; a path such as /Users/x/repo is not a command.
183 if (t === '' || /^\/[\w:-]+(\s|$)/.test(t)) return false
184 return TEAM_ASK.some(re => re.test(t))
185}
186
187/** What the model reads beside such a prompt. Conditional on purpose: a mention of "team" in another sense is not a request. */
188export const TEAM_HINT =
189 'CTK note: if this request asks for several agents, a team or parallel work, invoke the ctk:team skill first (Skill tool) and follow it. It names every worker, which makes each one a native teammate that the worker cap and Mission Control track; an Agent call without a name is only an ordinary subagent. If the request is not about agents, ignore this note.'
190
191/** The hint for a prompt, or null. Only the person's own words count: the terminal's Enter and the Remote Control bridge. */
192export const teamHintFor = (e: { text: string; origin?: { kind: string } }): string | null =>
193 (e.origin?.kind === 'composer' || e.origin?.kind === 'bridge') && teamIntent(e.text) ? TEAM_HINT : null
194hooks/config.ts 272 lines1import { DEFAULT_OPTIONS, HUD_IDLE } from '../shared/policy.ts'
2import type { PolicyOptions } from '../shared/policy.ts'
3
4// Pure logic for changing CTK's own plugin options from the Mods API: what may be set, how a
5// requested value is checked, and the pending-change state machine behind the Confirm button.
6// No host calls here and nothing is written: the one write, `$.config.set`, lives in the caller
7// and runs only after `confirm` returned `confirmed`. docs/CONFIG-API-NOTES.md has the evidence.
8
9/** The plugin's name; `$.config` rows are keyed `<plugin>.<field>`. */
10export const PLUGIN_NAME = 'ctk'
11
12export const OPTION_NAMES = [
13 'maxWorkers',
14 'explorerModel',
15 'implementerModel',
16 'reviewerModel',
17 'highRiskModel',
18 'designerModel',
19 'hudBand',
20 'hudIdle',
21 'recordStats',
22 'teamHint',
23] as const satisfies readonly (keyof PolicyOptions)[]
24export type OptionName = (typeof OPTION_NAMES)[number]
25
26// Compile-time guard: a PolicyOptions field missing from OPTION_NAMES fails the typecheck here.
27type Missing = Exclude<keyof PolicyOptions, OptionName>
28const noneMissing: [Missing] extends [never] ? true : never = true
29void noneMissing
30
31export type OptionValue = number | string | boolean
32
33export const isOptionName = (name: unknown): name is OptionName => (OPTION_NAMES as readonly unknown[]).includes(name)
34
35/** The `$.config.list()` / `$.config.set` row key of an option: `ctk.maxWorkers`. */
36export const optionKey = (name: OptionName): string => `${PLUGIN_NAME}.${name}`
37
38/** The cap the CLI schema (src/core/schema.ts) and the plugin.json `min`/`max` both enforce. */
39export const WORKERS_MIN = 1
40export const WORKERS_MAX = 12
41export const MODEL_MAX_LENGTH = 100
42/** A pending change older than this cannot be confirmed. */
43export const PENDING_TTL_MS = 10 * 60_000
44
45const MODEL_PATTERN = /^[A-Za-z0-9._:\-[\]]+$/
46// Credential shapes and long opaque tokens. A model alias or id is never one of these.
47const SECRET_PATTERN = /^(?:(?:sk|pk|rk)-[A-Za-z0-9_-]{8,}|gh[pos]_[A-Za-z0-9]{8,}|xox[a-z]-|AKIA[A-Z0-9]{12,}|AIza[A-Za-z0-9_-]{20,})|[A-Za-z0-9_-]{40,}/
48
49type Meta = { label: string; hint: string; allowed: string }
50
51const MODEL_ALLOWED = 'an alias (haiku, sonnet, opus), a full model id, or inherit'
52
53const META: Record<OptionName, Meta> = {
54 maxWorkers: {
55 label: 'Max live teammates',
56 hint: 'Hard cap on teammates alive at once. Further spawns are refused.',
57 allowed: `a whole number from ${WORKERS_MIN} to ${WORKERS_MAX}`,
58 },
59 explorerModel: { label: 'Explorer model', hint: 'Model for ctk:explorer.', allowed: MODEL_ALLOWED },
60 implementerModel: { label: 'Implementer model', hint: 'Model for ctk:implementer.', allowed: MODEL_ALLOWED },
61 reviewerModel: { label: 'Reviewer model', hint: 'Model for ctk:reviewer.', allowed: MODEL_ALLOWED },
62 highRiskModel: { label: 'High-risk reviewer model', hint: 'Model for ctk:high-risk-reviewer.', allowed: MODEL_ALLOWED },
63 designerModel: { label: 'Designer model', hint: 'Model for ctk:designer.', allowed: MODEL_ALLOWED },
64 hudBand: { label: 'Team band', hint: 'Show the one-line team status band above the prompt.', allowed: 'on or off' },
65 hudIdle: { label: 'Band while no team runs', hint: 'What the band shows until a teammate has started.', allowed: 'full, minimal or hidden' },
66 recordStats: { label: 'Record stats', hint: 'Write small per-session counters for the ctk stats command.', allowed: 'on or off' },
67 teamHint: { label: 'Team hint', hint: 'Add one hidden hint line to a prompt that asks for several agents or a team.', allowed: 'on or off' },
68}
69
70/** The sections Mission Control groups the options into, in display order. */
71export const OPTION_GROUPS: { title: string; names: OptionName[] }[] = [
72 { title: 'Team', names: ['maxWorkers'] },
73 { title: 'Models', names: ['explorerModel', 'implementerModel', 'reviewerModel', 'highRiskModel', 'designerModel'] },
74 { title: 'Band', names: ['hudBand', 'hudIdle'] },
75 { title: 'Other', names: ['recordStats', 'teamHint'] },
76]
77
78/** The group title for an option label as the pane receives it; unknown labels fall under Other. */
79export const groupOfLabel = (label: string): string => {
80 const name = OPTION_NAMES.find(n => META[n].label === label)
81 return OPTION_GROUPS.find(g => name !== undefined && g.names.includes(name))?.title ?? 'Other'
82}
83
84export type OptionRow = {
85 name: OptionName
86 label: string
87 /** The value now, typed. */
88 value: OptionValue
89 /** The value now as text (`on` / `off` for a switch). */
90 shown: string
91 hint: string
92 allowed: string
93 /** The shipped default, as text. */
94 defaultShown: string
95}
96
97export const showValue = (v: OptionValue): string => (typeof v === 'boolean' ? (v ? 'on' : 'off') : String(v))
98
99/** Every option with its current value, for display in a pane or a `show` answer. */
100export const describeOptions = (opts: PolicyOptions): OptionRow[] =>
101 OPTION_NAMES.map(name => ({
102 name,
103 label: META[name].label,
104 value: opts[name],
105 shown: showValue(opts[name]),
106 hint: META[name].hint,
107 allowed: META[name].allowed,
108 defaultShown: showValue(DEFAULT_OPTIONS[name]),
109 }))
110
111export type Change = {
112 name: OptionName
113 /** The key `$.config.set` takes. */
114 key: string
115 /** The validated, correctly typed new value. */
116 value: OptionValue
117 /** The value when the change was proposed. */
118 from: OptionValue
119 /** The new value (same as `value`). */
120 to: OptionValue
121 /** One line for the person: `Max live teammates (maxWorkers): 3 -> 2`. */
122 text: string
123}
124
125export type ValidateOk = { ok: true } & Change
126export type ValidateFail = {
127 ok: false
128 /** What went wrong, in words the model can relay. Never echoes a rejected string. */
129 reason: string
130 code: 'unknown_option' | 'invalid_value' | 'unchanged'
131}
132export type ValidateResult = ValidateOk | ValidateFail
133
134const fail = (code: ValidateFail['code'], reason: string): ValidateFail => ({ ok: false, reason, code })
135
136const SAFE_NAME = /^[A-Za-z0-9_.-]{1,40}$/
137
138const parseWorkers = (raw: unknown): number | string => {
139 let n: number | undefined
140 if (typeof raw === 'number') n = raw
141 else if (typeof raw === 'string' && /^\s*\d{1,3}\s*$/.test(raw)) n = Number(raw)
142 if (n === undefined) return `maxWorkers takes ${META.maxWorkers.allowed}, not ${typeof raw === 'string' ? 'that text' : typeof raw}.`
143 if (!Number.isInteger(n) || n < WORKERS_MIN || n > WORKERS_MAX) {
144 return `maxWorkers takes ${META.maxWorkers.allowed}; ${Number.isFinite(n) ? n : 'that value'} does not qualify.`
145 }
146 return n
147}
148
149const parseModel = (name: OptionName, raw: unknown): string => {
150 if (typeof raw !== 'string') throw new Error(`${name} takes ${MODEL_ALLOWED}, not ${typeof raw}.`)
151 const v = raw.trim()
152 if (v === '') throw new Error(`${name} cannot be empty; use inherit to follow the session model.`)
153 if (v.length > MODEL_MAX_LENGTH) throw new Error(`${name} is limited to ${MODEL_MAX_LENGTH} characters.`)
154 if (!MODEL_PATTERN.test(v)) {
155 throw new Error(`${name} takes ${MODEL_ALLOWED}: letters, digits and . _ : - [ ] only, no spaces.`)
156 }
157 if (SECRET_PATTERN.test(v)) throw new Error(`${name} looks like a credential, not a model; nothing was accepted.`)
158 return v
159}
160
161const parseIdle = (name: OptionName, raw: unknown): string => {
162 const v = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
163 if ((HUD_IDLE as readonly string[]).includes(v)) return v
164 throw new Error(`${name} takes ${META[name].allowed}.`)
165}
166
167const parseSwitch = (name: OptionName, raw: unknown): boolean => {
168 if (typeof raw === 'boolean') return raw
169 if (typeof raw === 'string') {
170 const v = raw.trim().toLowerCase()
171 if (v === 'true' || v === 'on') return true
172 if (v === 'false' || v === 'off') return false
173 }
174 throw new Error(`${name} takes ${META[name].allowed} (true or false).`)
175}
176
177/**
178 * Check a requested change against CTK's schema and the current options. Rejects unknown
179 * names, out-of-range or non-integer caps, malformed model ids, non-boolean switches, and a
180 * value equal to the current one. A rejection never echoes a rejected string.
181 */
182export const validateChange = (name: string, raw: unknown, opts: PolicyOptions): ValidateResult => {
183 if (!isOptionName(name)) {
184 const shown = SAFE_NAME.test(String(name)) ? ` "${String(name)}"` : ''
185 return fail('unknown_option', `Unknown option${shown}. Valid options: ${OPTION_NAMES.join(', ')}.`)
186 }
187 let value: OptionValue
188 if (name === 'maxWorkers') {
189 const n = parseWorkers(raw)
190 if (typeof n === 'string') return fail('invalid_value', n)
191 value = n
192 } else {
193 try {
194 value = name === 'hudBand' || name === 'recordStats' || name === 'teamHint' ? parseSwitch(name, raw) : name === 'hudIdle' ? parseIdle(name, raw) : parseModel(name, raw)
195 } catch (err) {
196 return fail('invalid_value', (err as Error).message)
197 }
198 }
199 const from = opts[name]
200 if (value === from) return fail('unchanged', `${name} is already ${showValue(from)}; nothing to change.`)
201 return {
202 ok: true,
203 name,
204 key: optionKey(name),
205 value,
206 from,
207 to: value,
208 text: `${META[name].label} (${name}): ${showValue(from)} -> ${showValue(value)}`,
209 }
210}
211
212// ---- pending change: proposed by the model, applied only after the person confirms ----
213
214export type Pending = { id: string; change: Change; createdAt: number }
215
216/** At most one change waits at a time; `seq` numbers the proposals so a stale button cannot match a newer one. */
217export type PendingState = { pending: Pending | null; seq: number }
218
219export const emptyPending = (): PendingState => ({ pending: null, seq: 0 })
220
221export const isExpired = (p: Pending, now: number): boolean => now - p.createdAt > PENDING_TTL_MS
222
223export type ProposeOutcome = { kind: 'proposed'; pending: Pending; replaced: Pending | null }
224export type ConfirmOutcome =
225 | { kind: 'confirmed'; change: Change }
226 | { kind: 'none' }
227 | { kind: 'mismatch'; pendingId: string }
228 | { kind: 'expired'; change: Change }
229export type CancelOutcome = { kind: 'cancelled'; change: Change } | { kind: 'none' } | { kind: 'mismatch'; pendingId: string }
230
231/** Store a validated change as the pending one. A change already waiting is replaced and reported. */
232export const propose = (
233 state: PendingState,
234 change: Change,
235 now: number,
236): { state: PendingState; outcome: ProposeOutcome } => {
237 const seq = state.seq + 1
238 const pending: Pending = { id: `c${seq}`, change, createdAt: now }
239 return { state: { pending, seq }, outcome: { kind: 'proposed', pending, replaced: state.pending } }
240}
241
242/**
243 * The person pressed Confirm on proposal `id`. Only a live, matching proposal is released, once:
244 * the state is cleared so a second press finds nothing. A different id leaves the waiting one alone;
245 * an expired one is dropped and not released. The caller then calls `$.config.set` with `change.key`
246 * and `change.value`, and re-runs `validateChange` against the options as they are at that moment.
247 */
248export const confirm = (
249 state: PendingState,
250 id: string,
251 now: number,
252): { state: PendingState; outcome: ConfirmOutcome } => {
253 const p = state.pending
254 if (p === null) return { state, outcome: { kind: 'none' } }
255 if (p.id !== id) return { state, outcome: { kind: 'mismatch', pendingId: p.id } }
256 const cleared: PendingState = { pending: null, seq: state.seq }
257 if (isExpired(p, now)) return { state: cleared, outcome: { kind: 'expired', change: p.change } }
258 return { state: cleared, outcome: { kind: 'confirmed', change: p.change } }
259}
260
261/** The person pressed Cancel on proposal `id`: discarded, nothing applied. */
262export const cancel = (state: PendingState, id: string): { state: PendingState; outcome: CancelOutcome } => {
263 const p = state.pending
264 if (p === null) return { state, outcome: { kind: 'none' } }
265 if (p.id !== id) return { state, outcome: { kind: 'mismatch', pendingId: p.id } }
266 return { state: { pending: null, seq: state.seq }, outcome: { kind: 'cancelled', change: p.change } }
267}
268
269/** Drop an expired proposal (for a redraw); a live one is returned as it was. */
270export const sweep = (state: PendingState, now: number): PendingState =>
271 state.pending !== null && isExpired(state.pending, now) ? { pending: null, seq: state.seq } : state
272shared/hudline.ts 231 lines1// Width-aware layout for the one-line HUD, shared by the Mods band (hooks/band.ts). The
2// dependency-free status line script (statusline/ctk-statusline.mjs) carries a copy of the
3// same functions because it is installed as a single file; tests/hudline.test.ts runs both
4// over one matrix so the two cannot drift. Pure, no imports.
5
6export const DASH = '–'
7export const SEP = ' │ '
8
9/** Terminal width used when none is known: narrow enough to be safe in an 80-column terminal. */
10export const DEFAULT_COLUMNS = 80
11
12/** 0 = 120+ columns (full), 1 = 80-119 (abbreviated), 2 = under 80 (only the essentials). */
13export type Tier = 0 | 1 | 2
14
15/** At most this many segments are drawn in the narrowest tier. */
16export const MIN_TIER_SEGMENTS = 3
17
18export const tierOf = (columns: number): Tier => (columns >= 120 ? 0 : columns >= 80 ? 1 : 2)
19
20export type Segment = {
21 id: string
22 /** Higher is kept longer when the line is too wide. */
23 prio: number
24 /** The text per tier, richest first; '' means the segment is not drawn at that tier. */
25 forms: readonly [string, string, string]
26 /** True when the figure was not reported: drawn as a dash and dropped before any real figure. */
27 missing?: boolean
28 bold?: boolean
29}
30
31// --- Display width -----------------------------------------------------------------------
32
33// CSI and OSC sequences. They take no cells.
34const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
35
36export const stripAnsi = (s: string): string => s.replace(ANSI, '')
37
38type Range = readonly [number, number]
39
40const inRanges = (cp: number, ranges: readonly Range[]): boolean => {
41 for (const [lo, hi] of ranges) if (cp >= lo && cp <= hi) return true
42 return false
43}
44
45// Combining marks, joiners and variation selectors take no cell of their own.
46const ZERO: readonly Range[] = [
47 [0x0300, 0x036f], [0x0483, 0x0489], [0x0591, 0x05bd], [0x0610, 0x061a], [0x064b, 0x065f],
48 [0x200b, 0x200f], [0x202a, 0x202e], [0x2060, 0x2064], [0x20d0, 0x20ff], [0x1ab0, 0x1aff],
49 [0x1dc0, 0x1dff], [0xfe00, 0xfe0f], [0xfe20, 0xfe2f], [0xe0100, 0xe01ef],
50]
51
52// East Asian Wide and Fullwidth, plus the emoji blocks terminals draw two cells wide.
53const WIDE: readonly Range[] = [
54 [0x1100, 0x115f], [0x231a, 0x231b], [0x23e9, 0x23ec], [0x2705, 0x2705], [0x2e80, 0x303e], [0x3041, 0x33ff],
55 [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
56 [0xf900, 0xfaff], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1f64f],
57 [0x1f680, 0x1f6ff], [0x1f900, 0x1f9ff], [0x20000, 0x3fffd],
58]
59
60// East Asian Ambiguous characters this file or a branch name is likely to hold: one cell in
61// most terminals, two in terminals set to a CJK ambiguous width (CTK_AMBIGUOUS_WIDTH=2).
62const AMBIGUOUS: readonly Range[] = [
63 [0x00a1, 0x00a1], [0x00a4, 0x00a4], [0x00a7, 0x00a8], [0x00aa, 0x00aa], [0x00ad, 0x00ae],
64 [0x00b0, 0x00b4], [0x00b6, 0x00ba], [0x00bc, 0x00bf], [0x00d7, 0x00d7], [0x00f7, 0x00f7],
65 [0x2010, 0x2027], [0x2030, 0x203b], [0x2190, 0x21ff], [0x2460, 0x24ff], [0x2500, 0x257f],
66 [0x2580, 0x258f], [0x2592, 0x2595], [0x25a0, 0x25ff],
67]
68
69export const charWidth = (cp: number, ambiguous: 1 | 2 = 1): number => {
70 if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) return 0
71 if (cp < 0x300) return ambiguous === 2 && inRanges(cp, AMBIGUOUS) ? 2 : 1
72 if (inRanges(cp, ZERO)) return 0
73 if (inRanges(cp, WIDE)) return 2
74 return ambiguous === 2 && inRanges(cp, AMBIGUOUS) ? 2 : 1
75}
76
77/** Terminal cells the text takes: ANSI sequences take none, CJK and emoji take two. */
78export const displayWidth = (s: string, ambiguous: 1 | 2 = 1): number => {
79 let w = 0
80 for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0) ?? 0, ambiguous)
81 return w
82}
83
84/** Cuts to at most `max` cells, ending with `…` when something was cut; never splits a wide character. */
85export const truncateToWidth = (s: string, max: number, ambiguous: 1 | 2 = 1): string => {
86 if (max < 1) return ''
87 const plain = stripAnsi(s)
88 if (displayWidth(plain, ambiguous) <= max) return plain
89 const ell = charWidth(0x2026, ambiguous)
90 let out = ''
91 let used = 0
92 for (const ch of plain) {
93 const w = charWidth(ch.codePointAt(0) ?? 0, ambiguous)
94 if (used + w + ell > max) break
95 out += ch
96 used += w
97 }
98 return ell > max ? '' : `${out}…`
99}
100
101// --- Figures -----------------------------------------------------------------------------
102
103const finite = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
104
105/** A usage percentage, rounded; null (a dash) when it was not reported. */
106export const fmtPct = (v: number | null | undefined): string => {
107 const n = finite(v)
108 return n === null || n < 0 ? DASH : `${Math.round(n)}%`
109}
110
111/**
112 * Reset time to epoch milliseconds. Claude Code's status line gives epoch seconds, the Mods
113 * API an ISO 8601 string; anything else (or a non-date) is null, never a guess.
114 */
115export const resetMs = (v: unknown): number | null => {
116 if (typeof v === 'number') {
117 if (!Number.isFinite(v) || v <= 0) return null
118 return v < 1e11 ? v * 1000 : v
119 }
120 if (typeof v === 'string' && v.trim() !== '') {
121 const t = Date.parse(v)
122 return Number.isNaN(t) ? null : t
123 }
124 return null
125}
126
127/** `34m`, `2h34m`, `3d12h`; `<1m` under a minute; null once the reset time has passed. */
128export const fmtCountdown = (resetAtMs: number | null, nowMs: number): string | null => {
129 if (resetAtMs === null || !Number.isFinite(nowMs) || resetAtMs <= nowMs) return null
130 const mins = Math.floor((resetAtMs - nowMs) / 60_000)
131 if (mins < 1) return '<1m'
132 if (mins < 60) return `${mins}m`
133 const hours = Math.floor(mins / 60)
134 if (hours < 24) return `${hours}h${String(mins % 60).padStart(2, '0')}m`
135 return `${Math.floor(hours / 24)}d${hours % 24}h`
136}
137
138export type Window = { pct: number | null | undefined; resetsAtMs: number | null }
139
140/**
141 * One rate-limit window. A window whose reset time has passed is stale (the percentage
142 * belongs to the window that ended), so it renders as a dash rather than a number that is
143 * no longer true. A percentage without a reset time still shows; the countdown is left out.
144 */
145export const windowFigures = (w: Window, nowMs: number): { pct: string; countdown: string | null; missing: boolean } => {
146 const stale = w.resetsAtMs !== null && Number.isFinite(nowMs) && w.resetsAtMs <= nowMs
147 const pct = stale ? DASH : fmtPct(w.pct)
148 return { pct, countdown: stale ? null : fmtCountdown(w.resetsAtMs, nowMs), missing: pct === DASH }
149}
150
151/** `5h 28% (2h34m)` / `5h 28% 2h34m`; a missing figure is `5h –`. */
152export const usageSegment = (id: string, label: string, prio: number, w: Window, nowMs: number): Segment => {
153 const f = windowFigures(w, nowMs)
154 if (f.missing) return { id, prio, missing: true, forms: [`${label} ${DASH}`, `${label} ${DASH}`, `${label} ${DASH}`] }
155 const base = `${label} ${f.pct}`
156 return {
157 id,
158 prio,
159 forms: f.countdown === null ? [base, base, base] : [`${base} (${f.countdown})`, `${base} ${f.countdown}`, `${base} ${f.countdown}`],
160 }
161}
162
163// --- Layout ------------------------------------------------------------------------------
164
165export type LayoutOptions = {
166 /** Cells the line may take. */
167 columns: number
168 /** The terminal width that picks the tier, when `columns` is less than the terminal (a margin). */
169 tierColumns?: number
170 /** 2 for terminals that draw East Asian Ambiguous characters (│ · …) two cells wide. */
171 ambiguous?: 1 | 2
172 /** Wrap `bold` segments in ANSI bold. The width maths ignores the sequences. */
173 color?: boolean
174}
175
176const BOLD = ['\x1b[1m', '\x1b[22m'] as const
177
178// A figure that was not reported is dropped before one that was, whatever its rank.
179const effective = (s: Segment): number => (s.missing === true ? s.prio - 1000 : s.prio)
180
181/**
182 * Lays the segments out on one line that is never wider than `columns` cells.
183 *
184 * The tier follows the width (120+ full, 80-119 abbreviated, under 80 the essentials only).
185 * When the tier's text is still too wide, whole segments are dropped lowest rank first and the
186 * line is never cut mid-figure. Only when a single segment alone does not fit is it shortened
187 * (a more abbreviated form first, then `…`). Segments keep their given display order.
188 */
189export const layoutLine = (segments: readonly Segment[], opts: LayoutOptions): string => {
190 const columns = Number.isFinite(opts.columns) && opts.columns >= 1 ? Math.floor(opts.columns) : DEFAULT_COLUMNS
191 const amb = opts.ambiguous === 2 ? 2 : 1
192 const tier = tierOf(Number.isFinite(opts.tierColumns) && (opts.tierColumns as number) >= 1 ? (opts.tierColumns as number) : columns)
193 const sepWidth = displayWidth(SEP, amb)
194 const width = (items: readonly { text: string }[]): number =>
195 items.reduce((n, i) => n + displayWidth(i.text, amb), 0) + sepWidth * Math.max(0, items.length - 1)
196
197 let items = segments
198 .map(s => ({ s, text: s.forms[tier] }))
199 .filter(i => i.text !== '')
200 if (tier === 2 && items.length > MIN_TIER_SEGMENTS) {
201 const keep = new Set(
202 [...items]
203 .sort((a, b) => effective(b.s) - effective(a.s))
204 .slice(0, MIN_TIER_SEGMENTS)
205 .map(i => i.s.id),
206 )
207 items = items.filter(i => keep.has(i.s.id))
208 }
209 while (items.length > 1 && width(items) > columns) {
210 let drop = 0
211 let lowest = Infinity
212 items.forEach((item, i) => {
213 if (effective(item.s) <= lowest) {
214 lowest = effective(item.s)
215 drop = i
216 }
217 })
218 items = items.filter((_, i) => i !== drop)
219 }
220 const only = items.length === 1 ? items[0] : undefined
221 if (only !== undefined && width(items) > columns) {
222 let text = only.text
223 for (let t = tier + 1; t <= 2 && displayWidth(text, amb) > columns; t++) {
224 const form = only.s.forms[t]
225 if (form !== undefined && form !== '') text = form
226 }
227 items = [{ s: only.s, text: truncateToWidth(text, columns, amb) }]
228 }
229 return items.map(i => (opts.color === true && i.s.bold === true ? `${BOLD[0]}${i.text}${BOLD[1]}` : i.text)).join(SEP)
230}
231hooks/branch.ts 45 lines1// The git branch for the band, read from .git/HEAD only: no `git` process, no network. Pure apart
2// from the `read` it is handed ($.fs.read in the mod); a missing file is "not a repository".
3
4type Read = (path: string) => Promise<string>
5
6const MAX_UP = 40
7const ABSOLUTE = /^(?:[\\/]|[A-Za-z]:)/
8
9const parentOf = (dir: string): string | null => {
10 const up = dir.replace(/[\\/][^\\/]*[\\/]*$/, '')
11 return up === dir || up === '' ? null : up
12}
13
14/** The branch name, a 7-character commit id when HEAD is detached, or null when it cannot be told. */
15export const parseHead = (head: string): string | null => {
16 const text = head.trim()
17 const ref = /^ref:\s*(.+)$/.exec(text)
18 if (ref) return (ref[1] as string).replace(/^refs\/heads\//, '').trim() || null
19 return /^[0-9a-f]{7,64}$/.test(text) ? text.slice(0, 7) : null
20}
21
22const tryRead = async (read: Read, path: string): Promise<string | null> => {
23 try {
24 return await read(path)
25 } catch {
26 return null
27 }
28}
29
30/** Walks up from `cwd` to the first `.git` (a directory, or the file a linked worktree has). */
31export const readBranch = async (read: Read, cwd: string): Promise<string | null> => {
32 let dir: string | null = cwd.replace(/[\\/]+$/, '')
33 for (let i = 0; dir !== null && i < MAX_UP; i++, dir = parentOf(dir)) {
34 const head = await tryRead(read, `${dir}/.git/HEAD`)
35 if (head !== null) return parseHead(head)
36 const link = await tryRead(read, `${dir}/.git`)
37 const gitdir = link === null ? null : /^gitdir:\s*(.+)$/m.exec(link)?.[1]?.trim()
38 if (gitdir) {
39 const linked = await tryRead(read, `${ABSOLUTE.test(gitdir) ? gitdir : `${dir}/${gitdir}`}/HEAD`)
40 return linked === null ? null : parseHead(linked)
41 }
42 }
43 return null
44}
45hooks/mission.ts 587 lines1import { DASH, fmtCountdown, fmtPct, resetMs } from '../shared/hudline.ts'
2import type { StatsRecord } from '../shared/stats.ts'
3import type { Snapshot, Subagent } from './team.ts'
4
5// The data behind Mission Control: what CTK observed this session, and the view model built
6// from it. Pure: no host calls (those are in register.tsx). Everything here is held in memory
7// only and is never written to the stats file. A figure that was not observed is null and is
8// shown as "unavailable"; nothing is estimated.
9
10export const UNAVAILABLE = 'unavailable'
11
12export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted' | 'unknown'
13
14export type TaskRec = {
15 id: string
16 subject: string
17 status: TaskStatus
18 owner: string | null
19 /** Ids this task waits for, as the model declared them (`addBlockedBy`). */
20 blockedBy: string[]
21 /** Ids waiting for this task (`addBlocks`). */
22 blocks: string[]
23 /** True when the task was only ever seen in an update: it was created before CTK loaded, so its subject and status are not known. */
24 seenByUpdateOnly?: boolean
25}
26
27export type WorkerRec = {
28 agentId: string
29 name: string
30 model: string | null
31 /** Tool calls the worker's own loop made (tool.call events carrying its agentId). */
32 toolCalls: number
33 /** Labels of its last RECENT_CALLS tool calls (see toolLabel), newest last. */
34 recent: string[]
35 /** Epoch ms of its last tool call, turn end or idle notice; null when none was seen. */
36 lastActivityAt: number | null
37 /** Epoch ms of the last TeammateIdle notice, cleared by the next activity. */
38 idleSinceAt: number | null
39 /** Epoch ms of the spawn CTK saw. */
40 startedAt: number
41}
42
43export type MissionState = {
44 workers: Map<string, WorkerRec>
45 tasks: Map<string, TaskRec>
46 /** Epoch ms of the first teammate this session started; null until one has. */
47 teamStartedAt: number | null
48 /** True once a TaskCreate or TaskUpdate call has been seen: the board is then a record of those calls. */
49 taskCallsSeen: boolean
50}
51
52export const newMissionState = (): MissionState => ({ workers: new Map(), tasks: new Map(), teamStartedAt: null, taskCallsSeen: false })
53
54/** Most workers and tasks remembered; the oldest are forgotten past this. */
55export const MEMORY_LIMIT = 500
56
57const trim = <V>(map: Map<string, V>): void => {
58 while (map.size > MEMORY_LIMIT) {
59 const first = map.keys().next()
60 if (first.done === true) break
61 map.delete(first.value)
62 }
63}
64
65// --- Observations ------------------------------------------------------------------------
66
67export const noteSpawn = (m: MissionState, agentId: string, name: string, model: string | null, now: number): void => {
68 m.workers.set(agentId, { agentId, name, model, toolCalls: 0, recent: [], lastActivityAt: now, idleSinceAt: null, startedAt: now })
69 m.teamStartedAt ??= now
70 trim(m.workers)
71}
72
73/** Most call labels kept per worker. */
74export const RECENT_CALLS = 6
75const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
76
77/** The tool name, plus a file's basename for file tools. Nothing else of the input is kept: a command or pattern can carry a secret. */
78export const toolLabel = (tool: string, input: unknown): string => {
79 const path = FILE_TOOLS.has(tool) && typeof input === 'object' && input !== null ? (input as Record<string, unknown>).file_path ?? (input as Record<string, unknown>).notebook_path : undefined
80 const base = typeof path === 'string' ? path.split(/[\\/]/).filter(Boolean).pop() : undefined
81 return base === undefined ? tool : `${tool} ${base}`.slice(0, 40)
82}
83
84/** A tool call made inside a subagent or teammate loop. The lead's own calls are not per-worker. */
85export const noteWorkerToolCall = (m: MissionState, agentId: string | undefined, now: number, label?: string): void => {
86 if (agentId === undefined) return
87 const w = m.workers.get(agentId)
88 if (w === undefined) return
89 w.toolCalls += 1
90 if (label !== undefined) w.recent = [...w.recent, label].slice(-RECENT_CALLS)
91 w.lastActivityAt = now
92 w.idleSinceAt = null
93}
94
95export const noteWorkerTurnEnd = (m: MissionState, agentId: string | undefined, now: number): void => {
96 if (agentId === undefined) return
97 const w = m.workers.get(agentId)
98 if (w !== undefined) w.lastActivityAt = now
99}
100
101/** TeammateIdle carries the teammate's name, not its agent id. */
102export const noteTeammateIdle = (m: MissionState, name: string, now: number): void => {
103 for (const w of m.workers.values()) {
104 if (w.name === name) {
105 w.idleSinceAt = now
106 w.lastActivityAt = now
107 }
108 }
109}
110
111const record = (v: unknown): Record<string, unknown> | null =>
112 typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null
113
114const text = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null)
115
116const idList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x !== '') : [])
117
118const STATUSES: readonly string[] = ['pending', 'in_progress', 'completed', 'deleted']
119
120/**
121 * Applies one TaskCreate or TaskUpdate call to the board. `input` is what the model passed
122 * (subject, taskId, status, owner, addBlockedBy, addBlocks) and `output` the tool's own record
123 * (`{ task: { id } }` for a create, `{ success }` for an update). Only those named fields are
124 * read; descriptions, metadata and everything else are ignored. A call whose result says it
125 * failed, or that carries no id, changes nothing.
126 */
127export const noteTaskCall = (m: MissionState, tool: string, input: Record<string, unknown>, output: unknown): void => {
128 const out = record(output)
129 if (tool === 'TaskCreate') {
130 const task = record(out?.task)
131 const id = text(task?.id)
132 const subject = text(task?.subject) ?? text(input.subject)
133 if (id === null || subject === null) return
134 m.tasks.set(id, { id, subject, status: 'pending', owner: null, blockedBy: [], blocks: [] })
135 m.taskCallsSeen = true
136 trim(m.tasks)
137 return
138 }
139 if (tool !== 'TaskUpdate') return
140 const id = text(input.taskId)
141 if (id === null || out?.success === false) return
142 const rec: TaskRec = m.tasks.get(id) ?? { id, subject: text(input.subject) ?? `(task ${id})`, status: 'unknown', owner: null, blockedBy: [], blocks: [], seenByUpdateOnly: true }
143 const subject = text(input.subject)
144 if (subject !== null) rec.subject = subject
145 if (typeof input.status === 'string' && STATUSES.includes(input.status)) rec.status = input.status as TaskStatus
146 const owner = text(input.owner)
147 if (owner !== null) rec.owner = owner
148 for (const b of idList(input.addBlockedBy)) if (!rec.blockedBy.includes(b)) rec.blockedBy.push(b)
149 for (const b of idList(input.addBlocks)) if (!rec.blocks.includes(b)) rec.blocks.push(b)
150 m.tasks.set(id, rec)
151 m.taskCallsSeen = true
152 trim(m.tasks)
153}
154
155// --- View model --------------------------------------------------------------------------
156
157export type GuardState = 'active' | 'available' | 'unavailable' | 'error'
158
159/** `why` is the full reason (Doctor, text, JSON); `short` is the one-sentence form the Overview wraps. */
160export type Guard = { state: GuardState; label: string; why: string; short: string }
161
162const ordinal = (n: number): string => {
163 const t = n % 100
164 return `${n}${t >= 11 && t <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[n % 10] ?? 'th'}`
165}
166
167/** The word shown for a guard state: `ON` for active, otherwise the state itself. */
168export const guardWord = (g: Guard): string => (g.state === 'active' ? 'ON' : g.state)
169
170/**
171 * Whether the worker cap is doing its job, from evidence and in this order.
172 * `error`: a spawn was refused because the cap could not be checked, the roster could not be read at
173 * the last refresh, or more teammates are live than the cap allows (they started before the cap was
174 * lowered, or outside the guard). `unavailable`: nothing has been read yet, or Agent Teams are off, so
175 * no teammate can start. `active`: Agent Teams are confirmed on, the roster is readable, and the guard
176 * has been reached by at least one `agent.spawn` event this session. `available`: the mod is loaded and
177 * armed, but the guard has not been exercised yet, or the Agent Teams flag could not be read; nothing
178 * claims more than that. A Claude Code version that supports Mods is never evidence by itself.
179 */
180export const guardOf = (stats: StatsRecord, snap: Snapshot, teamsEnabled: boolean | null, ready: boolean): Guard => {
181 if (stats.spawnsFailedClosed > 0) {
182 const why = `${stats.spawnsFailedClosed} spawn(s) were refused because the cap could not be checked (TEAM_GUARD_FAILED)`
183 return { state: 'error', label: 'ERR', why, short: why }
184 }
185 if (!ready) return { state: 'unavailable', label: DASH, why: 'nothing has been read yet', short: 'nothing has been read yet' }
186 if (snap.live === null) return { state: 'error', label: 'ERR', why: 'the roster could not be read at the last refresh', short: 'the roster could not be read at the last refresh' }
187 if (teamsEnabled === false) return { state: 'unavailable', label: DASH, why: 'Agent Teams are not enabled, so no teammate can start', short: 'Agent Teams are not enabled, so no teammate can start' }
188 if (snap.live > stats.maxWorkers) {
189 return {
190 state: 'error',
191 label: 'ERR',
192 why: `${snap.live} teammates are live, above the cap of ${stats.maxWorkers}: they started before the cap was lowered, or outside the guard`,
193 short: `${snap.live} teammates are live, above the cap of ${stats.maxWorkers}`,
194 }
195 }
196 if (teamsEnabled === true && stats.spawnsSeen > 0) {
197 return {
198 state: 'active',
199 label: 'ON',
200 why: `reached by ${stats.spawnsSeen} spawn(s) this session, ${stats.spawnsAccepted} of them teammate(s); a teammate spawn above ${stats.maxWorkers} live teammates is refused with TEAM_CAPACITY_REACHED`,
201 short: `${stats.spawnsSeen} spawn(s) reached the guard · refuses a ${ordinal(stats.maxWorkers + 1)} live teammate (TEAM_CAPACITY_REACHED)`,
202 }
203 }
204 return {
205 state: 'available',
206 label: 'ready',
207 why:
208 teamsEnabled === null
209 ? 'loaded, but the Agent Teams flag could not be read and no spawn has reached the guard yet'
210 : `loaded; no spawn has reached the guard yet. From the first teammate spawn it refuses one above ${stats.maxWorkers} live`,
211 short: teamsEnabled === null ? 'loaded; the Agent Teams flag could not be read and no spawn has reached the guard yet' : `no spawn has reached the guard yet; it refuses the ${ordinal(stats.maxWorkers + 1)} live teammate`,
212 }
213}
214
215export type TaskRow = {
216 id: string
217 subject: string
218 status: TaskStatus
219 owner: string | null
220 blockedBy: string[]
221 blocks: string[]
222 /** Ids in blockedBy that are not completed (an id the board has not seen counts as open). */
223 openBlockers: string[]
224 ready: boolean
225 blocked: boolean
226 seenByUpdateOnly: boolean
227}
228
229const byId = (a: { id: string }, b: { id: string }): number => {
230 const x = Number(a.id)
231 const y = Number(b.id)
232 return Number.isFinite(x) && Number.isFinite(y) ? x - y : a.id.localeCompare(b.id)
233}
234
235export const taskRows = (m: MissionState): TaskRow[] => {
236 const live = [...m.tasks.values()].filter(t => t.status !== 'deleted')
237 return live.sort(byId).map(t => {
238 const blockers = new Set([...t.blockedBy, ...live.filter(o => o.blocks.includes(t.id)).map(o => o.id)])
239 const open = [...blockers].filter(id => m.tasks.get(id)?.status !== 'completed' && m.tasks.get(id)?.status !== 'deleted')
240 return {
241 id: t.id,
242 subject: t.subject,
243 status: t.status,
244 owner: t.owner,
245 blockedBy: [...blockers].sort((a, b) => byId({ id: a }, { id: b })),
246 blocks: [...new Set([...t.blocks, ...live.filter(o => o.blockedBy.includes(t.id)).map(o => o.id)])].sort((a, b) => byId({ id: a }, { id: b })),
247 openBlockers: open.sort((a, b) => byId({ id: a }, { id: b })),
248 ready: t.status === 'pending' && open.length === 0,
249 blocked: (t.status === 'pending' || t.status === 'in_progress') && open.length > 0,
250 seenByUpdateOnly: t.seenByUpdateOnly === true,
251 }
252 })
253}
254
255export type WorkerRow = {
256 agentId: string
257 name: string
258 status: string
259 model: string | null
260 /** `#3 write tests` for the in-progress task the worker owns; null when the board does not show one. */
261 currentTask: string | null
262 toolCalls: number | null
263 /** Labels of its last tool calls, newest first; empty when none was seen. */
264 recent: string[]
265 lastActivityMs: number | null
266 idleMs: number | null
267 /** Time since the spawn CTK saw; null for a worker it did not see start. */
268 elapsedMs: number | null
269}
270
271const busyStatuses = new Set(['pending', 'running', 'waiting'])
272
273export const workerRows = (m: MissionState, snap: Snapshot, nowMs: number, rows: TaskRow[] = taskRows(m)): WorkerRow[] =>
274 snap.workers.map(w => {
275 const rec = m.workers.get(w.agentId)
276 const mine = rows.find(t => t.owner !== null && (t.owner === w.name || t.owner === w.teammateId) && t.status === 'in_progress')
277 const last = rec?.lastActivityAt ?? null
278 const idleFrom = rec?.idleSinceAt ?? last
279 return {
280 agentId: w.agentId,
281 name: w.name,
282 status: w.status,
283 model: rec?.model ?? null,
284 currentTask: m.taskCallsSeen && mine !== undefined ? `#${mine.id} ${mine.subject}` : null,
285 toolCalls: rec === undefined ? null : rec.toolCalls,
286 recent: rec === undefined ? [] : [...rec.recent].reverse(),
287 lastActivityMs: last === null || nowMs < last ? null : nowMs - last,
288 idleMs: w.status === 'idle' && idleFrom !== null && nowMs >= idleFrom ? nowMs - idleFrom : null,
289 elapsedMs: rec === undefined || nowMs < rec.startedAt ? null : nowMs - rec.startedAt,
290 }
291 })
292
293export type Mission = {
294 guard: Guard
295 cap: number
296 active: number | null
297 running: number | null
298 idle: number | null
299 completed: number | null
300 failed: number | null
301 rejected: number
302 /** Named agents Claude Code started as ordinary subagents (a call with `isolation` is one): outside the cap, counted only. */
303 outsideCap: number
304 /** Since the first teammate started; null before that. */
305 teamElapsedMs: number | null
306 /** Ordinary subagents the roster lists. They are not teammates: the cap does not count them and no worker detail is kept for them. */
307 subagents: { total: number; live: number; rows: Subagent[] }
308 /** Why the Workers and Tasks views are empty, and what to do; null when there is something to show. From observed facts only. */
309 empty: { workers: string | null; tasks: string | null }
310 tasks: {
311 /** Task rows were built from observed TaskCreate/TaskUpdate calls. */
312 detailed: boolean
313 /** Some tasks were seen only in an update (created before CTK loaded): the totals cannot be stated. */
314 partial: boolean
315 total: number | null
316 completed: number | null
317 pending: number | null
318 inProgress: number | null
319 blocked: number | null
320 ready: number | null
321 rows: TaskRow[]
322 }
323 workers: WorkerRow[]
324 usage: {
325 contextPct: string
326 fiveHour: string
327 sevenDay: string
328 /** The same windows as numbers for a bar; null when not observed. */
329 fiveHourPct: number | null
330 sevenDayPct: number | null
331 contextUsed: number | null
332 /** Time until each window resets; null when unknown or already reset. */
333 fiveHourReset: string | null
334 sevenDayReset: string | null
335 cost: string
336 toolCalls: number
337 model: string | null
338 }
339}
340
341// A window whose reset time has passed is no longer observed: its percent is stale.
342const windowPct = (pct: number | null, resetsAt: string | null, nowMs: number): number | null => {
343 const at = resetMs(resetsAt)
344 return at !== null && fmtCountdown(at, nowMs) === null ? null : pct
345}
346
347const usageText = (pct: number | null, resetsAt: string | null, nowMs: number): string => {
348 const at = resetMs(resetsAt)
349 const left = fmtCountdown(at, nowMs)
350 if (at !== null && left === null) return `${UNAVAILABLE} (window reset)`
351 const p = fmtPct(pct)
352 if (p === DASH) return UNAVAILABLE
353 return left === null ? p : `${p} (resets in ${left})`
354}
355
356export type MissionInput = {
357 stats: StatsRecord
358 snap: Snapshot
359 state: MissionState
360 nowMs: number
361 teamsEnabled: boolean | null
362 ready: boolean
363}
364
365const LIVE_STATUS = new Set(['pending', 'running', 'waiting', 'idle'])
366
367const EMPTY_TASKS =
368 'No task list yet. The lead creates one with TaskCreate (the /ctk:team skill does); tasks appear here once it has. On a Claude 5.x model the Task tools also need CLAUDE_CODE_ENABLE_TODO_TOOLS=1.'
369
370/** The reason the Workers view has no teammate to list, from what was observed; null when it has some. */
371const emptyWorkers = (snap: Snapshot, teamsEnabled: boolean | null): string | null => {
372 if (snap.workers.length > 0) return null
373 if (teamsEnabled === false) return 'Agent Teams are off, so no teammate can start. Turn them on (see /ctk-doctor), restart, then ask for a team.'
374 const sub = snap.subagents.length
375 const ran = sub > 0 ? ` ${sub} ordinary subagent${sub === 1 ? '' : 's'} ran or are running (listed below): they are not teammates, so the cap does not count them.` : ''
376 return `No teammate has started this session.${ran} A teammate is a named agent the lead starts for a team: run /ctk:team <goal> or ask for "a team".`
377}
378
379export const buildMission = ({ stats, snap, state, nowMs, teamsEnabled, ready }: MissionInput): Mission => {
380 const rows = taskRows(state)
381 const detailed = state.taskCallsSeen
382 const partial = rows.some(t => t.seenByUpdateOnly)
383 const count = (pred: (t: TaskRow) => boolean): number | null => (detailed && !partial ? rows.filter(pred).length : null)
384 const counted = stats.tasks
385 return {
386 guard: guardOf(stats, snap, teamsEnabled, ready),
387 cap: stats.maxWorkers,
388 active: snap.live,
389 running: snap.busy,
390 idle: snap.idle,
391 completed: snap.done === null ? null : snap.workers.filter(w => w.status === 'completed').length,
392 failed: snap.failed,
393 rejected: stats.spawnsRejected,
394 outsideCap: stats.spawnsOutsideCap,
395 teamElapsedMs: state.teamStartedAt === null || nowMs < state.teamStartedAt ? null : nowMs - state.teamStartedAt,
396 subagents: { total: snap.subagents.length, live: snap.subagents.filter(a => LIVE_STATUS.has(a.status)).length, rows: snap.subagents },
397 empty: {
398 workers: emptyWorkers(snap, teamsEnabled),
399 tasks: detailed ? null : EMPTY_TASKS,
400 },
401 tasks: {
402 detailed,
403 partial,
404 // Counts from the TaskCreated/TaskCompleted events are the fallback when no complete board was seen.
405 total: detailed && !partial ? rows.length : counted === null ? null : counted.created,
406 completed: detailed && !partial ? rows.filter(t => t.status === 'completed').length : counted === null ? null : counted.completed,
407 pending: count(t => t.status === 'pending'),
408 inProgress: count(t => t.status === 'in_progress'),
409 blocked: count(t => t.blocked),
410 ready: count(t => t.ready),
411 rows,
412 },
413 workers: workerRows(state, snap, nowMs, rows),
414 usage: {
415 contextPct: fmtPct(stats.measured.contextPct) === DASH ? UNAVAILABLE : fmtPct(stats.measured.contextPct),
416 fiveHour: usageText(stats.measured.fiveHourPct, stats.measured.fiveHourResetsAt, nowMs),
417 sevenDay: usageText(stats.measured.sevenDayPct, stats.measured.sevenDayResetsAt, nowMs),
418 fiveHourPct: windowPct(stats.measured.fiveHourPct, stats.measured.fiveHourResetsAt, nowMs),
419 sevenDayPct: windowPct(stats.measured.sevenDayPct, stats.measured.sevenDayResetsAt, nowMs),
420 contextUsed: stats.measured.contextPct,
421 fiveHourReset: fmtCountdown(resetMs(stats.measured.fiveHourResetsAt), nowMs),
422 sevenDayReset: fmtCountdown(resetMs(stats.measured.sevenDayResetsAt), nowMs),
423 cost: stats.measured.costUsd === null ? UNAVAILABLE : `$${stats.measured.costUsd.toFixed(2)}`,
424 toolCalls: stats.toolCalls,
425 model: stats.measured.model,
426 },
427 }
428}
429
430/** `5s`, `4m`, `1h05m`; used for "last activity" and "idle for". */
431export const fmtAge = (ms: number | null): string => {
432 if (ms === null) return UNAVAILABLE
433 const s = Math.floor(ms / 1000)
434 if (s < 60) return `${s}s ago`
435 const m = Math.floor(s / 60)
436 return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m ago`
437}
438
439export const fmtSpan = (ms: number | null): string => {
440 if (ms === null) return UNAVAILABLE
441 const s = Math.floor(ms / 1000)
442 if (s < 60) return `${s}s`
443 const m = Math.floor(s / 60)
444 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
445}
446
447// --- Pane state and presses --------------------------------------------------------------
448
449export type McView = 'overview' | 'workers' | 'tasks' | 'usage' | 'config' | 'stats' | 'doctor'
450
451export const MC_VIEWS: readonly { view: McView; label: string; hotkey: string }[] = [
452 { view: 'overview', label: 'Overview', hotkey: '1' },
453 { view: 'workers', label: 'Workers', hotkey: '2' },
454 { view: 'tasks', label: 'Tasks', hotkey: '3' },
455 { view: 'usage', label: 'Usage', hotkey: '4' },
456 { view: 'config', label: 'Config', hotkey: '5' },
457 { view: 'stats', label: 'Stats', hotkey: '6' },
458 { view: 'doctor', label: 'Doctor', hotkey: '7' },
459]
460
461/** How the HUD line is laid out: `auto` follows the terminal width, the rest force a form. */
462export type HudMode = 'auto' | 'compact' | 'standard' | 'full'
463
464export const HUD_MODES: readonly HudMode[] = ['auto', 'compact', 'standard', 'full']
465
466export type Selection = { kind: 'worker' | 'task'; id: string }
467
468export type McState = {
469 view: McView
470 /** A worker or task being inspected, shown in place of the list; null shows the list. */
471 selected: Selection | null
472 hudMode: HudMode
473 /** `/ctk-doctor` text, read when the Doctor view is opened or refreshed; null until then. */
474 doctorText: string | null
475}
476
477export const newMcState = (): McState => ({ view: 'overview', selected: null, hudMode: 'auto', doctorText: null })
478
479export const MC_PANE_ID = 'ctk-mission'
480export const OPEN_KEY = 'ctk-open'
481
482/** What a press asks the host to do besides changing the state. */
483export type McEffect = 'close' | 'refresh' | 'doctor' | 'confirm' | 'cancel'
484
485/** The id of the proposal a Confirm or Cancel press was drawn for. */
486
487const KEY = {
488 view: 'mc:view:',
489 worker: 'mc:worker:',
490 task: 'mc:task:',
491 hud: 'mc:hud:',
492}
493
494export const viewKey = (v: McView): string => `${KEY.view}${v}`
495export const workerKey = (agentId: string): string => `${KEY.worker}${agentId}`
496export const taskKey = (id: string): string => `${KEY.task}${id}`
497export const hudKey = (m: HudMode): string => `${KEY.hud}${m}`
498export const BACK_KEY = 'mc:back'
499export const CLOSE_KEY = 'mc:close'
500export const REFRESH_KEY = 'mc:refresh'
501const CONFIRM_PREFIX = 'mc:cfg:confirm:'
502const CANCEL_PREFIX = 'mc:cfg:cancel:'
503/** Confirm and Cancel are keyed with the proposal they were drawn for, so a press made for an earlier proposal cannot answer a later one. */
504export const confirmKey = (id: string): string => `${CONFIRM_PREFIX}${id}`
505export const cancelKey = (id: string): string => `${CANCEL_PREFIX}${id}`
506
507/**
508 * Applies a press on one of the pane's buttons. Pure and read-only: it changes what the pane shows
509 * (view, selection, HUD form) and never the team, the settings or the session. Unknown keys change
510 * nothing.
511 */
512export const pressMc = (mc: McState, key: string): { mc: McState; effect?: McEffect; id?: string } => {
513 if (key === CLOSE_KEY) return { mc, effect: 'close' }
514 if (key === REFRESH_KEY) return { mc, effect: mc.view === 'doctor' ? 'doctor' : 'refresh' }
515 if (key === BACK_KEY) return { mc: { ...mc, selected: null } }
516 if (key.startsWith(CONFIRM_PREFIX)) return { mc, effect: 'confirm', id: key.slice(CONFIRM_PREFIX.length) }
517 if (key.startsWith(CANCEL_PREFIX)) return { mc, effect: 'cancel', id: key.slice(CANCEL_PREFIX.length) }
518 if (key.startsWith(KEY.view)) {
519 const view = MC_VIEWS.find(v => v.view === key.slice(KEY.view.length))?.view
520 if (view === undefined) return { mc }
521 return { mc: { ...mc, view, selected: null }, ...(view === 'doctor' ? { effect: 'doctor' as const } : {}) }
522 }
523 if (key.startsWith(KEY.worker)) return { mc: { ...mc, view: 'workers', selected: { kind: 'worker', id: key.slice(KEY.worker.length) } } }
524 if (key.startsWith(KEY.task)) return { mc: { ...mc, view: 'tasks', selected: { kind: 'task', id: key.slice(KEY.task.length) } } }
525 if (key.startsWith(KEY.hud)) {
526 const hudMode = HUD_MODES.find(m => m === key.slice(KEY.hud.length))
527 return hudMode === undefined ? { mc } : { mc: { ...mc, hudMode } }
528 }
529 return { mc }
530}
531
532/** The terminal width the layout should assume for a forced form; auto leaves it to the real width. */
533export const forcedTierColumns = (mode: HudMode): number | undefined =>
534 mode === 'compact' ? 60 : mode === 'standard' ? 100 : mode === 'full' ? 200 : undefined
535
536/** The overview as plain text: the `/ctk-mission` answer where no pane can be drawn, and the status tool's summary. */
537export const missionText = (m: Mission): string => {
538 const n = (v: number | null): string => (v === null ? UNAVAILABLE : String(v))
539 const tasks =
540 m.tasks.total === null
541 ? UNAVAILABLE
542 : m.tasks.detailed && !m.tasks.partial
543 ? `${n(m.tasks.completed)}/${m.tasks.total} done, ${n(m.tasks.pending)} pending, ${n(m.tasks.inProgress)} in progress, ${n(m.tasks.blocked)} blocked, ${n(m.tasks.ready)} ready`
544 : `${n(m.tasks.completed)}/${m.tasks.total} done (from task events; ${m.tasks.partial ? 'some tasks were created before CTK loaded' : 'no task detail observed'})`
545 const lines = [
546 'CTK Mission Control (read-only):',
547 ` guard: ${guardWord(m.guard)} - ${m.guard.why}`,
548 ...(m.outsideCap > 0 ? [` outside the cap: ${m.outsideCap} named agent(s) started as ordinary subagents (with isolation); the cap does not count them`] : []),
549 ` workers: ${n(m.active)}/${m.cap} active, ${n(m.running)} running, ${n(m.idle)} idle, ${n(m.completed)} completed, ${n(m.failed)} failed; ${m.rejected} refused`,
550 ...(m.subagents.total > 0 ? [` subagents: ${m.subagents.total} ordinary (${m.subagents.live} live); not teammates, so the cap does not count them`] : []),
551 ` tasks: ${tasks}`,
552 ` team time: ${m.teamElapsedMs === null ? UNAVAILABLE : fmtSpan(m.teamElapsedMs)}`,
553 ` usage: 5h ${m.usage.fiveHour}; week ${m.usage.sevenDay}; context ${m.usage.contextPct}; cost ${m.usage.cost}; ${m.usage.toolCalls} tool calls`,
554 ]
555 if (m.empty.workers !== null) lines.push(` note: ${m.empty.workers}`)
556 if (m.empty.tasks !== null) lines.push(` note: ${m.empty.tasks}`)
557 for (const w of m.workers) {
558 lines.push(
559 ` worker ${w.name}: ${w.status}, model ${w.model ?? UNAVAILABLE}, tool calls ${w.toolCalls === null ? UNAVAILABLE : w.toolCalls}, last activity ${fmtAge(w.lastActivityMs)}${w.idleMs === null ? '' : `, idle ${fmtSpan(w.idleMs)}`}${w.currentTask === null ? '' : `, on ${w.currentTask}`}`,
560 )
561 }
562 return lines.join('\n')
563}
564
565/** A small, serialisable summary for the status tool, so the model can answer "how is the team doing" from facts. */
566export const missionJson = (m: Mission) => ({
567 guard: { state: m.guard.state, why: m.guard.why },
568 outsideCap: m.outsideCap,
569 subagents: { total: m.subagents.total, live: m.subagents.live },
570 explain: { workers: m.empty.workers, tasks: m.empty.tasks },
571 teamElapsedMs: m.teamElapsedMs,
572 running: m.running,
573 idle: m.idle,
574 completed: m.completed,
575 failed: m.failed,
576 tasks: {
577 detailed: m.tasks.detailed,
578 total: m.tasks.total,
579 completed: m.tasks.completed,
580 pending: m.tasks.pending,
581 inProgress: m.tasks.inProgress,
582 blocked: m.tasks.blocked,
583 ready: m.tasks.ready,
584 },
585 usage: m.usage,
586})
587hooks/missionui.tsx 42 lines1import type { McState, Mission } from './mission.ts'
2import { bodyRowsFor, createCtx } from './ui/ctx.tsx'
3import { renderConfig } from './ui/config.tsx'
4import { renderDoctor } from './ui/doctor.tsx'
5import { footer, header, stackedTabs, tabs } from './ui/frame.tsx'
6import { renderOverview } from './ui/overview.tsx'
7import { renderStats } from './ui/stats.tsx'
8import { renderTasks } from './ui/tasks.tsx'
9import type { Motion } from './ui/motion.ts'
10import type { Extras, Kit } from './ui/types.ts'
11import { renderUsage } from './ui/usage.tsx'
12import { renderWorkers } from './ui/workers.tsx'
13
14// Mission Control's body: a read-only view of what CTK observed. Every button here only changes
15// what the pane shows (see pressMc) except Confirm/Cancel on a pending option change, which is
16// handled by register.tsx and exists only while a change is waiting for the person's yes.
17// A figure that was not observed reads "unavailable". Each view lives in ./ui/<view>.tsx.
18
19export type { Extras, Kit, OptionRow, Pending } from './ui/types.ts'
20
21/** Rows the pane is opened with. */
22export const PANE_ROWS = 16
23
24const VIEWS = { overview: renderOverview, workers: renderWorkers, tasks: renderTasks, usage: renderUsage, config: renderConfig, stats: renderStats, doctor: renderDoctor }
25
26export const renderMission = (kit: Kit, m: Mission, mc: McState, extras: Extras, props: { bodyColumns: number; ambiguous?: 1 | 2; motion?: Motion; placement?: 'dock' | 'inline'; scroll?: { bodyRows: number } }) => {
27 const { Box } = kit
28 const base = bodyRowsFor(props)
29 const stacked = stackedTabs(Math.max(10, props.bodyColumns - 1), base)
30 const ctx = createCtx(kit, { ...props, rows: stacked ? base - 1 : base })
31 return (
32 <Box flexDirection="column" width={props.bodyColumns}>
33 {header(kit, m, ctx)}
34 {tabs(kit, mc, ctx, stacked)}
35 <Box key="body" flexDirection="column" marginTop={1}>
36 {VIEWS[mc.view](kit, m, mc, extras, ctx)}
37 </Box>
38 {footer(kit, ctx)}
39 </Box>
40 )
41}
42hooks/ui/motion.ts 79 lines1import type { Mission } from '../mission.ts'
2
3// Motion for Mission Control, kept as plain values so the views stay pure. What a person sees is
4// `Motion` (a frame counter and the keys to highlight); `MotionState` is what register.tsx keeps to
5// work out the next one. Nothing here starts a timer: register.tsx arms one only while the pane is
6// drawing, and each fire redraws the pane, so closing the pane ends the chain by itself.
7
8/** One slow frame: a running glyph dims and brightens once a second. */
9export const FRAME_MS = 1000
10
11/** How long a new worker, a completed task or a refusal stays highlighted. */
12export const HIGHLIGHT_MS = 3000
13
14export type Motion = {
15 frame: number
16 /** True when motion is off: views draw static glyphs and no highlight is ever set. */
17 reduced: boolean
18 /** Keys: `worker:<agentId>`, `task:<id>`, `guard`. */
19 hot: ReadonlySet<string>
20}
21
22export const STATIC_MOTION: Motion = { frame: 0, reduced: true, hot: new Set() }
23
24type Seen = { workers: ReadonlySet<string>; done: ReadonlySet<string>; rejected: number }
25
26export type MotionState = {
27 reduced: boolean
28 frame: number
29 /** What the previous draw showed; null before the first, which highlights nothing. */
30 seen: Seen | null
31 /** Highlight key to the clock time it ends. */
32 until: Readonly<Record<string, number>>
33}
34
35export const newMotionState = (reduced = false): MotionState => ({ reduced, frame: 0, seen: null, until: {} })
36
37/** The host exposes no reduced-motion flag, so CTK_REDUCED_MOTION=1 and a set NO_COLOR turn motion off. */
38export const reducedFrom = (reducedMotion: string | undefined, noColor: string | undefined): boolean =>
39 reducedMotion === '1' || (noColor !== undefined && noColor !== '')
40
41const seenOf = (m: Mission): Seen => ({
42 workers: new Set(m.workers.map(w => w.agentId)),
43 done: new Set(m.tasks.rows.filter(t => t.status === 'completed').map(t => t.id)),
44 rejected: m.rejected,
45})
46
47/** Compares this draw with the last one: what is new is highlighted from `now`, what has expired is dropped. */
48export const observe = (s: MotionState, m: Mission, now: number): MotionState => {
49 if (s.reduced) return { ...s, seen: null, until: {} }
50 const seen = seenOf(m)
51 const until: Record<string, number> = {}
52 for (const [k, t] of Object.entries(s.until)) if (t > now) until[k] = t
53 if (s.seen !== null) {
54 for (const id of seen.workers) if (!s.seen.workers.has(id)) until[`worker:${id}`] = now + HIGHLIGHT_MS
55 for (const id of seen.done) if (!s.seen.done.has(id)) until[`task:${id}`] = now + HIGHLIGHT_MS
56 if (seen.rejected > s.seen.rejected) until.guard = now + HIGHLIGHT_MS
57 }
58 return { ...s, seen, until }
59}
60
61export const motionOf = (s: MotionState): Motion => ({ frame: s.frame, reduced: s.reduced, hot: new Set(Object.keys(s.until)) })
62
63/** Milliseconds to the next redraw, or null when nothing moves: the frame needs a running worker, a highlight needs its end. */
64export const delayFor = (s: MotionState, m: Mission, now: number): number | null => {
65 if (s.reduced) return null
66 if ((m.running ?? 0) > 0) return FRAME_MS
67 const ends = Object.values(s.until).filter(t => t > now)
68 return ends.length === 0 ? null : Math.max(1, Math.min(...ends) - now)
69}
70
71/** A running glyph is drawn dim on odd frames; with motion off it never is. */
72export const pulseDim = (mo: Motion): boolean => !mo.reduced && mo.frame % 2 === 1
73
74/** Text props for a status glyph: a running one dims on odd frames, a freshly changed row (`key`) is inverted. */
75export const glyphProps = (mo: Motion, running: boolean, key: string): { dimColor: boolean; inverse: boolean } => ({
76 dimColor: running && pulseDim(mo),
77 inverse: mo.hot.has(key),
78})
79