SLOPSHOPPER

statusline-runtime

Native task timing and optional advanced statusline observations

newguardprocesstimeragents
A shopper browsing a rack in a slop shop
README

Claude Code Statusline

English | 简体中文

CI Native Mod PyPI MIT License

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>

Features

  • Compose your statusline: add, copy and arrange independent components, literal text and left/right alignment in either editor. Composition guide.
  • Control visibility and appearance: hide confirmed empty/clean states, set usage thresholds, choose per-item foreground/background colors and themes, and opt into basic Powerline.
  • Choose your languages: English, 简体中文, 繁體中文, 日本語 and 한국어 for interfaces and actual statusline output, independently; interface changes save immediately, output language saves with display settings. See language examples.
  • Find what to show: 60 main metrics and 14 subagent metrics, plus text and flexible spacers, with ranked multilingual search, category filters and source/requirement guidance.
  • Track the right scope: session token totals, per-task subagent rows, and total task time covering queueing, agents and main-agent wrap-up; optional execution time excludes verified user waits.
  • Adjust presentation: model and number formats, labels, built-in icons, colors, directory styles, and automatic or explicit rows with priorities and width limits.
  • Keep Unicode intact: joined emoji, combining marks and CJK text stay together when rows wrap, clip or appear in previews.
  • Review configuration changes: six editable presets and portable JSON, with import differences and candidate previews before accepting a draft and saving.
  • Choose an editor: Main, Subagents, Settings, and Layout pages share the same configuration. Claude appearance and behavior preferences use a separate Apply action.
  • Follow Claude themes: the in-session editor adapts text, keys and selection to the applied theme; sample previews retain production colors on a separately chosen light/dark background.
  • Follow terminal colors: external TUI text and bold keys use terminal defaults; previews show the selected palette on the current terminal background.
  • Enable optional live metrics: runtime state, agent count, tool progress, request timing, and per-task usage are opt-in. Missing or partial observations stay distinguishable.

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>

Screenshots

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.

English native language selector

简体中文 native language selector

简体中文 external language selector

In-session TUI

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

Linux in-session TUI Main page and actual main status line

Subagents: choose items and ordering for individual agent rows.

Linux in-session TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Linux in-session TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Linux in-session TUI Layout page with sample preview

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.

Linux light theme native editor

Linux dark theme native editor

Main: select and reorder main status line items.

Windows in-session TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Windows in-session TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Windows in-session TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Windows in-session TUI Layout page with sample preview

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.

macOS in-session TUI Main page with sample preview

External TUI

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

Linux light terminal with default palette

Light background, Palette: ansi

Linux light terminal with ANSI palette

Dark background, Palette: default

Linux dark terminal with default palette

Main: select and reorder main status line items.

Linux external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Linux external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Linux external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Linux external TUI Layout page with sample preview

Main: select and reorder main status line items.

Windows external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

Windows external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

Windows external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

Windows external TUI Layout page with sample preview

Main: select and reorder main status line items.

macOS external TUI Main page with sample preview

Subagents: choose items and ordering for individual agent rows.

macOS external TUI Subagents page with sample preview

Settings: adjust appearance, refresh behavior and formatting.

macOS external TUI Settings page with sample preview

Layout: set rows, item priorities and maximum widths.

macOS external TUI Layout page with sample preview

Earlier screenshots and terminal reconstructions remain in the archive.

Supported platforms

PlatformSupported environment
Linux / WSLPython 3.10+
Windows 10/11CPython 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>

Quick installation

Install Python, Claude Code CLI, and pipx. All supported platforms use the same wheel.

Install the package

Bash, Zsh, and PowerShell:

pipx install fbincon-claude-code-statusline
pipx ensurepath

Integrate with Claude Code

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.

Common configuration

Find items, understand their sources, and review imports.

Entry pointUse
/statusline-configure-nativeClient TUI in the current session; see native editor
/statusline-configureTUI in a supported external terminal; see external entry
claude-statusline configureFull TUI in the current standalone terminal
/statusline-configClaude 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>

Interface language

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.

Statusline language

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

English statusline output

Simplified Chinese statusline output

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.

Native configuration editor

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.

External and standalone terminal TUI

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>

Upgrading and uninstalling

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.

Project structure

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

Documentation and help

License

MIT License. Copyright (c) 2026 fbincon.

Bundled Unicode data retains the Unicode License v3 and the wcwidth MIT notice.

Source 9 files
hooks/observe.ts 280 lines
1/** 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};
280
lib/queue.ts 69 lines
1/** 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}
69
lib/generated-contracts.ts 19 lines
1// 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}
19
lib/checklists.ts 46 lines
1/** 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}
46
lib/streams.ts 29 lines
1/** 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}
29
lib/costs.ts 24 lines
1/** 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}
24
lib/identity.ts 15 lines
1/** 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}
15
lib/timing.ts 12 lines
1/** 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}
12
lib/launch.ts 26 lines
1// 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