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

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.
/city, or the band's City button), with six views:/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).| Host | What you get |
|---|---|
| Claude desktop app, Code tab | Everything, with the city and agent icons as vector drawings. |
claude in a terminal, including VS Code's integrated terminal | Everything as text; the city is a row of blocks per district. |
| VS Code extension's chat panel | Nothing 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.
Set in /config, or under pluginConfigs.polis.options in settings.
| Option | Default | Meaning |
|---|---|---|
estimate | both | How 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). |
guidance | true | Add a short standing instruction so Claude keeps tasks and milestones current, and offer it the mcp__polis__project tool. |
autoOpen | true | Open the pane the first time tasks or agents appear in a session. |
Name this folder in CLAUDE_CODE_PLUGIN_DIRS, in the env block of ~/.claude/settings.json. Sessions started after that load it.
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.
hooks/register.tsx 1001 lines1import { 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}
1001hooks/logic.ts 705 lines1import 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}
705hooks/theme.ts 139 lines1import 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
139hooks/views.tsx 770 lines1import 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}
770hooks/city.ts 629 lines1import 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}
629hooks/icons.ts 165 lines1import 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
165types/index.d.ts 188 lines1export 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