SLOPSHOPPER

polis

A project dashboard and isometric city map: progress, tasks, agents, resources, processes and skills

newpanebandguardcommandtoast
v0.1.0no licenseupdated 2026-10-10SashimmyUni/polis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · polis
│ ┃ Polis ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ POLIS app │ ┃ [ Overview ] [ City ] [ Tasks ] [ Agents ] [ ● polis: polis: turn.start: TypeError: undefined is not an object (ev │ ┃ ⏺ Read(src/auth.ts) │ ┃ CITY ⎿ Read 6 lines │ ┃ ◆ city hall idle ⏺ Update(src/auth.ts) │ ┃ No districts yet: they appear with the first ⎿ Added 2 lines, removed 1 line │ ┃ tasks. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ⚡ power █████ ░░░░░ context 49% │ ┃ ◈ works: 0 running ✦ library: 0 skills used ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /city │ ⎿ polis: Polis opened. │ │ ◆ app ctx 49% [ City ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ app ctx 49% [ City ] [ Hide ]
Pane · Polis
◆ POLIS app [ Overview ] [ City ] [ Tasks ] [ Agents ] [ Resources ] [ S CITY ◆ city hall idle No districts yet: they appear with the first tasks. ⚡ power █████ ░░░░░ context 49% ◈ works: 0 running ✦ library: 0 skills used
README

Polis

A Claude Code mod that keeps a complex project legible while Claude works on it: a band above the prompt, a pane with six views, and an isometric city in which districts are work areas, buildings are tasks and agents stand on what they are working on.

The user manual explains how to read every part of it.

What it shows

  • Band — project progress, tasks done, working agents, this prompt's tokens against its expected range, context window.
  • Pane (/city, or the band's City button), with six views:
  • Overview — milestones, the current prompt's actual against expected, tasks, agents, background processes, recent activity, most-edited files.
  • City — the map. A lot is a planned task, scaffolding is one in progress, lit windows are done, a rose barrier is blocked; dashed arcs show what waits on what. City Hall is the main session, the plant's tank is the context window.
  • Tasks — the task tree with sub-tasks, owners and blockers.
  • Agents — an icon per agent carrying the tools it uses most, with status, tokens and its task.
  • Resources — tokens, time and tool calls against expected; context window; rate limits; cost; what prompts usually cost in this project.
  • Skills — used, suggested for the work at hand, available.

Commands

  • /city [overview|city|tasks|agents|resources|skills] — open the pane at a view. Where nothing can draw it, the command answers with the board as text.
  • /orchestrate <goal> — plan a goal into tasks and run them with parallel agents (polis:worker, polis:verifier).

Where it draws

HostWhat you get
Claude desktop app, Code tabEverything, with the city and agent icons as vector drawings.
claude in a terminal, including VS Code's integrated terminalEverything as text; the city is a row of blocks per district.
VS Code extension's chat panelNothing is drawn there (as of extension 2.1.295). The mod's hooks still run; /city answers as text, and the mod asks nothing extra of the model.

The pane adapts to its width: under 44 columns the city is drawn one block wide and bars sit under their labels.

Options

Set in /config, or under pluginConfigs.polis.options in settings.

OptionDefaultMeaning
estimatebothHow a prompt's expected cost is sized: history (free, from this project's past turns), model (a small-model call per prompt), or both (history, plus the model for long prompts).
guidancetrueAdd a short standing instruction so Claude keeps tasks and milestones current, and offer it the mcp__polis__project tool.
autoOpentrueOpen the pane the first time tasks or agents appear in a session.

Loading it

Name this folder in CLAUDE_CODE_PLUGIN_DIRS, in the env block of ~/.claude/settings.json. Sessions started after that load it.

Developing

claude here means the Claude Code binary (on this machine: %APPDATA%\Claude\claude-code\<version>\<hash>\claude.exe).

claude plugin validate .
claude plugin test .

Every call on the engine ($) and every piece of state lives in hooks/register.tsx; the validator requires it. The other files under hooks/ are pure: logic.ts (tracking and estimates), views.tsx (band and pane), city.ts and icons.ts (the drawings), theme.ts (palette). tools/preview.ts draws the city for made-up sessions into an HTML page for judging its look in a browser.

The mod records the room each surface gives its band and pane under the rooms key of its store, which is the first thing to read when a drawing looks wrong somewhere.

Source 7 files
hooks/register.tsx 1001 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
3
4import type { AgentRun, AgentRunStatus, Proc, Turn, Usage, ViewName } from '../types'
5import {
6  SIZES,
7  SIZING_RUBRIC,
8  asHistory,
9  asProject,
10  bumpFamily,
11  clip,
12  createTask,
13  endProc,
14  expectedFor,
15  heuristicSize,
16  hitFile,
17  isLive,
18  matchTask,
19  mergeProject,
20  mergeTodos,
21  newProject,
22  patchTask,
23  pick,
24  pushActivity,
25  recordTasks,
26  reconcileProcs,
27  shouldAskModel,
28  suggestSkills,
29  taskStats,
30  upsertProc,
31} from './logic'
32import type { EstimateMode } from './logic'
33import { VIEWS, familyOf } from './theme'
34import { band, pane, summary } from './views'
35import type { Actions, Model } from './views'
36
37const PANE = 'polis'
38const TITLE = 'Polis'
39const PROJECT_TOOL = 'mcp__polis__project'
40
41// The room the pane asks for where a surface lets a plugin say: enough for
42// the city at a readable size while the conversation keeps about half the
43// window. A request, not a grant: the person's own size wins, and a pane
44// left narrow gets the narrow layout instead.
45const ROOM = { rows: 46, columns: 68 } as const
46
47const IDLE: Turn = {
48  id: null,
49  isWorking: false,
50  startedAt: 0,
51  endedAt: 0,
52  expected: null,
53  tokens: 0,
54  outTokens: 0,
55  cacheRead: 0,
56  tools: 0,
57  steps: 0,
58  agents: 0,
59  hasWarned: false,
60}
61
62const UNMEASURED: Usage = {
63  contextTokens: 0,
64  contextWindow: 0,
65  contextPercent: 0,
66  rateLimits: [],
67  costUsd: 0,
68  sessionTokens: 0,
69  mainFamilies: {},
70  hasWarnedContext: false,
71}
72
73const tasks = atom({ plugin: 'polis', key: 'tasks' } as const, [])
74const agents = atom({ plugin: 'polis', key: 'agents' } as const, [])
75const procs = atom({ plugin: 'polis', key: 'procs' } as const, [])
76const turn = atom({ plugin: 'polis', key: 'turn' } as const, IDLE)
77const usage = atom({ plugin: 'polis', key: 'usage' } as const, UNMEASURED)
78const history = atom({ plugin: 'polis', key: 'history' } as const, [])
79const project = atom({ plugin: 'polis', key: 'project' } as const, null)
80const skills = atom({ plugin: 'polis', key: 'skills' } as const, {
81  available: [],
82  used: {},
83  suggested: [],
84})
85const ui = atom({ plugin: 'polis', key: 'ui' } as const, {
86  view: 'overview',
87  isBandHidden: false,
88  hasOpened: false,
89})
90const activity = atom({ plugin: 'polis', key: 'activity' } as const, [])
91const files = atom({ plugin: 'polis', key: 'files' } as const, [])
92const tick = atom({ plugin: 'polis', key: 'tick' } as const, 0)
93
94const GUIDANCE = {
95  id: 'polis:guidance',
96  scope: 'session',
97  text: [
98    'The person watches a live project dashboard (Polis). For work of three or more steps,',
99    'record the steps with the task tool before starting, mark each one in progress as you',
100    'begin it and completed as soon as it is verified. When creating a task, set',
101    '`metadata.area` to a short work-area name (for example api, ui, tests, docs) and, for a',
102    "sub-task, `metadata.parent` to its parent task's id. Where this session has no task",
103    `tool, record and update the same steps through the \`tasks\` of the ${PROJECT_TOOL} tool`,
104    `instead. For project-level work, record the project's milestones with ${PROJECT_TOOL}`,
105    'once the plan is known, and update them whenever a milestone starts or completes.',
106  ].join(' '),
107} as const
108
109const PROJECT_SPEC = {
110  name: 'project',
111  description: [
112    "Records this project's plan on the person's Polis dashboard: its milestones and the",
113    'current phase. Call it once the plan for project-level work is known, and again when a',
114    'milestone starts or completes. Milestones are matched by `id`: send only the ones that',
115    'changed. The record is kept across sessions for this project folder. In a session with',
116    'no task tool of its own, `tasks` records the steps of the work at hand the same way:',
117    'send each task once with its subject and area, then again by `id` as its status changes.',
118  ].join(' '),
119  inputSchema: {
120    type: 'object',
121    properties: {
122      name: { type: 'string', description: "The project's display name." },
123      phase: { type: 'string', description: 'A few words on what is happening now.' },
124      milestones: {
125        type: 'array',
126        items: {
127          type: 'object',
128          properties: {
129            id: { type: 'string', description: 'A short stable slug.' },
130            title: { type: 'string' },
131            status: { type: 'string', enum: ['planned', 'active', 'done'] },
132          },
133          required: ['id'],
134        },
135      },
136      tasks: {
137        type: 'array',
138        description: 'Only where the session has no task tool: the steps of the current work.',
139        items: {
140          type: 'object',
141          properties: {
142            id: { type: 'string', description: 'A short stable slug.' },
143            subject: { type: 'string' },
144            status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'deleted'] },
145            area: { type: 'string', description: 'A short work-area name: api, ui, tests, docs.' },
146            parent: { type: 'string', description: 'The id of the task this is a sub-task of.' },
147            owner: { type: 'string', description: 'The name of the agent working on it.' },
148            blockedBy: { type: 'array', items: { type: 'string' } },
149            activeForm: { type: 'string', description: 'Shown while in progress: "Running tests".' },
150          },
151          required: ['id'],
152        },
153      },
154    },
155  },
156  isDeferred: false,
157} as const
158
159const WORKER_PROMPT = [
160  'You are a worker in an orchestrated run. You are given one self-contained',
161  'task with the files or area it covers and what done looks like.',
162  'Do exactly that task and stay inside its scope: another worker may be',
163  'editing other files at the same time.',
164  'End with a short report: what you changed (paths), how you checked it,',
165  'and anything you could not finish or that looked wrong.',
166].join(' ')
167
168const VERIFIER_PROMPT = [
169  'You are the verifier in an orchestrated run. You are given a claim about',
170  'finished work. Check it against the actual files and by running the',
171  'relevant tests or commands; never take the claim on trust and never edit',
172  'files. Answer PASS or FAIL first, then the evidence: commands run, their',
173  'output, and each defect with its path and line.',
174].join(' ')
175
176const orchestrationBrief = (goal: string): string =>
177  [
178    'Act as the orchestrator for the goal below. I am asking for subagents here: use them.',
179    '',
180    `1. Plan. Break the goal into 3 to 8 tasks and record every one with the task tool before any work starts, each with \`metadata.area\` (where this session has no task tool, through the \`tasks\` of ${PROJECT_TOOL}, each with its \`area\`). Note which tasks depend on each other.`,
181    '2. Delegate. Start every independent task as its own Agent call, all in one message so they run in parallel. Give each a self-contained brief: the task, the files or area, what done looks like. Use `polis:worker` for implementation, `Explore` for read-only searching, `polis:verifier` for checking. Keep tiny or strictly sequential tasks for yourself, and never let two agents write the same files.',
182    '3. Track. Mark a task in progress as it starts and completed only once it is verified.',
183    '4. Verify. Treat each agent report as a claim: read the diff or run the tests before accepting it, and send failed work back with what was wrong.',
184    '5. Report. Finish with what was done, what failed and what is left.',
185    '',
186    `Goal: ${goal}`,
187  ].join('\n')
188
189// The tools whose result can name work left running, or work stopped.
190const BACKGROUND = new Set([
191  'Bash',
192  'PowerShell',
193  'Monitor',
194  'Workflow',
195  'CronCreate',
196  'CronDelete',
197  'TaskStop',
198])
199
200const say = ($: EngineInterface, where: string, error: unknown): void => {
201  $.ui.log(`polis: ${where}: ${String(error)}`)
202}
203
204const note = async ($: EngineInterface, kind: string, text: string): Promise<void> => {
205  const at = await $.clock.now()
206  await update($, activity, list => pushActivity(list, { at, kind, text }))
207}
208
209const saveProject = async ($: EngineInterface): Promise<void> => {
210  const record = await read($, project)
211  if (record !== null) await $.store.set(`project:${record.root}`, record)
212}
213
214const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE, ...ROOM })
215
216// The room each surface last gave the band and the pane, kept in the store
217// so a drawing that looks wrong somewhere can be explained from its numbers.
218let rooms: { [site: string]: string } = {}
219let hasNewRoom = false
220
221const measure = (site: string, room: object): void => {
222  const text = JSON.stringify(room)
223  if (rooms[site] === text) return
224  rooms = { ...rooms, [site]: text }
225  hasNewRoom = true
226}
227
228// Whether any surface has asked for the band: the engine raises `AbovePrompt`
229// on some surfaces only, and a session drawn on none of them has no band.
230let isBandAsked = false
231
232// The status line stands in for the band while the band is hidden, and where
233// no surface draws one.
234const refresh = async ($: EngineInterface, shouldOpen: boolean): Promise<void> => {
235  const list = await read($, tasks)
236  const runs = await read($, agents)
237  const state = await read($, ui)
238  const stats = taskStats(list)
239  const live = runs.filter(isLive).length
240
241  if ((isBandAsked && !state.isBandHidden) || (stats.total === 0 && live === 0)) {
242    $.ui.status(undefined)
243  } else {
244    const parts = []
245    if (stats.total > 0) parts.push(`${stats.done}/${stats.total} tasks`)
246    if (live > 0) parts.push(`${live} agent${live === 1 ? '' : 's'} running`)
247    $.ui.status(`◆ ${parts.join(' · ')}`)
248  }
249
250  if (shouldOpen && !state.hasOpened && (stats.total > 0 || runs.length > 0)) {
251    await update($, ui, value => ({ ...value, hasOpened: true }))
252    void openPane($).catch(() => undefined)
253  }
254}
255
256const countDone = async ($: EngineInterface, gained: number): Promise<void> => {
257  if (gained <= 0) return
258  await update($, project, record =>
259    record === null ? record : { ...record, tasksDone: record.tasksDone + gained },
260  )
261  await saveProject($)
262}
263
264const clearFinished = async ($: EngineInterface): Promise<void> => {
265  await update($, tasks, list => list.filter(task => task.status !== 'completed'))
266  await update($, agents, list => list.filter(isLive))
267  await update($, procs, list => list.filter(proc => proc.status === 'running'))
268  await refresh($, false)
269}
270
271const stopTurn = async ($: EngineInterface): Promise<void> => {
272  const { id, isWorking } = await read($, turn)
273  if (isWorking && id !== null) await $.turn.abort({ turnId: id })
274}
275
276const openAt = async ($: EngineInterface, view: ViewName | undefined): Promise<UiOpenResult> => {
277  await update($, ui, value => ({
278    ...value,
279    isBandHidden: false,
280    hasOpened: true,
281    view: view ?? value.view,
282  }))
283
284  return openPane($)
285}
286
287const actionsOf = ($: EngineInterface): Actions => ({
288  setView: view => update($, ui, value => ({ ...value, view })),
289  openCity: () => openAt($, 'city'),
290  hideBand: async () => {
291    await update($, ui, value => ({ ...value, isBandHidden: true }))
292    await refresh($, false)
293  },
294  // A surface with a composer of its own takes no fill: say what to type.
295  orchestrate: async () => {
296    const filled = await $.prompt.fill({ text: '/orchestrate ', mode: 'replace' })
297    if (!filled.isFilled) $.ui.toast('Type /orchestrate <goal> to plan it and run it with agents')
298  },
299  clearFinished: () => clearFinished($),
300  stopTurn: () => stopTurn($),
301})
302
303const load = async ($: EngineInterface, columns: number): Promise<Model> => {
304  await read($, tick)
305
306  return {
307    now: await $.clock.now(),
308    columns,
309    tasks: await read($, tasks),
310    agents: await read($, agents),
311    procs: await read($, procs),
312    turn: await read($, turn),
313    usage: await read($, usage),
314    history: await read($, history),
315    project: await read($, project),
316    skills: await read($, skills),
317    ui: await read($, ui),
318    activity: await read($, activity),
319    files: await read($, files),
320  }
321}
322
323export const register: Register = (on, options) => {
324  const mode: EstimateMode =
325    options.estimate === 'history' || options.estimate === 'model' ? options.estimate : 'both'
326  const hasGuidance = options.guidance !== false
327  const shouldAutoOpen = options.autoOpen !== false
328  // What the person has asked for lately, for the skill suggestions.
329  let asked: string[] = []
330
331  on('session.start', async ($, e, next) => {
332    await $.command.register({
333      name: 'city',
334      description: 'Open the Polis project dashboard and city map',
335      argumentHint: '[overview|city|tasks|agents|resources|skills]',
336    })
337    await $.command.register({
338      name: 'orchestrate',
339      description: 'Plan a goal into tasks and run them with parallel agents',
340      argumentHint: '<goal>',
341    })
342
343    try {
344      if (hasGuidance) await $.tool.register(PROJECT_SPEC)
345      await $.agent.register({
346        name: 'worker',
347        description:
348          'Carries out one self-contained task of an orchestrated run and reports what it changed',
349        prompt: WORKER_PROMPT,
350      })
351      await $.agent.register({
352        name: 'verifier',
353        description:
354          'Checks a claim about finished work against the files and tests, read-only, and answers PASS or FAIL with evidence',
355        prompt: VERIFIER_PROMPT,
356        tools: ['Read', 'Grep', 'Glob', 'Bash'],
357      })
358    } catch (error) {
359      say($, 'registering its tool and agent types', error)
360    }
361
362    try {
363      const now = await $.clock.now()
364      const repo = await $.session.repo().catch(() => null)
365      const root = repo?.root ?? e.cwd
366      const sessionId = await $.session.id()
367      // A reload runs this again while the session goes on: what the session
368      // already holds for this project is newer than what the store kept.
369      const held = await read($, project)
370      const saved =
371        held !== null && held.root === root
372          ? held
373          : asProject(await $.store.get(`project:${root}`), root, now)
374      const record =
375        saved.lastSessionId === sessionId
376          ? { ...saved, lastActive: now }
377          : { ...saved, lastActive: now, lastSessionId: sessionId, sessions: saved.sessions + 1 }
378      await update($, project, () => record)
379      await saveProject($)
380      const kept = await $.store.get('rooms')
381      if (typeof kept === 'object' && kept !== null) {
382        rooms = { ...(kept as { [site: string]: string }), ...rooms }
383      }
384      const past = asHistory(await $.store.get(`history:${root}`))
385      await update($, history, list => (list.length > past.length ? list : past))
386
387      const commands = await $.command.list()
388      const available = commands
389        .filter(command => command.source !== 'builtin' && command.plugin !== 'polis')
390        .slice(0, 120)
391        .map(command => ({
392          name: command.name,
393          description: clip(command.description, 160),
394          source: command.plugin ?? command.source,
395        }))
396      await update($, skills, value => ({ ...value, available }))
397
398      const measured = await $.session.usage()
399      await update($, usage, value => ({
400        ...value,
401        contextTokens: measured.context.tokens ?? value.contextTokens,
402        contextWindow: measured.context.window,
403        contextPercent: measured.context.percent ?? value.contextPercent,
404        costUsd: measured.cost?.usd ?? value.costUsd,
405      }))
406    } catch (error) {
407      say($, 'loading the project', error)
408    }
409
410    // A pane left open across a reload asks again for its room: an open sets
411    // the size anew, and the person's own size still wins.
412    try {
413      const open = await $.ui.panes()
414      if (open.some(one => one.id === PANE)) void openPane($).catch(() => undefined)
415    } catch (error) {
416      say($, 'reopening the pane', error)
417    }
418
419    // Elapsed times in the pane move only while something is running.
420    $.clock.every(5000, () => {
421      void (async () => {
422        const { isWorking } = await read($, turn)
423        const runs = await read($, agents)
424        if (isWorking || runs.some(isLive)) await update($, tick, count => count + 1)
425        if (hasNewRoom) {
426          hasNewRoom = false
427          await $.store.set('rooms', rooms)
428        }
429      })().catch(() => undefined)
430    })
431
432    return next(e)
433  })
434
435  on('session.end', async ($, e, next) => {
436    if (e.reason === 'clear') {
437      await update($, tasks, () => [])
438      await update($, agents, () => [])
439      await update($, procs, () => [])
440      await update($, turn, () => IDLE)
441      await update($, activity, () => [])
442      await update($, files, () => [])
443      await refresh($, false)
444    }
445
446    return next(e)
447  })
448
449  if (hasGuidance) {
450    on('prompt.compose', async ($, e, next) => {
451      const composed = await next(e)
452      // Where nothing draws there is no dashboard to keep current.
453      if (e.surfaces.length === 0) return composed
454
455      return { sections: [...composed.sections, GUIDANCE] }
456    })
457
458    // The mod's own tool writes nothing but the mod's own record.
459    on('tool.check', { tool: PROJECT_TOOL }, () => ({ decision: 'allow' }))
460
461    on('tool.call', { tool: PROJECT_TOOL }, async ($, e) => {
462      const now = await $.clock.now()
463      const before = (await read($, project)) ?? newProject(await $.session.cwd(), now)
464      const after = mergeProject(before, {
465        name: e.name,
466        phase: e.phase,
467        milestones: e.milestones,
468      })
469      await update($, project, () => after)
470      await saveProject($)
471      const done = after.milestones.filter(one => one.status === 'done').length
472      if (e.milestones !== undefined) {
473        await note($, 'project', `Plan updated: ${done}/${after.milestones.length} milestones done`)
474      }
475
476      // The steps of the work at hand, where the host has no task tool to watch.
477      let steps = ''
478      if (e.tasks !== undefined) {
479        const had = taskStats(await read($, tasks)).done
480        const list = await update($, tasks, value => recordTasks(value, e.tasks))
481        const stats = taskStats(list)
482        await countDone($, stats.done - had)
483        // Whoever owns a task in progress stands at its building.
484        await update($, agents, runs =>
485          runs.map(run => {
486            const mine = list.find(
487              task => task.status === 'in_progress' && task.owner !== undefined && task.owner === run.name,
488            )
489
490            return isLive(run) && mine !== undefined ? { ...run, taskId: mine.id } : run
491          }),
492        )
493        await refresh($, shouldAutoOpen)
494        steps = ` Tasks: ${stats.done} done, ${stats.active} active, ${stats.waiting} waiting.`
495      }
496
497      return {
498        result: `Recorded. ${after.name}: ${done} of ${after.milestones.length} milestones done${
499          after.phase === '' ? '' : `; phase: ${after.phase}`
500        }.${steps}`,
501      }
502    })
503  }
504
505  on('command.run', { command: 'city' }, async ($, e) => {
506    const wanted = e.args.trim().toLowerCase()
507    const view = VIEWS.find(one => one.name === wanted)?.name
508    // Where no surface draws (an editor's chat panel, a headless run) the
509    // dashboard is given as the command's own text.
510    if ((await $.session.surfaces()).length === 0) {
511      return { text: summary(await load($, 80)) }
512    }
513    const opened = await openAt($, view ?? (wanted === '' ? 'city' : undefined))
514    if (opened.isPlaced) return { text: 'Polis opened.' }
515    const board = summary(await load($, 80))
516
517    return { text: `Polis is open but not drawn here yet: ${opened.reason}\n\n${board}` }
518  })
519
520  on('command.run', { command: 'orchestrate' }, async ($, e) => {
521    const goal = e.args.trim()
522    if (goal === '') {
523      return { text: 'Usage: /orchestrate <goal>' }
524    }
525    await update($, ui, value => ({ ...value, hasOpened: true }))
526    void openPane($).catch(() => undefined)
527    // A command.run hook holds the turn a submit would wait on: submit after it.
528    $.clock.after(50, () => {
529      void $.prompt
530        .submit({ text: orchestrationBrief(goal), asUser: true })
531        .catch(error => say($, 'submitting the brief', error))
532    })
533
534    return { text: `Orchestrating: ${goal}` }
535  })
536
537  on('turn.start', async ($, e, next) => {
538    try {
539      const now = await $.clock.now()
540      const past = await read($, history)
541      const size = heuristicSize(e.text)
542      await update($, turn, () => ({
543        ...IDLE,
544        id: e.turnId,
545        isWorking: true,
546        startedAt: now,
547        endedAt: now,
548        expected: expectedFor(size, 'heuristic', past),
549      }))
550
551      asked = [...asked, e.text.slice(0, 2000)].slice(-3)
552      const subjects = (await read($, tasks)).map(task => task.subject)
553      const corpus = [...asked, ...subjects].join(' ')
554      await update($, skills, value => ({
555        ...value,
556        suggested: suggestSkills(value.available, value.used, corpus),
557      }))
558
559      if (shouldAskModel(mode, e.text, size) && (await $.session.surfaces()).length > 0) {
560        const { turnId, text } = e
561        // Sized on a timer, so the prompt never waits on the small model.
562        $.clock.after(10, () => {
563          void (async () => {
564            const label = await $.model.classify(`${SIZING_RUBRIC}\n${text.slice(0, 4000)}`, SIZES)
565            const picked = SIZES.find(one => one === label)
566            if (picked === undefined) return
567            const known = await read($, history)
568            await update($, turn, value =>
569              value.id === turnId
570                ? { ...value, expected: expectedFor(picked, 'model', known) }
571                : value,
572            )
573          })().catch(() => undefined)
574        })
575      }
576    } catch (error) {
577      say($, 'turn.start', error)
578    }
579
580    return next(e)
581  })
582
583  // Passes every request through untouched, then counts what it cost.
584  on('turn.step', async function* ($, e, next) {
585    const result = yield* next(e)
586    try {
587      const cost = result.usage
588      if (cost !== null) {
589        const work = cost.input_tokens + cost.cache_creation_input_tokens + cost.output_tokens
590        const now = await update($, turn, value =>
591          value.isWorking
592            ? {
593                ...value,
594                tokens: value.tokens + work,
595                outTokens: value.outTokens + cost.output_tokens,
596                cacheRead: value.cacheRead + cost.cache_read_input_tokens,
597                steps: value.steps + 1,
598              }
599            : value,
600        )
601        await update($, usage, value => ({ ...value, sessionTokens: value.sessionTokens + work }))
602        const { agentId } = e
603        if (agentId !== undefined) {
604          await update($, agents, list =>
605            list.map(run =>
606              run.agentId === agentId ? { ...run, tokens: run.tokens + work } : run,
607            ),
608          )
609        }
610        if (
611          now.isWorking &&
612          !now.hasWarned &&
613          now.expected !== null &&
614          now.tokens > now.expected.tokens.high * 1.5
615        ) {
616          await update($, turn, value => ({ ...value, hasWarned: true }))
617          $.ui.toast('This prompt is running well past its expected token range')
618        }
619      }
620    } catch (error) {
621      say($, 'turn.step', error)
622    }
623
624    return result
625  })
626
627  on('turn.complete', async ($, e, next) => {
628    try {
629      const now = await $.clock.now()
630      const { agentId } = e
631      if (agentId === undefined) {
632        const ended = await update($, turn, value => ({ ...value, isWorking: false, endedAt: now }))
633        if (ended.expected !== null && ended.tokens > 0 && e.reason === 'answer') {
634          const record = {
635            size: ended.expected.size,
636            tokens: ended.tokens,
637            ms: e.durationMs,
638            tools: ended.tools,
639            agents: ended.agents,
640            at: now,
641          }
642          const past = await update($, history, list => [...list, record].slice(-300))
643          const saved = await update($, project, value =>
644            value === null
645              ? value
646              : {
647                  ...value,
648                  turns: value.turns + 1,
649                  tokens: value.tokens + ended.tokens,
650                  lastActive: now,
651                },
652          )
653          if (saved !== null) {
654            await $.store.set(`history:${saved.root}`, past)
655            await saveProject($)
656          }
657        }
658      } else {
659        const status: AgentRunStatus = e.reason === 'answer' ? 'done' : 'failed'
660        const runs = await read($, agents)
661        const run = runs.find(one => one.agentId === agentId)
662        await update($, agents, list =>
663          list.map(one => (one.agentId === agentId ? { ...one, status, endedAt: now } : one)),
664        )
665        if (run !== undefined) {
666          await note($, 'agent', `Agent ${status === 'done' ? 'finished' : 'failed'}: ${run.label}`)
667          if (status === 'failed') $.ui.toast(`Agent failed: ${clip(run.label, 60)}`)
668        }
669      }
670      await refresh($, shouldAutoOpen)
671    } catch (error) {
672      say($, 'turn.complete', error)
673    }
674
675    return next(e)
676  })
677
678  on('agent.spawn', async ($, e, next) => {
679    let key = ''
680    let startedAt = 0
681    try {
682      startedAt = await $.clock.now()
683      key = `${e.tool_use_id}:${startedAt}`
684      const label = e.description === '' ? e.subagentType : e.description
685      const taskId = matchTask(`${e.name ?? ''} ${label}`, await read($, tasks))
686      const run: AgentRun = {
687        key,
688        label,
689        type: e.subagentType,
690        status: 'starting',
691        tools: 0,
692        families: {},
693        tokens: 0,
694        startedAt,
695        ...(e.name === undefined ? {} : { name: e.name }),
696        ...(e.model === undefined ? {} : { model: e.model }),
697        ...(e.parentAgentId === undefined ? {} : { parentAgentId: e.parentAgentId }),
698        ...(taskId === undefined ? {} : { taskId }),
699      }
700      await update($, agents, list => [...list, run].slice(-60))
701      await update($, turn, value =>
702        value.isWorking ? { ...value, agents: value.agents + 1 } : value,
703      )
704      await note($, 'agent', `Agent started: ${label}`)
705      await refresh($, shouldAutoOpen)
706    } catch (error) {
707      say($, 'agent.spawn', error)
708    }
709
710    const started = await next(e)
711    try {
712      await update($, agents, list =>
713        list.map(one => {
714          if (one.key !== key) return one
715          if (started.deny !== undefined) {
716            return { ...one, status: 'failed' as const, endedAt: startedAt }
717          }
718
719          return {
720            ...one,
721            status: one.status === 'starting' ? ('running' as const) : one.status,
722            model: started.model,
723            ...(started.agentId === undefined ? {} : { agentId: started.agentId }),
724          }
725        }),
726      )
727      await refresh($, false)
728    } catch (error) {
729      say($, 'agent.spawn', error)
730    }
731
732    return started
733  })
734
735  // Every tool call: an agent's heartbeat and equipment, the files edited,
736  // and whatever the call left running in the background.
737  on('tool.call', async ($, e, next) => {
738    const tool = String(e.tool)
739    // The dashboard's own bookkeeping tool is no part of anyone's equipment.
740    if (tool === PROJECT_TOOL) return next(e)
741    try {
742      const family = familyOf(tool)
743      const { agentId } = e
744      if (agentId === undefined) {
745        await update($, usage, value => ({
746          ...value,
747          mainFamilies: bumpFamily(value.mainFamilies, family),
748        }))
749      } else {
750        await update($, agents, list =>
751          list.map(run =>
752            run.agentId === agentId
753              ? {
754                  ...run,
755                  tools: run.tools + 1,
756                  families: bumpFamily(run.families, family),
757                  lastTool: tool,
758                }
759              : run,
760          ),
761        )
762      }
763      await update($, turn, value =>
764        value.isWorking ? { ...value, tools: value.tools + 1 } : value,
765      )
766      if (family === 'edit') {
767        const path = pick(e, 'file_path') ?? pick(e, 'notebook_path')
768        if (path !== undefined) await update($, files, list => hitFile(list, path))
769      }
770    } catch (error) {
771      say($, 'tool.call', error)
772    }
773
774    const ran = await next(e)
775    try {
776      if (ran.deny === undefined && ran.isError !== true && BACKGROUND.has(tool)) {
777        const at = await $.clock.now()
778        const { result } = ran
779        const started = (id: string, kind: Proc['kind'], label: string): Proc => ({
780          id,
781          kind,
782          label: clip(label, 80),
783          status: 'running',
784          startedAt: at,
785        })
786        const shell = pick(result, 'backgroundTaskId')
787        const taskId = pick(result, 'taskId')
788        let proc: Proc | undefined
789        if (shell !== undefined) {
790          proc = started(shell, 'shell', pick(e, 'description') ?? pick(e, 'command') ?? tool)
791        } else if (tool === 'Monitor' && taskId !== undefined) {
792          proc = started(taskId, 'monitor', pick(e, 'description') ?? 'monitor')
793        } else if (tool === 'Workflow' && taskId !== undefined) {
794          proc = started(taskId, 'workflow', pick(result, 'workflowName') ?? 'workflow')
795        } else if (tool === 'CronCreate') {
796          const id = pick(result, 'id')
797          if (id !== undefined) {
798            proc = started(
799              id,
800              'cron',
801              `${pick(result, 'humanSchedule') ?? ''} · ${pick(e, 'prompt') ?? ''}`,
802            )
803          }
804        }
805        if (proc !== undefined) {
806          const added = proc
807          await update($, procs, list => upsertProc(list, added))
808          await note($, 'process', `Started ${added.kind}: ${added.label}`)
809        }
810        const stopped =
811          tool === 'TaskStop' ? pick(result, 'task_id') : tool === 'CronDelete' ? pick(result, 'id') : undefined
812        if (stopped !== undefined) {
813          await update($, procs, list => endProc(list, stopped, 'stopped', at))
814        }
815      }
816    } catch (error) {
817      say($, 'tool.call', error)
818    }
819
820    return ran
821  })
822
823  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
824    const ran = await next(e)
825    try {
826      if (ran.deny === undefined && ran.isError !== true) {
827        const { agentId } = e
828        const owner = (await read($, agents)).find(run => run.agentId === agentId)
829        const before = taskStats(await read($, tasks)).done
830        const after = await update($, tasks, list =>
831          mergeTodos(list, e.todos, agentId, agentId === undefined ? undefined : owner?.taskId),
832        )
833        await countDone($, taskStats(after).done - before)
834        await refresh($, shouldAutoOpen)
835      }
836    } catch (error) {
837      say($, 'TodoWrite', error)
838    }
839
840    return ran
841  })
842
843  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
844    const ran = await next(e)
845    try {
846      if (ran.deny === undefined && ran.isError !== true) {
847        const { agentId } = e
848        const owner = (await read($, agents)).find(run => run.agentId === agentId)
849        const parentId = agentId === undefined ? undefined : owner?.taskId
850        const draft = {
851          id: ran.result.task.id,
852          subject: e.subject,
853          metadata: e.metadata,
854          ...(e.activeForm === undefined ? {} : { activeForm: e.activeForm }),
855          ...(agentId === undefined ? {} : { agentId }),
856          ...(parentId === undefined ? {} : { parentId }),
857        }
858        await update($, tasks, list => createTask(list, draft))
859        await refresh($, shouldAutoOpen)
860      }
861    } catch (error) {
862      say($, 'TaskCreate', error)
863    }
864
865    return ran
866  })
867
868  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
869    const ran = await next(e)
870    try {
871      if (ran.deny === undefined && ran.isError !== true && ran.result.success) {
872        const { agentId, taskId, status, owner } = e
873        const before = (await read($, tasks)).find(task => task.id === taskId)
874        await update($, tasks, list => patchTask(list, e))
875        if (status === 'completed' && before !== undefined && before.status !== 'completed') {
876          await note($, 'task', `Task done: ${before.subject}`)
877          await countDone($, 1)
878        }
879        // Whoever takes a task up stands at its building.
880        if (status === 'in_progress' || owner !== undefined) {
881          await update($, agents, list =>
882            list.map(run =>
883              isLive(run) &&
884              ((agentId !== undefined && run.agentId === agentId && status === 'in_progress') ||
885                (owner !== undefined && run.name === owner))
886                ? { ...run, taskId }
887                : run,
888            ),
889          )
890        }
891        await refresh($, shouldAutoOpen)
892      }
893    } catch (error) {
894      say($, 'TaskUpdate', error)
895    }
896
897    return ran
898  })
899
900  on('skill.prompt', async ($, e, next) => {
901    try {
902      const { skill } = e
903      await update($, skills, value => ({
904        ...value,
905        used: { ...value.used, [skill]: (value.used[skill] ?? 0) + 1 },
906        suggested: value.suggested.filter(name => name !== skill),
907      }))
908      await note($, 'skill', `Skill used: ${skill}`)
909    } catch (error) {
910      say($, 'skill.prompt', error)
911    }
912
913    return next(e)
914  })
915
916  on('session.measure', async ($, e, next) => {
917    try {
918      const before = await read($, usage)
919      const percent = e.context.percent ?? before.contextPercent
920      await update($, usage, value => ({
921        ...value,
922        contextTokens: e.context.tokens ?? value.contextTokens,
923        contextWindow: e.context.window,
924        contextPercent: percent,
925        rateLimits: e.rateLimits.map(limit => ({
926          kind: limit.kind,
927          percentUsed: limit.percentUsed,
928          ...(limit.resetsAt === undefined ? {} : { resetsAt: limit.resetsAt }),
929        })),
930        costUsd: e.cost?.usd ?? value.costUsd,
931        hasWarnedContext: percent >= 80,
932      }))
933      if (percent >= 80 && !before.hasWarnedContext) {
934        $.ui.toast(`The context window is ${percent}% full`)
935      }
936    } catch (error) {
937      say($, 'session.measure', error)
938    }
939
940    return next(e)
941  })
942
943  // At each stop the engine says what is still in flight: the truth to hold to.
944  on('classic.Stop', async ($, e, next) => {
945    try {
946      const at = await $.clock.now()
947      // A stop that names no background list says nothing about what runs.
948      const background = e.background_tasks
949      const crons = e.session_crons ?? []
950      if (background !== undefined) {
951        await update($, procs, list => reconcileProcs(list, background, crons, at))
952      }
953      // An agent whose end was missed is settled by what the engine lists.
954      const listed = await $.agent.list()
955      await update($, agents, list =>
956        list.map(run => {
957          const info = listed.find(one => one.id === run.agentId)
958          if (!isLive(run) || info === undefined) return run
959          if (info.status === 'completed') return { ...run, status: 'done' as const, endedAt: at }
960          if (info.status === 'failed' || info.status === 'killed') {
961            return { ...run, status: 'failed' as const, endedAt: at }
962          }
963
964          return run
965        }),
966      )
967    } catch (error) {
968      say($, 'classic.Stop', error)
969    }
970
971    return next(e)
972  })
973
974  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
975    isBandAsked = true
976    measure(`${e.surface}:band`, {
977      bodyColumns: e.props.bodyColumns,
978      maxRows: e.props.maxRows,
979      viewport: e.viewport ?? null,
980    })
981    const state = await read($, ui)
982    if (e.props.hasSurvey || state.isBandHidden) return next(e)
983
984    return band($.ui.resolve(e), await load($, e.props.bodyColumns), actionsOf($))
985  })
986
987  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
988    measure(`${e.surface}:pane`, {
989      bodyColumns: e.props.bodyColumns,
990      bodyRows: e.props.scroll.bodyRows,
991      placement: e.props.placement,
992      viewport: e.viewport ?? null,
993    })
994    const model = await load($, e.props.bodyColumns)
995    // The terminal draws no vector graphics: it gets the city and icons as text.
996    const Svg = e.surface === 'terminal' ? undefined : $.ui.resolve(e).Svg
997
998    return pane($.ui.resolve(e), model, actionsOf($), Svg, e.surface !== 'mobile')
999  })
1000}
1001
hooks/logic.ts 705 lines
1import type {
2  Activity,
3  AgentRun,
4  Expected,
5  FamilyCounts,
6  FileHit,
7  Milestone,
8  MilestoneStatus,
9  Proc,
10  ProcKind,
11  Project,
12  Range,
13  Size,
14  SkillInfo,
15  Task,
16  TaskStatus,
17  ToolFamily,
18  TurnRecord,
19} from '../types'
20
21// ---------------------------------------------------------------- formatting
22
23export const elapsed = (ms: number): string => {
24  const seconds = Math.max(0, Math.round(ms / 1000))
25  if (seconds < 60) return `${seconds}s`
26  const minutes = Math.floor(seconds / 60)
27  if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
28
29  return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
30}
31
32export const compact = (n: number): string => {
33  if (n < 1000) return String(Math.round(n))
34  if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
35  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
36
37  return `${(n / 1_000_000).toFixed(1)}M`
38}
39
40export const clip = (text: string, max: number): string => {
41  const flat = text.replace(/\s+/g, ' ').trim()
42
43  return flat.length <= max ? flat : `${flat.slice(0, Math.max(1, max - 1))}…`
44}
45
46export const bar = (done: number, total: number, width: number): [string, string] => {
47  const share = total <= 0 ? 0 : Math.min(1, Math.max(0, done / total))
48  const filled = Math.round(share * width)
49
50  return ['█'.repeat(filled), '░'.repeat(Math.max(0, width - filled))]
51}
52
53const words = (text: string): string[] =>
54  text
55    .toLowerCase()
56    .split(/[^a-z0-9]+/)
57    .filter(word => word.length >= 3 && !STOP.has(word))
58
59const STOP = new Set([
60  'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'use',
61  'used', 'using', 'any', 'all', 'are', 'not', 'can', 'will', 'should', 'have',
62  'has', 'its', 'their', 'your', 'you', 'user', 'users', 'wants', 'want', 'new',
63  'file', 'files', 'code', 'make', 'create', 'add', 'run', 'task', 'tasks',
64  'claude', 'also', 'such', 'other', 'them', 'then', 'than', 'each', 'one',
65])
66
67// --------------------------------------------------------------------- tasks
68
69const AREAS: readonly [RegExp, string][] = [
70  [/\b(test\w*|specs?|verif\w*|qa|coverage)\b/, 'tests'],
71  [/\b(docs?|readme|document\w*|write-?ups?|changelog)\b/, 'docs'],
72  [/\b(ui|ux|views?|pages?|css|styl\w*|design\w*|layouts?|component\w*|frontend)\b/, 'ui'],
73  [/\b(apis?|endpoints?|servers?|backend|databases?|db|schemas?|migrat\w*|quer\w*)\b/, 'backend'],
74  [/\b(research\w*|explor\w*|investigat\w*|audit\w*|review\w*|analy\w*|survey\w*|find\w*)\b/, 'research'],
75  [/\b(deploy\w*|releas\w*|ci|build\w*|config\w*|setup|install\w*|infra\w*|pipelines?)\b/, 'ops'],
76]
77
78export const inferArea = (subject: string): string => {
79  const text = subject.toLowerCase()
80  for (const [pattern, area] of AREAS) {
81    if (pattern.test(text)) return area
82  }
83
84  return 'general'
85}
86
87const field = (bag: unknown, key: string): string | undefined => {
88  if (typeof bag !== 'object' || bag === null) return undefined
89  const value = (bag as { [key: string]: unknown })[key]
90  if (typeof value !== 'string') return undefined
91  const trimmed = value.trim()
92
93  return trimmed === '' ? undefined : trimmed
94}
95
96export const areaOf = (metadata: unknown, subject: string): string => {
97  const given = field(metadata, 'area')
98
99  return given === undefined ? inferArea(subject) : given.toLowerCase().slice(0, 24)
100}
101
102export type TodoItem = { content: string; status: TaskStatus; activeForm?: string }
103
104// A todo list belongs to one loop: writing it replaces that loop's todos only.
105export const mergeTodos = (
106  list: readonly Task[],
107  todos: readonly TodoItem[],
108  agentId: string | undefined,
109  parentId: string | undefined,
110): Task[] => {
111  const loop = agentId ?? 'main'
112  const kept = list.filter(task => !(task.source === 'todo' && (task.agentId ?? 'main') === loop))
113  const fresh = todos.map((todo, index): Task => ({
114    id: `todo:${loop}:${index}`,
115    subject: todo.content,
116    status: todo.status,
117    area: inferArea(todo.content),
118    blockedBy: [],
119    source: 'todo',
120    ...(todo.activeForm === undefined ? {} : { activeForm: todo.activeForm }),
121    ...(agentId === undefined ? {} : { agentId }),
122    ...(parentId === undefined ? {} : { parentId }),
123  }))
124
125  return [...kept, ...fresh].slice(-240)
126}
127
128export type TaskDraft = {
129  id: string
130  subject: string
131  activeForm?: string
132  metadata?: unknown
133  agentId?: string
134  parentId?: string
135}
136
137export const createTask = (list: readonly Task[], draft: TaskDraft): Task[] => {
138  const parentId = field(draft.metadata, 'parent') ?? draft.parentId
139  const parent = list.find(task => task.id === parentId)
140  const task: Task = {
141    id: draft.id,
142    subject: draft.subject,
143    status: 'pending',
144    // A sub-task with no area of its own lives in its parent's district.
145    area:
146      field(draft.metadata, 'area') === undefined && parent !== undefined
147        ? parent.area
148        : areaOf(draft.metadata, draft.subject),
149    blockedBy: [],
150    source: 'task',
151    ...(draft.activeForm === undefined ? {} : { activeForm: draft.activeForm }),
152    ...(draft.agentId === undefined ? {} : { agentId: draft.agentId }),
153    ...(parentId === undefined ? {} : { parentId }),
154  }
155
156  return [...list.filter(one => one.id !== task.id), task].slice(-240)
157}
158
159export type TaskPatch = {
160  taskId: string
161  subject?: string
162  activeForm?: string
163  status?: TaskStatus | 'deleted'
164  owner?: string
165  addBlockedBy?: readonly string[]
166  addBlocks?: readonly string[]
167  metadata?: unknown
168}
169
170const union = (a: readonly string[], b: readonly string[]): string[] => [...new Set([...a, ...b])]
171
172export const patchTask = (list: readonly Task[], patch: TaskPatch): Task[] => {
173  if (patch.status === 'deleted') {
174    return list
175      .filter(task => task.id !== patch.taskId)
176      .map(task => ({ ...task, blockedBy: task.blockedBy.filter(id => id !== patch.taskId) }))
177  }
178  const { status } = patch
179  const area = field(patch.metadata, 'area')
180  const parentId = field(patch.metadata, 'parent')
181  const blocks = patch.addBlocks ?? []
182
183  return list.map(task => {
184    if (task.id !== patch.taskId) {
185      return blocks.includes(task.id)
186        ? { ...task, blockedBy: union(task.blockedBy, [patch.taskId]) }
187        : task
188    }
189
190    return {
191      ...task,
192      ...(patch.subject === undefined ? {} : { subject: patch.subject }),
193      ...(patch.activeForm === undefined ? {} : { activeForm: patch.activeForm }),
194      ...(status === undefined ? {} : { status }),
195      ...(patch.owner === undefined ? {} : { owner: patch.owner }),
196      ...(area === undefined ? {} : { area: area.toLowerCase().slice(0, 24) }),
197      ...(parentId === undefined ? {} : { parentId }),
198      blockedBy: union(task.blockedBy, patch.addBlockedBy ?? []),
199    }
200  })
201}
202
203const TASK_STATUSES: readonly TaskStatus[] = ['pending', 'in_progress', 'completed']
204
205// Tasks as the model reports them through the mod's own tool, for a session
206// whose host gives it no task tool to watch: matched by id, made when new.
207export const recordTasks = (list: readonly Task[], notes: unknown): Task[] => {
208  let next = [...list]
209  for (const raw of Array.isArray(notes) ? notes.slice(0, 60) : []) {
210    const id = field(raw, 'id')
211    if (id === undefined) continue
212    const subject = field(raw, 'subject')
213    const stated = field(raw, 'status')
214    const status = stated === 'deleted' ? 'deleted' : TASK_STATUSES.find(one => one === stated)
215    const owner = field(raw, 'owner')
216    const activeForm = field(raw, 'activeForm')
217    const waits = (raw as { blockedBy?: unknown }).blockedBy
218    const metadata = { area: field(raw, 'area'), parent: field(raw, 'parent') }
219    if (!next.some(task => task.id === id)) {
220      if (subject === undefined || status === 'deleted') continue
221      next = createTask(next, { id, subject, metadata })
222    }
223    next = patchTask(next, {
224      taskId: id,
225      metadata,
226      addBlockedBy: Array.isArray(waits) ? waits.filter(one => typeof one === 'string') : [],
227      ...(subject === undefined ? {} : { subject }),
228      ...(status === undefined ? {} : { status }),
229      ...(owner === undefined ? {} : { owner }),
230      ...(activeForm === undefined ? {} : { activeForm }),
231    })
232  }
233
234  return next
235}
236
237// The blockers of a task that are still unfinished.
238export const openBlockers = (task: Task, list: readonly Task[]): string[] =>
239  task.blockedBy.filter(id => list.some(one => one.id === id && one.status !== 'completed'))
240
241export const isBlocked = (task: Task, list: readonly Task[]): boolean =>
242  task.status !== 'completed' && openBlockers(task, list).length > 0
243
244export type TaskStats = { done: number; active: number; waiting: number; total: number }
245
246export const taskStats = (list: readonly Task[]): TaskStats => {
247  const done = list.filter(task => task.status === 'completed').length
248  const active = list.filter(task => task.status === 'in_progress').length
249
250  return { done, active, waiting: list.length - done - active, total: list.length }
251}
252
253export type TaskRow = { task: Task; depth: number }
254
255// Top-level tasks in order, each followed by its sub-tasks.
256export const taskTree = (list: readonly Task[]): TaskRow[] => {
257  const ids = new Set(list.map(task => task.id))
258  const rows: TaskRow[] = []
259  const walk = (parent: string | undefined, depth: number): void => {
260    for (const task of list) {
261      const isRoot = task.parentId === undefined || !ids.has(task.parentId) || task.parentId === task.id
262      const isChild = parent === undefined ? isRoot : !isRoot && task.parentId === parent
263      if (!isChild || depth > 4) continue
264      rows.push({ task, depth })
265      walk(task.id, depth + 1)
266    }
267  }
268  walk(undefined, 0)
269
270  return rows
271}
272
273export type District = { area: string; tasks: Task[] }
274
275// Areas in the order their first task appeared.
276export const districtsOf = (list: readonly Task[]): District[] => {
277  const districts: District[] = []
278  for (const task of list) {
279    const found = districts.find(one => one.area === task.area)
280    if (found === undefined) districts.push({ area: task.area, tasks: [task] })
281    else found.tasks.push(task)
282  }
283
284  return districts
285}
286
287// -------------------------------------------------------------------- agents
288
289export const isLive = (run: AgentRun): boolean =>
290  run.status === 'starting' || run.status === 'running'
291
292// The task an agent's brief is most likely about: word overlap with the open
293// tasks, else the one task in progress when there is exactly one.
294export const matchTask = (label: string, list: readonly Task[]): string | undefined => {
295  const open = list.filter(task => task.status !== 'completed')
296  const wanted = new Set(words(label))
297  let best: { id: string; score: number } | undefined
298  for (const task of open) {
299    const have = words(task.subject)
300    if (have.length === 0 || wanted.size === 0) continue
301    const overlap = have.filter(word => wanted.has(word)).length
302    const score = overlap / Math.min(have.length, wanted.size) + (task.status === 'in_progress' ? 0.1 : 0)
303    if (overlap >= 1 && score >= 0.4 && (best === undefined || score > best.score)) {
304      best = { id: task.id, score }
305    }
306  }
307  if (best !== undefined) return best.id
308  const active = open.filter(task => task.status === 'in_progress')
309
310  return active.length === 1 ? active[0]?.id : undefined
311}
312
313export type AgentRow = { run: AgentRun; depth: number }
314
315// Agents in spawn order, each followed by the agents its own loop spawned.
316export const agentTree = (runs: readonly AgentRun[]): AgentRow[] => {
317  const ids = new Set(runs.map(run => run.agentId))
318  const rows: AgentRow[] = []
319  const walk = (parent: string | undefined, depth: number): void => {
320    for (const run of runs) {
321      const isRoot = run.parentAgentId === undefined || !ids.has(run.parentAgentId)
322      const isChild = parent === undefined ? isRoot : !isRoot && run.parentAgentId === parent
323      if (!isChild || depth > 6) continue
324      rows.push({ run, depth })
325      if (run.agentId !== undefined) walk(run.agentId, depth + 1)
326    }
327  }
328  walk(undefined, 0)
329
330  return rows
331}
332
333export const bumpFamily = (families: FamilyCounts, family: ToolFamily): FamilyCounts => ({
334  ...families,
335  [family]: (families[family] ?? 0) + 1,
336})
337
338// The families an agent has used most: its equipment. Planning and unknown
339// tools say little about what an agent does, so they come last.
340export const topFamilies = (families: FamilyCounts, limit: number): ToolFamily[] => {
341  const dull = (family: string): number => (family === 'plan' || family === 'other' ? 1 : 0)
342
343  return (Object.entries(families) as [ToolFamily, number][])
344    .filter(([, count]) => count > 0)
345    .sort((a, b) => dull(a[0]) - dull(b[0]) || b[1] - a[1])
346    .slice(0, limit)
347    .map(([family]) => family)
348}
349
350// ----------------------------------------------------------------- processes
351
352export const upsertProc = (list: readonly Proc[], proc: Proc): Proc[] =>
353  [...list.filter(one => one.id !== proc.id), proc].slice(-40)
354
355export const endProc = (
356  list: readonly Proc[],
357  id: string,
358  status: 'done' | 'stopped',
359  at: number,
360): Proc[] =>
361  list.map(proc =>
362    proc.id === id && proc.status === 'running' ? { ...proc, status, endedAt: at } : proc,
363  )
364
365export type Background = { id: string; type: string; description: string; command?: string }
366
367export type Cron = { id: string; schedule: string; prompt: string }
368
369const KIND_OF: { [type: string]: ProcKind } = {
370  shell: 'shell',
371  local_bash: 'shell',
372  monitor: 'monitor',
373  workflow: 'workflow',
374  local_workflow: 'workflow',
375  subagent: 'agent',
376  local_agent: 'agent',
377  remote_agent: 'agent',
378}
379
380// What the engine says is in flight is the truth: anything else we still hold
381// as running has ended.
382export const reconcileProcs = (
383  list: readonly Proc[],
384  background: readonly Background[],
385  crons: readonly Cron[],
386  at: number,
387): Proc[] => {
388  const live = new Map<string, Proc>()
389  for (const task of background) {
390    const kind = KIND_OF[task.type] ?? 'other'
391    if (kind === 'agent') continue
392    live.set(task.id, {
393      id: task.id,
394      kind,
395      label: clip(task.description === '' ? (task.command ?? task.type) : task.description, 80),
396      status: 'running',
397      startedAt: at,
398    })
399  }
400  for (const cron of crons) {
401    live.set(cron.id, {
402      id: cron.id,
403      kind: 'cron',
404      label: clip(`${cron.schedule} · ${cron.prompt}`, 80),
405      status: 'running',
406      startedAt: at,
407    })
408  }
409  const kept = list.map((proc): Proc => {
410    const now = live.get(proc.id)
411    live.delete(proc.id)
412    if (now !== undefined) return { ...proc, status: 'running' }
413
414    return proc.status === 'running' ? { ...proc, status: 'done', endedAt: at } : proc
415  })
416
417  return [...kept, ...live.values()].slice(-40)
418}
419
420// ------------------------------------------------------------------ estimate
421
422export const SIZES: readonly Size[] = ['S', 'M', 'L', 'XL']
423
424export const SIZE_LABEL: Record<Size, string> = {
425  S: 'small',
426  M: 'medium',
427  L: 'large',
428  XL: 'very large',
429}
430
431// Where a size class starts before this project has history of its own.
432export const DEFAULTS: Record<Size, { tokens: number; ms: number; tools: number }> = {
433  S: { tokens: 15_000, ms: 30_000, tools: 3 },
434  M: { tokens: 60_000, ms: 150_000, tools: 12 },
435  L: { tokens: 220_000, ms: 600_000, tools: 40 },
436  XL: { tokens: 700_000, ms: 1_800_000, tools: 120 },
437}
438
439const MIN_SAMPLES = 5
440
441const BIG = /\b(refactor\w*|implement\w*|build\w*|migrat\w*|rewrit\w*|redesign\w*|orchestrat\w*|end-to-end|across|entire|whole|every|all of)\b/i
442const ASK = /^(what|why|how|where|when|which|who|is|are|does|do|can|could|explain|show|tell)\b/i
443
444export const heuristicSize = (text: string): Size => {
445  const trimmed = text.trim()
446  const lines = trimmed.split('\n').filter(line => line.trim() !== '')
447  const items = lines.filter(line => /^\s*([-*•]|\d+[.)])\s+/.test(line)).length
448  let level = trimmed.length < 200 ? 0 : trimmed.length < 900 ? 1 : trimmed.length < 3500 ? 2 : 3
449  if (items >= 3) level = Math.max(level, 2)
450  if (items >= 8) level = 3
451  if (BIG.test(trimmed)) level += 1
452  if (level <= 1 && ASK.test(trimmed) && trimmed.length < 300 && !BIG.test(trimmed)) level = 0
453
454  return SIZES[Math.min(3, level)] ?? 'M'
455}
456
457export const quantile = (values: readonly number[], q: number): number => {
458  if (values.length === 0) return 0
459  const sorted = [...values].sort((a, b) => a - b)
460  const at = (sorted.length - 1) * q
461  const low = Math.floor(at)
462  const high = Math.ceil(at)
463  const a = sorted[low] ?? 0
464  const b = sorted[high] ?? a
465
466  return Math.round(a + (b - a) * (at - low))
467}
468
469const spread = (values: readonly number[]): Range => ({
470  low: quantile(values, 0.25),
471  high: Math.max(1, quantile(values, 0.8)),
472})
473
474export const expectedFor = (
475  size: Size,
476  source: 'heuristic' | 'model',
477  history: readonly TurnRecord[],
478): Expected => {
479  const same = history.filter(record => record.size === size)
480  if (same.length < MIN_SAMPLES) {
481    const base = DEFAULTS[size]
482
483    return {
484      size,
485      source,
486      tokens: { low: Math.round(base.tokens * 0.4), high: Math.round(base.tokens * 1.5) },
487      ms: { low: Math.round(base.ms * 0.4), high: Math.round(base.ms * 1.5) },
488      tools: { low: Math.round(base.tools * 0.4), high: Math.round(base.tools * 1.5) },
489      samples: same.length,
490    }
491  }
492
493  return {
494    size,
495    source,
496    tokens: spread(same.map(record => record.tokens)),
497    ms: spread(same.map(record => record.ms)),
498    tools: spread(same.map(record => record.tools)),
499    samples: same.length,
500  }
501}
502
503export type EstimateMode = 'both' | 'history' | 'model'
504
505// A small-model call costs a little on every prompt it sizes: under `both` it
506// is kept for prompts long or structured enough to be worth a second opinion.
507export const shouldAskModel = (mode: EstimateMode, text: string, size: Size): boolean => {
508  const trimmed = text.trim()
509  if (trimmed === '' || mode === 'history') return false
510  if (mode === 'model') return true
511
512  return trimmed.length >= 400 || trimmed.split('\n').length >= 4 || size === 'L' || size === 'XL'
513}
514
515export const SIZING_RUBRIC = [
516  'Classify how much agent work this coding request needs. Answer with the label alone.',
517  'S: a question, a lookup, or one small edit.',
518  'M: a focused change touching a few files, or a short investigation.',
519  'L: a multi-step feature, refactor or investigation across many files.',
520  'XL: a large multi-part project that needs planning and delegation.',
521  '',
522  'Request:',
523].join('\n')
524
525export type GaugeState = 'under' | 'within' | 'over'
526
527export const gaugeOf = (actual: number, range: Range): { share: number; state: GaugeState } => ({
528  share: range.high <= 0 ? 0 : actual / range.high,
529  state: actual > range.high ? 'over' : actual >= range.low ? 'within' : 'under',
530})
531
532export type SizeStat = { size: Size; count: number; tokens: number; ms: number }
533
534export const sizeStats = (history: readonly TurnRecord[]): SizeStat[] =>
535  SIZES.map(size => {
536    const same = history.filter(record => record.size === size)
537
538    return {
539      size,
540      count: same.length,
541      tokens: quantile(same.map(record => record.tokens), 0.5),
542      ms: quantile(same.map(record => record.ms), 0.5),
543    }
544  })
545
546// -------------------------------------------------------------------- skills
547
548// Skills whose name or description shares words with what is being worked on.
549export const suggestSkills = (
550  available: readonly SkillInfo[],
551  used: { [name: string]: number },
552  corpus: string,
553): string[] => {
554  const wanted = new Set(words(corpus))
555  if (wanted.size === 0) return []
556
557  return available
558    .filter(skill => used[skill.name] === undefined)
559    .map(skill => {
560      const named = words(skill.name).filter(word => wanted.has(word)).length
561      const described = new Set(words(skill.description).filter(word => wanted.has(word))).size
562
563      return { name: skill.name, score: named * 3 + described }
564    })
565    .filter(one => one.score >= 3)
566    .sort((a, b) => b.score - a.score)
567    .slice(0, 5)
568    .map(one => one.name)
569}
570
571// ------------------------------------------------------------------- project
572
573export const projectName = (root: string): string => {
574  const parts = root.split(/[\\/]+/).filter(part => part !== '')
575  if (parts.includes('scratch-workspaces')) return 'Scratch'
576
577  return parts[parts.length - 1] ?? 'Project'
578}
579
580export const newProject = (root: string, at: number): Project => ({
581  root,
582  name: projectName(root),
583  phase: '',
584  milestones: [],
585  sessions: 0,
586  lastSessionId: '',
587  turns: 0,
588  tokens: 0,
589  tasksDone: 0,
590  lastActive: at,
591})
592
593// What the store handed back, held to the record's shape: a value written by
594// other code, or none, starts the project over.
595export const asProject = (saved: unknown, root: string, at: number): Project => {
596  const fresh = newProject(root, at)
597  if (typeof saved !== 'object' || saved === null) return fresh
598  const bag = saved as { [key: string]: unknown }
599  const count = (key: string): number => (typeof bag[key] === 'number' ? (bag[key] as number) : 0)
600
601  return mergeProject(
602    {
603      ...fresh,
604      sessions: count('sessions'),
605      lastSessionId: typeof bag.lastSessionId === 'string' ? bag.lastSessionId : '',
606      turns: count('turns'),
607      tokens: count('tokens'),
608      tasksDone: count('tasksDone'),
609    },
610    { name: bag.name, phase: bag.phase, milestones: bag.milestones },
611  )
612}
613
614export const asHistory = (saved: unknown): TurnRecord[] => {
615  if (!Array.isArray(saved)) return []
616
617  return saved
618    .filter(
619      (one): one is TurnRecord =>
620        typeof one === 'object' &&
621        one !== null &&
622        SIZES.includes((one as TurnRecord).size) &&
623        typeof (one as TurnRecord).tokens === 'number' &&
624        typeof (one as TurnRecord).ms === 'number' &&
625        typeof (one as TurnRecord).tools === 'number',
626    )
627    .slice(-300)
628}
629
630// A loose read of one string field off a tool's result, whatever the tool.
631export const pick = (bag: unknown, key: string): string | undefined => field(bag, key)
632
633const STATUSES: readonly MilestoneStatus[] = ['planned', 'active', 'done']
634
635export type ProjectInput = { name?: unknown; phase?: unknown; milestones?: unknown }
636
637// What the model sent through the project tool, merged into the record:
638// milestones are matched by id, new ones appended in the order given.
639export const mergeProject = (project: Project, input: ProjectInput): Project => {
640  const milestones = [...project.milestones]
641  const given = Array.isArray(input.milestones) ? input.milestones : []
642  for (const raw of given.slice(0, 40)) {
643    const title = field(raw, 'title')
644    const id = field(raw, 'id') ?? title?.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 40)
645    if (id === undefined) continue
646    const stated = field(raw, 'status')
647    const at = milestones.findIndex(one => one.id === id)
648    const before = milestones[at]
649    const status = STATUSES.find(one => one === stated) ?? before?.status ?? 'planned'
650    const next: Milestone = { id, title: clip(title ?? before?.title ?? id, 80), status }
651    if (at === -1) milestones.push(next)
652    else milestones[at] = next
653  }
654
655  return {
656    ...project,
657    ...(typeof input.name === 'string' && input.name.trim() !== ''
658      ? { name: clip(input.name, 40) }
659      : {}),
660    ...(typeof input.phase === 'string' ? { phase: clip(input.phase, 60) } : {}),
661    milestones: milestones.slice(0, 40),
662  }
663}
664
665export type Progress = { done: number; total: number; percent: number; unit: string }
666
667// Milestones when the project has them, else this session's tasks.
668export const progressOf = (project: Project | null, tasks: readonly Task[]): Progress => {
669  const milestones = project?.milestones ?? []
670  if (milestones.length > 0) {
671    const done = milestones.filter(one => one.status === 'done').length
672    const active = milestones.filter(one => one.status === 'active').length
673    const percent = Math.round(((done + active * 0.5) / milestones.length) * 100)
674
675    return { done, total: milestones.length, percent, unit: 'milestones' }
676  }
677  const top = tasks.filter(task => task.parentId === undefined)
678  const done = top.filter(task => task.status === 'completed').length
679  const active = top.filter(task => task.status === 'in_progress').length
680  const percent = top.length === 0 ? 0 : Math.round(((done + active * 0.5) / top.length) * 100)
681
682  return { done, total: top.length, percent, unit: 'tasks' }
683}
684
685// ------------------------------------------------------------ activity, files
686
687export const pushActivity = (list: readonly Activity[], entry: Activity): Activity[] =>
688  [...list, { ...entry, text: clip(entry.text, 90) }].slice(-30)
689
690export const hitFile = (list: readonly FileHit[], path: string): FileHit[] => {
691  const found = list.find(one => one.path === path)
692  const rest = list.filter(one => one.path !== path)
693
694  return [...rest, { path, edits: (found?.edits ?? 0) + 1 }].slice(-60)
695}
696
697export const hotFiles = (list: readonly FileHit[], limit: number): FileHit[] =>
698  [...list].sort((a, b) => b.edits - a.edits).slice(0, limit)
699
700export const shortPath = (path: string): string => {
701  const parts = path.split(/[\\/]+/).filter(part => part !== '')
702
703  return parts.slice(-2).join('/')
704}
705
hooks/theme.ts 139 lines
1import type { ToolFamily } from '../types'
2
3// One soft dark palette, shared by the text views and the city.
4export const C = {
5  bg: '#14161c',
6  ground: '#1c2029',
7  tile: '#232834',
8  tileEdge: '#2b3140',
9  road: '#2b3140',
10  roadMark: '#3a4254',
11  text: '#c9cfdb',
12  muted: '#7b8496',
13  faint: '#4a5263',
14  done: '#8fbf9f',
15  run: '#e0c38c',
16  agent: '#8ea6e0',
17  fail: '#d98c9a',
18  skill: '#b39ddb',
19  proc: '#7fc8c2',
20} as const
21
22export const FAMILIES: readonly ToolFamily[] = [
23  'search',
24  'read',
25  'edit',
26  'shell',
27  'web',
28  'mcp',
29  'skill',
30  'delegate',
31  'plan',
32  'other',
33]
34
35export const FAMILY_LABEL: Record<ToolFamily, string> = {
36  search: 'search',
37  read: 'read',
38  edit: 'edit',
39  shell: 'shell',
40  web: 'web',
41  mcp: 'connectors',
42  skill: 'skills',
43  delegate: 'delegate',
44  plan: 'plan',
45  other: 'other',
46}
47
48// What a tool badge reads as where no icon can be drawn.
49export const FAMILY_GLYPH: Record<ToolFamily, string> = {
50  search: '⌕',
51  read: '▤',
52  edit: '✎',
53  shell: '❯',
54  web: '◍',
55  mcp: '⧉',
56  skill: '✦',
57  delegate: '⇄',
58  plan: '☰',
59  other: '·',
60}
61
62export const FAMILY_COLOR: Record<ToolFamily, string> = {
63  search: C.proc,
64  read: C.agent,
65  edit: C.run,
66  shell: C.done,
67  web: C.proc,
68  mcp: C.skill,
69  skill: C.skill,
70  delegate: C.agent,
71  plan: C.muted,
72  other: C.muted,
73}
74
75const FAMILY_OF: { [tool: string]: ToolFamily } = {
76  Read: 'read',
77  Grep: 'search',
78  Glob: 'search',
79  ToolSearch: 'search',
80  Edit: 'edit',
81  Write: 'edit',
82  NotebookEdit: 'edit',
83  Bash: 'shell',
84  PowerShell: 'shell',
85  Monitor: 'shell',
86  TaskStop: 'shell',
87  WebFetch: 'web',
88  WebSearch: 'web',
89  Skill: 'skill',
90  Agent: 'delegate',
91  Workflow: 'delegate',
92  SendMessage: 'delegate',
93  TodoWrite: 'plan',
94  TaskCreate: 'plan',
95  TaskUpdate: 'plan',
96  TaskList: 'plan',
97  TaskGet: 'plan',
98  EnterPlanMode: 'plan',
99  ExitPlanMode: 'plan',
100  AskUserQuestion: 'plan',
101}
102
103export const familyOf = (tool: string): ToolFamily =>
104  tool.startsWith('mcp__') ? 'mcp' : (FAMILY_OF[tool] ?? 'other')
105
106export type AgentLook = { color: string; glyph: string; icon: string }
107
108const LOOKS: { [type: string]: AgentLook } = {
109  main: { color: C.agent, glyph: '◆', icon: 'a-main' },
110  Explore: { color: C.proc, glyph: '◎', icon: 'a-explore' },
111  Plan: { color: C.skill, glyph: '▧', icon: 'a-plan' },
112  worker: { color: C.run, glyph: '⚒', icon: 'a-worker' },
113  verifier: { color: C.done, glyph: '✓', icon: 'a-verifier' },
114  'general-purpose': { color: C.agent, glyph: '●', icon: 'a-general' },
115  claude: { color: C.agent, glyph: '●', icon: 'a-general' },
116}
117
118const SPARE = [C.agent, C.proc, C.skill, C.run, C.done]
119
120// A plugin's agent type is `<plugin>:<name>`: its look goes by the name.
121export const lookOf = (type: string): AgentLook => {
122  const short = type.includes(':') ? type.slice(type.lastIndexOf(':') + 1) : type
123  const known = LOOKS[short]
124  if (known !== undefined) return known
125  let hash = 0
126  for (const char of short) hash = (hash * 31 + char.charCodeAt(0)) % 9973
127
128  return { color: SPARE[hash % SPARE.length] ?? C.agent, glyph: '●', icon: 'a-general' }
129}
130
131export const VIEWS = [
132  { name: 'overview', label: 'Overview' },
133  { name: 'city', label: 'City' },
134  { name: 'tasks', label: 'Tasks' },
135  { name: 'agents', label: 'Agents' },
136  { name: 'resources', label: 'Resources' },
137  { name: 'skills', label: 'Skills' },
138] as const
139
hooks/views.tsx 770 lines
1import type { Elements, RenderElement } from 'claude-code'
2
3import type {
4  Activity,
5  AgentRun,
6  FamilyCounts,
7  FileHit,
8  Proc,
9  Project,
10  Range,
11  Skills,
12  Task,
13  ToolFamily,
14  Turn,
15  TurnRecord,
16  Ui,
17  Usage,
18  ViewName,
19} from '../types'
20import { cityAlt, renderCity } from './city'
21import { avatarAlt, avatarSvg } from './icons'
22import {
23  SIZE_LABEL,
24  agentTree,
25  bar,
26  clip,
27  compact,
28  districtsOf,
29  elapsed,
30  gaugeOf,
31  hotFiles,
32  isBlocked,
33  isLive,
34  openBlockers,
35  progressOf,
36  shortPath,
37  sizeStats,
38  taskStats,
39  taskTree,
40  topFamilies,
41} from './logic'
42import { C, FAMILY_COLOR, FAMILY_GLYPH, FAMILY_LABEL, VIEWS, lookOf } from './theme'
43
44export type El = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
45
46export type SvgEl = Elements['desktop']['Svg']
47
48export type Model = {
49  now: number
50  columns: number
51  tasks: Task[]
52  agents: AgentRun[]
53  procs: Proc[]
54  turn: Turn
55  usage: Usage
56  history: TurnRecord[]
57  project: Project | null
58  skills: Skills
59  ui: Ui
60  activity: Activity[]
61  files: FileHit[]
62}
63
64export type Actions = {
65  setView: (view: ViewName) => void
66  openCity: () => void
67  hideBand: () => void
68  orchestrate: () => void
69  clearFinished: () => void
70  stopTurn: () => void
71}
72
73
74const clamp = (value: number, low: number, high: number): number =>
75  Math.max(low, Math.min(high, value))
76
77const TASK_MARK = {
78  completed: { glyph: '✔', color: C.done },
79  in_progress: { glyph: '▶', color: C.run },
80  pending: { glyph: '○', color: C.muted },
81} as const
82
83const RUN_MARK = {
84  starting: { glyph: '◌', color: C.muted },
85  running: { glyph: '●', color: C.run },
86  done: { glyph: '✔', color: C.done },
87  failed: { glyph: '✘', color: C.fail },
88} as const
89
90const STATE_COLOR = { under: C.done, within: C.run, over: C.fail } as const
91
92// Under this many columns a row cannot hold a label, a bar and a figure side
93// by side. The desktop app docks the pane about twenty wide when the
94// conversation shares its window with other panes.
95const NARROW = 44
96
97const isNarrow = (m: Model): boolean => m.columns < NARROW
98
99// Long text wraps where the pane is narrow and is cut where it is wide.
100const fit = (m: Model): 'wrap' | 'truncate-end' => (isNarrow(m) ? 'wrap' : 'truncate-end')
101
102// How a bar is drawn in this pane: beside its label, or under it.
103type Room = { bar: number; isStacked: boolean }
104
105const roomOf = (m: Model): Room =>
106  isNarrow(m)
107    ? { bar: clamp(m.columns - 1, 6, 40), isStacked: true }
108    : { bar: clamp(m.columns - 40, 8, 28), isStacked: false }
109
110const heading = (el: El, m: Model, text: string, note = ''): RenderElement => {
111  const { Box, Text } = el
112
113  return (
114    <Box flexDirection={isNarrow(m) ? 'column' : 'row'} gap={isNarrow(m) ? 0 : 1} marginTop={1}>
115      <Text color={C.agent} bold>
116        {text}
117      </Text>
118      {note !== '' && (
119        <Text color={C.muted} wrap="wrap">
120          {note}
121        </Text>
122      )}
123    </Box>
124  )
125}
126
127const empty = (el: El, text: string): RenderElement => {
128  const { Text } = el
129
130  return <Text color={C.muted}>{text}</Text>
131}
132
133const meter = (
134  el: El,
135  label: string,
136  share: number,
137  color: string,
138  room: Room,
139  note: string,
140): RenderElement => {
141  const { Box, Text } = el
142  const [full, rest] = bar(Math.min(1, share), 1, room.bar)
143  if (room.isStacked) {
144    return (
145      <Box flexDirection="column">
146        <Box flexDirection="row" gap={1}>
147          <Text color={C.muted}>{label}</Text>
148          <Text color={C.text} wrap="wrap">
149            {note}
150          </Text>
151        </Box>
152        <Box flexDirection="row">
153          <Text color={color}>{full}</Text>
154          <Text color={C.faint}>{rest}</Text>
155        </Box>
156      </Box>
157    )
158  }
159
160  return (
161    <Box flexDirection="row" gap={1}>
162      <Text color={C.muted}>{label.padEnd(8)}</Text>
163      <Text color={color}>{full}</Text>
164      <Text color={C.faint}>{rest}</Text>
165      <Text color={C.text} wrap="truncate-end">
166        {note}
167      </Text>
168    </Box>
169  )
170}
171
172const gauge = (
173  el: El,
174  label: string,
175  actual: number,
176  range: Range,
177  show: (value: number) => string,
178  room: Room,
179): RenderElement => {
180  const { share, state } = gaugeOf(actual, range)
181
182  return meter(
183    el,
184    label,
185    share,
186    STATE_COLOR[state],
187    room,
188    `${show(actual)} of ${show(range.low)}–${show(range.high)}`,
189  )
190}
191
192const equipment = (el: El, families: FamilyCounts, limit: number): RenderElement[] => {
193  const { Text } = el
194
195  return topFamilies(families, limit).map((family: ToolFamily) => (
196    <Text color={FAMILY_COLOR[family]}>{FAMILY_GLYPH[family]}</Text>
197  ))
198}
199
200const turnTime = (m: Model): number =>
201  m.turn.isWorking ? m.now - m.turn.startedAt : m.turn.endedAt - m.turn.startedAt
202
203const promptBlock = (el: El, m: Model, room: Room): RenderElement[] => {
204  const { Box, Text } = el
205  const { turn } = m
206  const { expected } = turn
207  if (expected === null) {
208    return [heading(el, m, 'THIS PROMPT'), empty(el, 'No prompt yet this session.')]
209  }
210  const basis =
211    expected.samples >= 5 ? `${expected.samples} past turns` : 'starting defaults'
212
213  return [
214    heading(
215      el,
216      m,
217      turn.isWorking ? 'THIS PROMPT' : 'LAST PROMPT',
218      `${SIZE_LABEL[expected.size]} · sized by ${expected.source} · range from ${basis}`,
219    ),
220    gauge(el, 'tokens', turn.tokens, expected.tokens, compact, room),
221    gauge(el, 'time', turnTime(m), expected.ms, elapsed, room),
222    gauge(el, 'tools', turn.tools, expected.tools, String, room),
223    <Box flexDirection="row" gap={1}>
224      <Text color={C.muted} wrap="wrap">
225        {turn.steps} requests · {compact(turn.outTokens)} written · {compact(turn.cacheRead)}{' '}
226        from cache · {turn.agents} agent{turn.agents === 1 ? '' : 's'}
227      </Text>
228    </Box>,
229  ]
230}
231
232// ---------------------------------------------------------------------- band
233
234export const band = (el: El, m: Model, a: Actions): RenderElement => {
235  const { Box, Text, Button } = el
236  const stats = taskStats(m.tasks)
237  const progress = progressOf(m.project, m.tasks)
238  const live = m.agents.filter(isLive)
239  const [full, rest] = bar(progress.percent, 100, clamp(m.columns - 84, 6, 14))
240  const current = m.tasks.find(task => task.status === 'in_progress')
241  const { expected } = m.turn
242  const state = expected === null ? 'under' : gaugeOf(m.turn.tokens, expected.tokens).state
243  const context = m.usage.contextPercent
244  const contextColor = context >= 80 ? C.fail : context >= 60 ? C.run : C.muted
245
246  return (
247    <Box flexDirection="row" gap={1} flexWrap="wrap">
248      <Text color={C.agent} bold>
249        ◆ {clip(m.project?.name ?? 'Polis', 18)}
250      </Text>
251      {progress.total > 0 && <Text color={C.done}>{full}</Text>}
252      {progress.total > 0 && <Text color={C.faint}>{rest}</Text>}
253      {progress.total > 0 && <Text color={C.text}>{progress.percent}%</Text>}
254      {stats.total > 0 && (
255        <Text color={C.muted}>
256          {stats.done}/{stats.total} tasks
257        </Text>
258      )}
259      {live.slice(0, 6).map(run => (
260        <Text color={lookOf(run.type).color}>{lookOf(run.type).glyph}</Text>
261      ))}
262      {expected !== null && (
263        <Text color={STATE_COLOR[state]}>
264          ⚡{compact(m.turn.tokens)}/{compact(expected.tokens.high)}
265        </Text>
266      )}
267      {context > 0 && <Text color={contextColor}>ctx {context}%</Text>}
268      {current !== undefined && m.columns >= 70 && (
269        <Text color={C.muted} wrap="truncate-end">
270          {clip(current.activeForm ?? current.subject, 40)}
271        </Text>
272      )}
273      <Button key="city" label="City" variant="primary" onPress={a.openCity} />
274      <Button key="hide" label="Hide" dimColor onPress={a.hideBand} />
275    </Box>
276  )
277}
278
279// --------------------------------------------------------------------- views
280
281const overview = (el: El, m: Model, a: Actions): RenderElement[] => {
282  const { Box, Text, Button } = el
283  const room = roomOf(m)
284  const stats = taskStats(m.tasks)
285  const progress = progressOf(m.project, m.tasks)
286  const milestones = m.project?.milestones ?? []
287  const live = m.agents.filter(isLive)
288  const running = m.procs.filter(proc => proc.status === 'running')
289  const active = m.tasks.filter(task => task.status === 'in_progress')
290  const MARK = { done: TASK_MARK.completed, active: TASK_MARK.in_progress, planned: TASK_MARK.pending }
291
292  return [
293    heading(el, m, 'PROJECT', m.project === null ? '' : `session ${m.project.sessions} · ${m.project.turns} turns · ${compact(m.project.tokens)} tokens so far`),
294    meter(el, 'progress', progress.percent / 100, C.done, room, `${progress.percent}% · ${progress.done}/${progress.total} ${progress.unit}`),
295    ...milestones.slice(0, 8).map(one => (
296      <Box flexDirection="row" gap={1}>
297        <Text color={MARK[one.status].color}>{MARK[one.status].glyph}</Text>
298        <Text color={one.status === 'done' ? C.muted : C.text} bold={one.status === 'active'} wrap={fit(m)}>
299          {one.title}
300        </Text>
301      </Box>
302    )),
303    ...(milestones.length === 0
304      ? [empty(el, 'No milestones recorded yet: progress follows this session’s tasks.')]
305      : []),
306
307    ...promptBlock(el, m, room),
308
309    heading(el, m, 'TASKS', `${stats.done} done · ${stats.active} active · ${stats.waiting} waiting`),
310    ...active.slice(0, 3).map(task => (
311      <Box flexDirection="row" gap={1}>
312        <Text color={C.run}>▶</Text>
313        <Text color={C.text} wrap={fit(m)}>
314          {task.activeForm ?? task.subject}
315        </Text>
316      </Box>
317    )),
318    ...(stats.total === 0 ? [empty(el, 'No tasks yet.')] : []),
319
320    heading(el, m, 'AGENTS', `${live.length} working · ${m.agents.length} this session`),
321    ...live.slice(0, 5).map(run => (
322      <Box flexDirection="row" gap={1}>
323        <Text color={lookOf(run.type).color}>{lookOf(run.type).glyph}</Text>
324        <Text color={C.text} wrap={fit(m)}>
325          {clip(run.label, 40)}
326        </Text>
327        {equipment(el, run.families, 3)}
328        <Text color={C.muted}>{elapsed(m.now - run.startedAt)}</Text>
329      </Box>
330    )),
331
332    heading(el, m, 'PROCESSES', `${running.length} running`),
333    ...running.slice(0, 4).map(proc => (
334      <Box flexDirection="row" gap={1}>
335        <Text color={C.proc}>◈</Text>
336        <Text color={C.muted}>{proc.kind}</Text>
337        <Text color={C.text} wrap={fit(m)}>
338          {proc.label}
339        </Text>
340      </Box>
341    )),
342
343    heading(el, m, 'ACTIVITY'),
344    ...m.activity
345      .slice(-5)
346      .reverse()
347      .map(entry => (
348        <Box flexDirection="row" gap={1}>
349          <Text color={C.faint}>{elapsed(m.now - entry.at).padStart(6)}</Text>
350          <Text color={C.muted} wrap={fit(m)}>
351            {entry.text}
352          </Text>
353        </Box>
354      )),
355    ...(m.activity.length === 0 ? [empty(el, 'Nothing has happened yet.')] : []),
356
357    ...(m.files.length === 0
358      ? []
359      : [
360          heading(el, m, 'HOT FILES'),
361          ...hotFiles(m.files, 4).map(hit => (
362            <Box flexDirection="row" gap={1}>
363              <Text color={C.run}>{String(hit.edits).padStart(3)}×</Text>
364              <Text color={C.muted} wrap="truncate-start">
365                {shortPath(hit.path)}
366              </Text>
367            </Box>
368          )),
369        ]),
370
371    <Box flexDirection="row" gap={1} marginTop={1} flexWrap="wrap">
372      <Button key="orchestrate" label="Orchestrate" hotkey="o" onPress={a.orchestrate} />
373      <Button key="clear" label="Clear finished" hotkey="c" onPress={a.clearFinished} />
374      {m.turn.isWorking && <Button key="stop" label="Stop turn" hotkey="s" onPress={a.stopTurn} />}
375    </Box>,
376  ]
377}
378
379const tasksView = (el: El, m: Model): RenderElement[] => {
380  const { Box, Text } = el
381  const rows = taskTree(m.tasks)
382  const stats = taskStats(m.tasks)
383
384  return [
385    heading(el, m, 'TASKS', `${stats.done} done · ${stats.active} active · ${stats.waiting} waiting`),
386    ...(rows.length === 0 ? [empty(el, 'No tasks yet.')] : []),
387    ...rows.slice(0, 60).map(({ task, depth }) => {
388      const blocked = isBlocked(task, m.tasks)
389
390      return (
391        <Box flexDirection="row" gap={1} marginLeft={depth * 2}>
392          <Text color={blocked ? C.fail : TASK_MARK[task.status].color}>
393            {depth > 0 ? '└ ' : ''}
394            {blocked ? '⊘' : TASK_MARK[task.status].glyph}
395          </Text>
396          <Box flexDirection={isNarrow(m) ? 'column' : 'row'} gap={isNarrow(m) ? 0 : 1}>
397            <Text
398              color={task.status === 'completed' ? C.muted : C.text}
399              bold={task.status === 'in_progress'}
400              wrap={fit(m)}
401            >
402              {task.status === 'in_progress' ? (task.activeForm ?? task.subject) : task.subject}
403            </Text>
404            <Box flexDirection="row" gap={1}>
405              <Text color={C.faint}>{task.area}</Text>
406              {task.owner !== undefined && <Text color={C.agent}>@{task.owner}</Text>}
407              {blocked && <Text color={C.fail}>waits on {openBlockers(task, m.tasks).join(', ')}</Text>}
408            </Box>
409          </Box>
410        </Box>
411      )
412    }),
413    ...(rows.length > 60 ? [empty(el, `+${rows.length - 60} more`)] : []),
414  ]
415}
416
417const equipmentLine = (el: El, families: FamilyCounts): RenderElement => {
418  const { Box, Text } = el
419  const top = topFamilies(families, 5)
420
421  return (
422    <Box flexDirection="row" gap={2} flexWrap="wrap">
423      {top.length === 0 && <Text color={C.faint}>no tools used yet</Text>}
424      {top.map(family => (
425        <Text color={FAMILY_COLOR[family]}>
426          {FAMILY_GLYPH[family]} {FAMILY_LABEL[family]} ×{families[family] ?? 0}
427        </Text>
428      ))}
429    </Box>
430  )
431}
432
433// An agent's icon: its avatar carrying its tools where the surface draws
434// vectors, its glyph where it does not.
435const portrait = (
436  el: El,
437  Svg: SvgEl | undefined,
438  type: string,
439  families: FamilyCounts,
440  isWorking: boolean,
441): RenderElement => {
442  const { Text } = el
443  const look = lookOf(type)
444  if (Svg === undefined) return <Text color={look.color}>{look.glyph}</Text>
445
446  return (
447    <Svg
448      source={avatarSvg(type, families, isWorking)}
449      alt={avatarAlt(type, families)}
450      width={44}
451      height={44}
452    />
453  )
454}
455
456const agentsView = (el: El, m: Model, Svg: SvgEl | undefined): RenderElement[] => {
457  const { Box, Text } = el
458  const rows = agentTree(m.agents.slice(-30))
459
460  return [
461    heading(el, m, 'AGENTS', `${m.agents.filter(isLive).length} working · ${m.agents.length} this session`),
462    <Box flexDirection="row" gap={1}>
463      {portrait(el, Svg, 'main', m.usage.mainFamilies, m.turn.isWorking)}
464      <Box flexDirection="column">
465        <Box flexDirection="row" gap={1}>
466          <Text color={C.text} bold>
467            Main session
468          </Text>
469          <Text color={C.muted}>{m.turn.isWorking ? 'working' : 'idle'} · city hall</Text>
470        </Box>
471        {equipmentLine(el, m.usage.mainFamilies)}
472      </Box>
473    </Box>,
474    ...rows.map(({ run, depth }) => {
475      const task = m.tasks.find(one => one.id === run.taskId)
476
477      return (
478        <Box flexDirection="row" gap={1} marginLeft={depth * 2} marginTop={1}>
479          {depth > 0 && <Text color={C.faint}>└</Text>}
480          {portrait(el, Svg, run.type, run.families, isLive(run))}
481          <Box flexDirection="column">
482            <Box flexDirection="row" gap={1}>
483              <Text color={isLive(run) ? C.text : C.muted} bold={isLive(run)} wrap={fit(m)}>
484                {run.name === undefined ? run.label : `${run.name}: ${run.label}`}
485              </Text>
486              <Text color={RUN_MARK[run.status].color}>{RUN_MARK[run.status].glyph}</Text>
487            </Box>
488            <Text color={C.muted} wrap={fit(m)}>
489              {run.type} · {run.status} · {elapsed((run.endedAt ?? m.now) - run.startedAt)} ·{' '}
490              {compact(run.tokens)} tokens · {run.tools} tool{run.tools === 1 ? '' : 's'}
491              {isLive(run) && run.lastTool !== undefined ? ` · ${run.lastTool}` : ''}
492            </Text>
493            {equipmentLine(el, run.families)}
494            {task !== undefined && (
495              <Text color={C.faint} wrap={fit(m)}>
496                → {task.subject}
497              </Text>
498            )}
499          </Box>
500        </Box>
501      )
502    }),
503  ]
504}
505
506const resourcesView = (el: El, m: Model): RenderElement[] => {
507  const { Box, Text } = el
508  const room = roomOf(m)
509  const { usage } = m
510  const spenders = [...m.agents].sort((a, b) => b.tokens - a.tokens).slice(0, 5)
511  const most = Math.max(1, usage.sessionTokens)
512  const contextColor = usage.contextPercent >= 80 ? C.fail : usage.contextPercent >= 60 ? C.run : C.done
513
514  return [
515    ...promptBlock(el, m, room),
516
517    heading(el, m, 'CONTEXT WINDOW'),
518    meter(
519      el,
520      'filled',
521      usage.contextPercent / 100,
522      contextColor,
523      room,
524      `${usage.contextPercent}% · ${compact(usage.contextTokens)} of ${compact(usage.contextWindow)}`,
525    ),
526
527    heading(el, m, 'LIMITS AND COST'),
528    ...usage.rateLimits.map(limit => {
529      const resets = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - m.now
530      const note = Number.isFinite(resets) && resets > 0 ? ` · resets in ${elapsed(resets)}` : ''
531
532      return meter(
533        el,
534        limit.kind.replace('_', ' ').slice(0, 8),
535        limit.percentUsed / 100,
536        limit.percentUsed >= 90 ? C.fail : limit.percentUsed >= 70 ? C.run : C.done,
537        room,
538        `${limit.percentUsed}%${note}`,
539      )
540    }),
541    <Text color={C.muted} wrap="wrap">
542      session cost ${usage.costUsd.toFixed(2)} · {compact(usage.sessionTokens)} tokens processed
543    </Text>,
544
545    heading(el, m, 'TOKENS BY AGENT'),
546    ...(spenders.length === 0 ? [empty(el, 'No agents yet: everything so far is the main session.')] : []),
547    ...spenders.map(run =>
548      meter(el, clip(run.type, 8), run.tokens / most, lookOf(run.type).color, room, `${compact(run.tokens)} · ${clip(run.label, 30)}`),
549    ),
550
551    heading(el, m, 'WHAT PROMPTS USUALLY COST HERE'),
552    ...sizeStats(m.history).map(stat => (
553      <Box flexDirection="row" gap={1}>
554        <Text color={C.text}>{stat.size.padEnd(2)}</Text>
555        <Text color={C.muted}>
556          {stat.count === 0
557            ? 'no turns yet'
558            : `${stat.count} turn${stat.count === 1 ? '' : 's'} · typically ${compact(stat.tokens)} tokens, ${elapsed(stat.ms)}`}
559        </Text>
560      </Box>
561    )),
562  ]
563}
564
565const skillsView = (el: El, m: Model): RenderElement[] => {
566  const { Box, Text } = el
567  const { skills } = m
568  const used = Object.entries(skills.used).sort((a, b) => b[1] - a[1])
569  const describe = (name: string): string =>
570    skills.available.find(skill => skill.name === name)?.description ?? ''
571
572  return [
573    heading(el, m, 'USED THIS SESSION'),
574    ...(used.length === 0 ? [empty(el, 'No skills used yet.')] : []),
575    ...used.map(([name, count]) => (
576      <Box flexDirection="row" gap={1}>
577        <Text color={C.skill}>✦ {name}</Text>
578        <Text color={C.muted}>×{count}</Text>
579      </Box>
580    )),
581
582    heading(el, m, 'SUGGESTED FOR THIS WORK'),
583    ...(skills.suggested.length === 0 ? [empty(el, 'Nothing stands out yet.')] : []),
584    ...skills.suggested.map(name => (
585      <Box flexDirection="column">
586        <Text color={C.run}>✧ /{name}</Text>
587        <Box marginLeft={2}>
588          <Text color={C.muted} wrap={fit(m)}>
589            {clip(describe(name), 110)}
590          </Text>
591        </Box>
592      </Box>
593    )),
594
595    heading(el, m, 'AVAILABLE', `${skills.available.length}`),
596    ...skills.available.slice(0, 40).map(skill => (
597      <Box flexDirection="row" gap={1}>
598        <Text color={skills.used[skill.name] === undefined ? C.text : C.skill}>/{skill.name}</Text>
599        <Text color={C.faint} wrap="truncate-end">
600          {isNarrow(m) ? '' : clip(skill.description, 70)}
601        </Text>
602      </Box>
603    )),
604  ]
605}
606
607// The city where no vector drawing is possible: one row per district.
608const cityText = (el: El, m: Model): RenderElement[] => {
609  const { Box, Text } = el
610  const districts = districtsOf(m.tasks)
611  const running = m.procs.filter(proc => proc.status === 'running').length
612  const used = Object.keys(m.skills.used).length
613  const [full, rest] = bar(m.usage.contextPercent, 100, 10)
614  const BLOCK = { completed: '█', in_progress: '▓', pending: '░' } as const
615  const homeless = m.agents.filter(run => isLive(run) && !m.tasks.some(task => task.id === run.taskId))
616
617  return [
618    heading(el, m, 'CITY'),
619    <Box flexDirection="row" gap={1}>
620      <Text color={C.agent}>◆ city hall</Text>
621      <Text color={C.muted}>{m.turn.isWorking ? 'working' : 'idle'}</Text>
622      {homeless.map(run => (
623        <Text color={lookOf(run.type).color}>{lookOf(run.type).glyph}</Text>
624      ))}
625    </Box>,
626    ...(districts.length === 0 ? [empty(el, 'No districts yet: they appear with the first tasks.')] : []),
627    ...districts.slice(0, 12).map(district => {
628      const done = district.tasks.filter(task => task.status === 'completed').length
629      const here = m.agents.filter(
630        run => isLive(run) && district.tasks.some(task => task.id === run.taskId),
631      )
632
633      return (
634        <Box flexDirection="row" gap={1}>
635          <Text color={C.text}>{district.area.padEnd(10).slice(0, 10)}</Text>
636          {district.tasks.slice(0, 24).map(task => (
637            <Text color={isBlocked(task, m.tasks) ? C.fail : TASK_MARK[task.status].color}>
638              {BLOCK[task.status]}
639            </Text>
640          ))}
641          <Text color={C.muted}>
642            {done}/{district.tasks.length}
643          </Text>
644          {here.map(run => (
645            <Text color={lookOf(run.type).color}>{lookOf(run.type).glyph}</Text>
646          ))}
647        </Box>
648      )
649    }),
650    <Box flexDirection="row" gap={1} marginTop={1}>
651      <Text color={C.run}>⚡ power</Text>
652      <Text color={C.run}>{full}</Text>
653      <Text color={C.faint}>{rest}</Text>
654      <Text color={C.muted}>context {m.usage.contextPercent}%</Text>
655    </Box>,
656    <Box flexDirection="row" gap={2}>
657      <Text color={C.proc}>◈ works: {running} running</Text>
658      <Text color={C.skill}>✦ library: {used} skills used</Text>
659    </Box>,
660  ]
661}
662
663const cityView = (
664  el: El,
665  m: Model,
666  Svg: SvgEl | undefined,
667  canHover: boolean,
668): RenderElement[] => {
669  const { Box, Text } = el
670  if (Svg === undefined) return cityText(el, m)
671
672  return [
673    <Box marginTop={1}>
674      <Svg source={renderCity(m, isNarrow(m) ? 'column' : 'avenue')} alt={cityAlt(m)} isInteractive />
675    </Box>,
676    <Text color={C.muted} wrap="wrap">
677      {isNarrow(m)
678        ? 'Buildings are tasks; agents stand on what they work on.'
679        : 'Buildings are tasks: a lot is planned, scaffolding is in progress, lit windows are done. Agents stand on what they work on, carrying the tools they use most.'}
680      {canHover ? ' Hover anything for detail.' : ''}
681    </Text>,
682  ]
683}
684
685// ------------------------------------------------------------------- summary
686
687// The dashboard as plain text, for where no surface can draw it.
688export const summary = (m: Model): string => {
689  const stats = taskStats(m.tasks)
690  const progress = progressOf(m.project, m.tasks)
691  const live = m.agents.filter(isLive)
692  const running = m.procs.filter(proc => proc.status === 'running')
693  const active = m.tasks.filter(task => task.status === 'in_progress')
694  const { expected } = m.turn
695  const phase = m.project === null || m.project.phase === '' ? '' : ` · ${m.project.phase}`
696  const spent =
697    expected === null
698      ? 'This prompt: none yet'
699      : `${m.turn.isWorking ? 'This' : 'Last'} prompt: ${compact(m.turn.tokens)} tokens of ` +
700        `${compact(expected.tokens.low)}–${compact(expected.tokens.high)} expected · ` +
701        `${m.turn.tools} tool calls · ${elapsed(turnTime(m))}`
702
703  return [
704    `Polis · ${m.project?.name ?? 'this project'}${phase}`,
705    `Progress: ${progress.percent}% (${progress.done}/${progress.total} ${progress.unit})`,
706    `Tasks: ${stats.done} done · ${stats.active} active · ${stats.waiting} waiting`,
707    ...active.slice(0, 5).map(task => `  ▶ ${clip(task.activeForm ?? task.subject, 70)}`),
708    `Agents: ${live.length} working · ${m.agents.length} this session`,
709    ...live.slice(0, 5).map(run => `  ● ${run.type}: ${clip(run.label, 60)}`),
710    spent,
711    `Context: ${m.usage.contextPercent}% · session cost $${m.usage.costUsd.toFixed(2)}`,
712    `Background: ${running.length} running`,
713  ].join('\n')
714}
715
716// ---------------------------------------------------------------------- pane
717
718// `Svg` is the surface's vector element, absent where it draws none: the
719// city and the agents' icons are then text.
720export const pane = (
721  el: El,
722  m: Model,
723  a: Actions,
724  Svg: SvgEl | undefined,
725  canHover = true,
726): RenderElement => {
727  const { Box, Text, Button } = el
728  const { view } = m.ui
729  const body =
730    view === 'city'
731      ? cityView(el, m, Svg, canHover)
732      : view === 'tasks'
733        ? tasksView(el, m)
734        : view === 'agents'
735          ? agentsView(el, m, Svg)
736          : view === 'resources'
737            ? resourcesView(el, m)
738            : view === 'skills'
739              ? skillsView(el, m)
740              : overview(el, m, a)
741
742  return (
743    <Box flexDirection="column">
744      <Box flexDirection="row" gap={1}>
745        <Text color={C.agent} bold>
746          ◆ POLIS
747        </Text>
748        <Text color={C.text} bold>
749          {clip(m.project?.name ?? '', 28)}
750        </Text>
751        <Text color={C.muted} wrap="truncate-end">
752          {m.project?.phase ?? ''}
753        </Text>
754      </Box>
755      <Box flexDirection="row" gap={1} flexWrap="wrap">
756        {VIEWS.map((one, index) => (
757          <Button
758            key={`view-${one.name}`}
759            label={one.label}
760            hotkey={String(index + 1)}
761            variant={one.name === view ? 'primary' : 'secondary'}
762            onPress={() => a.setView(one.name)}
763          />
764        ))}
765      </Box>
766      {body}
767    </Box>
768  )
769}
770
hooks/city.ts 629 lines
1import type { AgentRun, Proc, Project, Skills, Task, Turn, Usage } from '../types'
2import { avatar, esc, mix, n } from './icons'
3import { clip, compact, districtsOf, gaugeOf, isBlocked, isLive, taskStats } from './logic'
4import type { District } from './logic'
5import { C } from './theme'
6
7export type CityInput = {
8  now: number
9  tasks: Task[]
10  agents: AgentRun[]
11  procs: Proc[]
12  turn: Turn
13  usage: Usage
14  project: Project | null
15  skills: Skills
16}
17
18// The city is laid out on a square grid and drawn in a 2:1 isometric view:
19// +gx runs down and to the right, +gy down and to the left.
20const TW = 30
21const TH = 15
22const BLOCK = 4
23const PITCH = 5
24const FONT = `font-family="system-ui, -apple-system, 'Segoe UI', sans-serif"`
25
26type Slot = readonly [number, number]
27
28// Where districts are built, in order: City Hall keeps the back corner and
29// the city grows as an avenue straight toward the viewer, so the drawing
30// stays narrow enough for a side pane however many districts there are.
31const SLOTS: readonly Slot[] = [
32  [1, 0],
33  [0, 1],
34  [1, 1],
35  [2, 1],
36  [1, 2],
37  [2, 2],
38  [3, 2],
39  [2, 3],
40  [3, 3],
41  [4, 3],
42  [3, 4],
43]
44
45// A pane too narrow for the avenue gets the same city one block wide: the
46// districts in a single file straight toward the viewer.
47const COLUMN: readonly Slot[] = SLOTS.map((_, index): Slot => [index + 1, index + 1])
48
49export type CityLayout = 'avenue' | 'column'
50
51type Drawable = { depth: number; svg: string }
52
53type Lot = { gx: number; gy: number; size: number; height: number }
54
55const sx = (gx: number, gy: number): number => ((gx - gy) * TW) / 2
56const sy = (gx: number, gy: number): number => ((gx + gy) * TH) / 2
57const at = (gx: number, gy: number, z = 0): string => `${n(sx(gx, gy))},${n(sy(gx, gy) - z)}`
58
59const tile = (gx: number, gy: number, w: number, d: number, z: number, rest: string): string =>
60  `<polygon points="${at(gx, gy, z)} ${at(gx + w, gy, z)} ${at(gx + w, gy + d, z)} ${at(gx, gy + d, z)}" ${rest}/>`
61
62const seg = (
63  a: readonly [number, number, number],
64  b: readonly [number, number, number],
65  rest: string,
66): string =>
67  `<line x1="${n(sx(a[0], a[1]))}" y1="${n(sy(a[0], a[1]) - a[2])}" x2="${n(sx(b[0], b[1]))}" y2="${n(sy(b[0], b[1]) - b[2])}" ${rest}/>`
68
69// A box standing on the grid: its two front faces and its top.
70const prism = (gx: number, gy: number, w: number, d: number, h: number, color: string, base = 0): string =>
71  `<polygon points="${at(gx, gy + d, base)} ${at(gx + w, gy + d, base)} ${at(gx + w, gy + d, base + h)} ${at(gx, gy + d, base + h)}" fill="${mix(color, C.bg, 0.22)}"/>` +
72  `<polygon points="${at(gx + w, gy, base)} ${at(gx + w, gy + d, base)} ${at(gx + w, gy + d, base + h)} ${at(gx + w, gy, base + h)}" fill="${mix(color, C.bg, 0.48)}"/>` +
73  tile(gx, gy, w, d, base + h, `fill="${mix(color, '#ffffff', 0.1)}"`)
74
75// Rows of lit windows across both front faces.
76const windows = (lot: Lot, color: string): string => {
77  const { gx, gy, size } = lot
78  let out = ''
79  for (let z = 5; z < lot.height - 3; z += 6.5) {
80    out +=
81      seg([gx + size * 0.16, gy + size, z], [gx + size * 0.84, gy + size, z], `stroke="${color}" stroke-width="2" stroke-dasharray="2.2 2.2" opacity=".9"`) +
82      seg([gx + size, gy + size * 0.84, z], [gx + size, gy + size * 0.16, z], `stroke="${color}" stroke-width="2" stroke-dasharray="2.2 2.2" opacity=".5"`)
83  }
84
85  return out
86}
87
88// Scaffolding up to the height the building will reach, with a crane on top.
89const scaffold = (lot: Lot, from: number, color: string): string => {
90  const { gx, gy, size, height } = lot
91  const rest = `stroke="${color}" stroke-width=".6" opacity=".75"`
92  let out =
93    seg([gx, gy + size, 0], [gx, gy + size, height], rest) +
94    seg([gx + size, gy + size, 0], [gx + size, gy + size, height], rest) +
95    seg([gx + size, gy, 0], [gx + size, gy, height], rest)
96  for (let z = Math.ceil(from / 6) * 6; z <= height; z += 6) {
97    out +=
98      seg([gx, gy + size, z], [gx + size, gy + size, z], rest) +
99      seg([gx + size, gy + size, z], [gx + size, gy, z], rest)
100  }
101  const mx = sx(gx + size, gy)
102  const my = sy(gx + size, gy) - height
103
104  return (
105    out +
106    `<path d="M${n(mx)} ${n(my)}v-10h-13m3 0v5" fill="none" stroke="${color}" stroke-width=".9"/>` +
107    `<circle cx="${n(mx)}" cy="${n(my - 10)}" r="1.7" fill="${color}"><animate attributeName="opacity" values="1;.15;1" dur="1.6s" repeatCount="indefinite"/></circle>`
108  )
109}
110
111const hash = (text: string): number => {
112  let value = 7
113  for (const char of text) value = (value * 31 + char.charCodeAt(0)) % 9973
114
115  return value
116}
117
118const STATUS_WORD = { pending: 'planned', in_progress: 'in progress', completed: 'done' } as const
119
120// How high a task's building stands now: a lot, a half-built shell, or all of it.
121const roofOf = (task: Task, lot: Lot): number =>
122  task.status === 'pending' ? 0 : task.status === 'in_progress' ? Math.max(6, lot.height * 0.55) : lot.height
123
124const building = (task: Task, lot: Lot, blocked: boolean, hasDetail: boolean): string => {
125  const { gx, gy, size, height } = lot
126  const owner = task.owner === undefined ? '' : ` · ${clip(task.owner, 24)}`
127  const title = `<title>${esc(`${clip(task.subject, 80)} · ${blocked ? 'blocked' : STATUS_WORD[task.status]}${owner} · ${task.area}`)}</title>`
128  if (task.status === 'pending') {
129    const edge = blocked ? C.fail : C.muted
130    const mid = [gx + size / 2, gy + size / 2] as const
131    const barrier = blocked
132      ? seg([gx + size * 0.15, gy + size * 0.55, 4], [gx + size * 0.85, gy + size * 0.55, 4], `stroke="${C.fail}" stroke-width="2.2" stroke-dasharray="2.4 1.6"`) +
133        seg([gx + size * 0.2, gy + size * 0.55, 0], [gx + size * 0.2, gy + size * 0.55, 4], `stroke="${C.fail}" stroke-width=".8"`) +
134        seg([gx + size * 0.8, gy + size * 0.55, 0], [gx + size * 0.8, gy + size * 0.55, 4], `stroke="${C.fail}" stroke-width=".8"`)
135      : seg([mid[0], mid[1], 0], [mid[0], mid[1], 5], `stroke="${C.muted}" stroke-width=".8"`) +
136        `<circle cx="${n(sx(mid[0], mid[1]))}" cy="${n(sy(mid[0], mid[1]) - 5.5)}" r="1.3" fill="${C.muted}"/>`
137
138    return `<g>${title}${tile(gx, gy, size, size, 0, `fill="${C.ground}" stroke="${edge}" stroke-width=".8" stroke-dasharray="2 2"`)}${barrier}</g>`
139  }
140  if (task.status === 'in_progress') {
141    const color = blocked ? C.fail : C.run
142
143    return (
144      `<g>${title}${prism(gx, gy, size, size, roofOf(task, lot), mix(color, C.tile, 0.45))}` +
145      (hasDetail ? scaffold(lot, roofOf(task, lot), color) : '') +
146      `</g>`
147    )
148  }
149
150  return (
151    `<g>${title}${prism(gx, gy, size, size, height, mix(C.done, C.tile, 0.42))}` +
152    (hasDetail ? windows(lot, mix(C.run, '#ffffff', 0.25)) : '') +
153    `</g>`
154  )
155}
156
157type Label = { anchor: 'start' | 'middle' | 'end'; size: number; fill: string; hasPill: boolean }
158
159const label = (x: number, y: number, text: string, how: Partial<Label> = {}): string => {
160  const { anchor = 'middle', size = 8, fill = C.muted, hasPill = false } = how
161  const width = text.length * size * 0.58 + 8
162  const left = anchor === 'middle' ? x - width / 2 : anchor === 'end' ? x - width + 4 : x - 4
163  const pill = hasPill
164    ? `<rect x="${n(left)}" y="${n(y - size)}" width="${n(width)}" height="${n(size + 4.5)}" rx="3.5" fill="${C.bg}" opacity=".85"/>`
165    : ''
166
167  return `${pill}<text x="${n(x)}" y="${n(y)}" ${FONT} font-size="${size}" fill="${fill}" text-anchor="${anchor}">${esc(text)}</text>`
168}
169
170const meter = (x: number, y: number, share: number, color: string): string =>
171  `<rect x="${n(x)}" y="${n(y)}" width="30" height="3" rx="1.5" fill="${C.faint}"/>` +
172  `<rect x="${n(x)}" y="${n(y)}" width="${n(30 * Math.min(1, Math.max(0, share)))}" height="3" rx="1.5" fill="${color}"/>`
173
174// ----------------------------------------------------------- civic buildings
175
176type Place = { gx: number; gy: number; size: number }
177
178const HALL: Place = { gx: 0.4, gy: 0.4, size: 1.7 }
179const PLANT: Place = { gx: 2.6, gy: 0.5, size: 1.1 }
180const LIBRARY: Place = { gx: 0.5, gy: 2.6, size: 1.1 }
181const WORKS: Place = { gx: 2.6, gy: 2.6, size: 1.1 }
182const HALL_COLOR = mix(C.agent, C.tile, 0.45)
183const PLANT_COLOR = mix(C.run, C.tile, 0.55)
184const LIBRARY_COLOR = mix(C.skill, C.tile, 0.55)
185const WORKS_COLOR = mix(C.proc, C.tile, 0.6)
186
187// Boxes that do not overlap are painted back to front by their centers.
188const depthOf = (place: Place): number => place.gx + place.gy + place.size
189
190const levelColor = (percent: number): string => (percent >= 80 ? C.fail : percent >= 60 ? C.run : C.done)
191
192const cityHall = (m: CityInput): Drawable => {
193  const { gx, gy, size } = HALL
194  const pillar = `stroke="${mix(C.agent, '#ffffff', 0.35)}" stroke-width="1.3"`
195  let columns = ''
196  for (let k = 0; k < 5; k += 1) {
197    const along = 0.14 + k * 0.18
198    columns +=
199      seg([gx + size * along, gy + size, 2], [gx + size * along, gy + size, 17], `${pillar} opacity=".75"`) +
200      seg([gx + size, gy + size * along, 2], [gx + size, gy + size * along, 17], `${pillar} opacity=".4"`)
201  }
202  const cx = sx(gx + size / 2, gy + size / 2)
203  const cy = sy(gx + size / 2, gy + size / 2) - 36
204  const wave = m.turn.isWorking
205    ? `<animate attributeName="opacity" values="1;.45;1" dur="1.8s" repeatCount="indefinite"/>`
206    : ''
207
208  return {
209    depth: depthOf(HALL),
210    svg:
211      `<g><title>${esc(`City hall: the main session, ${m.turn.isWorking ? 'working' : 'idle'}`)}</title>` +
212      prism(gx, gy, size, size, 20, HALL_COLOR) +
213      columns +
214      prism(gx + 0.4, gy + 0.4, 0.9, 0.9, 10, mix(C.agent, C.tile, 0.34), 20) +
215      prism(gx + 0.65, gy + 0.65, 0.4, 0.4, 6, mix(C.agent, C.tile, 0.22), 30) +
216      `<line x1="${n(cx)}" y1="${n(cy)}" x2="${n(cx)}" y2="${n(cy - 17)}" stroke="${C.muted}" stroke-width=".9"/>` +
217      `<path d="M${n(cx)} ${n(cy - 17)}l10 3.2-10 3.2z" fill="${m.turn.isWorking ? C.run : C.muted}">${wave}</path>` +
218      `</g>`,
219  }
220}
221
222const powerPlant = (m: CityInput): Drawable => {
223  const { gx, gy, size } = PLANT
224  const share = Math.min(1, Math.max(0, m.usage.contextPercent / 100))
225  const level = levelColor(m.usage.contextPercent)
226  const cx = sx(gx + size * 0.62, gy + size * 0.62)
227  const cy = sy(gx + size * 0.62, gy + size * 0.62) - 8
228  const { expected } = m.turn
229  const spent =
230    expected === null
231      ? 'no prompt yet'
232      : `this prompt ${compact(m.turn.tokens)} tokens of ${compact(expected.tokens.low)}–${compact(expected.tokens.high)} expected`
233  const stack: Place = { gx: gx + 0.08, gy: gy + 0.08, size: 0.24 }
234  const tx = sx(stack.gx + stack.size / 2, stack.gy + stack.size / 2)
235  const ty = sy(stack.gx + stack.size / 2, stack.gy + stack.size / 2) - 40
236  const smoke = m.turn.isWorking
237    ? [0, 0.9, 1.8]
238        .map(
239          delay =>
240            `<circle cx="${n(tx)}" cy="${n(ty)}" r="2.2" fill="${C.muted}" opacity="0">` +
241            `<animate attributeName="cy" values="${n(ty)};${n(ty - 16)}" dur="2.7s" begin="${delay}s" repeatCount="indefinite"/>` +
242            `<animate attributeName="opacity" values=".55;0" dur="2.7s" begin="${delay}s" repeatCount="indefinite"/>` +
243            `<animate attributeName="r" values="1.6;4" dur="2.7s" begin="${delay}s" repeatCount="indefinite"/></circle>`,
244        )
245        .join('')
246    : ''
247
248  return {
249    depth: depthOf(PLANT),
250    svg:
251      `<g><title>${esc(`Power plant: context window ${m.usage.contextPercent}% full (${compact(m.usage.contextTokens)} of ${compact(m.usage.contextWindow)}); ${spent}`)}</title>` +
252      prism(gx, gy, size, size, 8, PLANT_COLOR) +
253      prism(stack.gx, stack.gy, stack.size, stack.size, 32, mix(C.muted, C.tile, 0.45), 8) +
254      smoke +
255      `<rect x="${n(cx - 7)}" y="${n(cy - 24)}" width="14" height="24" fill="${C.bg}" stroke="${C.faint}" stroke-width=".7"/>` +
256      `<ellipse cx="${n(cx)}" cy="${n(cy)}" rx="7" ry="2.6" fill="${share > 0 ? level : C.bg}" stroke="${C.faint}" stroke-width=".7"/>` +
257      `<rect x="${n(cx - 6.6)}" y="${n(cy - 24 * share)}" width="13.2" height="${n(24 * share)}" fill="${level}" opacity=".88"/>` +
258      `<ellipse cx="${n(cx)}" cy="${n(cy - 24)}" rx="7" ry="2.6" fill="${mix(C.tile, '#ffffff', 0.08)}" stroke="${C.faint}" stroke-width=".7"/>` +
259      `</g>`,
260  }
261}
262
263const library = (m: CityInput): Drawable => {
264  const { gx, gy, size } = LIBRARY
265  const used = Object.keys(m.skills.used)
266  const pillar = `stroke="${mix(C.skill, '#ffffff', 0.3)}" stroke-width="1.1" opacity=".7"`
267  let detail = ''
268  for (let k = 0; k < 4; k += 1) {
269    const along = 0.16 + k * 0.225
270    detail += seg([gx + size * along, gy + size, 2], [gx + size * along, gy + size, 12], pillar)
271  }
272  // One lit book on the side wall for each skill used.
273  used.slice(0, 5).forEach((_, index) => {
274    const along = 0.84 - index * 0.16
275    detail += seg([gx + size, gy + size * along, 4], [gx + size, gy + size * along, 10], `stroke="${C.skill}" stroke-width="2"`)
276  })
277  const names = used.length === 0 ? 'none used yet' : `used ${used.slice(0, 6).join(', ')}`
278  const suggested = m.skills.suggested.length === 0 ? '' : `; suggested ${m.skills.suggested.join(', ')}`
279
280  return {
281    depth: depthOf(LIBRARY),
282    svg:
283      `<g><title>${esc(`Library: skills, ${names}${suggested}`)}</title>` +
284      prism(gx, gy, size, size, 14, LIBRARY_COLOR) +
285      detail +
286      prism(gx - 0.07, gy - 0.07, size + 0.14, size + 0.14, 2.6, mix(C.skill, C.tile, 0.36), 14) +
287      `</g>`,
288  }
289}
290
291const works = (running: readonly Proc[]): Drawable => {
292  const { gx, gy, size } = WORKS
293  const count = Math.max(1, Math.min(4, running.length))
294  let stacks = ''
295  for (let k = 0; k < count; k += 1) {
296    const place: Place = { gx: gx + 0.1 + k * 0.25, gy: gy + 0.12, size: 0.16 }
297    const tall = 9 + ((k * 5) % 9)
298    stacks += prism(place.gx, place.gy, place.size, place.size, tall, mix(C.muted, C.tile, 0.4), 11)
299    if (k < running.length) {
300      const bx = sx(place.gx + 0.08, place.gy + 0.08)
301      const by = sy(place.gx + 0.08, place.gy + 0.08) - 11 - tall - 2
302      stacks +=
303        `<circle cx="${n(bx)}" cy="${n(by)}" r="1.8" fill="${C.proc}">` +
304        `<animate attributeName="opacity" values="1;.2;1" dur="${n(1.4 + k * 0.3)}s" repeatCount="indefinite"/></circle>`
305    }
306  }
307  const names =
308    running.length === 0
309      ? 'nothing running in the background'
310      : running.slice(0, 5).map(proc => `${proc.kind}: ${clip(proc.label, 40)}`).join('; ')
311
312  return {
313    depth: depthOf(WORKS),
314    svg:
315      `<g><title>${esc(`Works yard: ${names}`)}</title>` +
316      prism(gx, gy, size, size, 11, running.length === 0 ? mix(WORKS_COLOR, C.tile, 0.5) : WORKS_COLOR) +
317      windows({ gx, gy, size, height: 11 }, running.length === 0 ? C.faint : C.proc) +
318      stacks +
319      `</g>`,
320  }
321}
322
323// ------------------------------------------------------------------ the city
324
325const tree = (gx: number, gy: number, r: number): Drawable => {
326  const x = sx(gx, gy)
327  const y = sy(gx, gy)
328
329  return {
330    depth: gx + gy,
331    svg:
332      `<line x1="${n(x)}" y1="${n(y)}" x2="${n(x)}" y2="${n(y - 6)}" stroke="${C.faint}" stroke-width="1"/>` +
333      `<circle cx="${n(x)}" cy="${n(y - 8)}" r="${r}" fill="${mix(C.done, C.bg, 0.55)}"/>`,
334  }
335}
336
337// A district's lots: as few and as large as its tasks allow, with a tree or
338// two where a lot is left over.
339const lotsOf = (
340  district: District,
341  slot: Slot,
342  all: readonly Task[],
343): { lots: Map<string, Lot>; trees: Drawable[] } => {
344  const bx = slot[0] * PITCH
345  const by = slot[1] * PITCH
346  const shown = district.tasks.slice(0, 16)
347  const side = shown.length <= 1 ? 1 : shown.length <= 4 ? 2 : shown.length <= 9 ? 3 : 4
348  const cell = (BLOCK - 0.6) / side
349  // Lots fill from the front corner back, which keeps the back corner, under
350  // the name of the block behind, free for longest.
351  const corner = (index: number, size: number): readonly [number, number] => {
352    const from = side * side - 1 - index
353
354    return [
355      bx + 0.3 + (from % side) * cell + (cell - size) / 2,
356      by + 0.3 + Math.floor(from / side) * cell + (cell - size) / 2,
357    ]
358  }
359  const lots = new Map<string, Lot>()
360  shown.forEach((task, index) => {
361    const isSub = task.parentId !== undefined
362    const size = Math.min(1.25, cell * 0.64) * (isSub ? 0.72 : 1)
363    const subs = all.filter(one => one.parentId === task.id).length
364    const tall = (15 + (hash(task.id) % 11) + Math.min(28, subs * 7)) * Math.min(1.5, Math.max(1, size / 0.8))
365    const [gx, gy] = corner(index, size)
366    lots.set(task.id, { gx, gy, size, height: isSub ? tall * 0.7 : tall })
367  })
368  const trees: Drawable[] = []
369  for (let index = side * side - 1; index >= shown.length && trees.length < 2; index -= 1) {
370    const [gx, gy] = corner(index, 0)
371    trees.push(tree(gx, gy, 3 + (index % 2)))
372  }
373
374  return { lots, trees }
375}
376
377const draw = (m: CityInput, hasDetail: boolean, layout: CityLayout): string => {
378  const isColumn = layout === 'column'
379  // The column is drawn for a narrow slot: its words are set larger against
380  // its buildings, so they stay readable once the surface scales it down.
381  const type = isColumn ? 1.2 : 1
382  const slots = isColumn ? COLUMN : SLOTS
383  const districts = districtsOf(m.tasks).slice(0, slots.length)
384  const used: Slot[] = [[0, 0], ...districts.map((_, index) => slots[index] ?? ([0, 0] as const))]
385  const isUsed = (i: number, j: number): boolean => used.some(slot => slot[0] === i && slot[1] === j)
386
387  let ground = ''
388  const things: Drawable[] = []
389  const people: Drawable[] = []
390  const labels: string[] = []
391  const overlays: string[] = []
392
393  // Each block sits on a pad of road; neighbours' pads join into streets.
394  for (const [i, j] of used) {
395    ground += tile(i * PITCH - 0.5, j * PITCH - 0.5, BLOCK + 1, BLOCK + 1, 0, `fill="${C.road}" stroke="${C.road}" stroke-width="4" stroke-linejoin="round"`)
396  }
397  const mark = `stroke="${C.roadMark}" stroke-width=".8" stroke-dasharray="3 4"`
398  for (const [i, j] of used) {
399    const bx = i * PITCH
400    const by = j * PITCH
401    if (isUsed(i + 1, j)) ground += seg([bx + BLOCK + 0.5, by + 0.2, 0], [bx + BLOCK + 0.5, by + BLOCK - 0.2, 0], mark)
402    if (isUsed(i, j + 1)) ground += seg([bx + 0.2, by + BLOCK + 0.5, 0], [bx + BLOCK - 0.2, by + BLOCK + 0.5, 0], mark)
403    ground += tile(bx, by, BLOCK, BLOCK, 0, `fill="${C.tile}" stroke="${C.tileEdge}" stroke-width=".6"`)
404  }
405
406  // The civic block: hall, plant, library and works around a small square.
407  const running = m.procs.filter(proc => proc.status === 'running')
408  things.push(cityHall(m), powerPlant(m), library(m), works(running))
409
410  // Districts: a block per work area, a building per task.
411  const where = new Map<string, Lot>()
412  const roofs = new Map<string, number>()
413  districts.forEach((district, index) => {
414    const slot = slots[index] ?? ([0, 0] as const)
415    const bx = slot[0] * PITCH
416    const by = slot[1] * PITCH
417    const { lots, trees } = lotsOf(district, slot, m.tasks)
418    things.push(...trees)
419    for (const task of district.tasks) {
420      const lot = lots.get(task.id)
421      if (lot === undefined) continue
422      where.set(task.id, lot)
423      roofs.set(task.id, roofOf(task, lot))
424      things.push({
425        depth: depthOf(lot),
426        svg: building(task, lot, isBlocked(task, m.tasks), hasDetail),
427      })
428    }
429    const stats = taskStats(district.tasks)
430    const x = sx(bx + BLOCK, by + BLOCK)
431    const y = sy(bx + BLOCK, by + BLOCK) + 12
432    const hidden = district.tasks.length - lots.size
433    labels.push(
434      label(x, y, `${clip(district.area, 10).toUpperCase()}  ${stats.done}/${stats.total}${hidden > 0 ? ` +${hidden}` : ''}`, { size: 8.5 * type, fill: C.text, hasPill: true }) +
435        `<rect x="${n(x - 18)}" y="${n(y + 4.5)}" width="36" height="2.4" rx="1.2" fill="${C.faint}"/>` +
436        `<rect x="${n(x - 18)}" y="${n(y + 4.5)}" width="${n(stats.total === 0 ? 0 : (36 * stats.done) / stats.total)}" height="2.4" rx="1.2" fill="${C.done}"/>`,
437    )
438  })
439
440  // What waits on what: a dashed arc from the blocker to the blocked.
441  let arcs = 0
442  for (const task of m.tasks) {
443    const to = where.get(task.id)
444    if (to === undefined || task.status === 'completed') continue
445    for (const id of task.blockedBy) {
446      const from = where.get(id)
447      const blocker = m.tasks.find(one => one.id === id)
448      if (from === undefined || blocker === undefined || blocker.status === 'completed' || arcs >= 12) continue
449      arcs += 1
450      const x1 = sx(from.gx + from.size / 2, from.gy + from.size / 2)
451      const y1 = sy(from.gx + from.size / 2, from.gy + from.size / 2) - (roofs.get(id) ?? 0)
452      const x2 = sx(to.gx + to.size / 2, to.gy + to.size / 2)
453      const y2 = sy(to.gx + to.size / 2, to.gy + to.size / 2) - (roofs.get(task.id) ?? 0)
454      overlays.push(
455        `<path d="M${n(x1)} ${n(y1)}Q${n((x1 + x2) / 2)} ${n(Math.min(y1, y2) - 24)} ${n(x2)} ${n(y2)}" fill="none" stroke="${C.fail}" stroke-width=".9" stroke-dasharray="2.5 2.5" opacity=".75"/>` +
456          `<circle cx="${n(x2)}" cy="${n(y2)}" r="1.7" fill="${C.fail}"/>`,
457      )
458    }
459  }
460
461  // People are drawn over the buildings so nobody is hidden: the main
462  // session in the square before City Hall, each agent on the roof of the
463  // task it works on, the rest lined up beside the main session.
464  const square = [BLOCK - 0.05, BLOCK - 0.05] as const
465  const mayorX = sx(square[0], square[1])
466  const mayorY = sy(square[0], square[1])
467  people.push({
468    depth: square[0] + square[1],
469    svg: avatar(mayorX, mayorY, 'main', m.usage.mainFamilies, {
470      isLive: m.turn.isWorking,
471      title: `Main session · ${m.turn.isWorking ? 'working' : 'idle'} · ${m.turn.tools} tool calls this prompt`,
472    }),
473  })
474  const live = m.agents.filter(isLive).slice(-24)
475  const standing = new Map<string, readonly [number, number]>()
476  const crowd = new Map<string, number>()
477  let loose = 0
478  for (const run of live) {
479    const lot = run.taskId === undefined ? undefined : where.get(run.taskId)
480    let x: number
481    let y: number
482    let depth: number
483    if (lot === undefined || run.taskId === undefined) {
484      if (loose >= 6) continue
485      const step = (Math.floor(loose / 2) + 1) * 1.25
486      const gx = square[0] - (loose % 2 === 0 ? step : 0)
487      const gy = square[1] - (loose % 2 === 0 ? 0 : step)
488      loose += 1
489      x = sx(gx, gy)
490      y = sy(gx, gy)
491      depth = gx + gy
492    } else {
493      const beside = crowd.get(run.taskId) ?? 0
494      crowd.set(run.taskId, beside + 1)
495      x = sx(lot.gx + lot.size / 2, lot.gy + lot.size / 2) + (beside % 2 === 0 ? 1 : -1) * Math.ceil(beside / 2) * 11
496      y = sy(lot.gx + lot.size / 2, lot.gy + lot.size / 2) - (roofs.get(run.taskId) ?? 0)
497      depth = depthOf(lot)
498    }
499    if (run.agentId !== undefined) standing.set(run.agentId, [x, y])
500    const parent = run.parentAgentId === undefined ? undefined : standing.get(run.parentAgentId)
501    const [px, py] = parent ?? [mayorX, mayorY]
502    overlays.push(
503      `<line x1="${n(px)}" y1="${n(py - 8)}" x2="${n(x)}" y2="${n(y - 8)}" stroke="${C.agent}" stroke-width=".7" stroke-dasharray="1.5 3" opacity=".5"/>`,
504    )
505    people.push({
506      depth,
507      svg: avatar(x, y, run.type, run.families, {
508        isLive: true,
509        title: `${run.name === undefined ? '' : `${clip(run.name, 24)}: `}${clip(run.label, 60)} · ${run.type} · ${run.tools} tool calls · ${compact(run.tokens)} tokens`,
510      }),
511    })
512  }
513
514  // The view is as wide as its layout ever gets and as tall as what is built.
515  // The column keeps its key above City Hall, where the avenue has it beside.
516  const minX = isColumn ? -73 : -158
517  const maxX = -minX
518  const minY = isColumn ? -146 : -94
519  const maxY =
520    Math.max(...used.map(slot => sy(slot[0] * PITCH + BLOCK, slot[1] * PITCH + BLOCK))) +
521    (isColumn ? 60 : 48)
522  const boxW = maxX - minX
523  const boxH = maxY - minY
524  // Drawn larger than any pane that picks the layout: a surface scales a
525  // drawing down to its slot, never up.
526  const scale = isColumn ? 3 : Math.min(840 / boxW, 660 / boxH, 2.6)
527
528  // The key: each civic building by its color, with the figure it stands for.
529  const keyX = minX + (isColumn ? 7 : 12)
530  const keyY = minY + 16
531  const { expected } = m.turn
532  const spent = expected === null ? undefined : gaugeOf(m.turn.tokens, expected.tokens)
533  const spentColor = spent === undefined ? C.muted : spent.state === 'over' ? C.fail : spent.state === 'within' ? C.run : C.done
534  const skillCount = Object.keys(m.skills.used).length
535  const rows: readonly (readonly [string, string, string, number | undefined])[] = [
536    [HALL_COLOR, `main session ${m.turn.isWorking ? 'working' : 'idle'}`, C.muted, undefined],
537    [PLANT_COLOR, `context ${m.usage.contextPercent}%`, levelColor(m.usage.contextPercent), m.usage.contextPercent / 100],
538    [
539      spentColor,
540      expected === null ? 'prompt: none yet' : `prompt ${compact(m.turn.tokens)}/${compact(expected.tokens.high)}`,
541      spentColor,
542      spent?.share,
543    ],
544    [WORKS_COLOR, `background ${running.length} running`, C.muted, undefined],
545    [LIBRARY_COLOR, `skills ${skillCount} used`, C.muted, undefined],
546  ]
547  const key = rows
548    .map(([swatch, text, color, share], index) => {
549      const y = keyY + index * 10.5 * type
550
551      return (
552        `<rect x="${n(keyX)}" y="${n(y - 5.6)}" width="6" height="6" rx="1.5" fill="${swatch}"/>` +
553        label(keyX + 10, y, text, { anchor: 'start', size: 7.5 * type, fill: C.muted }) +
554        (share === undefined ? '' : meter(keyX + (isColumn ? 100 : 88), y - 4.4, share, color))
555      )
556    })
557    .join('')
558
559  const legend = [
560    [mix(C.done, C.tile, 0.42), 'done'],
561    [mix(C.run, C.tile, 0.45), 'in progress'],
562    [C.faint, 'planned'],
563    [C.fail, 'blocked'],
564  ]
565    .map(
566      ([color = C.muted, name = ''], index) => {
567        // Four across where there is room, two rows of two in the column.
568        const x = keyX + (isColumn ? (index % 2) * 66 : index * 50)
569        const y = maxY - (isColumn ? 27 - Math.floor(index / 2) * 11 : 16)
570
571        return (
572          `<rect x="${n(x)}" y="${n(y)}" width="6" height="6" rx="1.5" fill="${color}"/>` +
573          label(x + 9, y + 5.5, name, { anchor: 'start', size: 7 * type })
574        )
575      },
576    )
577    .join('')
578
579  if (districts.length === 0) {
580    labels.push(
581      label(0, sy(BLOCK, BLOCK) + 28, isColumn ? 'districts appear with tasks' : 'districts appear with the first tasks', { size: 8 * type }),
582    )
583  }
584
585  things.sort((a, b) => a.depth - b.depth)
586  people.sort((a, b) => a.depth - b.depth)
587
588  return (
589    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${n(minX)} ${n(minY)} ${n(boxW)} ${n(boxH)}" width="${Math.round(boxW * scale)}" height="${Math.round(boxH * scale)}">` +
590    `<rect x="${n(minX)}" y="${n(minY)}" width="${n(boxW)}" height="${n(boxH)}" rx="10" fill="${C.bg}"/>` +
591    ground +
592    things.map(thing => thing.svg).join('') +
593    overlays.join('') +
594    people.map(person => person.svg).join('') +
595    labels.join('') +
596    key +
597    legend +
598    `</svg>`
599  )
600}
601
602const LIMIT = 131072
603
604// Tooltips are the one part of the drawing whose length the tasks decide.
605const untitled = (svg: string): string => svg.replace(/<title>[^<]*<\/title>/g, '')
606
607// A city too large for the surface's limit is drawn again without the detail
608// on its buildings, and without its tooltips if that is still too large.
609export const renderCity = (m: CityInput, layout: CityLayout = 'avenue'): string => {
610  const full = draw(m, true, layout)
611  if (full.length <= 120_000) return full
612  const plain = draw(m, false, layout)
613
614  return plain.length < LIMIT ? plain : untitled(plain)
615}
616
617export const cityAlt = (m: CityInput): string => {
618  const stats = taskStats(m.tasks)
619  const districts = districtsOf(m.tasks)
620  const live = m.agents.filter(isLive).length
621  const name = m.project?.name ?? 'the project'
622
623  return (
624    `City map of ${name}: ${districts.length} district${districts.length === 1 ? '' : 's'}, ` +
625    `${stats.done} of ${stats.total} task${stats.total === 1 ? '' : 's'} done, ` +
626    `${live} agent${live === 1 ? '' : 's'} working, context window ${m.usage.contextPercent}% full.`
627  )
628}
629
hooks/icons.ts 165 lines
1import type { FamilyCounts, ToolFamily } from '../types'
2import { topFamilies } from './logic'
3import { C, FAMILY_COLOR, FAMILY_LABEL, lookOf } from './theme'
4
5// Every icon is drawn inline with its own colors: no <use>, no stylesheet,
6// so the drawing reads the same as an image and in a sandboxed frame.
7
8export const esc = (text: string): string =>
9  text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
10
11export const n = (value: number): string => String(Math.round(value * 10) / 10)
12
13const channel = (hex: string, at: number): number => parseInt(hex.slice(at, at + 2), 16)
14
15// `a` blended toward `b` by `t` (0 is all `a`).
16export const mix = (a: string, b: string, t: number): string => {
17  const part = (at: number): string =>
18    Math.round(channel(a, at) + (channel(b, at) - channel(a, at)) * t)
19      .toString(16)
20      .padStart(2, '0')
21
22  return `#${part(1)}${part(3)}${part(5)}`
23}
24
25// Tool badges, on a 12 by 12 grid, stroked.
26const TOOL_PATH: Record<ToolFamily, string> = {
27  search: 'M8 8l2.6 2.6M8.4 5.2a3.2 3.2 0 1 1-6.4 0a3.2 3.2 0 0 1 6.4 0z',
28  read: 'M6 3.4v6.8M6 3.4C5 2.6 3.2 2.4 2 2.8v6.6c1.2-.4 3-.2 4 .8M6 3.4c1-.8 2.8-1 4-.6v6.6c-1.2-.4-3-.2-4 .8',
29  edit: 'M2.4 9.6l.6-2.4 5.2-5.2 1.8 1.8-5.2 5.2zM7.2 3l1.8 1.8',
30  shell: 'M2.4 3.4l3 2.6-3 2.6M6.6 9h3.2',
31  web: 'M10 6a4 4 0 1 1-8 0a4 4 0 0 1 8 0zM2 6h8M6 2c-2 2.2-2 5.8 0 8M6 2c2 2.2 2 5.8 0 8',
32  mcp: 'M4.5 1.8v2.6M7.5 1.8v2.6M3.4 4.4h5.2v1.8a2.6 2.6 0 0 1-5.2 0zM6 8.8v1.6',
33  skill: 'M6 1.6l1.3 3 3.2.3-2.4 2.1.7 3.1L6 8.5 3.2 10.1l.7-3.1L1.5 4.9l3.2-.3z',
34  delegate: 'M2.4 4.4h6.8l-2-2M9.6 7.6H2.8l2 2',
35  plan: 'M2.4 3.4h1M5 3.4h4.6M2.4 6h1M5 6h4.6M2.4 8.6h1M5 8.6h4.6',
36  other: 'M5.2 6a.8.8 0 1 0 1.6 0a.8.8 0 1 0-1.6 0',
37}
38
39// The figures are small chibi characters of our own, each agent type dressed
40// after a shonen archetype: a straw-hatted captain, headband ninja, a
41// blindfolded sorcerer. Drawn around feet at (0, 0), head centered at -13.5.
42const SKIN = '#f3d6bd'
43const INK = '#2a2d38'
44const STRAW = '#e6cd8a'
45const SPIKES = 'M-6.8-14.6l-1.6-4.2 3 1.6.6-4.4 2.7 3.1 2.1-4.1 2.1 4.1 2.7-3.1.6 4.4 3-1.6-1.6 4.2z'
46
47const eyes =
48  [-2.4, 2.4]
49    .map(
50      cx =>
51        `<ellipse cx="${cx}" cy="-12.8" rx="1" ry="1.5" fill="${INK}"/>` +
52        `<circle cx="${cx + 0.3}" cy="-13.4" r=".4" fill="#fff"/>` +
53        `<circle cx="${cx * 1.7}" cy="-10.7" r="1" fill="#e79aa6" opacity=".55"/>`,
54    )
55    .join('') +
56  `<path d="M-1.2-10.1q1.2 1 2.4 0" fill="none" stroke="${INK}" stroke-width=".5" stroke-linecap="round"/>`
57
58const headband = (band: string): string =>
59  `<rect x="-6.6" y="-17.2" width="13.2" height="2.1" rx=".6" fill="${band}"/>` +
60  `<rect x="-2.3" y="-17.1" width="4.6" height="1.9" rx=".4" fill="${C.text}"/>`
61
62const collar = `<path d="M-4.4-5.4v-2.4q4.4 1.8 8.8 0v2.4q-4.4 1.6-8.8 0z" fill="${INK}"/>`
63
64// What goes on and around the head, by the look's icon name.
65const HEADS: { [icon: string]: string } = {
66  // The captain: a straw hat with a red band over dark hair.
67  'a-main':
68    eyes +
69    `<path d="M-6.4-14.2q6.4-4.6 12.8 0v-2.4h-12.8z" fill="${INK}"/>` +
70    `<ellipse cx="0" cy="-17" rx="9.4" ry="2.1" fill="${STRAW}"/>` +
71    `<path d="M-5.2-17.2a5.2 4.8 0 0 1 10.4 0z" fill="${STRAW}"/>` +
72    `<rect x="-5.2" y="-18.6" width="10.4" height="1.4" fill="${C.fail}"/>`,
73  // The scout: a blond ninja with a forehead protector.
74  'a-explore': eyes + `<path d="${SPIKES}" fill="#e9c96b"/>` + headband('#3a4254'),
75  // The planner: a white-haired sorcerer who works blindfolded.
76  'a-plan':
77    `<path d="M-1.4-10.1q1.4 1.2 2.8 0" fill="none" stroke="${INK}" stroke-width=".5" stroke-linecap="round"/>` +
78    `<path d="${SPIKES}" fill="#eef1f7"/>` +
79    `<rect x="-6.6" y="-14.6" width="13.2" height="2.9" rx=".9" fill="${INK}"/>`,
80  // The worker: a dark-haired ninja.
81  'a-worker': eyes + `<path d="${SPIKES}" fill="#3b3f4d"/>` + headband(C.agent),
82  // The verifier: a sorcery student with a fringe.
83  'a-verifier':
84    eyes +
85    `<path d="M-6.7-13.2a6.7 6.7 0 0 1 13.4 0q-1.6-3.2-4.2-2.6-2.6 1.4-5.6-.2-2.2.4-3.6 2.8z" fill="#3b3f4d"/>`,
86  // Everyone else: a deckhand in a bandana.
87  'a-general':
88    eyes +
89    `<path d="M-6.7-14.4a6.7 6.7 0 0 1 13.4 0q-6.7-2.4-13.4 0z" fill="${C.fail}"/>` +
90    `<path d="M6.2-15.4l3.2-1.2-.6 2.9z" fill="${C.fail}"/>`,
91}
92
93// The sorcerers wear a high dark collar.
94const COLLARED = new Set(['a-plan', 'a-verifier'])
95
96export const toolIcon = (family: ToolFamily, x: number, y: number, size: number, color: string): string =>
97  `<path transform="translate(${n(x)} ${n(y)}) scale(${n(size / 12)})" d="${TOOL_PATH[family]}" ` +
98  `fill="none" stroke="${color}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>`
99
100export type AvatarOptions = {
101  /** Pulses while the agent is working. */
102  isLive: boolean
103  /** Drawn faint: an agent that has finished. */
104  isFaded?: boolean
105  title?: string
106}
107
108// One agent, feet at (x, y): a chibi figure in its type's color and
109// costume, with the tools it has used most carried above its head.
110export const avatar = (
111  x: number,
112  y: number,
113  type: string,
114  families: FamilyCounts,
115  options: AvatarOptions,
116): string => {
117  const look = lookOf(type)
118  const top = topFamilies(families, 3)
119  const head = HEADS[look.icon] ?? HEADS['a-general'] ?? ''
120  const badges = top
121    .map((family, index) => {
122      const bx = (index - (top.length - 1) / 2) * 11.5
123      const by = -34 - (top.length === 3 && index === 1 ? 3 : 0)
124      const color = FAMILY_COLOR[family]
125
126      return (
127        `<line x1="${n(bx * 0.4)}" y1="-23" x2="${n(bx)}" y2="${n(by + 5)}" stroke="${C.faint}" stroke-width=".6"/>` +
128        `<circle cx="${n(bx)}" cy="${n(by)}" r="5.2" fill="${C.tile}" stroke="${color}" stroke-width=".9"/>` +
129        toolIcon(family, bx - 3.6, by - 3.6, 7.2, color)
130      )
131    })
132    .join('')
133  const pulse = options.isLive
134    ? `<circle cx="0" cy="-13.5" r="8" fill="none" stroke="${look.color}" stroke-width=".8">` +
135      `<animate attributeName="r" values="8;12;8" dur="2.4s" repeatCount="indefinite"/>` +
136      `<animate attributeName="opacity" values=".7;0;.7" dur="2.4s" repeatCount="indefinite"/></circle>`
137    : ''
138
139  return (
140    `<g transform="translate(${n(x)} ${n(y)})"${options.isFaded === true ? ' opacity=".45"' : ''}>` +
141    (options.title === undefined ? '' : `<title>${esc(options.title)}</title>`) +
142    `<ellipse cx="0" cy="0" rx="6" ry="2.2" fill="#000" opacity=".28"/>` +
143    `<path d="M-4.4 0v-5a4.4 4.4 0 0 1 8.8 0V0z" fill="${look.color}"/>` +
144    (COLLARED.has(look.icon) ? collar : '') +
145    `<circle cx="0" cy="-13.5" r="6.6" fill="${SKIN}"/>` +
146    head +
147    pulse +
148    badges +
149    `</g>`
150  )
151}
152
153// An agent's icon by itself, for a card: the avatar with its badges.
154export const avatarSvg = (type: string, families: FamilyCounts, isLive: boolean): string =>
155  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">` +
156  `<rect width="48" height="48" rx="8" fill="${C.ground}"/>` +
157  avatar(24, 45, type, families, { isLive }) +
158  `</svg>`
159
160export const avatarAlt = (type: string, families: FamilyCounts): string => {
161  const tools = topFamilies(families, 3).map(family => FAMILY_LABEL[family])
162
163  return `${type} agent${tools.length === 0 ? '' : ` equipped with ${tools.join(', ')}`}`
164}
165
types/index.d.ts 188 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = {
4  id: string
5  subject: string
6  status: TaskStatus
7  activeForm?: string
8  /** The work area the task belongs to: its district on the map. */
9  area: string
10  /** The task this one is a sub-task of. */
11  parentId?: string
12  owner?: string
13  /** The subagent loop that recorded it; absent for the main loop's. */
14  agentId?: string
15  blockedBy: string[]
16  source: 'task' | 'todo'
17}
18
19export type ToolFamily =
20  | 'search'
21  | 'read'
22  | 'edit'
23  | 'shell'
24  | 'web'
25  | 'mcp'
26  | 'skill'
27  | 'delegate'
28  | 'plan'
29  | 'other'
30
31export type FamilyCounts = { [family: string]: number }
32
33export type AgentRunStatus = 'starting' | 'running' | 'done' | 'failed'
34
35export type AgentRun = {
36  key: string
37  agentId?: string
38  parentAgentId?: string
39  label: string
40  type: string
41  name?: string
42  model?: string
43  status: AgentRunStatus
44  tools: number
45  /** Tool calls by family: what the agent is equipped with on its icon. */
46  families: FamilyCounts
47  lastTool?: string
48  /** The task the agent is working on, when one could be matched. */
49  taskId?: string
50  tokens: number
51  startedAt: number
52  endedAt?: number
53}
54
55export type ProcKind = 'shell' | 'monitor' | 'workflow' | 'cron' | 'agent' | 'other'
56
57export type ProcStatus = 'running' | 'done' | 'stopped'
58
59export type Proc = {
60  id: string
61  kind: ProcKind
62  label: string
63  status: ProcStatus
64  startedAt: number
65  endedAt?: number
66}
67
68export type Size = 'S' | 'M' | 'L' | 'XL'
69
70export type Range = { low: number; high: number }
71
72export type Expected = {
73  size: Size
74  source: 'heuristic' | 'model'
75  tokens: Range
76  ms: Range
77  tools: Range
78  /** How many past turns of this size the ranges were drawn from. */
79  samples: number
80}
81
82export type Turn = {
83  id: string | null
84  isWorking: boolean
85  startedAt: number
86  endedAt: number
87  expected: Expected | null
88  /** Tokens processed this turn: uncached input, cache writes and output. */
89  tokens: number
90  outTokens: number
91  cacheRead: number
92  tools: number
93  steps: number
94  agents: number
95  hasWarned: boolean
96}
97
98export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
99
100export type Usage = {
101  contextTokens: number
102  contextWindow: number
103  contextPercent: number
104  rateLimits: RateLimit[]
105  costUsd: number
106  sessionTokens: number
107  mainFamilies: FamilyCounts
108  hasWarnedContext: boolean
109}
110
111export type TurnRecord = {
112  size: Size
113  tokens: number
114  ms: number
115  tools: number
116  agents: number
117  at: number
118}
119
120export type MilestoneStatus = 'planned' | 'active' | 'done'
121
122export type Milestone = { id: string; title: string; status: MilestoneStatus }
123
124export type Project = {
125  root: string
126  name: string
127  phase: string
128  milestones: Milestone[]
129  sessions: number
130  lastSessionId: string
131  turns: number
132  tokens: number
133  tasksDone: number
134  lastActive: number
135}
136
137export type SkillInfo = { name: string; description: string; source: string }
138
139export type Skills = {
140  available: SkillInfo[]
141  used: { [name: string]: number }
142  suggested: string[]
143}
144
145export type ViewName = 'overview' | 'city' | 'tasks' | 'agents' | 'resources' | 'skills'
146
147export type Ui = { view: ViewName; isBandHidden: boolean; hasOpened: boolean }
148
149export type Activity = { at: number; kind: string; text: string }
150
151export type FileHit = { path: string; edits: number }
152
153declare module 'claude-code' {
154  interface McpToolInputs {
155    mcp__polis__project: {
156      name?: string
157      phase?: string
158      milestones?: { id: string; title?: string; status?: MilestoneStatus }[]
159      tasks?: {
160        id: string
161        subject?: string
162        status?: TaskStatus | 'deleted'
163        area?: string
164        parent?: string
165        owner?: string
166        blockedBy?: string[]
167        activeForm?: string
168      }[]
169    }
170  }
171  interface PluginState {
172    polis: {
173      tasks: Task[]
174      agents: AgentRun[]
175      procs: Proc[]
176      turn: Turn
177      usage: Usage
178      history: TurnRecord[]
179      project: Project | null
180      skills: Skills
181      ui: Ui
182      activity: Activity[]
183      files: FileHit[]
184      tick: number
185    }
186  }
187}
188