SLOPSHOPPER

ctk

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

newpanebandguardcommandprompt
★ 1v0.1.1MITupdated 2026-10-09tc3oliver/claude-team-kit/plugins/ctk
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctk
│ ┃ CTK Mission Control ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ CTK MISSION CONTROL ○ unavailable read-o │ ┃ 1: Overview 2: Workers 3: Tasks ⏺ Read(src/auth.ts) │ ┃ 4: Usage 5: Config 6: Stats 7: Doctor ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ SLOTS · · · · · 0/5 ⎿ Added 2 lines, removed 1 line │ ┃ WORKERS TASKS REFUSED CO ⏺ Bash(bun test) │ ┃ 0/5 unavailable 0 $0 ⎿ 3 pass, 1 fail │ ┃ No native team yet. │ ┃ Use a team to start coordinated work. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Ordinary subagents are not workers │ ┃ and do not count toward the CTK Worker Cap. ✻ Worked for 42s · done 4:20 PM │ ┃ 5H [███░░░░░░░] 31% · resets 59m │ ┃ WK [──────────] – unavailable › /ctk-stats │ ┃ guard Agent Teams are not enabled, so n ⎿ ctk: CTK session preview-session │ ┃ can start ⎿ ctk: counted by CTK: │ ┃ session 49% ctx · 9 tool calls · unavaila ⎿ ctk: teammate spawns: 0 accepted, 0 refused at capacity, 0 fai │ ┃ HUD form HUD form auto HUD form compact H ⎿ ctk: guard reached by 0 spawn event(s); named agents started o │ ┃ ⎿ ctk: peak live teammates: 0 (cap 5); now 0 │ ┃ [ Refresh ] [ Close ] Esc closes ⎿ ctk: worker models: – │ │ Open CTK Mission Control ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Open CTK Mission Control
Pane · CTK Mission Control
◆ CTK MISSION CONTROL ○ unavailable read-only 1: Overview 2: Workers 3: Tasks 4: Usage 5: Config 6: Stats 7: Doctor SLOTS · · · · · 0/5 WORKERS TASKS REFUSED COST 0/5 unavailable 0 $0.42 No native team yet. Use a team to start coordinated work. Ordinary subagents are not workers and do not count toward the CTK Worker Cap. 5H [███░░░░░░░] 31% · resets 59m WK [──────────] – unavailable guard Agent Teams are not enabled, so no teammate can start session 49% ctx · 9 tool calls · unavailable team HUD form HUD form auto HUD form compact HUD form standard [ Refresh ] [ Close ] Esc closes
README

<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> &nbsp;·&nbsp; <a href="#mission-control"><b>Watch Mission Control</b></a> &nbsp;·&nbsp; <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">

Three reasons to use CTK

Coordinated native teams

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.

Hard worker limits

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.

Mission Control

See workers, tasks, dependencies and usage without leaving Claude Code. It is read-only: it never starts, stops or changes anything.

Install

Two ways, both through Claude Code's own Plugin Manager.

Install with your AI Agent

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.

Manual install

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.

Your first team

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.

How CTK works

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

Mission Control

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>

Everything else

  • Models by role. Explorers on Haiku, implementers and reviewers on Sonnet, the high-risk reviewer on Opus, unless a spawn names a model. An optional read-only designer on Opus writes UI/UX briefs; it is new on main and has not yet been part of a recorded run.
  • Risk-based review. /ctk:review scales reviewer depth to the risk of the change.
  • Debugging workflow. /ctk:debug asks for a failing reproduction before a fix.
  • Natural-language control. Ask in words for a team or for a setting change, such as "set the worker cap to 2"; the change applies only after you press Confirm in Mission Control (details).
  • Usage HUD. A team line above the prompt with the model, 5-hour and weekly usage, agents against the limit, tasks and cost, fitted to your terminal width. It reads only what Claude Code hands it: no network calls, no model calls.
  • Portable configuration. Change options with /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).
  • Small footprint. The plugin adds about 480 tokens to a session (measured with a real call). No speed, cost or token-saving claim is made.

Limitations

  • Agent Teams are experimental, and Mods, which carry the limit and the team line, are early access. Where Mods are missing, /ctk:team says the limit and the team line are off.
  • The hard limit counts native teammates only. Ordinary subagents are not counted or limited.
  • The team workflow is guided by a skill that the model may follow imperfectly. CTK is not another scheduler.
  • Used interactively on macOS. Linux and Windows are covered by CI only, and the agent install prompt has not been run end to end.
  • Status: public preview, released as the GitHub pre-release 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

Documentation

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

Source 27 files
hooks/register.tsx 753 lines
1import 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}
753
shared/policy.ts 100 lines
1// 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]
100
shared/stats.ts 99 lines
1// 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}
99
hooks/band.ts 178 lines
1import { 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')
178
hooks/doctor.ts 145 lines
1import 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}
145
hooks/team.ts 194 lines
1import 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
194
hooks/config.ts 272 lines
1import { 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
272
shared/hudline.ts 231 lines
1// 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}
231
hooks/branch.ts 45 lines
1// 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}
45
hooks/mission.ts 587 lines
1import { 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})
587
hooks/missionui.tsx 42 lines
1import 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}
42
hooks/ui/motion.ts 79 lines
1import 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