Native task timing and optional advanced statusline observations

English | 简体中文
A Claude Code status line for Linux, WSL, Windows, and macOS. See model and reasoning effort, directory, Git, context, usage limits, tokens, and task timing at a glance. Configure the main line and individual subagent rows through an in-session editor, a terminal UI, a wizard, or the CLI.
Features · Screenshots · Quick installation · Common configuration · User guide · Troubleshooting
<a id="phase-4-in-stable-v150"></a> <a id="phase-5-in-stable-v160"></a> <a id="v161-clearer-external-tui-sections"></a>
Rendering uses Claude Code input and local state without making network requests or using model tokens. The question-and-answer wizard uses Claude model turns. See field definitions for data sources and availability.
<a id="界面预览"></a>
Component instances, copies and left/right layout captures.
Search, categories, source guidance and import review captures.
The main status line at the bottom of session screenshots shows actual data; configuration Preview regions use fixed samples. Fonts, colors and widths depend on terminal settings. Image sources and archive.
Linux terminal captures show the shared language selector and Chinese interface. These are reconstructions of actual PTY cells with sample previews; sources and all pages.



Run /statusline-configure-native inside the current Claude Code session, then click the Client region once before using the keyboard. The editor follows the Claude theme; choose the preview background to match the terminal and compare the selected palette. Compare terminal/theme combinations.
Linux: Main and the status line

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Earlier theme captures used a dark preview surface; the current editor offers a separate light/dark preview choice. These images reconstruct real terminal cells. Capture provenance.


Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Only Main was supplied for this batch. The existing interaction limitation and checks are documented in macOS mouse reporting and Client focus.
Main: select and reorder main status line items.

Run /statusline-configure in Claude Code, or claude-statusline configure in a standalone terminal.
Previews use the current terminal background and selected palette. Compare ansi in Settings when default looks faint. These are terminal-output reconstructions from dedicated GNOME profiles; capture sources.
Light background, Palette: default

Light background, Palette: ansi

Dark background, Palette: default

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Main: select and reorder main status line items.

Subagents: choose items and ordering for individual agent rows.

Settings: adjust appearance, refresh behavior and formatting.

Layout: set rows, item priorities and maximum widths.

Earlier screenshots and terminal reconstructions remain in the archive.
| Platform | Supported environment |
|---|---|
| Linux / WSL | Python 3.10+ |
| Windows 10/11 | CPython 3.10–3.14, x86/x64; windows-curses>=2.4.2 installs automatically |
| macOS 14+ | CPython 3.10–3.14, Intel / Apple Silicon |
Windows ARM devices can use x64 Python emulation; native ARM64 Python is outside the current support contract. Git information requires git.
Claude Code feature requirements: subagent rows 2.1.205+; local argument-based configuration and external TUI entry 2.1.258+; in-session Client 2.1.287+; native timing and advanced live-metrics collection 2.1.289+. Unsupported or unknown host versions suspend the corresponding integration. See requirements.
<a id="install-current-source"></a> <a id="install-from-a-release-recommended"></a> <a id="install-source-at-a-fixed-tag"></a> <a id="从-release-安装推荐"></a> <a id="从固定标签源码安装"></a> <a id="从当前源码安装"></a> <a id="升级与卸载"></a> <a id="常用配置"></a> <a id="快速安装"></a> <a id="接入-claude-code"></a> <a id="支持范围"></a> <a id="文档与帮助"></a> <a id="许可证"></a>
Install Python, Claude Code CLI, and pipx. All supported platforms use the same wheel.
Bash, Zsh, and PowerShell:
pipx install fbincon-claude-code-statusline
pipx ensurepath
Reopen the terminal so PATH changes take effect, then run:
claude-statusline --version
claude-statusline install --dry-run
claude-statusline install
claude-statusline doctor
On Windows, use claude-statusline.exe. Restart Claude Code in a trusted terminal after integration.
Both editors default on for compatible hosts, respecting saved disablement preferences. Native timing metadata defaults on for Claude Code 2.1.289+; advanced live metrics remain opt-in. Package installation and Claude integration are separate steps; install does not open an editor. Existing conflicting resources require explicit handling.
Source development on main uses Node and TypeScript; follow the source development guide. Fixed-tag and Release wheel installation for the published Python product remains documented in the installation guide.
Download checksums and platform-specific instructions are in the installation guide; source builds are in the development guide.
Find items, understand their sources, and review imports.
| Entry point | Use |
|---|---|
/statusline-configure-native | Client TUI in the current session; see native editor |
/statusline-configure | TUI in a supported external terminal; see external entry |
claude-statusline configure | Full TUI in the current standalone terminal |
/statusline-config | Claude question-and-answer wizard; supported argument-based commands execute locally on compatible hosts |
claude-statusline config ... | Inspect settings, set exact ordering, or configure from scripts |
<a id="v130a2-external-tui-and-in-session-client"></a>
Both editors have Interface language (saved immediately) in Settings, with choices English / 简体中文 / 繁體中文 / 日本語 / 한국어. The default is English. Switching keeps your page, selection, search and unsaved display edits; cancelling display changes keeps the language choice. Other open windows read it when reopened or reloaded.
claude-statusline config language set ja
claude-statusline config language show
claude-statusline config language reset
claude-statusline --language en --help
--language en|zh-CN|zh-Hant|ja|ko precedes the command and affects only that invocation; for configure it sets the initial language. Commands, IDs, configuration values and custom text retain their values. Interface language does not change output language. See language settings.
Choose Statusline language (save with display settings) → English / 简体中文 / 繁體中文 / 日本語 / 한국어 in either editor. Preview switches immediately; Save changes the actual main and subagent output on the next refresh, while Cancel discards the draft. English is the default.
claude-statusline config set statusline-language zh-Hant
claude-statusline config set statusline-language en


These are real CLI terminal captures with fixed input.
Built-in labels and known states are translated. Technical units, model names, branches, paths and custom text are preserved. See output language.
Click the Client region once. Use Tab to change pages, Space to toggle, arrows to select or reorder, Ctrl+E for item formatting and source guidance, / to search, and Ctrl+F for categories. Clear filters before reordering. S saves and stays, F saves and closes, and Q discards unsaved changes; lowercase letters work too. Footer controls follow the current page or input mode. Ctrl+G cancels input. Claude preferences apply separately.
Use Tab to change pages, Space to toggle, and arrows to select or reorder. Ctrl+S saves after field input is finished; Enter saves on item and original settings rows, or edits/confirms advanced fields. Esc cancels input first, then cancels the editor; Ctrl+C interrupts without saving. The minimum terminal size is 64×18.
The external entry uses tmux or GNOME Terminal on Linux, tmux or Terminal.app on macOS, and the system's new-console launcher on Windows. For SSH or unavailable launchers, run claude-statusline configure in the current terminal.
Define a compact main line:
claude-statusline config set-items model-with-effort current-dir git context-remaining task-timer
claude-statusline config set directory-style home
claude-statusline config show
Configuration is per user. set-items replaces the enabled set; enable and disable make incremental changes. See recipes, formatting and layouts, and the CLI reference.
<a id="upgrade-to-v161"></a>
Upgrade the package, synchronize integration, then restart Claude Code:
pipx upgrade fbincon-claude-code-statusline
claude-statusline install
claude-statusline doctor
Existing wheel installations from this repository should first follow the package-name migration. Saved display settings, runtime state, and integration preferences remain. For older display schemas or package downgrade, follow version compatibility.
Remove Claude integration before uninstalling the package:
claude-statusline uninstall --dry-run
claude-statusline uninstall
pipx uninstall fbincon-claude-code-statusline
Display settings and backups remain available; see uninstalling.
claude-code-statusline/
├── .github/workflows/
│ ├── ci.yml # Candidate build, eight installations and thirteen hosts
│ └── reference.yml # Manual historical Python verification
├── packages/ # Four business packages
│ ├── core/src/ # Configuration, protocols, pure state, rendering and Unicode
│ │ └── i18n/ # Five generated languages; sources in core/resources
│ ├── platform/src/ # Files, locks, processes, clocks and terminal adapters
│ ├── cli/ # Commands, integration, runtime and recovery
│ │ ├── src/ # Installation and editor protocols
│ │ └── resources/ # Two configuration skill templates
│ └── tui/src/ # Standalone terminal editor
├── mods/
│ ├── statusline-native/ # In-session editor: hooks, lib, ui, tests
│ └── statusline-runtime/ # Timing and optional live metrics
├── tests/fixtures/ # Shared contracts, Unicode and frozen reference data
├── tools/
│ ├── generation/ # Shared resource generation
│ ├── checks/ # Documentation, archive and aggregate checks
│ ├── build/ # Fixed sources, universal offline packaging and digests
│ ├── acceptance/ # Installation, Mods, terminals, power and budgeted acceptance
│ ├── benchmarks/ # Functional fixtures and optional descriptive measurement
│ ├── release/ # Candidate evidence and release-note preparation
│ ├── migration/ # Stage ledgers, frozen references and compatibility tests
│ ├── testing/ # Development-only test support package
│ ├── typescript/ # Runtime constraints and test entry points
│ └── unicode/ # Pinned data, provenance and licenses
├── archive/python/ # Frozen Python implementation, tests, tools and publishing workflows
├── docs/ # Usage, development, migration and historical releases
│ ├── reference/ # CLI and configuration reference
│ ├── development/ # Architecture, testing and stage records
│ ├── images/ # UI screenshots and historical captures
│ └── releases/ # Historical release notes
├── package.json / package-lock.json
├── tsconfig*.json / .node-version
├── README.md / README.zh-CN.md
├── CHANGELOG.md / CHANGELOG.zh-CN.md
└── LICENSE
MIT License. Copyright (c) 2026 fbincon.
Bundled Unicode data retains the Unicode License v3 and the wcwidth MIT notice.
hooks/observe.ts 280 lines1/** Passive observations only: every hook passes its original event/result on. */
2import type { EngineInterface, Register, PluginOptions } from 'claude-code';
3import { Queue, confirmedResponse } from '../lib/queue.ts';
4import { PROTOCOL_VERSION } from '../lib/generated-contracts.ts';
5import { checklist } from '../lib/checklists.ts';
6import { tapStream, usage } from '../lib/streams.ts';
7import { cost } from '../lib/costs.ts';
8import { promptLink, reportAgent } from '../lib/identity.ts';
9import { WaitCoverage } from '../lib/timing.ts';
10import { binding } from '../lib/launch.ts';
11
12type State = { queue: Queue; ready: boolean; busy: boolean; executable: string; args: readonly string[]; directory: string; hostVersion: string; turns: Record<string, string>; agentTypes: string[] };
13
14async function bind($: EngineInterface, options: PluginOptions, state: State): Promise<void> {
15 const queue = state.queue;
16 const session = await $.session.id();
17 if (state.ready && queue.session === session) return;
18 const launch = binding(options, await $.env.get('CLAUDE_STATUSLINE_RUNTIME_EXECUTABLE') || undefined);
19 state.executable = launch.executable;
20 state.args = launch.args;
21 state.directory = (await $.env.get('CLAUDE_CONFIG_DIR')) ||
22 (typeof options.configDir === 'string' ? options.configDir : '');
23 state.hostVersion = (await $.session.version()).version;
24 const now = await $.clock.now();
25 queue.bind(session, now);
26 state.turns = {};
27 state.agentTypes = [];
28 queue.push('heartbeat', { host_version: state.hostVersion, loaded_at_ms: now }, now);
29 state.ready = true;
30}
31
32async function flush($: EngineInterface, options: PluginOptions, state: State): Promise<void> {
33 const queue = state.queue;
34 if (state.busy || !queue.pending.length) return;
35 state.busy = true;
36 const batch = queue.batch();
37 try {
38 const argv = [state.executable, ...state.args, 'runtime', ...(state.directory ? ['--config-dir', state.directory] : [])];
39 const result = await $.process.run(argv, {
40 stdin: JSON.stringify({ protocol_version: PROTOCOL_VERSION, operation: 'observe', payload: { observations: batch } }),
41 timeoutMs: 2000,
42 });
43 const expected = typeof options.backendVersion === 'string' ? options.backendVersion : '';
44 if (result.exitCode === 0 && confirmedResponse(result.stdout, expected, batch.length)) queue.acknowledge(batch);
45 } catch { /* Keep exact identities for an idempotent retry. */ }
46 finally { state.busy = false; }
47}
48
49
50export const register: Register = (on, options: PluginOptions) => {
51 const state: State = { queue: new Queue(), ready: false, busy: false, executable: '', args: [], directory: '', hostVersion: '', turns: {}, agentTypes: [] };
52 const queue = state.queue;
53 queue.timing = options.nativeTiming !== false;
54 queue.metrics = options.liveMetrics === true;
55 const waitCoverage = new WaitCoverage();
56 const unknownWait = (reason: string, at: number, agent?: string) => {
57 for (const [loop, turn] of Object.entries(state.turns)) {
58 if (agent && loop !== agent) continue;
59 waitCoverage.mark(loop, turn);
60 queue.push('wait_unknown', { reason }, at, { turn_id: turn, agent_id: loop === 'main' ? null : loop });
61 }
62 };
63 on('session.start', async ($, e, next) => {
64 try {
65 await bind($, options, state);
66 await flush($, options, state);
67 $.clock.every(1000, async () => { try { await bind($, options, state); await flush($, options, state); } catch {} });
68 $.clock.every(5000, async () => {
69 try {
70 await bind($, options, state);
71 const now = await $.clock.now();
72 queue.push('heartbeat', { host_version: state.hostVersion, loaded_at_ms: queue.loadedAt }, now);
73 const agents = await $.agent.list();
74 queue.push('agents', { agents: agents.slice(0, 256).map(agent => ({
75 id: agent.id, parent_id: agent.parentId ?? null, status: agent.status,
76 local: !agent.teammateId || Boolean(state.turns[agent.id]),
77 })) }, now);
78 await flush($, options, state);
79 } catch {}
80 });
81 } catch { /* A refused API cannot change the session start. */ }
82 return next(e);
83 });
84
85 on('classic.UserPromptSubmit', async ($, e, next) => {
86 try {
87 await bind($, options, state);
88 const now = await $.clock.now();
89 const report = reportAgent(e.prompt);
90 if (report && e.prompt_id)
91 queue.push('prompt_alias', { agent_id: report }, now, { prompt_id: e.prompt_id }, 'classic_hook');
92 if (e.prompt_id && !e.agent_id && !report && !/^\s*<(?:command-|local-command-|bash-)/.test(e.prompt))
93 queue.push('prompt', {}, now, { prompt_id: e.prompt_id }, 'classic_hook');
94 if (e.prompt_id && e.agent_id)
95 queue.push('prompt_alias', { agent_id: e.agent_id }, now, { prompt_id: e.prompt_id, agent_id: e.agent_id }, 'classic_hook');
96 if (e.permission_mode)
97 queue.push('permission', { mode: e.permission_mode, live: false }, now,
98 { prompt_id: e.prompt_id ?? null, agent_id: e.agent_id ?? null }, 'classic_hook');
99 } catch {}
100 return next(e);
101 });
102
103 on('classic.SubagentStart', async ($, e, next) => {
104 try {
105 await bind($, options, state);
106 const now = await $.clock.now();
107 queue.push('agent_start', { started_at_ms: now }, now,
108 { prompt_id: e.prompt_id ?? null, agent_id: e.agent_id }, 'classic_hook');
109 } catch {}
110 return next(e);
111 });
112
113 on('classic.SubagentStop', async ($, e, next) => {
114 try {
115 await bind($, options, state);
116 const now = await $.clock.now();
117 queue.push('agent_end', { ended_at_ms: now, status: 'completed' }, now,
118 { prompt_id: e.prompt_id ?? null, agent_id: e.agent_id }, 'classic_hook');
119 } catch {}
120 return next(e);
121 });
122
123 on('turn.start', async ($, e, next) => {
124 try {
125 await bind($, options, state);
126 state.turns.main = e.turnId;
127 queue.push('turn_start', {}, await $.clock.now(), { turn_id: e.turnId });
128 } catch {}
129 return next(e);
130 });
131
132 on('session.append', { door: 'prompt' }, async ($, e, next) => {
133 try {
134 await bind($, options, state);
135 const now = await $.clock.now();
136 if (e.agentId) {
137 queue.push('prompt_alias', { agent_id: e.agentId }, now, { prompt_id: e.uuid, agent_id: e.agentId });
138 } else if (e.message.type === 'user' && ['composer', 'bridge', 'sdk'].includes(e.origin.kind)) {
139 queue.push('prompt', {}, now, { prompt_id: e.uuid });
140 }
141 } catch {}
142 return next(e);
143 });
144
145 on('agent.spawn', async ($, e, next) => {
146 const loop = e.parentAgentId ?? 'main';
147 const turn = state.turns[loop];
148 const epoch = queue.epoch;
149 const result = await next(e);
150 try {
151 if (result.agentId && epoch === queue.epoch) {
152 if (!state.agentTypes.includes(e.subagentType)) state.agentTypes.push(e.subagentType);
153 const now = await $.clock.now();
154 queue.push('agent_start', { started_at_ms: now }, now, {
155 agent_id: result.agentId, parent_agent_id: e.parentAgentId ?? null, turn_id: turn ?? null,
156 });
157 }
158 } catch {}
159 return result;
160 });
161
162 on('turn.complete', async ($, e, next) => {
163 try {
164 await bind($, options, state);
165 const now = await $.clock.now();
166 const status = e.isAborted ? 'interrupted' : e.reason === 'error' || e.reason === 'refusal' ? 'failed' : 'completed';
167 queue.push('turn_end', { status, duration_ms: e.durationMs, wait_coverage: waitCoverage.finish(e.agentId ?? 'main', e.turnId) }, now, { agent_id: e.agentId ?? null, turn_id: e.turnId });
168 queue.push('turn_usage', { usage: usage(e.usage) }, now, { agent_id: e.agentId ?? null, turn_id: e.turnId });
169 if (e.agentId)
170 queue.push('agent_end', { ended_at_ms: now, status }, now, { agent_id: e.agentId, turn_id: e.turnId });
171 } catch {}
172 delete state.turns[e.agentId ?? 'main'];
173 return next(e);
174 });
175
176 on('turn.step', async function* ($, e, next) {
177 const identity = { agent_id: e.agentId ?? null, turn_id: e.turnId, request_id: (e.agentId ?? 'main') + ':' + e.turnId + ':' + String(e.index) };
178 let epoch = queue.epoch;
179 let first = false;
180 let native = false;
181 let counted: Record<string, number> | null = null;
182 let status = 'interrupted';
183 try {
184 await bind($, options, state);
185 epoch = queue.epoch;
186 state.turns[e.agentId ?? 'main'] = e.turnId;
187 const now = await $.clock.now();
188 queue.push('turn_start', {}, now, identity);
189 queue.push('request_start', {}, now, identity);
190 } catch {}
191 if (!queue.metrics) return yield* next(e);
192 const observed = tapStream(next(e), async (item, operation) => {
193 if (epoch !== queue.epoch) return;
194 if (item.done) { if (operation !== 'return') status = 'completed'; return; }
195 const chunk = item.value;
196 if (typeof chunk.ref === 'number') {
197 native = true;
198 const content = chunk.kind === 'tool' || ((chunk.kind === 'text' || chunk.kind === 'thinking') && chunk.text.length > 0);
199 if (content && !first) {
200 first = true;
201 queue.push('request_first', {}, await $.clock.now(), identity);
202 }
203 if (chunk.kind === 'stop') counted = usage(chunk.usage);
204 }
205 });
206 try {
207 return yield* observed;
208 } catch (error) {
209 status = next.signal.aborted ? 'interrupted' : 'failed';
210 throw error;
211 } finally {
212 try {
213 if (epoch === queue.epoch)
214 queue.push('request_end', { native, usage: native ? counted : null, status }, await $.clock.now(), identity);
215 } catch {}
216 }
217 });
218
219 on('telemetry.log', { to: 'collector' }, async ($, e, next) => {
220 try {
221 const link = promptLink(e, queue.session);
222 if (link) queue.push('prompt_link', link.payload, await $.clock.now(), link.identity, 'otel');
223 const row = queue.metrics ? cost(e, queue.session, state.agentTypes) : null;
224 if (row) queue.push('request_cost', row.payload, await $.clock.now(), row.identity, 'otel');
225 } catch {}
226 return next(e);
227 });
228
229 // A declarative 'ask' does not expose when the actual approval dialog
230 // opens/closes. Preserve that uncertainty instead of subtracting tool time.
231 on('tool.check', async ($, e, next) => {
232 const result = await next(e);
233 if (e.tool_use_id && result.decision === 'ask') {
234 try { unknownWait('permission_wait_unobserved', await $.clock.now()); } catch {}
235 }
236 return result;
237 });
238 on('classic.Elicitation', async ($, e, next) => {
239 try { unknownWait('mcp_wait_unobserved', await $.clock.now()); } catch {}
240 return next(e);
241 });
242 on('tool.call', async ($, e, next) => {
243 const identity = { agent_id: e.agentId ?? null, turn_id: state.turns[e.agentId ?? 'main'] ?? null, request_id: e.tool_use_id };
244 const epoch = queue.epoch;
245 if (e.tool === 'AskUserQuestion' || e.tool.startsWith('mcp__')) {
246 try { unknownWait('question_wait_unobserved', await $.clock.now(), e.agentId); } catch {}
247 }
248 try { queue.push('tool_start', { name: e.tool }, await $.clock.now(), identity); } catch {}
249 try {
250 const result = await next(e);
251 try {
252 if (epoch === queue.epoch) {
253 const now = await $.clock.now();
254 queue.push('tool_end', { name: e.tool, status: result.deny !== undefined ? 'denied' : result.isError ? 'error' : 'success' }, now, identity);
255 if (queue.metrics && result.deny === undefined && !result.isError) {
256 const change = checklist(e.tool, e, result.result);
257 if (change) queue.push(change.kind, change.payload, now, identity);
258 }
259 }
260 } catch {}
261 return result;
262 } catch (error) {
263 try {
264 if (epoch === queue.epoch)
265 queue.push('tool_end', { name: e.tool, status: next.signal.aborted ? 'interrupted' : 'error' }, await $.clock.now(), identity);
266 } catch {}
267 throw error;
268 }
269 });
270
271 on('session.end', async ($, e, next) => {
272 try {
273 queue.push('invalidate', { reason: 'session_' + e.reason }, await $.clock.now());
274 await flush($, options, state);
275 } catch {}
276 state.ready = false;
277 return next(e);
278 });
279};
280lib/queue.ts 69 lines1/** Bounded, retryable batches. Host access belongs to the hooks entry. */
2import { PROTOCOL_VERSION, type Observation, type Kind, type Source } from './generated-contracts.ts';
3
4export const TIMING_KINDS: ReadonlySet<Kind> = new Set([
5 'heartbeat', 'invalidate', 'prompt', 'prompt_alias', 'prompt_link',
6 'agent_start', 'agent_end', 'turn_start', 'turn_end', 'agents',
7 'wait_start', 'wait_end', 'wait_unknown',
8]);
9
10export type Identity = Partial<Pick<Observation,
11 'prompt_id' | 'agent_id' | 'parent_agent_id' | 'turn_id' | 'request_id'>>;
12
13export class Queue {
14 constructor(public timing = true, public metrics = true) {}
15 pending: Observation[] = [];
16 sequence = 0;
17 session = '';
18 epoch = '';
19 loadedAt = 0;
20 dropped = false;
21
22 bind(session: string, loadedAt: number): void {
23 this.session = session;
24 this.loadedAt = loadedAt;
25 this.epoch = String(loadedAt) + '-' + Math.random().toString(16).slice(2);
26 this.sequence = 0;
27 this.pending = [];
28 this.dropped = false;
29 }
30
31 push(kind: Kind, payload: Record<string, unknown>, at: number,
32 identity: Identity = {}, source: Source = 'native'): void {
33 if (!this.session || (!this.metrics && (!this.timing || !TIMING_KINDS.has(kind)))) return;
34 const record: Observation = {
35 session_id: this.session, epoch: this.epoch, seq: this.sequence++,
36 observed_at_ms: at, source, kind,
37 prompt_id: identity.prompt_id ?? null, agent_id: identity.agent_id ?? null,
38 parent_agent_id: identity.parent_agent_id ?? null, turn_id: identity.turn_id ?? null,
39 request_id: identity.request_id ?? null, payload,
40 };
41 // Preserve the initial heartbeat even when an unavailable backend fills the queue.
42 if (this.pending.length >= 1024) {
43 this.dropped = true;
44 this.pending.splice(this.pending[0]?.kind === 'heartbeat' ? 1 : 0, 1);
45 }
46 this.pending.push(record);
47 }
48
49 batch(): Observation[] { return this.pending.slice(0, 256); }
50
51 acknowledge(batch: readonly Observation[]): void {
52 const acknowledged = new Set(batch.map(row => row.epoch + ':' + row.seq));
53 this.pending = this.pending.filter(row => !acknowledged.has(row.epoch + ':' + row.seq));
54 }
55}
56
57export function confirmedResponse(stdout: string, expected: string, count?: number): boolean {
58 try {
59 const value = JSON.parse(stdout) as Record<string, unknown>;
60 const result = value.result as Record<string, unknown> | undefined;
61 return value.protocol_version === PROTOCOL_VERSION && result !== undefined &&
62 typeof result.backend_version === 'string' && (!expected || result.backend_version === expected) &&
63 typeof result.enabled === 'boolean' &&
64 typeof result.accepted === 'number' && Number.isInteger(result.accepted) && result.accepted >= 0 &&
65 typeof result.ignored === 'number' && Number.isInteger(result.ignored) && result.ignored >= 0 &&
66 (count === undefined || result.accepted + result.ignored === count);
67 } catch { return false; }
68}
69lib/generated-contracts.ts 19 lines1// Generated by tools/generation/resources.ts; do not edit.
2export const PROTOCOL_VERSION = 2 as const;
3export type Source = "native" | "classic_hook" | "otel";
4export type Kind = "heartbeat" | "invalidate" | "prompt" | "prompt_alias" | "permission" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "wait_start" | "wait_end" | "wait_unknown" | "agents" | "tool_start" | "tool_end" | "task_snapshot" | "task_update" | "request_start" | "request_first" | "request_end" | "request_cost" | "turn_usage" | "prompt_link";
5export interface Observation {
6 session_id: string;
7 epoch: string;
8 seq: number;
9 observed_at_ms: number;
10 source: "native" | "classic_hook" | "otel";
11 kind: "heartbeat" | "invalidate" | "prompt" | "prompt_alias" | "permission" | "agent_start" | "agent_end" | "turn_start" | "turn_end" | "wait_start" | "wait_end" | "wait_unknown" | "agents" | "tool_start" | "tool_end" | "task_snapshot" | "task_update" | "request_start" | "request_first" | "request_end" | "request_cost" | "turn_usage" | "prompt_link";
12 prompt_id: string | null;
13 turn_id: string | null;
14 agent_id: string | null;
15 parent_agent_id: string | null;
16 request_id: string | null;
17 payload: Record<string, unknown>;
18}
19lib/checklists.ts 46 lines1/** Only successful structured tool records contribute; no text heuristics. */
2type Task = { id: string; status: string };
3type Change = { kind: 'task_snapshot' | 'task_update'; payload: Record<string, unknown> };
4const statuses = new Set(['pending', 'in_progress', 'completed', 'deleted']);
5
6function record(value: unknown): Record<string, unknown> | null {
7 return value !== null && typeof value === 'object' && !Array.isArray(value)
8 ? value as Record<string, unknown> : null;
9}
10
11function task(value: unknown): Task | null {
12 const row = record(value);
13 if (!row || (typeof row.id !== 'string' && typeof row.id !== 'number') ||
14 typeof row.status !== 'string' || !statuses.has(row.status)) return null;
15 return { id: String(row.id), status: row.status };
16}
17
18export function checklist(tool: string, input: unknown, result: unknown): Change | null {
19 const args = record(input);
20 const output = record(result);
21 if (!args || !output) return null;
22 if (tool === 'TodoWrite' || tool === 'TaskList') {
23 const rows = tool === 'TodoWrite' ? output.newTodos : output.tasks;
24 if (!Array.isArray(rows) || rows.length > 1000) return null;
25 const tasks: Task[] = [];
26 for (let i = 0; i < rows.length; i++) {
27 const row = record(rows[i]);
28 const parsed = task(tool === 'TodoWrite' && row ? { ...row, id: String(i) } : row);
29 if (!parsed) return null;
30 tasks.push(parsed);
31 }
32 if (new Set(tasks.map(row => row.id)).size !== tasks.length) return null;
33 return { kind: 'task_snapshot', payload: { provider: tool === 'TodoWrite' ? 'todos' : 'tasks', tasks } };
34 }
35 if (tool === 'TaskCreate') {
36 const created = task(output.task);
37 return created ? { kind: 'task_update', payload: { provider: 'tasks', ...created } } : null;
38 }
39 if (tool === 'TaskUpdate' && output.success === true &&
40 (typeof args.taskId === 'string' || typeof args.taskId === 'number') &&
41 typeof args.status === 'string' && statuses.has(args.status)) {
42 return { kind: 'task_update', payload: { provider: 'tasks', id: String(args.taskId), status: args.status } };
43 }
44 return null;
45}
46lib/streams.ts 29 lines1/** Forward generator next/return/throw exactly, including downstream results. */
2export function tapStream<C, R>(source: AsyncGenerator<C, R, void>,
3 inspect: (item: IteratorResult<C, R>, operation: 'next' | 'return' | 'throw') => Promise<void>): AsyncGenerator<C, R, void> {
4 async function tap(result: Promise<IteratorResult<C, R>>, operation: 'next' | 'return' | 'throw'): Promise<IteratorResult<C, R>> {
5 const item = await result;
6 try { await inspect(item, operation); } catch { /* Observation never changes the stream. */ }
7 return item;
8 }
9 return {
10 next: (...args: [] | [void]) => tap(source.next(...args), 'next'),
11 return: (value: R | PromiseLike<R>) => tap(source.return(value), 'return'),
12 throw: (error: unknown) => tap(source.throw(error), 'throw'),
13 [Symbol.asyncIterator]() { return this; },
14 };
15}
16
17const fields = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'] as const;
18export function usage(value: unknown): Record<string, number> | null {
19 if (!value || typeof value !== 'object') return null;
20 const row = value as Record<string, unknown>;
21 const result: Record<string, number> = {};
22 for (const field of fields) {
23 const count = row[field];
24 if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) return null;
25 result[field] = count;
26 }
27 return result;
28}
29lib/costs.ts 24 lines1/** Whitelist official collector attributes; never enable or redirect exporters. */
2import type { TelemetryLogInput } from 'claude-code';
3import { usage } from './streams.ts';
4
5export { promptLink } from './identity.ts';
6
7export function cost(e: TelemetryLogInput, session: string, agentTypes: readonly string[]) {
8 if (e.to !== 'collector' || !['api_request', 'claude_code.api_request'].includes(e.event)) return null;
9 const a = e.attributes;
10 if (a['session.id'] !== session || a['workflow.run_id'] ||
11 typeof a['prompt.id'] !== 'string' || typeof a.request_id !== 'string' ||
12 !['repl_main_thread', ...agentTypes].includes(String(a.query_source))) return null;
13 const usd = typeof a.cost_usd === 'number' ? a.cost_usd :
14 typeof a.cost_usd_micros === 'number' && Number.isSafeInteger(a.cost_usd_micros) ? a.cost_usd_micros / 1000000 : null;
15 if (usd === null || !Number.isFinite(usd) || usd < 0) return null;
16 return {
17 identity: { prompt_id: a['prompt.id'], request_id: a.request_id },
18 payload: { cost_usd: usd, usage: usage({
19 input_tokens: a.input_tokens, output_tokens: a.output_tokens,
20 cache_read_input_tokens: a.cache_read_tokens, cache_creation_input_tokens: a.cache_creation_tokens,
21 }) },
22 };
23}
24lib/identity.ts 15 lines1/** Official task/message identity only; never retain prompt text. */
2import type { TelemetryLogInput } from 'claude-code';
3
4export function promptLink(e: TelemetryLogInput, session: string) {
5 if (e.to !== 'collector' || !['user_prompt', 'claude_code.user_prompt'].includes(e.event)) return null;
6 const a = e.attributes;
7 if (a['session.id'] !== session || a['workflow.run_id'] || a.command_name ||
8 typeof a['prompt.id'] !== 'string' || typeof a['message.uuid'] !== 'string') return null;
9 return { identity: { prompt_id: a['prompt.id'] }, payload: { message_id: a['message.uuid'] } };
10}
11
12export function reportAgent(prompt: string): string | null {
13 return /^\s*<task-notification>\s*<task-id>([^<>\s]+)<\/task-id>/.exec(prompt)?.[1] ?? null;
14}
15lib/timing.ts 12 lines1/** Coverage of user waits; unsupported boundaries invalidate the metric. */
2export class WaitCoverage {
3 private unknown = new Set<string>();
4 mark(loop: string, turn: string): void { this.unknown.add(loop + ':' + turn); }
5 finish(loop: string, turn: string): boolean {
6 const key = loop + ':' + turn;
7 const complete = !this.unknown.has(key);
8 this.unknown.delete(key);
9 return complete;
10 }
11}
12lib/launch.ts 26 lines1// Generated by tools/generation/resources.ts; do not edit.
2/** Host-independent argv binding, shared by Node and the sandboxed Mods. */
3export interface LaunchSpec { executable: string; args: readonly string[] }
4
5function executable(value: unknown): value is string {
6 return typeof value === 'string' && value.length > 0 && !/[\0\r\n]/u.test(value);
7}
8
9export function binding(options: { backendExecutable?: unknown; backendArgs?: unknown } = {}, override?: string): LaunchSpec {
10 if (override) {
11 if (!executable(override)) throw new TypeError('Invalid backend executable');
12 return { executable: override, args: [] };
13 }
14 const program = options.backendExecutable ?? 'claude-statusline';
15 // Official configure --values-stdin accepts strings, including multiple inputs.
16 // Defaults and SDK callers may already provide the array.
17 let args: unknown = options.backendArgs ?? [];
18 if (typeof args === 'string') {
19 try { args = JSON.parse(args); } catch { throw new TypeError('Invalid backend argument array'); }
20 }
21 if (!executable(program) || !Array.isArray(args) || !args.every(x => typeof x === 'string' && !x.includes('\0'))) {
22 throw new TypeError('Invalid backend binding');
23 }
24 return { executable: program, args: [...args] };
25}
26