Logs what the engine hands a mod, to check an engine fact in a real host before building on it

A Claude Code mod that keeps a complex project legible while Claude works on it: a band above the prompt, a pane with seven views, and an isometric city in which districts are the parts of the code being worked on, buildings are tasks and agents stand on what they are working on.
The user manual explains how to read every part of it.
/clear or /compact./city, or the band's City button), with seven views:.polis/city.html (current) and .polis/city-full.html (full), each linking to the other. Open one once in a browser or VS Code's Integrated Browser and leave it beside your editor; no server is needed. The header says what needs you, the smart-zone state and tasks done./city [overview|project|city|tasks|agents|resources|skills] — open the pane at a view. Where nothing can draw it, the command answers with the board as text, and /city project with the project's sessions as text./city report — the session as a Markdown report, as text. /city report save also writes it to polis-report.md in the working directory./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./city page — reply with the paths and file:/// URLs of both pages on disk, and how to open one in VS Code's Integrated Browser./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 | The pane, with City's top strip and the city as drawings, but no band; the status line stands in for it. |
The pane adapts to its size. The city is cropped to what is built and drawn to the pane's shape, in one of three layouts (wide, avenue, or a one-block-wide column under 44 columns), with its key written on the buildings. Under 44 columns 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. |
motion | true | Let the city move: its cars, its boat, its fireworks. false draws it still. |
page | true | Write the city to .polis/city.html and the full city to .polis/city-full.html each time the board or archive changes. |
smartZone | 150000 | The smart-zone limit in tokens. The caution comes at 80% of it; on a smaller window the limit is 80% of the window. |
From this repository, as a marketplace:
claude plugin marketplace add SashimmyUni/polis
claude plugin install polis@polis
Or from a local copy: name its 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).
node tools/check.ts
The check command runs five stages: install (the pinned TypeScript and Node types of package.json, only when node_modules is missing or out of date; the mod itself has no dependencies), validation, type-check (hooks/, types/ and tests/ against the engine's types), tools type-check (tools/ against Node's types) and tests. It stops at the first stage that fails, names it and prints that stage's full output; tools/check.ts's header and stage list say what each one runs. Both type-checks need the engine-generated .claude-plugin/types/ (gitignored); when it is missing, the check command's message says how to produce it. .github/workflows/ci.yml has the engine write those types, then runs the check command, on pushes to main and on pull requests.
A tracked pre-commit hook in .githooks/ runs the same command and refuses the commit when it fails. Turn it on once per clone; it then applies to every worktree of that clone:
git config core.hooksPath .githooks
git commit --no-verify skips it for one commit.
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, estimates, usage-limit forecasts, carried tasks), checks.ts (what counts as a test, lint, type or build run), codemap.ts (code areas, git), verify.ts (verdicts), projectlog.ts (the project's sessions), archive.ts (every finished task, for the full city), page.ts (the pages on disk), report.ts (the Markdown report), 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; tools/measure.ts reports at how many buildings a large city starts to shed detail (node --import ./tools/ts-resolve.mjs tools/measure.ts).
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.
MIT. See LICENSE.
hooks/probe.ts 75 lines1// The probe: a mod that only writes down what the engine hands it, one JSON
2// line per event, so a fact Polis would build on (does this tool reach
3// tool.call, does prompt.submit carry the command, does one session see
4// another's store) is read from a real host rather than assumed. Run it with
5// tools/probe.ts, which sets PROBE_LOG and PROBE_ROLE.
6import type { EngineInterface, Register } from 'claude-code'
7
8// Long inputs are cut so a log stays readable.
9const clip = (value: unknown): string => {
10 const text = JSON.stringify(value) ?? 'undefined'
11 return text.length > 400 ? `${text.slice(0, 400)}…` : text
12}
13
14const lines: string[] = []
15let log = ''
16
17async function write($: EngineInterface, event: string, fields: Record<string, unknown>): Promise<void> {
18 // A failed write is dropped: the probe never holds up the session it watches.
19 try {
20 lines.push(clip({ at: await $.clock.now(), event, ...fields }))
21 if (log !== '') await $.fs.write(log, lines.join('\n') + '\n')
22 } catch {}
23}
24
25// With a role, the session pings the store every 500 ms and notes what the
26// other sessions wrote: whether one running session sees another's store.
27async function pingStore($: EngineInterface, role: string, n: number): Promise<void> {
28 await $.store.set(`probe:${role}`, { n })
29 const others = (await $.store.keys()).filter((key) => key.startsWith('probe:') && key !== `probe:${role}`)
30 const seen: Record<string, unknown> = {}
31 for (const key of others) seen[key] = await $.store.get(key)
32 await write($, 'store', { role, wrote: n, seen })
33}
34
35export const register: Register = (on) => {
36 on('session.start', async ($, e, next) => {
37 log = (await $.env.get('PROBE_LOG')) ?? ''
38 const role = (await $.env.get('PROBE_ROLE')) ?? ''
39 await write($, 'session.start', { session: await $.session.id(), repo: await $.session.repo() })
40 if (role !== '') {
41 let n = 0
42 $.clock.every(500, async () => {
43 n += 1
44 await pingStore($, role, n)
45 })
46 }
47 return next(e)
48 })
49
50 on('prompt.submit', async ($, e, next) => {
51 await write($, 'prompt.submit', { text: e.text, command: e.command })
52 return next(e)
53 })
54
55 on('tool.call', async ($, e, next) => {
56 await write($, 'tool.call', { tool: e.tool, agentId: e.agentId, input: e })
57 return next(e)
58 })
59
60 on('skill.prompt', async ($, e, next) => {
61 await write($, 'skill.prompt', { skill: e.skill, source: e.source })
62 return next(e)
63 })
64
65 on('turn.complete', async ($, e, next) => {
66 await write($, 'turn.complete', { reason: e.reason, agentId: e.agentId, answer: e.answer })
67 return next(e)
68 })
69
70 on('session.end', async ($, e, next) => {
71 await write($, 'session.end', { reason: e.reason })
72 return next(e)
73 })
74}
75