Standalone census as a Claude Code mod: records every interactive session into the census store through its own bundled ingest, draws the status line above the…

Census v2 recorded and drawn by a Claude Code mod. census-mod is standalone: it bundles census's store, ingest and vitals (scripts/, byte-identical copies of the census plugin's own Python) and never needs, calls or looks for the census plugin. census and census-mod are alternatives: install one or the other. census is the classic, mod-free plugin (a command status line); census-mod is this. A status line is a command Claude Code re-runs every 60 s in every session, idle or not; a mod acts on events, so the steady-state cost is about zero.
sessions/<sid>.json and limits/ files, the same census read output), by piping a status-line-shaped payload to its own bundled scripts/cli.py ingest. The first ingest also publishes <census dir>/cli.path pointing at that bundled cli.py, so the overseer dashboard, vigil and other readers find a census CLI without census being installed./census-mod:vitals (and /census-mod:vitals-lean, /census-mod:vitals-detailed) show this session's vital signs on a phone, read straight from the store (the command is /census-mod:vitals, not /census:vitals, because the plugin is census-mod).census_mod.{version,pid,proc_start,event,ended} so a reader can tell "open but idle" from "gone" by the session's process: stale when census_mod.ended is set, the pid is gone, or Claude Code's registry file <config dir>/sessions/<pid>.json is missing or has a different procStart string (the bundled store implements this; see "Liveness" in the census README). pid and proc_start come from that same registry, matched by session id; if the entry cannot be found when a write is made they are left out for that write (and looked for again on the next ones). A census_mod block with no pid is "unknown" and treated as live unless ended is set (the 90 s rule is not applied), so a session whose registry entry never turns up can stay non-stale after it died until its entry is pruned (24 h).Run /census-setup (the first session after install offers it once). It asks a few questions one at a time through $.ui.ask, so nothing reaches the model and the phone can answer; every answer is saved as it is given. First it looks, with no questions: this account's settings.json statusLine, whether its script carries census's ingest block (or the command is census ingest / census statusline), whether another writer wrote into the store in the last few minutes, and whether the census plugin is enabled. census and census-mod are alternatives, so if it is, setup stops first: "census-mod replaces the census plugin — disable it with claude plugin disable census@<marketplace>", and offers to continue anyway (two writers on one store: a warning, not a ban). Then:
/census-mod:vitals (bundled) and session liveness read (context, cost, limits, git, PR); when nothing else records into census they see nothing from this account. Where this account's status line (or another writer) already records, No leaves that writer active, so they keep getting this account's data from it; the option says so. Almost nobody else records into census, so the common question is just Yes (recommended) or No. Two more answers appear only where something already records into the real store, i.e. this account's status line feeds census (or another writer wrote in the last few minutes): Shadow (a separate store, <config dir>/census-shadow, to compare first; the dashboards and vitals do not read it, and /census-setup with Yes switches to the real store later) and, when the status line itself feeds census, Replace my status line (the recommended answer there): census-mod records and draws it, setup asks only where to draw, then removes the status line as in step 3 without asking again. A status line that does not feed census is never offered for replacement and never touched. CENSUS_MOD_STORE still forces shadow regardless, and while it is set Replace is not offered at all (the status line is then the real store's only writer and is kept): unset it and run /census-setup again to replace the status line.statusLine (offered only when drawing; it is backed up exactly to <census dir>/census-mod.statusline.json and settings.json is rewritten atomically with every other key kept; invalid JSON is never edited), keep the status line and not record, or keep both. The shared status-line script and the census launcher are never touched.gh; No means gh is never called./census-setup off (or answering No to record and band) stops recording and drawing and puts a removed status line back exactly, but only if settings.json has no status line now (or already has that one); otherwise it says so and keeps the backup.
The payload's census_mod.git (branch, uncommitted, ahead, has_upstream, detached, or null when unknown) carries the git state from the mod's own -uno pass, so readers need not shell out to git or gh. For a phone-sized readout of a session, /census-mod:vitals is bundled (the setup summary always mentions it).
Precedence: an environment variable (CENSUS_MOD_STORE, CENSUS_STATUSLINE_SEGMENTS) wins, then the answers (kept in $.store), then the defaults. Before any answer the mod records to the real store only if this account's status line does not carry the census ingest block (otherwise it records nothing until you answer), draws the band, and uses gh. Dismissing the first-session offer keeps those defaults and is never repeated.
Recording needs only the bundled scripts/cli.py and python3; there is no discovery step. If that file is missing (a broken install) the band still draws, nothing is recorded, and one line says so.
A mod loads from disk: claude --plugin-dir plugins/census-mod/plugin for one session, or add the folder to CLAUDE_CODE_PLUGIN_DIRS in the account's settings.json env for every session. Run it beside your status line first (shadow mode, below). Do not install the census plugin as well: the two are alternatives.
Works on Windows. The config dir is CLAUDE_CONFIG_DIR, else <home>\.claude (home: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH); ~, ~/ and ~\ in CENSUS_STORE and CENSUS_MOD_STORE expand with the same home, and every path keeps the separator of its base (no C:\Users\you/.claude). Recording runs the bundled recorder with the first of python3, python, py -3 that answers --version (chosen once per process); with none the band still draws, nothing is recorded and one log line says so. /census-setup writes settings.json in place on Windows (a temp file and mv on macOS/Linux): nothing is ever spelled into a cmd line, and an undo empties the backup file instead of deleting it. /census-mod:vitals runs through bin/pyrun.sh, which tries the same three launchers and says nothing about the ones that fail (a Git Bash sh is needed for it). The cache lifetime and session title are read with tail/grep where sh runs (Git Bash) and from the transcript file itself where it does not (a transcript over the engine's 4 MiB read cap is then skipped: the cache keeps its last known lifetime and the title its last value). Liveness on Windows asks the OS (OpenProcess) and never os.kill, which there ends the process.
| Event | Why |
|---|---|
session.start; classic SessionStart for startup, resume, clear and fork (compact only refreshes the transcript path; the cache goes cold on PostCompact) | register the session; a /clear, resume or fork is a new session id and a new entry |
turn.complete, main thread only, with usage | cost, context, cache counters moved |
session.measure, when a rate-limit window's percentage or reset changed | limits for the dashboard |
PostModelSwitch, CwdChanged, branch change, PR change | the fields readers show changed |
the cache-cold timer (last main turn plus the ttl), and PostCompact | prompt_cache.warm: false |
session.end | census_mod.ended: <reason>, written at once inside the hook's budget |
Writes are coalesced: at most one ingest per 2 s; the one that runs carries the latest state. -p runs and subagents are not recorded.
Counters (turns, summed token usage, ttl, the last turn's time) are kept per session id in $.store, so a reload or a restart carries on. Census reads the turns as prompt_cache.requests and the summed tokens as context_window.total_*_tokens for its activity (idle) test. The per-turn hit ratio is prompt_cache.hit_ratio; the session's is prompt_cache.session_hit_ratio. A turn's usage is the sum over its requests (the typings), so both are ratios over a turn's requests, not per API call. misses is not measurable from a mod and is left out.
git --no-optional-locks status --porcelain=2 --branch -uno, run at the start and after an Edit/Write/NotebookEdit/Bash call, a .git/HEAD or index change (watched through classic SessionStart's watchPaths, resolved for worktrees), or a cwd change; at most one every 5 seconds. Detached and unborn HEAD follow census's rules.gh pr list --head <branch> --state open --limit 1 --json number,url,reviewDecision, in the session's directory, 5 s timeout. Run on a branch change, after a Bash call containing git push or gh pr, and when the answer is over 10 minutes old and the session was active in the last 10. Cached per repo and branch in $.store; a failure keeps the cached value and backs off 10 minutes. review_state is approved, pending (REVIEW_REQUIRED), changes_requested, or left out.Same segments as census statusline, in CENSUS_STATUSLINE_SEGMENTS syntax (, joins on a line, / starts the next; default context,cache,limits,cost/model,git,dir,changes,pr). Also read: CENSUS_STATUSLINE_MASCOT (any string, drawn as given; the default is a bold ✻ in Claude's orange, #D97757, in a two-column slot like an emoji), CLAUDE_COST_BUDGET. NO_COLOR/CENSUS_STATUSLINE_COLOR are not read: the terminal's theme and the surface decide colour.
Placement. /census-setup asks where the status line goes (CENSUS_MOD_PLACEMENT=above|below overrides the answer; an install that never answered stays above, the setup recommends below):
maxRows, and drops whole trailing parts of a line wider than bodyColumns.Only the chosen site draws; the other passes through untouched. A 30 s tick redraws the countdowns; it never records.
The pr segment, 🔀 #12 approved (green approved, yellow pending, red changes_requested), ends line two and is hidden without a PR; the mod's data is its gh cache. Differences from census statusline: spend_limit is recorded but not drawn; colours are Text colours (green, red, gray, yellowBright, #ff8700 for orange).
Set CENSUS_MOD_STORE to a census dir (e.g. ~/.claude-personal/census-shadow) and the mod records there instead: the bundled ingest runs with CENSUS_STORE set to it, and the shadow dir gets its own cli.path, limits/ and sweep. Compare the two views with census read against each dir, then cut over: unset CENSUS_MOD_STORE and remove statusLine from settings.
census-mod always runs its own bundled recorder: CENSUS_CLI, a cli.path pointer, a sibling census plugin and census on PATH are never consulted. The bundled files are copied by plugins/census-mod/sync-bundle.sh from plugins/census/scripts/ (the one source), and a test in the census suite fails if they ever differ.
plugins/census-mod/tests/ (fake engine, world.tsx; not shipped), run by claude plugin test plugins/census-mod (or tests/run-mods.sh census-mod); typecheck with plugins/census-mod/typecheck.sh. The bundle is checked by tests/census/test_census_mod_bundle.py in the census suite: it fails when plugin/scripts/ differs from plugins/census/scripts/, runs the bundled recorder and reader with no census plugin anywhere, and checks the vitals command and skills ship. After changing census's Python, run plugins/census-mod/sync-bundle.sh.
hooks/register.tsx 1028 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2import { COUNTER_KEEP_MS, EMPTY_COUNTERS, STORE_PREFIX, TAIL_CMD, addTurn, compacted, counterKey, DEFAULT_TTL, expiresAtMs, isWarm, parseWrites, ttlFromWrites, ttlMs, withTtl } from '../core/cache'
3import { INGEST_TIMEOUT_MS, bundledCli, censusDir, delayFor, endTimeoutMs, ingestArgv, ingestEnv } from '../core/census'
4import type { CensusEnv } from '../core/census'
5import { GH_TIMEOUT_MS, ghArgv, ghKey, parsePrList, shouldRefresh, touchesPr } from '../core/gh'
6import type { GhEntry, Why } from '../core/gh'
7import { COALESCE_MS as GIT_COALESCE_MS, GIT_DIR_ARGV, GIT_STATUS_ARGV, parseStatus, touchesGit, watchPaths, worktreeOf } from '../core/git'
8import { homeOf, joinPath } from '../core/home'
9import { configRoot, transcriptPathFor } from '../core/name'
10import { PYTHON_CANDIDATES, cacheLinesFromText, copyModeArgv, moveArgv, pickPython, removeArgv, titleLinesFromText } from '../core/portable'
11import { buildPayload, modelOf, rateLimitsOf } from '../core/payload'
12import type { Event } from '../core/payload'
13import { TITLE_ARGV, TITLE_TAIL_CMD, findProc, lastTitle } from '../core/registry'
14import { TONE_COLOR, draw, fit } from '../core/render'
15import { BACKUP_FILE, backupBlocks, placementFrom, replaceFrom, L, NO_DETECTION, PRESETS, Q, SETUP_KEY, commandIsCensus, continueAnyway, enabledCensusPlugins, effective, hasIngestBlock, parseSettings, presetFrom, recordFrom, removeStatusLine, restoreStatusLine, scriptCandidates, settingsTmp, statusLineCommand, writerActive, writersFrom, is } from '../core/setup'
16import type { Detection, Effective, Saved } from '../core/setup'
17import type { Line, RenderEnv, RenderInput } from '../core/render'
18import type { Counters, RateLimit, Snap } from '../core/types'
19
20// The effectful shell: the ONLY file that touches `$`. Decisions live in ../core.
21//
22// State is module variables plus $.store (counters, gh cache): a hot reload loses the variables, and
23// session.start (which re-fires on a reload) rebuilds them. Nothing is kept in $.state, so a /clear
24// has nothing to wipe and classic.SessionStart(clear) simply binds the new session.
25
26type Env = CensusEnv & RenderEnv & { USERPROFILE?: string; CENSUS_MOD_PLACEMENT?: string }
27
28let env: Env = {} // what the mod runs on: the environment, then the answers laid over it
29let rawEnv: Env = {} // the environment alone: it outranks an answer
30let saved: Saved = {}
31let det: Detection = NO_DETECTION
32let eff: Effective = effective({}, {}, NO_DETECTION, null)
33let setupRun: object | null = null
34const titleScanned = new WeakMap<Snap, string>() // snapshot -> the transcript path whose whole file was scanned
35let offerTimer: Timer | null = null
36let interactive: boolean | null = null
37let snap: Snap | null = null
38let hydrating: Promise<void> | null = null
39let gitReady: Promise<void> | null = null // the first `git status` of the bound session, so the first write carries it
40let gitReadyDone: (() => void) | null = null
41const ended = new Set<string>()
42let lastActiveAt: number | null = null
43let limitsKey = ''
44let ingestTimer: Timer | null = null
45let lastIngestAt: number | null = null
46let pending: Event | null = null
47let coldTimer: Timer | null = null
48let tickTimer: Timer | null = null
49let gitTimer: Timer | null = null
50let lastGitAt: number | null = null
51let cli: string | null | undefined // census-mod's own bundled recorder, once found there
52let saidNoCli = false
53let python: string[] | null | undefined // the launcher that ran --version, once; null = none found
54let shell: boolean | undefined // whether `sh` runs here (it does not on Windows)
55let saidNoGh = false
56
57const TICK_MS = 30_000
58
59async function nowMs($: EngineInterface): Promise<number> {
60 return $.clock.now()
61}
62
63async function loadEnv($: EngineInterface): Promise<Env> {
64 return {
65 CENSUS_MOD_STORE: await $.env.get('CENSUS_MOD_STORE'),
66 CENSUS_STORE: await $.env.get('CENSUS_STORE'),
67 CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
68 HOME: await $.env.get('HOME'),
69 USERPROFILE: await $.env.get('USERPROFILE'),
70 HOMEDRIVE: await $.env.get('HOMEDRIVE'),
71 HOMEPATH: await $.env.get('HOMEPATH'),
72 CENSUS_STATUSLINE_SEGMENTS: await $.env.get('CENSUS_STATUSLINE_SEGMENTS'),
73 CENSUS_MOD_PLACEMENT: await $.env.get('CENSUS_MOD_PLACEMENT'),
74 CENSUS_STATUSLINE_MASCOT: await $.env.get('CENSUS_STATUSLINE_MASCOT'),
75 CLAUDE_COST_BUDGET: await $.env.get('CLAUDE_COST_BUDGET'),
76 CLAUDE_PROFILE: await $.env.get('CLAUDE_PROFILE'),
77 }
78}
79
80function fresh(id: string, cwd: string): Snap {
81 return {
82 sessionId: id, transcriptPath: null, cwd, worktreePath: null, version: null, model: null, sessionName: null,
83 ctxPct: null, ctxWindow: null, costUsd: null, startedAt: null, rateLimits: [],
84 counters: EMPTY_COUNTERS, ttl: DEFAULT_TTL, git: null, pr: null, proc: null,
85 }
86}
87
88const repaint = ($: EngineInterface) => void $.ui.invalidate('ui.render')
89
90// ---- engine facts -------------------------------------------------------------------------
91
92async function refreshEngine($: EngineInterface) {
93 if (!snap) return
94 const usage = await $.session.usage().catch(() => null)
95 if (usage) {
96 snap.ctxPct = usage.context.percent ?? null
97 snap.ctxWindow = usage.context.window ?? null
98 snap.costUsd = usage.cost?.usd ?? null
99 snap.startedAt = usage.startedAt
100 snap.rateLimits = usage.rateLimits as RateLimit[]
101 limitsKey = limitsFingerprint(snap.rateLimits)
102 }
103 const model = await $.session.model().catch(() => null)
104 if (model) snap.model = modelOf(model)
105}
106
107const limitsFingerprint = (l: RateLimit[]): string => l.map(x => `${x.kind}:${x.percentUsed}:${x.resetsAt ?? ''}`).sort().join('|')
108
109async function locateProc($: EngineInterface) {
110 if (!snap || snap.proc) return // looked for again on every write until found: it is one small read
111 try {
112 const root = configRoot(env)
113 if (!root) return
114 const sessions = joinPath(root, 'sessions')
115 const entries = await $.fs.list(sessions)
116 const files: { text: string }[] = []
117 for (const f of entries) {
118 if (!f.name.endsWith('.json')) continue
119 const text = await $.fs.read(joinPath(sessions, f.name)).catch(() => undefined)
120 if (typeof text === 'string') files.push({ text })
121 }
122 const proc = findProc(files, snap.sessionId)
123 if (proc) {
124 snap.proc = proc
125 snap.version = proc.version ?? snap.version
126 }
127 } catch {
128 // the registry is a nicety for liveness; recording goes on without it
129 }
130}
131
132/**
133 * The session's title from its transcript. Whole file once (at bind); after that only the tail, where a later
134 * /rename lands, so a long session is not re-read end to end on every turn. Applied to `s`, the snapshot the
135 * read was asked for, never to whatever the module holds by the time the read returns.
136 */
137async function readName($: EngineInterface, s: Snap | null = snap, whole = false) {
138 if (!s?.transcriptPath) return
139 try {
140 if (!(await hasShell($))) {
141 s.sessionName = lastTitle(titleLinesFromText(await transcriptText($, s.transcriptPath), whole ? undefined : 262144)) ?? s.sessionName
142 return
143 }
144 const argv = whole ? TITLE_ARGV(s.transcriptPath) : ['sh', '-c', TITLE_TAIL_CMD, 'sh', s.transcriptPath]
145 const r = await $.process.run(argv)
146 if (r.exitCode === 0) s.sessionName = lastTitle(r.stdout) ?? s.sessionName
147 } catch {
148 // keep the last name
149 }
150}
151
152async function readTtl($: EngineInterface, s: Snap | null = snap) {
153 if (!s?.transcriptPath) return
154 try {
155 const out = (await hasShell($))
156 ? await $.process.run(['sh', '-c', TAIL_CMD, 'sh', s.transcriptPath]).then(r => (r.exitCode === 0 ? r.stdout : ''))
157 : cacheLinesFromText(await transcriptText($, s.transcriptPath))
158 const found = out ? ttlFromWrites(parseWrites(out)) : null
159 if (found) {
160 s.ttl = found
161 s.counters = withTtl(s.counters, found)
162 }
163 } catch {
164 // keep the last known (default 5m)
165 }
166}
167
168// ---- counters (survive a reload or restart) ------------------------------------------------
169
170async function loadCounters($: EngineInterface, id: string): Promise<Counters> {
171 const stored = (await $.store.get(counterKey(id)).catch(() => undefined)) as Counters | undefined
172 return stored && typeof stored.requests === 'number' ? { ...EMPTY_COUNTERS, ...stored } : EMPTY_COUNTERS
173}
174
175async function saveCounters($: EngineInterface, s: Snap | null = snap) {
176 if (s) await $.store.set(counterKey(s.sessionId), s.counters).catch(() => undefined)
177}
178
179async function pruneCounters($: EngineInterface, keep: string, now: number) {
180 try {
181 for (const key of await $.store.keys()) {
182 if (!key.startsWith(STORE_PREFIX) || key === counterKey(keep)) continue
183 const c = (await $.store.get(key)) as Counters | undefined
184 if (!c || now - (c.updatedAt ?? 0) > COUNTER_KEEP_MS) await $.store.delete(key)
185 }
186 } catch {
187 // housekeeping only
188 }
189}
190
191// ---- recording -----------------------------------------------------------------------------
192
193/** census-mod records through its own bundled copy of census's ingest: no census plugin is ever needed or looked for. */
194async function discover($: EngineInterface): Promise<string | null> {
195 if (cli) return cli
196 let root: string | undefined
197 try {
198 root = $.plugin.root
199 } catch {
200 root = undefined
201 }
202 const path = root ? bundledCli(root) : null
203 if (path && (await $.fs.exists(path).catch(() => false))) return (cli = path)
204 if (!saidNoCli) {
205 saidNoCli = true
206 $.ui.log('census-mod\'s bundled recorder is missing (scripts/cli.py): reinstall census-mod. The band is drawn, nothing is recorded')
207 }
208 return null
209}
210
211/** `python3`, `python` or `py -3`, the first that runs `--version`; asked once per process. null: none, said once in the log. */
212async function pythonLauncher($: EngineInterface, budgetMs = INGEST_TIMEOUT_MS): Promise<string[] | null> {
213 if (python !== undefined) return python
214 // Three probes share the hook's budget (a session.end has little): too little left, and the probe waits for a later ingest.
215 const probeMs = Math.min(2000, Math.floor(budgetMs / 6))
216 if (probeMs < 200) return null
217 const found = await pickPython(async argv => (await $.process.run(argv, { timeoutMs: probeMs })).exitCode === 0)
218 python = found
219 if (!python) $.ui.log('census-mod found no Python (tried python3, python, py -3): the band is drawn, nothing is recorded')
220 return python
221}
222
223/** Whether `sh` runs here; asked once per process. */
224async function hasShell($: EngineInterface): Promise<boolean> {
225 if (shell === undefined) shell = await $.process.run(['sh', '-c', 'exit 0'], { timeoutMs: 5000 }).then(r => r.exitCode === 0, () => false)
226 return shell
227}
228
229/** The transcript's text (a file over the engine's read cap reads as nothing): for where there is no sh/tail/grep. */
230let lastRead: { path: string; at: number; text: Promise<string> } | undefined
231const transcriptText = async ($: EngineInterface, path: string): Promise<string> => {
232 // The TTL and the title are read together every turn: one read serves both.
233 const now = await nowMs($)
234 if (lastRead && lastRead.path === path && now - lastRead.at < 2000) return lastRead.text
235 const text = Promise.resolve($.fs.read(path)).then(t => (typeof t === 'string' ? t : ''), () => '')
236 lastRead = { path, at: now, text }
237
238 return text
239}
240
241async function ingest($: EngineInterface, event: Event, endedReason?: string, timeoutMs = INGEST_TIMEOUT_MS, of: Snap | null = snap) {
242 if (!of) return
243 if (eff.record === 'no') return
244 const path = await discover($)
245 if (!path) return
246 const py = await pythonLauncher($, timeoutMs)
247 const payload = buildPayload(of, await nowMs($), event, endedReason)
248 const run = (launcher: readonly string[]) => $.process.run(ingestArgv(path, launcher), { stdin: JSON.stringify(payload), env: ingestEnv(env), timeoutMs })
249 try {
250 if (!py) {
251 // Not chosen yet and too little budget to probe (a session.end has about a second, and no later ingest): run the
252 // ingest itself through each launcher in turn. One that cannot be spawned (or is missing: 127, 9009) is skipped.
253 if (python !== undefined) return
254 for (const candidate of PYTHON_CANDIDATES) {
255 const out = await run(candidate).catch(() => undefined)
256 if (!out || out.exitCode === 127 || out.exitCode === 9009) continue
257 python = [...candidate]
258 if (out.exitCode !== 0) cli = undefined
259 return
260 }
261 return
262 }
263 const out = await run(py)
264 if (out.exitCode !== 0) cli = undefined // the bundle moved or broke: look again next time
265 } catch {
266 cli = undefined
267 }
268}
269
270/** Ask for an ingest. At most one per COALESCE_MS; the one that runs carries the latest state. */
271function record($: EngineInterface, event: Event) {
272 if (!interactive || !snap) return
273 pending = event
274 if (ingestTimer) return
275 void (async () => {
276 const wait = delayFor(await nowMs($), lastIngestAt)
277 if (ingestTimer) return
278 ingestTimer = $.clock.after(wait, () => void flush($).catch(() => undefined))
279 })()
280}
281
282async function flush($: EngineInterface) {
283 ingestTimer = null
284 const event = pending
285 pending = null
286 if (!event || !snap) return
287 lastIngestAt = await nowMs($)
288 await hydrating
289 // The first write waits (briefly) for the first git status, or census_mod.git would be null until the next one.
290 if (gitReady) await Promise.race([gitReady, new Promise<void>(done => $.clock.after(3000, done))])
291 await refreshEngine($)
292 await locateProc($)
293 await ingest($, event)
294}
295
296// ---- git and gh ----------------------------------------------------------------------------
297
298function scheduleGit($: EngineInterface) {
299 if (!interactive || gitTimer) return
300 void (async () => {
301 const wait = lastGitAt === null ? 0 : Math.max(0, lastGitAt + GIT_COALESCE_MS - (await nowMs($)))
302 if (gitTimer) return
303 gitTimer = $.clock.after(wait, () => void runGit($).catch(() => undefined))
304 })()
305}
306
307async function runGit($: EngineInterface) {
308 gitTimer = null
309 if (!snap) return
310 lastGitAt = await nowMs($)
311 const cwd = snap.cwd
312 const r = await $.process.run(GIT_STATUS_ARGV, { cwd }).catch(() => undefined)
313 const first = gitReadyDone
314 gitReadyDone = null
315 first?.()
316 if (!snap || snap.cwd !== cwd) return
317 const before = snap.git
318 snap.git = r && r.exitCode === 0 ? parseStatus(r.stdout) : null
319 if (before?.branch !== snap.git?.branch) {
320 snap.pr = null
321 // The first sight of a branch is not a change of it.
322 if (before !== null) record($, 'branch')
323 scheduleGh($, before === null ? 'start' : 'branch')
324 }
325 repaint($)
326}
327
328function scheduleGh($: EngineInterface, why: Why) {
329 if (!interactive || !eff.pr) return
330 $.clock.after(0, () => void refreshGh($, why).catch(() => undefined))
331}
332
333async function refreshGh($: EngineInterface, why: Why) {
334 if (!eff.pr) return // answered No: gh is never called
335 await hydrating // the worktree path gh runs in is read there
336 if (!snap?.git?.branch || snap.git.detached) return
337 const key = ghKey(snap.worktreePath ?? snap.cwd, snap.git.branch)
338 const branch = snap.git.branch
339 const now = await nowMs($)
340 const cached = (await $.store.get(key).catch(() => undefined)) as GhEntry | undefined
341 if (cached) snap.pr = cached.pr
342 if (!shouldRefresh(why, cached, now, lastActiveAt)) return
343 const before = snap.pr
344 let entry: GhEntry
345 try {
346 const r = await $.process.run(ghArgv(branch), { cwd: snap.worktreePath ?? snap.cwd, timeoutMs: GH_TIMEOUT_MS })
347 const pr = r.exitCode === 0 ? parsePrList(r.stdout) : undefined
348 entry = pr === undefined ? { at: cached?.at ?? 0, pr: cached?.pr ?? null, failedAt: now } : { at: now, pr }
349 } catch {
350 if (!saidNoGh) {
351 saidNoGh = true
352 $.ui.log('gh unavailable: no PR segment (is gh installed and logged in?)')
353 }
354 entry = { at: cached?.at ?? 0, pr: cached?.pr ?? null, failedAt: now }
355 }
356 await $.store.set(key, entry).catch(() => undefined)
357 if (!snap || snap.git?.branch !== branch) return
358 snap.pr = entry.pr
359 if (before?.number !== entry.pr?.number || before?.reviewState !== entry.pr?.reviewState) record($, 'pr')
360 repaint($)
361}
362
363async function readGitDir($: EngineInterface, cwd: string) {
364 return $.process.run(GIT_DIR_ARGV, { cwd }).then(r => ({ exitCode: r.exitCode, stdout: r.stdout })).catch(() => ({ exitCode: 1, stdout: '' }))
365}
366
367// ---- lifecycle -----------------------------------------------------------------------------
368
369function cancelTimers() {
370 for (const t of [ingestTimer, coldTimer, tickTimer, gitTimer, offerTimer]) t?.cancel()
371 ingestTimer = coldTimer = tickTimer = gitTimer = offerTimer = null
372}
373
374function armCold($: EngineInterface) {
375 coldTimer?.cancel()
376 coldTimer = null
377 if (!snap) return
378 const at = expiresAtMs(snap.counters, snap.ttl)
379 if (at === null || snap.counters.cold) return
380 void (async () => {
381 const wait = at - (await nowMs($))
382 if (wait <= 0 || !snap) return
383 coldTimer = $.clock.after(wait, () => {
384 coldTimer = null
385 repaint($)
386 record($, 'cache.cold')
387 })
388 })()
389}
390
391function armTimers($: EngineInterface) {
392 tickTimer?.cancel()
393 // Countdowns only: redraws, never records.
394 tickTimer = $.clock.every(TICK_MS, () => {
395 repaint($)
396 scheduleGh($, 'age')
397 })
398 armCold($)
399}
400
401/** The git dir (worktree) and the session name: shell-outs kept off the start hooks' path. The first write waits for them. */
402function hydrate($: EngineInterface, known?: { exitCode: number; stdout: string }) {
403 hydrating = new Promise<void>(done => {
404 $.clock.after(0, () => {
405 void (async () => {
406 await loadSetup($)
407 if (!snap) return
408 const gitDir = known ?? (await readGitDir($, snap.cwd))
409 if (snap) snap.worktreePath = worktreeOf(gitDir)
410 const scanned = snap ? titleScanned.get(snap) : undefined
411 if (snap) titleScanned.set(snap, snap.transcriptPath ?? '')
412 await readName($, snap, scanned !== snap?.transcriptPath)
413 repaint($)
414 offerOnce($)
415 })()
416 .catch(() => undefined)
417 .finally(done)
418 })
419 })
420}
421
422/** An ended record for a session this process is leaving, from its last snapshot; once per session. */
423function closeOld($: EngineInterface, old: Snap | null, why: string) {
424 if (!old || ended.has(old.sessionId)) return
425 ended.add(old.sessionId)
426 $.clock.after(0, () => void ingest($, 'session.end', why, INGEST_TIMEOUT_MS, old).catch(() => undefined))
427}
428
429/** (Re)bind the live state to a session. Idempotent: a reload or a repeated start finds it bound. */
430async function bind($: EngineInterface, id: string, cwd: string, transcript: string | null, event: Event, gitDir?: { exitCode: number; stdout: string }) {
431 const same = snap?.sessionId === id
432 ended.delete(id) // a resumed id is live again
433 if (!same) {
434 const old = snap
435 snap = fresh(id, cwd)
436 limitsKey = ''
437 lastGitAt = null // a new session's first git status is not held back by the old one's
438 if (old && event !== 'session.start') {
439 closeOld($, old, event.replace('session.', ''))
440 setupRun = null // an open setup dialog belongs to the session that is gone
441 }
442 cancelTimers()
443 pending = null
444 snap.counters = await loadCounters($, id)
445 snap.ttl = snap.counters.ttl ?? DEFAULT_TTL
446 }
447 if (!snap) return
448 const root = configRoot(env)
449 snap.transcriptPath = transcript ?? snap.transcriptPath ?? (root ? transcriptPathFor(root, snap.cwd, id) : null)
450 await refreshEngine($)
451 await locateProc($)
452 hydrate($, gitDir)
453 armTimers($)
454 if (!same || !gitReady) gitReady = new Promise<void>(done => { gitReadyDone = done })
455 scheduleGit($)
456 record($, event)
457 void pruneCounters($, id, await nowMs($))
458}
459
460async function isInteractive($: EngineInterface): Promise<boolean> {
461 if (interactive !== null) return interactive
462 return (await $.session.surfaces().catch(() => [])).length > 0
463}
464
465// ---- /census-setup -------------------------------------------------------------------------------------
466//
467// Asks through $.ui.ask, one question at a time: nothing is submitted, nothing reaches the model, and a
468// phone can answer. Each answer is saved the moment it is given, so a /clear or a dismissal loses only
469// what was not yet asked. The precedence is the environment, then these answers, then the defaults.
470
471async function loadSetup($: EngineInterface) {
472 saved = ((await $.store.get(SETUP_KEY).catch(() => undefined)) as Saved | undefined) ?? {}
473 det = await detect($)
474 applyEffective($)
475}
476
477function applyEffective($: EngineInterface) {
478 const before = env.CENSUS_MOD_STORE
479 eff = effective(saved, rawEnv, det, configRoot(rawEnv))
480 env = { ...rawEnv, CENSUS_MOD_STORE: eff.shadowDir ?? undefined, CENSUS_STATUSLINE_SEGMENTS: eff.segments }
481 repaint($)
482}
483
484async function saveAnswer($: EngineInterface, patch: Partial<Saved>) {
485 saved = { ...saved, ...patch }
486 await $.store.set(SETUP_KEY, saved).catch(() => undefined)
487 applyEffective($)
488}
489
490const settingsPath = (): string | null => {
491 const root = configRoot(rawEnv)
492 return root ? joinPath(root, 'settings.json') : null
493}
494const realCensusDir = (): string | null => censusDir(rawEnv)
495
496/** Step 1 of setup, no questions: this account's status line, any other writer, whether the census plugin is enabled. */
497async function detect($: EngineInterface): Promise<Detection> {
498 const out: Detection = { ...NO_DETECTION }
499 try {
500 const path = settingsPath()
501 const text = path ? await $.fs.read(path).catch(() => undefined) : undefined
502 if (typeof text === 'string') {
503 const parsed = parseSettings(text)
504 if (!parsed.ok) out.settingsInvalid = true
505 else {
506 out.censusPlugins = enabledCensusPlugins(parsed.data)
507 const command = statusLineCommand(parsed.data)
508 out.statusLineCommand = command
509 if (command) {
510 out.ingestBlock = commandIsCensus(command)
511 for (const file of scriptCandidates(command, homeOf(rawEnv) ?? undefined, rawEnv.USERPROFILE)) {
512 const script = await $.fs.read(file).catch(() => undefined)
513 if (typeof script === 'string' && hasIngestBlock(script)) out.ingestBlock = true
514 }
515 }
516 }
517 }
518 const dir = realCensusDir()
519 if (dir) {
520 const files = ((await $.fs.list(joinPath(dir, 'sessions')).catch(() => [])) as { name: string; mtimeMs: number }[])
521 .filter(f => f.name.endsWith('.json'))
522 .sort((a, b) => b.mtimeMs - a.mtimeMs)
523 .slice(0, 20)
524 const entries: { updatedAt: number; hasCensusMod: boolean }[] = []
525 for (const f of files) {
526 const body = await $.fs.read(joinPath(dir, 'sessions', f.name)).catch(() => undefined)
527 try {
528 const d = JSON.parse(typeof body === 'string' ? body : '{}') as { updated_at?: number; payload?: { census_mod?: unknown } }
529 if (typeof d.updated_at === 'number') entries.push({ updatedAt: d.updated_at * 1000, hasCensusMod: d.payload?.census_mod !== undefined })
530 } catch {
531 // a file caught mid-write
532 }
533 }
534 out.otherWriter = writerActive(entries, await nowMs($))
535 }
536 } catch {
537 // detection is advice; setup goes on with what it has
538 }
539 return out
540}
541
542function say($: EngineInterface, headline: string, lines: string[] = []) {
543 $.ui.toast(headline)
544 for (const l of [headline, ...lines]) $.ui.log(l)
545}
546
547/** Remove a file. Windows has no argv-only delete, so there it is emptied, and an empty backup reads as none. */
548const removeFile = async ($: EngineInterface, path: string): Promise<void> => {
549 const argv = removeArgv(path)
550 if (argv) await $.process.run(argv).catch(() => undefined)
551 else await $.fs.write(path, '').catch(() => undefined)
552}
553
554/** The backup's text; an emptied one (Windows) is no backup. */
555const readBackup = async ($: EngineInterface, path: string): Promise<string | null> => {
556 const text = (await $.fs.read(path).catch(() => undefined)) as string | undefined
557
558 return typeof text === 'string' && text.trim() ? text : null
559}
560
561const writeSettings = async ($: EngineInterface, path: string, text: string): Promise<boolean> => {
562 // Through a symlink to its target (a dotfiles repo), never replacing the link; a temp file beside the
563 // target, started as a copy so the mode survives, then renamed over it: a reader never sees half a file.
564 const target = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
565 const tmp = settingsTmp(target)
566 if (!moveArgv(tmp, target)) {
567 // Windows: no shell to rename with (cmd would parse the path), so the file is written in place.
568 return $.fs.write(target, text).then(() => true, () => false)
569 }
570 try {
571 const copy = copyModeArgv(target, tmp) // keeps the mode on POSIX; a Windows file has none
572 if (copy) await $.process.run(copy).catch(() => undefined)
573 await $.fs.write(tmp, text)
574 const mv = await $.process.run(moveArgv(tmp, target)!)
575 if (mv.exitCode === 0) return true
576 } catch {
577 // fall through to the cleanup
578 }
579 await removeFile($, tmp)
580 return false
581}
582
583/** Remove this account's statusLine for the band to replace; false (and a message) when it was left alone. */
584async function removeOwnStatusLine($: EngineInterface): Promise<{ done: boolean; backup?: string }> {
585 const path = settingsPath()
586 const dir = realCensusDir()
587 if (!path || !dir) return { done: false }
588 const text = await $.fs.read(path).catch(() => undefined)
589 if (typeof text !== 'string') return { done: false }
590 const r = removeStatusLine(text, path, new Date(await nowMs($)).toISOString())
591 if (!r.ok) {
592 if (r.reason === 'invalid') say($, `🧭 census-setup: ${path} is not valid JSON, so I left your status line alone`)
593 return { done: r.reason === 'none' }
594 }
595 const backup = joinPath(dir, BACKUP_FILE)
596 const existing = await readBackup($, backup)
597 if (backupBlocks(existing, r.backup)) {
598 say($, `🧭 census-setup: the backup already holds a different status line (${backup}), so I left your status line alone`)
599 return { done: false }
600 }
601 try {
602 await $.fs.write(backup, r.backup) // first: the removal is undoable before it happens
603 } catch {
604 say($, `🧭 census-setup: could not write the backup ${backup}, so I left your status line alone`)
605 return { done: false }
606 }
607 if (!(await writeSettings($, path, r.text))) {
608 say($, `🧭 census-setup: could not write ${path}, so I left your status line alone`)
609 return { done: false }
610 }
611 return { done: true, backup }
612}
613
614/** `/census-setup off`: stop recording and drawing, and put a status line we removed back exactly. */
615async function turnOff($: EngineInterface) {
616 await saveAnswer($, { record: 'no', draw: false, answeredAt: await nowMs($), offered: true })
617 const lines: string[] = ['recording: off', 'band: off']
618 const path = settingsPath()
619 const dir = realCensusDir()
620 const backupPath = dir ? joinPath(dir, BACKUP_FILE) : null
621 const backup = backupPath ? await readBackup($, backupPath) : null
622 if (path && backupPath && backup !== null) {
623 const text = await $.fs.read(path).catch(() => undefined)
624 const r = restoreStatusLine(typeof text === 'string' ? text : '', backup)
625 if (r.done === 'restored') {
626 if (await writeSettings($, path, r.text)) {
627 await removeFile($, backupPath)
628 lines.push(`your status line is back in ${path}`)
629 } else lines.push(`could not write ${path}: your status line is still backed up in ${backupPath}`)
630 } else if (r.done === 'already') {
631 await removeFile($, backupPath)
632 lines.push('your status line is already in place')
633 } else lines.push(`left ${path} alone (${r.why === 'present' ? 'it has a different status line now' : r.why === 'invalid' ? 'it is not valid JSON' : 'no usable backup'}); the backup stays in ${backupPath}`)
634 }
635 say($, '🧭 census-mod is off', [...lines, 'run /census-setup to turn it on again'])
636}
637
638type Answer = { a: string } | 'dismissed' | 'superseded'
639
640async function askOne($: EngineInterface, run: object, q: { header: string; question: string; options: string[] }): Promise<Answer> {
641 const got = await $.ui.ask(q.question, { header: q.header, options: q.options }).then(a => ({ a: String(a) }), () => null)
642 if (setupRun !== run) return 'superseded' // a /clear or another setup took over while the dialog was open
643 return got ?? 'dismissed'
644}
645
646async function setup($: EngineInterface, run: object, mode: 'full' | 'offer') {
647 await loadSetup($)
648 if (!saved.offered) await saveAnswer($, { offered: true }) // asked, so never offered again, whatever the answer
649 const stop = async (why: Answer) => {
650 if (why === 'dismissed') say($, '🧭 census-setup stopped; what you answered is saved. Run /census-setup to carry on')
651 if (setupRun === run) setupRun = null
652 }
653 if (mode === 'offer') {
654 await saveAnswer($, { offered: true })
655 const a = await askOne($, run, Q.offer())
656 if (typeof a !== 'object' || !is(a.a, L.offerYes)) {
657 if (setupRun === run) setupRun = null // not now, or dismissed: the defaults stand and it is not offered again
658 return
659 }
660 }
661 // step 1, no questions
662 const d = det
663 say($, '🧭 census-setup: looking around', [
664 `status line: ${d.statusLineCommand ?? 'none'}${d.ingestBlock ? ' (it records into census)' : ''}${d.settingsInvalid ? ' (settings.json is not valid JSON)' : ''}`,
665 `another writer on the store: ${d.otherWriter ? 'yes, active in the last few minutes' : 'no'}`,
666 `the census plugin: ${d.censusPlugins.length ? `enabled (${d.censusPlugins.join(', ')})` : 'not enabled'}`,
667 ])
668 // census and census-mod are alternatives: with the census plugin enabled there would be two writers on one store.
669 if (d.censusPlugins.length) {
670 const a = await askOne($, run, Q.exclusive(d.censusPlugins))
671 if (typeof a !== 'object') return stop(a)
672 if (!continueAnyway(a.a)) {
673 say($, `🧭 census-setup: census-mod replaces the census plugin — disable it first: ${d.censusPlugins.map(k => `claude plugin disable ${k}`).join('; ')}. Then run /census-setup again`)
674 if (setupRun === run) setupRun = null
675 return
676 }
677 say($, '🧭 census-setup: continuing with the census plugin still enabled — two writers on one store; expect muddled liveness')
678 }
679 let recordMode: 'yes' | 'shadow' | 'no' | null = null
680 let replace = false
681 if (recordMode === null) {
682 const a = await askOne($, run, Q.record(d, Boolean(rawEnv.CENSUS_MOD_STORE?.trim())))
683 if (typeof a !== 'object') return stop(a)
684 recordMode = recordFrom(a.a) ?? 'no'
685 replace = replaceFrom(a.a)
686 await saveAnswer($, { record: recordMode })
687 }
688 // Replacing the status line means census-mod must draw: only ask where.
689 const band = await askOne($, run, replace ? Q.place() : Q.draw())
690 if (typeof band !== 'object') return stop(band)
691 const placement = placementFrom(band.a)
692 const drawOn = placement !== null
693 await saveAnswer($, placement ? { draw: true, placement } : { draw: false })
694 // CENSUS_MOD_STORE outranks the answer: census-mod records to the shadow store, so the status line is still the
695 // real store's only writer. Removing it would stop that store before the comparison is done.
696 const shadowByEnv = Boolean(rawEnv.CENSUS_MOD_STORE?.trim())
697 const keptForShadow = shadowByEnv && recordMode === 'yes' && d.ingestBlock
698 let removed: string | undefined
699 if (recordMode === 'yes' && d.ingestBlock && !shadowByEnv) {
700 let choice: ReturnType<typeof writersFrom>
701 if (replace && drawOn) choice = 'remove'
702 else {
703 const a = await askOne($, run, Q.writers(drawOn, d.otherWriter))
704 if (typeof a !== 'object') return stop(a)
705 choice = writersFrom(a.a)
706 }
707 if (choice === 'remove') {
708 const r = await removeOwnStatusLine($)
709 if (r.done) {
710 removed = r.backup
711 det = await detect($)
712 } else {
713 recordMode = 'no'
714 await saveAnswer($, { record: recordMode })
715 }
716 } else if (choice === 'keep') {
717 recordMode = 'no'
718 await saveAnswer($, { record: recordMode })
719 }
720 }
721 if (recordMode === 'no' && !drawOn) {
722 await turnOff($)
723 if (setupRun === run) setupRun = null
724 return
725 }
726 let preset = saved.preset
727 if (drawOn) {
728 const a = await askOne($, run, Q.preset())
729 if (typeof a !== 'object') return stop(a)
730 preset = presetFrom(a.a) ?? 'two'
731 await saveAnswer($, { preset })
732 }
733 const pr = await askOne($, run, Q.pr())
734 if (typeof pr !== 'object') return stop(pr)
735 await saveAnswer($, { pr: is(pr.a, L.prYes), answeredAt: await nowMs($), offered: true })
736 if (setupRun !== run) return
737 setupRun = null
738 say($, '🧭 census-mod is set up', [
739 `recording: ${eff.record === 'yes' ? 'into census (the real store)' : eff.record === 'shadow' ? `shadow store ${eff.shadowDir ?? ''} — the dashboards and vitals do not read it; run /census-setup and answer Yes to record into census itself` : 'off'}`,
740 `band: ${drawOn ? `on, ${eff.placement === 'below' ? 'below the input' : 'above the input'}, ${PRESETS[preset ?? 'two']}` : 'off'}${rawEnv.CENSUS_STATUSLINE_SEGMENTS?.trim() ? ' (CENSUS_STATUSLINE_SEGMENTS overrides the layout)' : ''}`,
741 `PR segment (gh): ${saved.pr === false ? 'off, gh is never called' : 'on'}`,
742 ...(removed ? [`your status line was removed from settings.json; it is backed up in ${removed}`] : []),
743 ...(keptForShadow ? ['your status line was kept: CENSUS_MOD_STORE makes census-mod record to a shadow store, so the status line is still the real store\'s writer. Unset it and run /census-setup again to replace it'] : []),
744 'undo any time: /census-setup off (it restores a removed status line exactly), or /census-setup to answer again',
745 '📊 /census-mod:vitals shows this session on your phone',
746 ])
747}
748
749function startSetup($: EngineInterface, mode: 'full' | 'offer') {
750 const run = {}
751 setupRun = run
752 // After the command has replied: the dialogs follow it rather than holding it open.
753 $.clock.after(0, () => void setup($, run, mode).catch(() => { if (setupRun === run) setupRun = null }))
754}
755
756/** The first session after install: say so once, ever. An answer or a dismissal both count. */
757function offerOnce($: EngineInterface) {
758 if (saved.offered || setupRun || !interactive || offerTimer) return
759 const id = snap?.sessionId
760 offerTimer = $.clock.after(3000, () => {
761 offerTimer = null
762 // A quick exit (or a /clear) in between: there is no session left to ask in.
763 if (saved.offered || setupRun || !id || snap?.sessionId !== id) return
764 startSetup($, 'offer')
765 })
766}
767
768const DEFAULT_COLUMNS = 100
769
770/** The status line as runs, from the live snapshot: the same lines whichever site draws them. */
771async function statusLines($: EngineInterface): Promise<Line[]> {
772 if (!snap) return []
773 const now = (await nowMs($)) / 1000
774 const c = snap.counters
775 const expires = expiresAtMs(c, snap.ttl)
776 const input: RenderInput = {
777 now,
778 ctxPct: snap.ctxPct,
779 cache: { hitRatio: c.lastRatio, requests: c.requests, warm: isWarm(c, snap.ttl, now * 1000), expiresAt: expires === null ? null : expires / 1000, misses: 0 },
780 limits: rateLimitsOf(snap.rateLimits),
781 costUsd: snap.costUsd,
782 durationMs: snap.startedAt === null ? null : now * 1000 - snap.startedAt,
783 modelName: snap.model?.display_name ?? null,
784 git: snap.git,
785 pr: snap.pr,
786 cwd: snap.worktreePath ?? snap.cwd,
787 env,
788 }
789 return draw(input)
790}
791
792/** One <Box> row per line, a <Text> per coloured run. */
793function statusRows($: EngineInterface, e: Parameters<typeof $.ui.resolve>[0], lines: Line[]) {
794 const { Box, Text } = $.ui.resolve(e)
795 return lines.map((line, i) => (
796 <Box key={`census-${i}`}>
797 {line.map((run, j) => (
798 <Text key={`r${j}`} color={run.tone ? TONE_COLOR[run.tone] : undefined} bold={run.bold} wrap="truncate-end">
799 {run.t}
800 </Text>
801 ))}
802 </Box>
803 ))
804}
805
806/** Our work after `next`: whatever it throws must not cost the other mods their result. */
807async function quietly(work: () => Promise<void>): Promise<void> {
808 try {
809 await work()
810 } catch {
811 // recording is best-effort; the session carries on
812 }
813}
814
815export const register: Register = on => {
816 on('session.start', async ($, e, next) => {
817 const r = await next(e)
818 interactive = e.isInteractive
819 if (!interactive) return r
820 await quietly(async () => {
821 await $.command.register({ name: 'census-setup', description: 'Guided census-mod setup: record, draw, layout, gh. `off` stops it.', argumentHint: '[off]' }).catch(() => undefined)
822 rawEnv = await loadEnv($)
823 env = { ...rawEnv }
824 await bind($, await $.session.id(), e.cwd, null, 'session.start')
825 })
826 return r
827 })
828
829 // Every source: startup, resume, clear (a new session id), compact, fork.
830 on('classic.SessionStart', async ($, e, next) => {
831 const r = await next(e)
832 let watch: string[] = []
833 await quietly(async () => {
834 if (!(await isInteractive($))) return
835 interactive = true
836 if (Object.keys(rawEnv).length === 0) {
837 rawEnv = await loadEnv($)
838 env = { ...rawEnv }
839 }
840 // watchPaths are this hook's answer, so this one git call is awaited; bind reuses it.
841 const gitDir = await readGitDir($, e.cwd)
842 watch = watchPaths(gitDir)
843 if (e.source !== 'compact') {
844 const event: Event = e.source === 'startup' ? 'session.start' : (`session.${e.source}` as Event)
845 await bind($, e.session_id, e.cwd, e.transcript_path || null, event, gitDir)
846 } else if (snap && e.transcript_path) snap.transcriptPath = e.transcript_path
847 })
848 return watch.length ? { ...r, watchPaths: [...(r.watchPaths ?? []), ...watch] } : r
849 })
850
851 on('turn.complete', async ($, e, next) => {
852 const r = await next(e)
853 if (!interactive || !snap || e.agentId !== undefined || !e.usage) return r
854 const usage = e.usage
855 await quietly(async () => {
856 if (!snap) return
857 const now = await nowMs($)
858 lastActiveAt = now
859 snap.counters = addTurn(snap.counters, usage, now)
860 await saveCounters($)
861 // The reads below shell out: off the turn's path. They belong to THIS session's snapshot: a /clear
862 // that rebinds meanwhile must not get this transcript's ttl or title, nor a turn.complete write.
863 const mine = snap
864 $.clock.after(0, () => {
865 void (async () => {
866 await readTtl($, mine)
867 await readName($, mine)
868 await saveCounters($, mine)
869 if (snap !== mine) return
870 armCold($)
871 repaint($)
872 record($, 'turn.complete')
873 })().catch(() => undefined)
874 })
875 })
876 return r
877 })
878
879 on('session.measure', async ($, e, next) => {
880 const r = await next(e)
881 if (!interactive || !snap) return r
882 await quietly(async () => {
883 if (!snap) return
884 snap.ctxPct = e.context.percent ?? null
885 snap.ctxWindow = e.context.window ?? snap.ctxWindow
886 snap.costUsd = e.cost?.usd ?? snap.costUsd
887 const rates = e.rateLimits as RateLimit[]
888 const key = limitsFingerprint(rates)
889 if (key !== limitsKey) {
890 limitsKey = key
891 snap.rateLimits = rates
892 record($, 'rate-limit')
893 }
894 repaint($)
895 })
896 return r
897 })
898
899 on('classic.PostModelSwitch', async ($, e, next) => {
900 const r = await next(e)
901 if (!interactive || !snap) return r
902 await quietly(async () => {
903 if (!snap) return
904 if (e.to_model) snap.model = modelOf(e.to_model)
905 const label = (e as unknown as { cache_ttl?: unknown }).cache_ttl
906 if (label === '1h' || label === '5m') {
907 snap.ttl = label
908 snap.counters = withTtl(snap.counters, label)
909 armCold($)
910 }
911 repaint($)
912 record($, 'model')
913 })
914 return r
915 })
916
917 on('classic.CwdChanged', async ($, e, next) => {
918 const r = await next(e)
919 if (!interactive || !snap) return r
920 await quietly(async () => {
921 if (!snap) return
922 snap.cwd = e.new_cwd
923 snap.worktreePath = worktreeOf(await readGitDir($, e.new_cwd))
924 lastGitAt = null
925 scheduleGit($)
926 repaint($)
927 record($, 'cwd')
928 })
929 return r
930 })
931
932 // A compaction rewrites the prefix: the next request writes the cache afresh.
933 on('classic.PostCompact', async ($, e, next) => {
934 const r = await next(e)
935 if (!interactive || !snap) return r
936 await quietly(async () => {
937 if (!snap) return
938 snap.counters = compacted(snap.counters, await nowMs($))
939 await saveCounters($)
940 coldTimer?.cancel()
941 coldTimer = null
942 repaint($)
943 record($, 'compact')
944 })
945 return r
946 })
947
948 on('classic.FileChanged', async ($, e, next) => {
949 scheduleGit($)
950 return next(e)
951 })
952
953 // After the tool ran: its effect is what can have moved git or the PR.
954 on('tool.call', async ($, e, next) => {
955 const r = await next(e)
956 if (!interactive || !snap) return r
957 await quietly(async () => {
958 const call = e as unknown as { tool: string; command?: unknown }
959 lastActiveAt = await nowMs($)
960 if (touchesGit(call.tool)) scheduleGit($)
961 if (call.tool === 'Bash' && typeof call.command === 'string' && touchesPr(call.command)) scheduleGh($, 'push')
962 })
963 return r
964 })
965
966 // The ending session's last word: runs under the chain's shared budget, so it carries its own timeout.
967 on('command.run', { command: 'census-setup' }, async ($, e) => {
968 const args = ((e as unknown as { args?: string }).args ?? '').trim()
969 if (args === 'off') {
970 setupRun = null
971 await quietly(async () => { if (Object.keys(rawEnv).length === 0) { rawEnv = await loadEnv($); env = { ...rawEnv } } await loadSetup($); await turnOff($) })
972 return { text: 'census-mod is off. /census-setup to turn it on again.' }
973 }
974 if (args !== '') return { text: 'Usage: /census-setup (guided setup) or /census-setup off' }
975 if (Object.keys(rawEnv).length === 0) { rawEnv = await loadEnv($); env = { ...rawEnv } }
976 startSetup($, 'full')
977 return { text: '🧭 census-setup: a few questions follow.' }
978 })
979
980 on('session.end', async ($, e, next) => {
981 await quietly(async () => {
982 if (interactive && snap && snap.sessionId === e.sessionId) {
983 cancelTimers()
984 pending = null
985 const timeoutMs = endTimeoutMs(next.budget.remainingMs)
986 if (timeoutMs !== null && !ended.has(e.sessionId)) {
987 ended.add(e.sessionId)
988 await ingest($, 'session.end', e.reason, timeoutMs)
989 }
990 }
991 })
992 return next(e)
993 })
994
995 // Above the input: the band. Ours first, whatever other mods draw beneath it after.
996 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
997 const inner = await next(e)
998 if (e.props.hasSurvey || !interactive || !snap || !eff.draw || eff.placement !== 'above') return inner
999 const lines = (await statusLines($)).slice(0, Math.max(0, e.props.maxRows)).map(l => fit(l, e.props.bodyColumns))
1000 if (lines.length === 0) return inner
1001 const { Box } = $.ui.resolve(e)
1002 return (
1003 <Box flexDirection="column">
1004 {statusRows($, e, lines)}
1005 {inner}
1006 </Box>
1007 )
1008 })
1009
1010 // Below the input: under Claude Code's own hint line. The engine always draws its permission pill and hint
1011 // first and a tree cannot go above them, so the engine's line (`inner`) leads and our rows follow on their own
1012 // lines; a tree without `inner` would put row one on the pill's line. Drawn while typing and while working too.
1013 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
1014 const inner = await next(e)
1015 if (!interactive || !snap || !eff.draw || eff.placement !== 'below') return inner
1016 const columns = e.viewport?.columns ?? DEFAULT_COLUMNS
1017 const lines = (await statusLines($)).map(l => fit(l, columns))
1018 if (lines.length === 0) return inner
1019 const { Box } = $.ui.resolve(e)
1020 return (
1021 <Box flexDirection="column">
1022 {inner}
1023 {statusRows($, e, lines)}
1024 </Box>
1025 )
1026 })
1027}
1028core/cache.ts 93 lines1import type { Counters, Ttl } from './types'
2
3// The prompt-cache lifetime the session's main conversation writes. Read once per turn off the
4// transcript tail; ported from context-vigil-mod's core/cache-ttl.ts (same TAIL_CMD, same rule:
5// any 5m write makes it 5m). Unknown is treated as 5m, the shorter and so the safer claim.
6export type Writes = { h1: number; m5: number }
7export const TAIL_CMD = 'tail -c 65536 "$1" | grep -o \'"cache_creation":{[^}]*}\''
8export const DEFAULT_TTL: Ttl = '5m'
9
10const field = (line: string, key: string): number => {
11 const m = new RegExp(`"ephemeral_${key}_input_tokens":(\\d+)`).exec(line)
12 return m ? Number.parseInt(m[1] ?? '0', 10) : 0
13}
14
15export function parseWrites(stdout: string): Writes | null {
16 const lines = stdout.split('\n')
17 for (let i = lines.length - 1; i >= 0; i--) {
18 const line = lines[i] ?? ''
19 if (!line.includes('"cache_creation"')) continue
20 const w = { h1: field(line, '1h'), m5: field(line, '5m') }
21 if (w.h1 > 0 || w.m5 > 0) return w
22 }
23 return null
24}
25
26/** null = nothing found in the tail. */
27export function ttlFromWrites(w: Writes | null): Ttl | null {
28 if (w === null) return null
29 return w.m5 > 0 ? '5m' : '1h'
30}
31
32export const ttlMs = (ttl: Ttl): number => (ttl === '1h' ? 3_600_000 : 300_000)
33
34export const EMPTY_COUNTERS: Counters = {
35 requests: 0, input: 0, read: 0, create: 0, output: 0,
36 lastTurnAt: null, ttl: null, lastRatio: null, cold: false, updatedAt: 0,
37}
38
39export type TurnTokens = {
40 input_tokens: number
41 output_tokens: number
42 cache_read_input_tokens: number
43 cache_creation_input_tokens: number
44}
45
46/** read / (read + creation + uncached input); null when the turn read no input at all. */
47export function ratio(read: number, create: number, input: number): number | null {
48 const total = read + create + input
49 return total > 0 ? read / total : null
50}
51
52/**
53 * Fold one main-thread turn in. `TurnUsage` is the turn's real requests SUMMED (typings), so a
54 * turn that ran several tool steps reports one ratio over all of them, and `requests` counts
55 * turns, not API calls.
56 */
57export function addTurn(c: Counters, u: TurnTokens, now: number): Counters {
58 return {
59 requests: c.requests + 1,
60 input: c.input + u.input_tokens,
61 read: c.read + u.cache_read_input_tokens,
62 create: c.create + u.cache_creation_input_tokens,
63 output: c.output + u.output_tokens,
64 lastTurnAt: now,
65 ttl: c.ttl,
66 lastRatio: ratio(u.cache_read_input_tokens, u.cache_creation_input_tokens, u.input_tokens),
67 cold: false,
68 updatedAt: now,
69 }
70}
71
72export const sessionRatio = (c: Counters): number | null => ratio(c.read, c.create, c.input)
73export const totalInputTokens = (c: Counters): number => c.input + c.read + c.create
74
75/** Epoch ms the cache goes cold, or null before any counted turn. */
76export function expiresAtMs(c: Counters, ttl: Ttl): number | null {
77 return c.lastTurnAt === null ? null : c.lastTurnAt + ttlMs(ttl)
78}
79
80export function isWarm(c: Counters, ttl: Ttl, now: number): boolean {
81 const at = expiresAtMs(c, ttl)
82 return !c.cold && at !== null && now < at
83}
84
85/** A compaction rewrites the prefix: the next request writes the cache afresh. */
86export const compacted = (c: Counters, now: number): Counters => ({ ...c, cold: true, updatedAt: now })
87
88export const withTtl = (c: Counters, ttl: Ttl): Counters => ({ ...c, ttl })
89
90export const STORE_PREFIX = 'counters:'
91export const counterKey = (sessionId: string): string => `${STORE_PREFIX}${sessionId}`
92export const COUNTER_KEEP_MS = 7 * 24 * 3_600_000
93core/census.ts 66 lines1import { configRootOf, expandHome, joinPath, trimSeps } from './home'
2import type { HomeEnv } from './home'
3
4export type CensusEnv = HomeEnv & {
5 CENSUS_MOD_STORE?: string
6 CENSUS_STORE?: string
7}
8
9/** Census reads a store ending `.json` as that FILE: its census dir is the parent (store.census_dir). */
10const asDir = (p: string): string => (p.endsWith('.json') ? trimSeps(p.slice(0, Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))) || '/') : p)
11
12/** Shadow mode: the store value as given (`~` expanded), which census itself reads, `.json` and all. */
13export function shadowStore(env: CensusEnv): string | null {
14 return env.CENSUS_MOD_STORE ? trimSeps(expandHome(env.CENSUS_MOD_STORE, env)) : null
15}
16
17/** Shadow mode: the DIR that store lives in, where `cli.path` sits. */
18export function shadowDir(env: CensusEnv): string | null {
19 const store = shadowStore(env)
20 return store ? asDir(store) : null
21}
22
23/** The census dir readers use: `CENSUS_STORE`, else `<config dir>/census`. */
24export function censusDir(env: CensusEnv): string | null {
25 if (env.CENSUS_STORE) return asDir(trimSeps(expandHome(env.CENSUS_STORE, env)))
26 const root = configRootOf(env)
27 return root ? joinPath(root, 'census') : null
28}
29
30/**
31 * census-mod records through its OWN bundled copy of census's ingest (`<plugin root>/scripts/cli.py`), never through a
32 * census plugin: a Python script run under python3 with a list argv.
33 */
34export const bundledCli = (pluginRoot: string): string => joinPath(pluginRoot, 'scripts', 'cli.py')
35
36/** The ingest argv for a launcher that passed `--version`. */
37export const ingestArgv = (cli: string, python: readonly string[] = ['python3']): string[] => [...python, cli, 'ingest']
38
39/** Shadow mode points the child's census at the shadow dir; ingest honours `CENSUS_STORE`. */
40export function ingestEnv(env: CensusEnv): Record<string, string> | undefined {
41 // Unchanged, `.json` included: census reads a file-valued store itself; only our pointer lookup wants the parent.
42 const store = shadowStore(env)
43 return store ? { CENSUS_STORE: store } : undefined
44}
45
46/** Time budgets (ms) */
47export const INGEST_TIMEOUT_MS = 10_000
48export const COALESCE_MS = 2000
49export const END_RESERVE_MS = 200
50export const END_MIN_MS = 300
51
52/**
53 * `timeoutMs` for the final ingest so it finishes inside `session.end`'s shared budget, or null to
54 * skip it: with under END_MIN_MS left an ingest would only overrun the exit.
55 */
56export function endTimeoutMs(remainingMs: number): number | null {
57 if (!Number.isFinite(remainingMs)) return 1000
58 if (remainingMs < END_MIN_MS) return null
59 return Math.min(1000, Math.floor(remainingMs) - END_RESERVE_MS)
60}
61
62/** How long to wait before the next ingest may run: 0 when the last was over `COALESCE_MS` ago. */
63export function delayFor(now: number, lastAt: number | null): number {
64 return lastAt === null ? 0 : Math.max(0, lastAt + COALESCE_MS - now)
65}
66core/gh.ts 55 lines1import type { Pr } from './types'
2
3export const GH_TIMEOUT_MS = 5000
4export const BACKOFF_MS = 10 * 60_000
5export const MAX_AGE_MS = 10 * 60_000
6export const ACTIVE_MS = 10 * 60_000
7
8/** Exactly: gh pr list --head <branch> --state open --limit 1 --json number,url,reviewDecision */
9export const ghArgv = (branch: string): string[] =>
10 ['gh', 'pr', 'list', '--head', branch, '--state', 'open', '--limit', '1', '--json', 'number,url,reviewDecision']
11
12/** What is kept per repo+branch. `pr: null` = checked, no open PR. */
13export type GhEntry = { at: number; pr: Pr | null; failedAt?: number }
14
15const REVIEW: Record<string, string> = {
16 APPROVED: 'approved',
17 REVIEW_REQUIRED: 'pending',
18 CHANGES_REQUESTED: 'changes_requested',
19}
20
21/** `gh pr list` JSON to a PR; empty list = no open PR; undefined = not parseable. */
22export function parsePrList(stdout: string): Pr | null | undefined {
23 let data: unknown
24 try {
25 data = JSON.parse(stdout)
26 } catch {
27 return undefined
28 }
29 if (!Array.isArray(data)) return undefined
30 const first = data[0] as { number?: unknown; url?: unknown; reviewDecision?: unknown } | undefined
31 if (first === undefined) return null
32 if (typeof first.number !== 'number') return undefined
33 const pr: Pr = { number: first.number, url: typeof first.url === 'string' ? first.url : '' }
34 const state = typeof first.reviewDecision === 'string' ? REVIEW[first.reviewDecision] : undefined
35 if (state) pr.reviewState = state
36 return pr
37}
38
39/** `start`: the first sight of a branch by a bound session (a start, a /clear, a restart): a fresh cached answer stands. */
40export type Why = 'branch' | 'push' | 'age' | 'start'
41
42/** A Bash command that can have opened, updated or closed a PR. */
43export const touchesPr = (command: string): boolean => command.includes('git push') || command.includes('gh pr')
44
45export function shouldRefresh(why: Why, entry: GhEntry | undefined, now: number, lastActiveAt: number | null): boolean {
46 if (entry?.failedAt !== undefined && now < entry.failedAt + BACKOFF_MS) return false
47 if (entry === undefined) return true
48 if (why === 'branch' || why === 'push') return true
49 if (why === 'start') return now - entry.at > MAX_AGE_MS
50 return now - entry.at > MAX_AGE_MS && lastActiveAt !== null && now - lastActiveAt < ACTIVE_MS
51}
52
53/** JSON-encoded pair: any root/branch (a `|` in either included) maps to its own key. */
54export const ghKey = (root: string, branch: string): string => `gh:${JSON.stringify([root, branch])}`
55core/git.ts 52 lines1import { isAbsolute } from './home'
2import type { GitState } from './types'
3
4// Exactly one status call (no untracked scan: the changes segment never counted untracked files).
5export const GIT_STATUS_ARGV = ['git', '--no-optional-locks', 'status', '--porcelain=2', '--branch', '-uno'] as const
6// Line 1: the git dir (HEAD and index live there, elsewhere for a linked worktree); line 2: the top level.
7export const GIT_DIR_ARGV = ['git', 'rev-parse', '--absolute-git-dir', '--show-toplevel'] as const
8export const COALESCE_MS = 5000
9
10export type RunOut = { exitCode: number; stdout: string }
11
12/**
13 * Port of census's gitcache.parse_status. Detached HEAD reports the short oid; an unborn branch
14 * (oid `(initial)`) has no branch to report; untracked (`?`) and ignored (`!`) never count.
15 */
16export function parseStatus(stdout: string): GitState {
17 let head: string | null = null
18 let oid: string | null = null
19 let upstream: string | null = null
20 let ahead = 0
21 let uncommitted = 0
22 for (const line of stdout.split('\n')) {
23 if (line.startsWith('# branch.head ')) head = line.slice('# branch.head '.length).trim()
24 else if (line.startsWith('# branch.oid ')) oid = line.slice('# branch.oid '.length).trim()
25 else if (line.startsWith('# branch.upstream ')) upstream = line.slice('# branch.upstream '.length).trim()
26 else if (line.startsWith('# branch.ab ')) {
27 for (const part of line.split(/\s+/).slice(2)) if (/^\+\d+$/.test(part)) ahead = Number.parseInt(part.slice(1), 10)
28 } else if (['1 ', '2 ', 'u '].includes(line.slice(0, 2))) uncommitted++
29 }
30 const detached = head === '(detached)'
31 const unborn = oid === '(initial)'
32 const branch = detached ? (oid && !unborn ? oid.slice(0, 7) : null) : unborn ? null : head || null
33 return { branch, detached, uncommitted, ahead: upstream ? ahead : 0, hasUpstream: upstream !== null }
34}
35
36/** What to watch for a `rev-parse --absolute-git-dir --show-toplevel` answer; nothing when it failed. */
37export function watchPaths(out: RunOut): string[] {
38 const dir = out.exitCode === 0 ? (out.stdout.split('\n')[0] ?? '').trim() : ''
39 // git prints forward slashes even on Windows (`C:/repo/.git`)
40 return isAbsolute(dir) ? [`${dir}/HEAD`, `${dir}/index`] : []
41}
42
43/** The linked worktree's top level, or null in the main checkout (git dir is `<repo>/.git`). */
44export function worktreeOf(out: RunOut): string | null {
45 if (out.exitCode !== 0) return null
46 const [dir = '', top = ''] = out.stdout.split('\n').map(l => l.trim())
47 return /[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/.test(dir) && isAbsolute(top) ? top : null
48}
49
50const TOUCH = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
51export const touchesGit = (tool: string): boolean => TOUCH.has(tool)
52core/home.ts 76 lines1// Where "home" and the config dir are, on macOS, Linux and Windows. THE SAME FILE lives in census-mod, context-vigil-mod
2// and agent-roster (plugins share no code); tests/census/test_home_copies.py fails if the copies differ.
3//
4// Rule: the config dir is CLAUDE_CONFIG_DIR, else <home>/.claude; <home> is HOME, else USERPROFILE, else
5// HOMEDRIVE+HOMEPATH. `~`, `~/x` and `~\x` expand with the same home.
6
7export type HomeEnv = {
8 CLAUDE_CONFIG_DIR?: string
9 HOME?: string
10 USERPROFILE?: string
11 HOMEDRIVE?: string
12 HOMEPATH?: string
13}
14
15/** What was looked at, for messages: name the variables actually checked. */
16export const HOME_VARS_CHECKED = 'CLAUDE_CONFIG_DIR, HOME, USERPROFILE and HOMEDRIVE+HOMEPATH'
17
18const nonEmpty = (v: string | undefined): string | undefined => (v && v.trim() ? v : undefined)
19
20/** The home dir: HOME, else USERPROFILE, else HOMEDRIVE+HOMEPATH; null when none is set. */
21export function homeOf(env: HomeEnv): string | null {
22 const home = nonEmpty(env.HOME) ?? nonEmpty(env.USERPROFILE)
23 if (home) return home
24 const drive = nonEmpty(env.HOMEDRIVE)
25 const path = nonEmpty(env.HOMEPATH)
26 return drive && path ? `${drive}${path}` : null
27}
28
29/** The separator a base path uses: a backslash when it has one and no slash (`C:\Users\x`), else a slash. */
30export const sepOf = (p: string): '/' | '\\' => (p.includes('\\') && !p.includes('/') ? '\\' : '/')
31
32/** Whether a path is absolute on either platform: `/x`, `C:\x`, `C:/x`, `\\server\share`. */
33export const isAbsolute = (p: string): boolean => p.startsWith('/') || p.startsWith('\\\\') || /^[A-Za-z]:[\\/]/.test(p)
34
35/**
36 * Trailing separators dropped (slash or backslash), the root kept: `/a/b//` -> `/a/b`, `/` -> `/`,
37 * `C:\Users\x\` -> `C:\Users\x`, `C:\` -> `C:\`.
38 */
39export function trimSeps(p: string): string {
40 if (/^[A-Za-z]:$/.test(p)) return p // `C:` is the drive-relative cwd of C:, not the root `C:\`
41 if (/^[A-Za-z]:[\\/]+$/.test(p)) return `${p.slice(0, 2)}${p.includes('/') ? '/' : '\\'}`
42 const t = p.replace(/[\\/]+$/, '')
43 return t || (/^[\\/]/.test(p) ? p[0] ?? '/' : p)
44}
45
46/** `base` + parts, joined with the separator the base uses, so `C:\Users\x` + `.claude` stays all backslashes. */
47export function joinPath(base: string, ...parts: string[]): string {
48 const sep = sepOf(base)
49 const root = trimSeps(base)
50 const tail = parts.map(p => p.replace(/^[\\/]+|[\\/]+$/g, '')).filter(Boolean).join(sep)
51 const joined = root.endsWith('/') || root.endsWith('\\') || /^[A-Za-z]:$/.test(root) ? `${root}${tail}` : `${root}${sep}${tail}`
52 return tail ? joined.replace(sep === '\\' ? /\//g : /\\/g, sep) : root
53}
54
55/** `~`, `~/x` or `~\x` with the home dir (in the home's own separator); anything else unchanged. */
56export function expandHome(p: string, env: HomeEnv): string {
57 const home = homeOf(env)
58 if (!home || !(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) return p
59 return p === '~' ? trimSeps(home) : joinPath(home, p.slice(2))
60}
61
62/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude; null when neither can be told. */
63export function configRootOf(env: HomeEnv): string | null {
64 const set = nonEmpty(env.CLAUDE_CONFIG_DIR)
65 if (set) return trimSeps(set)
66 const home = homeOf(env)
67 return home ? joinPath(home, '.claude') : null
68}
69
70/** A path as a comparison key: one separator, no trailing one, lower-cased when it is a Windows (drive or UNC) path. */
71export function pathKey(p: string): string {
72 const flat = p.replace(/\\/g, '/')
73 const trimmed = flat.length > 1 ? flat.replace(/\/+$/, '') || '/' : flat
74 return /^[A-Za-z]:/.test(p) || p.startsWith('\\\\') ? trimmed.toLowerCase() : trimmed
75}
76core/name.ts 20 lines1import { configRootOf, joinPath, trimSeps } from './home'
2import type { HomeEnv } from './home'
3
4export const NAME = 'census-mod'
5export const SCHEMA = 1
6
7/** `/a/b//` -> `/a/b`; `C:\x\` -> `C:\x`; a root stays a root (a bare trim would leave the empty string). */
8export const trimSlashes = trimSeps
9
10/** The config dir: CLAUDE_CONFIG_DIR, else <home>/.claude (home: HOME, USERPROFILE, HOMEDRIVE+HOMEPATH); null if none can be told. */
11export function configRoot(env: HomeEnv): string | null {
12 return configRootOf(env)
13}
14
15// Where Claude Code keeps a session's transcript, for when no hook has carried the path yet (a hot
16// reload fires session.start but not classic.SessionStart). Ported from context-vigil-mod.
17export function transcriptPathFor(root: string, cwd: string, sessionId: string): string {
18 return joinPath(root, 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${sessionId}.jsonl`)
19}
20core/portable.ts 56 lines1// What the hooks file runs outside the engine, per platform. Pure: no `$`. A path says which platform it is on
2// (`C:\x` and `\\server\x` are Windows, `/x` is POSIX), so nothing here sniffs the OS.
3import { isAbsolute } from './home'
4
5export const onWindows = (path: string): boolean => isAbsolute(path) && !path.startsWith('/')
6
7/** Python launchers to try, in order: `python3` (macOS, Linux), `python`, then the Windows launcher `py -3`. */
8export const PYTHON_CANDIDATES: readonly (readonly string[])[] = [['python3'], ['python'], ['py', '-3']]
9
10/** The first launcher whose `--version` ran, given a probe that says whether one did; null when none. */
11export async function pickPython(works: (argv: string[]) => Promise<boolean>): Promise<string[] | null> {
12 for (const candidate of PYTHON_CANDIDATES) {
13 const argv = [...candidate, '--version']
14 if (await works(argv).catch(() => false)) return [...candidate]
15 }
16 return null
17}
18
19// Windows has no argv-only move/delete: `cmd /c` would parse the paths as a command line. So there, nothing is spawned:
20// the caller writes the file in place and empties a backup instead of deleting it (see the hooks file).
21
22/** Rename `from` over `to` with `mv -f`; null on Windows. */
23export const moveArgv = (from: string, to: string): string[] | null => (onWindows(to) ? null : ['mv', '-f', from, to])
24
25/** Remove a file, quietly when it is not there, with `rm -f`; null on Windows. */
26export const removeArgv = (path: string): string[] | null => (onWindows(path) ? null : ['rm', '-f', path])
27
28/** Copy keeping the mode (POSIX only: a Windows file has no mode to keep, so there is nothing to run). */
29export const copyModeArgv = (from: string, to: string): string[] | null => (onWindows(to) ? null : ['cp', '-p', from, to])
30
31/** The last `bytes` UTF-8 bytes of the text (as `tail -c` takes them), not the last UTF-16 units: a cut mid-character is dropped. */
32export function tailBytes(text: string, bytes: number): string {
33 let used = 0
34 let i = text.length
35 while (i > 0) {
36 const code = text.charCodeAt(i - 1)
37 const isLow = code >= 0xdc00 && code <= 0xdfff && i > 1
38 const size = isLow ? 4 : code < 0x80 ? 1 : code < 0x800 ? 2 : 3
39 if (used + size > bytes) break
40 used += size
41 i -= isLow ? 2 : 1
42 }
43
44 return text.slice(i)
45}
46
47/** What `tail -c N file | grep -o '"cache_creation":{[^}]*}'` prints, from the file's text (for where there is no sh). */
48export function cacheLinesFromText(text: string, bytes = 65536): string {
49 return (tailBytes(text, bytes).match(/"cache_creation":\{[^}]*\}/g) ?? []).join('\n')
50}
51
52/** What `grep -F '"type":"custom-title"'` prints (the last `bytes` of the file only when given). */
53export function titleLinesFromText(text: string, bytes?: number): string {
54 return (bytes ? tailBytes(text, bytes) : text).split('\n').filter(l => l.includes('"type":"custom-title"')).join('\n')
55}
56core/payload.ts 79 lines1import { SCHEMA } from './name'
2import { expiresAtMs, isWarm, sessionRatio, totalInputTokens } from './cache'
3import type { RateLimit, Snap } from './types'
4
5export type Event =
6 | 'session.start' | 'session.clear' | 'session.resume' | 'session.fork' | 'turn.complete'
7 | 'rate-limit' | 'model' | 'cwd' | 'branch' | 'pr' | 'cache.cold' | 'compact' | 'session.end'
8
9/** `claude-opus-5-5[1m]` -> `{ id: 'claude-opus-5-5', display_name: 'Opus 5.5' }`. */
10export function modelOf(raw: string): { id: string; display_name: string } {
11 const id = raw.replace(/\[[^\]]*\]$/, '')
12 const m = /^claude-(opus|sonnet|haiku|fable)-(\d+)-(\d+)/.exec(id)
13 const name = m ? `${(m[1] ?? '').charAt(0).toUpperCase()}${(m[1] ?? '').slice(1)} ${m[2]}.${m[3]}` : id
14 return { id, display_name: name }
15}
16
17/** ISO `resetsAt` and decimal `percentUsed` to census's windows: epoch SECONDS, or census drops it. */
18export function rateLimitsOf(limits: RateLimit[]): Record<string, { used_percentage: number; resets_at: number }> {
19 const out: Record<string, { used_percentage: number; resets_at: number }> = {}
20 for (const l of limits) {
21 const ms = l.resetsAt ? Date.parse(l.resetsAt) : Number.NaN
22 if (!Number.isFinite(ms) || !Number.isFinite(l.percentUsed)) continue
23 out[l.kind] = { used_percentage: l.percentUsed, resets_at: Math.floor(ms / 1000) }
24 }
25 return out
26}
27
28/**
29 * The status-line payload shape census reads, plus `census_mod`. `now` in ms. Keys census does not
30 * know are stored verbatim, so every extra here is free; a key this cannot fill is OMITTED, not
31 * faked (census keeps the prior value of a null, and drops a half-formed window).
32 */
33export function buildPayload(s: Snap, now: number, event: Event, ended?: string): Record<string, unknown> {
34 const c = s.counters
35 const expires = expiresAtMs(c, s.ttl)
36 const cache: Record<string, unknown> = {
37 warm: isWarm(c, s.ttl, now),
38 ttl: s.ttl,
39 requests: c.requests,
40 }
41 if (expires !== null) cache.expires_at = Math.floor(expires / 1000)
42 if (c.lastRatio !== null) cache.hit_ratio = c.lastRatio
43 const whole = sessionRatio(c)
44 if (whole !== null) cache.session_hit_ratio = whole
45
46 const p: Record<string, unknown> = { session_id: s.sessionId }
47 if (s.transcriptPath) p.transcript_path = s.transcriptPath
48 p.cwd = s.cwd
49 // No project_dir: the mod tracks the current directory, not a project root, and would go stale after a cd.
50 p.workspace = { current_dir: s.cwd }
51 if (s.worktreePath) p.worktree = { path: s.worktreePath }
52 if (s.model) p.model = s.model
53 p.context_window = {
54 used_percentage: s.ctxPct,
55 ...(s.ctxWindow !== null ? { context_window_size: s.ctxWindow } : {}),
56 total_input_tokens: totalInputTokens(c),
57 total_output_tokens: c.output,
58 }
59 const cost: Record<string, number> = {}
60 if (s.costUsd !== null) cost.total_cost_usd = s.costUsd
61 if (s.startedAt !== null) cost.total_duration_ms = Math.max(0, Math.round(now - s.startedAt))
62 p.cost = cost
63 const limits = rateLimitsOf(s.rateLimits)
64 if (Object.keys(limits).length) p.rate_limits = limits
65 p.prompt_cache = cache
66 if (s.sessionName) p.session_name = s.sessionName
67 if (s.pr) p.pr = { number: s.pr.number, url: s.pr.url, ...(s.pr.reviewState ? { review_state: s.pr.reviewState } : {}) }
68 if (s.version) p.version = s.version
69 p.census_mod = {
70 version: SCHEMA,
71 ...(s.proc ? { pid: s.proc.pid, proc_start: s.proc.procStart } : {}),
72 event,
73 ...(ended !== undefined ? { ended } : {}),
74 // The `-uno` git pass, in gitcache's field names, so readers never shell out to git themselves.
75 git: s.git ? { branch: s.git.branch, uncommitted: s.git.uncommitted, ahead: s.git.ahead, has_upstream: s.git.hasUpstream, detached: s.git.detached } : null,
76 }
77 return p
78}
79core/registry.ts 42 lines1import type { Proc } from './types'
2
3/**
4 * Claude Code's own session registry holds `<config dir>/sessions/<pid>.json` per live process,
5 * with the `procStart` string (ps lstart text, in another zone: only ever compared as a string)
6 * and the version. The entry whose `sessionId` is ours names our process.
7 */
8export function findProc(files: { text: string }[], sessionId: string): Proc | null {
9 for (const f of files) {
10 let d: unknown
11 try {
12 d = JSON.parse(f.text)
13 } catch {
14 continue
15 }
16 if (!d || typeof d !== 'object' || Array.isArray(d)) continue // null, a scalar or an array is no registry entry
17 const r = d as { pid?: unknown; sessionId?: unknown; procStart?: unknown; version?: unknown }
18 if (r.sessionId !== sessionId || typeof r.pid !== 'number' || r.pid <= 1 || typeof r.procStart !== 'string') continue
19 return { pid: r.pid, procStart: r.procStart, ...(typeof r.version === 'string' ? { version: r.version } : {}) }
20 }
21 return null
22}
23
24/** The last `custom-title` row of `grep -F '"type":"custom-title"'` output. */
25export function lastTitle(stdout: string): string | null {
26 const lines = stdout.split('\n').filter(l => l.includes('"custom-title"'))
27 for (let i = lines.length - 1; i >= 0; i--) {
28 try {
29 const t = (JSON.parse(lines[i] ?? '') as { customTitle?: unknown }).customTitle
30 if (typeof t === 'string' && t.trim()) return t.trim()
31 } catch {
32 // a row cut mid-write; try the one before
33 }
34 }
35 return null
36}
37
38/** The whole transcript, once per bound session. */
39export const TITLE_ARGV = (path: string): string[] => ['grep', '-h', '-F', '"type":"custom-title"', path]
40/** After each turn: only the tail, where a later /rename lands, so a long session is not re-read whole every turn. */
41export const TITLE_TAIL_CMD = 'tail -c 262144 "$1" | grep -F \'"type":"custom-title"\''
42core/render.ts 231 lines1// The status line as data: a port of census's render.py (segments, glyphs, thresholds, the `/` line
2// syntax). A line is a list of runs; the shell turns runs into <Text>, tests read them as text.
3import type { GitState, Pr } from './types'
4
5export type Tone = 'grey' | 'cyan' | 'green' | 'yellow' | 'magenta' | 'pac' | 'white' | 'red' | 'orange' | 'claude'
6export type Run = { t: string; tone?: Tone; bold?: boolean }
7export type Line = Run[]
8
9/** render.py's ANSI palette as Text colours (names the surface's chalk-style colours accept; orange is 256-colour 208). */
10export const TONE_COLOR: Record<Tone, string> = {
11 grey: 'gray', cyan: 'cyan', green: 'green', yellow: 'yellow', magenta: 'magenta',
12 pac: 'yellowBright', white: 'whiteBright', red: 'red', orange: '#ff8700',
13 claude: '#D97757', // the asterisk's own orange (proven live as a raw hex Text colour)
14}
15
16export type RenderEnv = {
17 CENSUS_STATUSLINE_SEGMENTS?: string
18 CENSUS_STATUSLINE_MASCOT?: string
19 CLAUDE_COST_BUDGET?: string
20 CLAUDE_PROFILE?: string
21 CLAUDE_CONFIG_DIR?: string
22}
23
24export type RenderInput = {
25 now: number // epoch seconds
26 ctxPct: number | null
27 cache: { hitRatio: number | null; requests: number; warm: boolean; expiresAt: number | null; misses: number }
28 limits: Record<string, { used_percentage: number; resets_at: number } | undefined>
29 costUsd: number | null
30 durationMs: number | null
31 modelName: string | null
32 git: Pick<GitState, 'branch' | 'uncommitted' | 'ahead' | 'hasUpstream'> | null
33 pr: Pr | null
34 cwd: string
35 env: RenderEnv
36}
37
38export const DEFAULT_SEGMENTS = 'context,cache,limits,cost/model,git,dir,changes,pr'
39export const DEFAULT_BUDGET = 20
40export const BAR_WIDTH = 10
41
42/** printf "%.0f": round half to even. */
43export function roundHalfEven(x: number): number {
44 const f = Math.floor(x)
45 const d = x - f
46 if (d < 0.5) return f
47 if (d > 0.5) return f + 1
48 return f % 2 === 0 ? f : f + 1
49}
50
51export const levelTone = (pct: number): Tone => (pct >= 90 ? 'red' : pct >= 75 ? 'orange' : 'green')
52export const levelToneInv = (pct: number): Tone => (pct >= 90 ? 'green' : pct >= 75 ? 'orange' : 'red')
53
54export function pacBar(pct: number, width = BAR_WIDTH, glyph = '•', ahead?: string): Run[] {
55 const track = ahead ?? glyph
56 const filled = Math.max(0, Math.min(Math.floor((pct / 100) * width + 0.5), width))
57 const pac = Math.min(filled, width - 1)
58 const out: Run[] = []
59 for (let j = 0; j < width; j++) {
60 if (j < pac) out.push({ t: glyph, tone: levelTone(Math.floor(((j + 1) * 100) / width)) })
61 else if (j === pac) out.push({ t: 'ᗧ', tone: 'pac' })
62 else out.push({ t: track, tone: 'white' })
63 }
64 return out
65}
66
67export function fmtReset(target: number, now: number): string {
68 const diff = Math.max(0, Math.floor(target - now))
69 if (diff >= 86400) return `${Math.floor(diff / 86400)}d${Math.floor((diff % 86400) / 3600)}h`
70 if (diff >= 3600) return `${Math.floor(diff / 3600)}h${Math.floor((diff % 3600) / 60)}m`
71 return `${Math.floor(diff / 60)}m`
72}
73
74const sp: Run = { t: ' ' }
75const grey = (t: string): Run => ({ t, tone: 'grey' })
76
77function segContext(i: RenderInput): Line[] {
78 if (i.ctxPct === null) {
79 return [[grey('🧠'), sp, { t: 'ᗧ', tone: 'pac' }, { t: '•'.repeat(BAR_WIDTH - 1), tone: 'white' }, sp, grey('--%')]]
80 }
81 const shown = roundHalfEven(i.ctxPct)
82 return [[grey('🧠'), sp, ...pacBar(i.ctxPct), sp, { t: `${shown}%`, tone: levelTone(shown) }]]
83}
84
85function segCache(i: RenderInput): Line[] {
86 const { hitRatio, requests, warm, expiresAt, misses } = i.cache
87 if (hitRatio === null || !(requests > 0)) return []
88 const pct = roundHalfEven(Math.max(0, Math.min(100, hitRatio * 100)))
89 const line: Line = [grey(warm ? '🎯' : '🧊'), sp, { t: `${pct}%`, tone: levelToneInv(pct) }]
90 if (warm && expiresAt !== null) line.push(sp, grey(`⟳ ${fmtReset(expiresAt, i.now)}`))
91 if (misses > 0) line.push(sp, { t: `✗${misses}`, tone: 'red' })
92 return [line]
93}
94
95function usageBar(i: RenderInput, w: { used_percentage: number; resets_at: number }): Line {
96 const shown = roundHalfEven(w.used_percentage)
97 return [...pacBar(w.used_percentage), sp, { t: `${shown}%`, tone: levelTone(shown) }, sp, grey(`⟳ ${fmtReset(w.resets_at, i.now)}`)]
98}
99
100function segLimits(i: RenderInput): Line[] {
101 const out: Line[] = []
102 for (const [key, emoji] of [['five_hour', '⏳'], ['seven_day', '📅']] as const) {
103 const w = i.limits[key]
104 if (!w) continue
105 if (w.resets_at <= i.now) continue // an expired window is a fossil
106 out.push([grey(emoji), sp, ...usageBar(i, w)])
107 }
108 return out
109}
110
111function budget(env: RenderEnv): number {
112 // The whole value or nothing: "5oops" is not a $5 budget.
113 const t = (env.CLAUDE_COST_BUDGET ?? '').trim()
114 const v = /^-?\d+(\.\d+)?$/.test(t) ? Number(t) : Number.NaN
115 return Number.isFinite(v) ? v : DEFAULT_BUDGET
116}
117
118function segCost(i: RenderInput): Line[] {
119 if (i.costUsd === null) return []
120 const b = budget(i.env)
121 const pct = b <= 0 ? 0 : Math.max(0, roundHalfEven((i.costUsd / b) * 100))
122 const out: Line[] = [[grey('💸'), sp, ...pacBar(Math.min(pct, 100), BAR_WIDTH, '•', '$'), sp, { t: `$${i.costUsd.toFixed(2)}`, tone: levelTone(pct) }]]
123 if (i.durationMs !== null && i.durationMs > 0) {
124 const burn = i.costUsd / (i.durationMs / 3_600_000)
125 const bi = roundHalfEven(burn)
126 const [tone, emoji]: [Tone, string] = bi >= 20 ? ['red', '🚀'] : bi >= 8 ? ['orange', '🔥'] : ['green', '🐌']
127 out.push([{ t: `${emoji} ` }, { t: `$${bi >= 100 ? String(bi) : burn.toFixed(2)}/hr`, tone }])
128 }
129 return out
130}
131
132export function detectAccount(env: RenderEnv): 'personal' | 'work' {
133 if (env.CLAUDE_PROFILE) return ['personal', 'home', 'p'].includes(env.CLAUDE_PROFILE) ? 'personal' : 'work'
134 if (env.CLAUDE_CONFIG_DIR) return env.CLAUDE_CONFIG_DIR.includes('personal') ? 'personal' : 'work'
135 return 'work'
136}
137
138export const DEFAULT_MASCOT = '✻'
139export const mascot = (env: RenderEnv): string => env.CENSUS_STATUSLINE_MASCOT || DEFAULT_MASCOT
140
141function segModel(i: RenderInput): Line[] {
142 const custom = i.env.CENSUS_STATUSLINE_MASCOT
143 // The default is a coloured run of its own; an override is drawn as given.
144 const glyph: Run = custom ? { t: `${custom} ` } : { t: `${DEFAULT_MASCOT} `, tone: 'claude', bold: true } // bold, and the glyph plus one space: a two-column slot like an emoji
145 return [[glyph, ...(custom ? [] : [sp]), { t: i.modelName || 'Claude', tone: 'cyan' }]]
146}
147
148function segGit(i: RenderInput): Line[] {
149 if (!i.git?.branch) return []
150 return [[{ t: `🌿 ${i.git.branch}`, tone: 'green' }]]
151}
152
153function segDir(i: RenderInput): Line[] {
154 const parts = i.cwd.split(/[\\/]/)
155 const shown = parts.length <= 3 ? i.cwd : `…${parts.slice(-3).map(p => `/${p}`).join('')}`
156 return [[{ t: `📁 ${shown}`, tone: 'magenta' }]]
157}
158
159function segChanges(i: RenderInput): Line[] {
160 if (!i.git?.branch) return []
161 const line: Line = [{ t: `✏️ ${i.git.uncommitted}`, tone: 'yellow' }]
162 if (i.git.ahead > 0 && i.git.hasUpstream) line.push({ t: ' ' }, { t: `⬆️ ${i.git.ahead}`, tone: 'yellow' })
163 return [line]
164}
165
166const REVIEW_TONE: Record<string, Tone> = { approved: 'green', pending: 'yellow', changes_requested: 'red' }
167
168/** The branch's open PR (from the mod's gh cache): `🔀 #12 approved`, the state coloured; hidden without one. */
169function segPr(i: RenderInput): Line[] {
170 if (!i.pr || !(i.pr.number > 0)) return []
171 const line: Line = [{ t: `🔀 #${i.pr.number}` }]
172 if (i.pr.reviewState) line.push(sp, { t: i.pr.reviewState, tone: REVIEW_TONE[i.pr.reviewState] ?? 'grey' })
173 return [line]
174}
175
176export const SEGMENTS: Record<string, (i: RenderInput) => Line[]> = {
177 context: segContext, cache: segCache, limits: segLimits, cost: segCost, model: segModel, git: segGit, dir: segDir, changes: segChanges, pr: segPr,
178}
179
180/** The configured segments as lines of names; `/` starts a new line, unknown names are dropped. */
181export function layout(spec: string | undefined): string[][] {
182 const text = spec?.trim() || DEFAULT_SEGMENTS
183 return text.split('/').map(line => line.split(',').map(s => s.trim()).filter(n => Object.hasOwn(SEGMENTS, n)))
184}
185
186const SEPARATOR: Run = { t: ' │ ', tone: 'grey' }
187
188/** Each configured line as runs, parts joined by the grey bar; an empty line is dropped. */
189export function draw(i: RenderInput): Line[] {
190 const lines: Line[] = []
191 for (const names of layout(i.env.CENSUS_STATUSLINE_SEGMENTS)) {
192 const parts = names.flatMap(n => (Object.hasOwn(SEGMENTS, n) ? SEGMENTS[n]?.(i) : undefined) ?? [])
193 if (parts.length) lines.push(parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p])))
194 }
195 return lines
196}
197
198export const plain = (line: Line): string => line.map(r => r.t).join('')
199
200/** Terminal columns a string takes: wide emoji and CJK count 2, a variation selector widens its base. */
201export function displayWidth(text: string): number {
202 let w = 0
203 let joined = false // after a zero-width joiner the next emoji is part of the same grapheme
204 for (const ch of text) {
205 const cp = ch.codePointAt(0) ?? 0
206 if (joined) {
207 joined = false
208 if (cp >= 0x1f000 || WIDE_BMP.has(cp) || cp === 0x2640 || cp === 0x2642 || cp === 0x2695 || cp === 0x2764) continue
209 }
210 if (cp === 0x200d) joined = true
211 if (cp === 0xfe0f) w += 1
212 else if (cp === 0x200d || (cp >= 0x300 && cp <= 0x36f)) w += 0
213 else if (cp >= 0x1f000 || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xff00 && cp <= 0xff60) || WIDE_BMP.has(cp)) w += 2
214 else w += 1
215 }
216 return w
217}
218const WIDE_BMP = new Set([0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x2614, 0x2615, 0x26a1, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x2753, 0x2754, 0x2755, 0x2757, 0x2b50, 0x2b55])
219export const lineWidth = (line: Line): number => displayWidth(plain(line))
220
221/** Drop whole trailing parts until the line fits `columns`; a lone part is kept (the surface truncates it). */
222export function fit(line: Line, columns: number): Line {
223 const parts: Line[] = [[]]
224 for (const r of line) {
225 if (r === SEPARATOR) parts.push([])
226 else parts[parts.length - 1]?.push(r)
227 }
228 while (parts.length > 1 && lineWidth(parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p]))) > columns) parts.pop()
229 return parts.flatMap((p, k) => (k === 0 ? p : [SEPARATOR, ...p]))
230}
231core/setup.ts 336 lines1// /census-setup: the questions, what an answer means, and the settings.json surgery. Pure: no `$`.
2import { expandHome, joinPath } from './home'
3import type { HomeEnv } from './home'
4import { DEFAULT_SEGMENTS } from './render'
5
6export type RecordMode = 'yes' | 'shadow' | 'no'
7export type Preset = 'two' | 'compact' | 'minimal'
8export type Placement = 'above' | 'below'
9
10/** What the person answered, in $.store. Absent = not answered; the defaults below apply. */
11export type Saved = {
12 offered?: boolean
13 record?: RecordMode
14 draw?: boolean
15 placement?: Placement
16 preset?: Preset
17 pr?: boolean
18 answeredAt?: number
19}
20
21export const SETUP_KEY = 'census-mod:setup'
22export const BACKUP_FILE = 'census-mod.statusline.json'
23export const SHADOW_DIRNAME = 'census-shadow'
24
25export const PRESETS: Record<Preset, string> = {
26 two: DEFAULT_SEGMENTS,
27 compact: 'context,cache,limits,cost,model,git',
28 minimal: 'context,limits/git',
29}
30
31// ---- what is on this machine ------------------------------------------------------------------
32
33export type Detection = {
34 /** This account's settings.json `statusLine.command`; null when there is none. */
35 statusLineCommand: string | null
36 /** Whether that command (or the script it runs) carries census's ingest block. */
37 ingestBlock: boolean
38 /** A census status-line writer touched the real store in the last few minutes. */
39 otherWriter: boolean
40 /** settings.json exists but is not valid JSON: it is never edited. */
41 settingsInvalid: boolean
42 /** The census PLUGIN is enabled for this account (`census@<marketplace>` keys): census-mod replaces it. */
43 censusPlugins: string[]
44}
45
46export const NO_DETECTION: Detection = { statusLineCommand: null, ingestBlock: false, otherWriter: false, settingsInvalid: false, censusPlugins: [] }
47
48/** The enabled `census@<marketplace>` plugin keys in a settings.json `enabledPlugins` (census-mod does not count). */
49export function enabledCensusPlugins(data: Record<string, unknown>): string[] {
50 const enabled = data.enabledPlugins
51 if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return []
52 return Object.entries(enabled as Record<string, unknown>)
53 .filter(([key, on]) => on === true && /^census@[^@]+$/.test(key))
54 .map(([key]) => key)
55 .sort()
56}
57
58// Census's own sentinel (plugins/census/scripts/statusline.py START): the line that opens the block it adds.
59export const INGEST_MARKER = '# --- census: record status-line payload'
60export const hasIngestBlock = (text: string): boolean => text.includes(INGEST_MARKER)
61/** A command that is itself census recording: `census ingest` or `census statusline`. */
62export const commandIsCensus = (command: string): boolean =>
63 /(^|[\s/\\'"])census(\.exe)?\s+(ingest|statusline)\b/.test(command) ||
64 // Windows: `census install` points the status line at its launcher, `python "<census dir>\launcher.py" statusline`.
65 /launcher\.py['"]?\s+(ingest|statusline)\b/.test(command)
66
67/**
68 * The files a status-line command runs, as absolute paths: `bash ~/.claude/line.sh`, `"$HOME/x.sh" --flag`,
69 * `/abs/x`, and on Windows `powershell -File "C:\Users\u\my line.ps1"` (quoted, with spaces and backslashes).
70 */
71export function scriptCandidates(command: string, home: string | undefined, userProfile?: string): string[] {
72 const out: string[] = []
73 const root = home?.replace(/[\\/]+$/, '')
74 const profile = (userProfile ?? home)?.replace(/[\\/]+$/, '') // %USERPROFILE% is its own variable
75 for (const m of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
76 const t = m[1] ?? m[2] ?? m[3] ?? ''
77 let p = t
78 if (root && (t === '~' || /^~[\\/]/.test(t))) p = root + t.slice(1)
79 else if (profile && /^%USERPROFILE%[\\/]/.test(t)) p = profile + t.slice(t.search(/[\\/]/))
80 else if (root && /^(\$HOME|\$\{HOME\})[\\/]/.test(t)) p = root + t.slice(t.search(/[\\/]/))
81 if ((p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\')) && !out.includes(p)) out.push(p)
82 }
83 return out
84}
85
86export type Parsed = { ok: true; data: Record<string, unknown> } | { ok: false }
87export function parseSettings(text: string): Parsed {
88 try {
89 const data = JSON.parse(text) as unknown
90 return data && typeof data === 'object' && !Array.isArray(data) ? { ok: true, data: data as Record<string, unknown> } : { ok: false }
91 } catch {
92 return { ok: false }
93 }
94}
95
96export function statusLineCommand(data: Record<string, unknown>): string | null {
97 const sl = data.statusLine as { command?: unknown } | undefined
98 return sl && typeof sl === 'object' && typeof sl.command === 'string' ? sl.command : null
99}
100
101/** A census writer is active: a session entry written in the last `withinMs` that the mod did not write. */
102export function writerActive(entries: { updatedAt: number; hasCensusMod: boolean }[], nowMs: number, withinMs = 180_000): boolean {
103 return entries.some(e => !e.hasCensusMod && nowMs - e.updatedAt <= withinMs)
104}
105
106// ---- settings: env > $.store > defaults ----------------------------------------------------------
107
108export type SetupEnv = HomeEnv & { CENSUS_MOD_STORE?: string; CENSUS_STATUSLINE_SEGMENTS?: string; CENSUS_MOD_PLACEMENT?: string }
109export type Effective = { record: RecordMode; shadowDir: string | null; draw: boolean; placement: Placement; segments: string | undefined; pr: boolean }
110
111/**
112 * Before any answer: record to the real store ONLY if this account's status line does not carry the
113 * census ingest block (else a second writer would share the store), draw yes, gh yes. An answer replaces
114 * the default; an environment variable replaces both.
115 */
116export function effective(saved: Saved, env: SetupEnv, det: Detection, configRoot: string | null): Effective {
117 const envShadow = env.CENSUS_MOD_STORE?.trim()
118 const shadowAt = configRoot ? joinPath(configRoot, SHADOW_DIRNAME) : null
119 let record: RecordMode = saved.record ?? (det.ingestBlock ? 'no' : 'yes')
120 let shadowDir: string | null = record === 'shadow' ? shadowAt : null
121 if (envShadow) {
122 record = 'shadow'
123 shadowDir = expandHome(envShadow, env)
124 }
125 if (record === 'shadow' && !shadowDir) record = 'no' // nowhere to write
126 const envSegments = env.CENSUS_STATUSLINE_SEGMENTS?.trim()
127 return {
128 record,
129 shadowDir,
130 draw: saved.draw ?? true,
131 // An install that never answered stays where it was (above); the question recommends below for new ones.
132 placement: ((p: string | undefined): Placement | null => (p === 'above' || p === 'below' ? p : null))(env.CENSUS_MOD_PLACEMENT?.trim().toLowerCase()) ?? saved.placement ?? 'above',
133 segments: envSegments || (saved.preset ? PRESETS[saved.preset] : undefined),
134 pr: saved.pr ?? true,
135 }
136}
137
138// ---- the questions (labels only; the recommendation rides in the label) -------------------------------
139
140export type Question = { header: string; question: string; options: string[] }
141const REC = ' (Recommended)'
142
143export const L = {
144 offerYes: 'Set it up now',
145 offerLater: 'Not now — use the defaults',
146 exStop: "Stop — I'll disable census first",
147 exGo: 'Continue anyway (two writers)',
148 recReplace: 'Replace my status line — census-mod records and draws it',
149 recYes: 'Yes — record into census (dashboards, vitals and liveness use it)',
150 recShadow: "Shadow — record into a separate store to compare; dashboards won't see it",
151 recNo: "No — don't record (dashboards and vitals won't see this account)",
152 recNoFed: 'No — leave recording to my status line (it keeps feeding census)',
153 recNoOther: "No — don't record (whatever else writes keeps feeding census)",
154 drawBelow: "Yes — below the input, under Claude Code's hint line",
155 drawAbove: 'Yes — above the input, in the band',
156 drawNo: 'No — record only, draw nothing',
157 wRemove: "Remove this account's status line (census-mod draws it instead)",
158 wKeep: "Keep my status line; census-mod won't record",
159 wBoth: 'Keep both (not recommended)',
160 presetTwo: 'Your two lines',
161 presetCompact: 'Compact — one line',
162 presetMinimal: 'Minimal — context, limits / git',
163 prYes: 'Yes',
164 prNo: "No — never call gh",
165} as const
166
167const rec = (s: string, on: boolean) => (on ? `${s}${REC}` : s)
168
169export const Q = {
170 offer: (): Question => ({
171 header: '🧭 Setup',
172 question: 'census-mod is installed. Set it up now? It takes a few questions: whether to record sessions into census, whether and where to draw the status line, and which layout.',
173 options: [rec(L.offerYes, true), L.offerLater],
174 }),
175 /** census and census-mod are alternatives: with the census plugin enabled there would be two writers. */
176 exclusive: (keys: string[]): Question => ({
177 header: '⚠️ census',
178 question: `census-mod replaces the census plugin, and ${keys.join(', ')} is still enabled here. Disable it first (${keys.map(k => `claude plugin disable ${k}`).join('; ')}), or two writers will share one store. Stop here, or continue anyway?`,
179 options: [rec(L.exStop, true), L.exGo],
180 }),
181 /**
182 * Almost nobody else records into census, so the common path is Yes or No. Shadow (a separate store to compare
183 * in) and Replace (swap a status line that feeds census for census-mod) are offered only where something already
184 * records into the real store.
185 */
186 record: (det: Detection, shadowByEnv = false): Question => {
187 const readers = 'the overseer dashboard, /census-mod:vitals and session liveness'
188 const OVERSEER = 'the overseer dashboard, /census-mod:vitals and liveness'
189 if (det.ingestBlock && shadowByEnv) {
190 return {
191 header: '📝 Record',
192 question: `CENSUS_MOD_STORE is set, so census-mod records into a separate shadow store whatever you answer, and your status line — which records this account's sessions into census, the store ${OVERSEER} read — stays as that store's writer. Unset it and run /census-setup again to replace the status line. Compare in the shadow store, or leave recording to your status line?`,
193 options: [rec(L.recShadow, true), L.recYes, L.recNoFed],
194 }
195 }
196 if (det.ingestBlock) {
197 return {
198 header: '📝 Record',
199 question: `Your status line already records this account's sessions into census — the store ${OVERSEER} read. Replace it with census-mod (records and draws the line; yours is backed up), compare first, or leave recording to your status line (it keeps feeding census)?`,
200 options: [rec(L.recReplace, true), L.recShadow, L.recYes, L.recNoFed],
201 }
202 }
203 if (det.otherWriter) {
204 return {
205 header: '📝 Record',
206 question: `Something has been recording this account's sessions into census in the last few minutes — the store ${OVERSEER} read. Two writers on one store muddle liveness: compare first in a separate store, or record anyway?`,
207 options: [rec(L.recShadow, true), L.recYes, L.recNoOther],
208 }
209 }
210 return {
211 header: '📝 Record',
212 question: `Record this account's sessions into census? That's what ${readers} read (context, cost, limits, git, PR) — without it they see nothing from this account.`,
213 options: [rec(L.recYes, true), L.recNo],
214 }
215 },
216 place: (): Question => ({
217 header: '🎛️ Draw',
218 question: 'Where should census-mod draw your status line?',
219 options: [rec(L.drawBelow, true), L.drawAbove],
220 }),
221 draw: (): Question => ({
222 header: '🎛️ Draw',
223 question: 'Should census-mod draw your status line, and where?',
224 options: [rec(L.drawBelow, true), L.drawAbove, L.drawNo],
225 }),
226 writers: (draw: boolean, otherWriter: boolean): Question => ({
227 header: '⚠️ Writers',
228 question: `This account's status line also records into the census store${otherWriter ? ' (and it has written in the last few minutes)' : ''}. Two writers on one store muddle idle and liveness. What now?`,
229 options: draw ? [rec(L.wRemove, true), L.wKeep, L.wBoth] : [rec(L.wKeep, true), L.wBoth],
230 }),
231 preset: (): Question => ({
232 header: '📐 Layout',
233 question: 'Which segments in the band? (CENSUS_STATUSLINE_SEGMENTS still overrides this.)',
234 options: [rec(L.presetTwo, true), L.presetCompact, L.presetMinimal],
235 }),
236 pr: (): Question => ({
237 header: '🔀 PR',
238 question: "Show the branch's open PR (number, review state) using gh? No means gh is never called.",
239 options: [rec(L.prYes, true), L.prNo],
240 }),
241}
242
243const strip = (label: string): string => label.replace(REC, '')
244export const is = (answer: string, label: string): boolean => strip(answer) === label || strip(answer).trim() === label
245
246export function recordFrom(answer: string): RecordMode | null {
247 if (is(answer, L.recYes) || is(answer, L.recReplace)) return 'yes'
248 if (is(answer, L.recShadow)) return 'shadow'
249 if (is(answer, L.recNo) || is(answer, L.recNoFed) || is(answer, L.recNoOther)) return 'no'
250 return null
251}
252/** The record answer that also hands the status line over to census-mod. */
253export const replaceFrom = (answer: string): boolean => is(answer, L.recReplace)
254/** The draw answer as a placement; null means "don't draw". */
255export function placementFrom(answer: string): Placement | null {
256 if (is(answer, L.drawAbove)) return 'above'
257 if (is(answer, L.drawBelow)) return 'below'
258 return null
259}
260export function presetFrom(answer: string): Preset | null {
261 if (is(answer, L.presetTwo)) return 'two'
262 if (is(answer, L.presetCompact)) return 'compact'
263 if (is(answer, L.presetMinimal)) return 'minimal'
264 return null
265}
266export type Writers = 'remove' | 'keep' | 'both'
267export function writersFrom(answer: string): Writers | null {
268 if (is(answer, L.wRemove)) return 'remove'
269 if (is(answer, L.wKeep)) return 'keep'
270 if (is(answer, L.wBoth)) return 'both'
271 return null
272}
273export const yes = (answer: string, label: string): boolean | null => (is(answer, label) ? true : null)
274
275// ---- settings.json: remove the statusLine, back it up exactly, restore it ------------------------------
276
277function indentOf(text: string): string | number {
278 const m = /\n([ \t]+)"/.exec(text)
279 return m ? (m[1] ?? 2) : 2
280}
281const serialise = (data: Record<string, unknown>, like: string): string => `${JSON.stringify(data, null, indentOf(like))}\n`
282
283export type Removal = { ok: true; text: string; backup: string } | { ok: false; reason: 'invalid' | 'none' }
284
285/** settings.json without its `statusLine`, every other key and its order kept; the backup holds the removed value verbatim. */
286export function removeStatusLine(text: string, settingsPath: string, nowIso: string): Removal {
287 const p = parseSettings(text)
288 if (!p.ok) return { ok: false, reason: 'invalid' }
289 if (!('statusLine' in p.data)) return { ok: false, reason: 'none' }
290 const rest = Object.fromEntries(Object.entries(p.data).filter(([k]) => k !== 'statusLine'))
291 return { ok: true, text: serialise(rest, text), backup: `${JSON.stringify({ statusLine: p.data.statusLine, removedAt: nowIso, from: settingsPath }, null, 2)}\n` }
292}
293
294export type Restoration =
295 | { done: 'restored'; text: string }
296 | { done: 'already' }
297 | { done: 'kept'; why: 'present' | 'invalid' | 'no-backup' }
298
299const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
300
301/** Put the backed-up statusLine back, but only into a settings.json that has none (or already has exactly it). */
302export function restoreStatusLine(text: string, backupText: string | null): Restoration {
303 let backup: { statusLine?: unknown } | null = null
304 try {
305 const parsed: unknown = backupText === null ? null : JSON.parse(backupText)
306 // A scalar, an array or null is not a backup: nothing to put back.
307 backup = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as { statusLine?: unknown }) : null
308 } catch {
309 backup = null
310 }
311 if (!backup || !('statusLine' in backup)) return { done: 'kept', why: 'no-backup' }
312 const p = parseSettings(text)
313 if (!p.ok) return { done: 'kept', why: 'invalid' }
314 if ('statusLine' in p.data) return same(p.data.statusLine, backup.statusLine) ? { done: 'already' } : { done: 'kept', why: 'present' }
315 return { done: 'restored', text: serialise({ ...p.data, statusLine: backup.statusLine }, text) }
316}
317
318export const settingsTmp = (path: string): string => `${path}.census-mod.tmp`
319
320/** An existing backup holding a DIFFERENT status line than the one about to be removed: keep it, refuse the removal. */
321export function backupBlocks(existing: string | null, newBackup: string): boolean {
322 const statusOf = (t: string): unknown => {
323 try {
324 return (JSON.parse(t) as { statusLine?: unknown }).statusLine
325 } catch {
326 return undefined
327 }
328 }
329 if (existing === null) return false
330 const old = statusOf(existing)
331 return old !== undefined && !same(old, statusOf(newBackup))
332}
333
334/** The answer to the mutual-exclusion question: true to go on despite the census plugin. */
335export const continueAnyway = (answer: string): boolean => is(answer, L.exGo)
336