@caph42/pi-ccline 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,12 +18,15 @@ pi session state → buildCclineInput() → JSON → ccline (spawn, COLUMNS=curr
18
18
  | Reasoning effort | `ctx.thinkingLevel` → `effort.level` |
19
19
  | Working directory | `ctx.cwd` → `cwd` / `workspace.current_dir` |
20
20
  | Context percentage / token count | `ctx.getContextUsage()` → current context (the aggregate estimate); **no lifetime session tokens are sent** |
21
+ | Prompt-cache hit share | assistant messages' `usage.cacheRead` over `input + cacheRead + cacheWrite`, summed over the branch → `prompt_cache.hit_ratio`; sent from the second request on |
22
+ | Output speed (`tok/s`) | measured from `message_start` → first `message_update` → `message_end`, written to ccline's `bridge/<session>.json` for its `timing.tps` metric |
23
+ | Running tools | `tool_execution_start` / `tool_execution_end` → the same bridge file's `tools` array, for ccline's `activity.tools` metric |
21
24
  | Cost, session duration | assistant messages' `usage.cost.total` summed; duration = now − earliest entry on the branch |
22
25
  | Session id / name | `ctx.sessionManager` |
23
26
  | git branch and anything else ccline probes itself | ccline reads the disk itself |
24
27
  | Status lines from other extensions | `footerData.getExtensionStatuses()`, sorted by key into one line below the ccline frame |
25
28
 
26
- Not shown, and never fabricated: the 5h / 7d quota segment (pi has no `rate_limits` source, the field is omitted, and the segment disappears by itself).
29
+ Not shown, and never fabricated: the 5h / 7d quota segment (pi has no `rate_limits` source, the field is omitted, and the segment disappears by itself), and the cache warmth / TTL / miss-cause fields (pi knows neither the provider's cache expiry nor why a cache was rebuilt, so only the hit share is sent and ccline is left to hide the rest).
27
30
 
28
31
  ## Installation
29
32
 
@@ -102,6 +105,8 @@ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccli
102
105
  - Because there is only one footer slot globally, other extensions' custom footers (e.g. pi-cursor-provider's fast badge) get displaced once this extension is installed; their status lines still show. Conversely, when they take over, the ccline frame disappears and `/pi-ccline on` takes it back.
103
106
  - **The provider can only be folded into the model segment.** ccline 2.0.1's input schema has no provider field and the `model` segment renders only `display_name`, so the provider is appended after the model name as `name (provider)`; it isn't appended twice when the name already carries the same suffix. `showProvider: false` turns it off. **At narrow widths the provider may be invisible**: ccline wraps/truncates by `COLUMNS` itself, and in practice at width 40 the model segment is left with just `GPT-5.6` — that's upstream layout behaviour, and this extension only guarantees the provider is written into `display_name`.
104
107
  - The input JSON **omits the `version` field**: ccline 2.0.1 validates it as a Claude Code version and rejects pi's version outright.
108
+ - **Cache hit share:** `prompt_cache` is sent only from the second assistant message on. That is ccline's own display gate (`requests > 1`), whose rationale is that a single request usually only writes the cache — an initial request can still hit a prefix cached by an earlier session, so the gate is display behaviour, not a claim about request #1. Sending `hit_ratio` before that gate would also make ccline fall back to its "cold" wording, which pi cannot assert. `current_usage` deliberately still carries only `input_tokens`: pi's context estimate already includes the cached tokens, and ccline sums the `current_usage` fields when `total_input_tokens` is absent, so adding the cache counts there would double-count them in the context bar.
109
+ - **Output speed and running tools go through ccline's bridge, not stdin.** ccline's `timing.tps` / `timing.ttft` and `activity.tools` metrics read `bridge/<session>.json` under its data dir (`$CCLINE_HOME` or `~/.claude/ccline`), the file its companion mod writes inside Claude Code. The statusline payload has no per-request timing and no running-tool list, so this extension measures the first and tracks the second, and writes the same documented `v: 1` shape, atomically. Because both live in one file, every write carries everything known: the last finished answer's timing plus the tools running now, so a tool starting or ending never blanks `tok/s` and a new answer never leaves a finished tool behind. ttft is stream start → first content block; pi emits `message_start` when the provider starts responding, so that number excludes the connect wait. tps divides the answer's output tokens by the time from the first content block to `message_end`, so the wait for the first token is not counted as generation. Durations use a monotonic clock (`performance.now()`); only the bridge's freshness stamp is wall-clock. `tools` skips calls carrying `parentToolCallId` (codemode scripts and tools calling tools), so the segment lists what the user actually issued. The file is removed on `session_shutdown`, and ccline ignores bridges older than 6h.
105
110
  - No runtime dependencies: only `node:child_process` / `node:fs` / `node:os` / `node:path` plus the pi peer package `@earendil-works/pi-tui`.
106
111
  - Non-TUI modes (`-p`, json, rpc) install no footer and spawn nothing.
107
112
  - The extension's own config lives at `~/.pi/agent/pi-ccline/config.json`; pi's `settings.json` is never touched.
@@ -129,7 +134,10 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
129
134
  ## Known limitations
130
135
 
131
136
  - **The 5h / 7d quota segment is always empty.** pi provides no `rate_limits`, the field is omitted entirely, and ccline shows nothing.
132
- - **ccline writes `~/.claude/ccline/state/`** (session state, model cache, update.lock). That's upstream behaviour and cannot be disabled here.
137
+ - **ccline writes `~/.claude/ccline/state/`** (session state, model cache, update.lock). That's upstream behaviour and cannot be disabled here. The extension additionally writes `~/.claude/ccline/bridge/<session>.json` for the `timing.tps` segment, and deletes it when the session shuts down.
138
+ - **The cache segment shows only the hit share.** Warmth, TTL countdown, miss counts and miss causes need data pi does not have (the provider's cache expiry and rebuild reasons), so those fields are omitted — with a config `format` of `{hit}% hit` the segment reduces to that one number, and before the second request it disappears entirely rather than claiming "cold".
139
+ - **`tok/s` describes the last finished answer, not a running average.** It is measured per request and only updates when an answer ends (a non-streaming provider, or a streamed answer of a few tokens, produces no sample and leaves the previous value on screen until the next one arrives). `duration` and `tok/s` are unrelated numbers: duration is session wall-clock, tps is streaming speed.
140
+ - **The tools segment shows only what is running right now.** ccline names the two oldest calls and folds the rest into `+N`, with the elapsed time of the oldest; there is no history, no per-tool duration and no output. Calls made through `parentToolCallId` (codemode scripts, tools invoking tools) are not listed, and a call originating inside a subagent appears only if pi hands that event to the main extension, which today it does not.
133
141
  - **External binary required.** Without `ccline` the footer is a single install hint; upgrading `ccline` needs no extension restart, but the binary path stays cached until the next resolution (`/pi-ccline on` or a new session recomputes it).
134
142
  - **Upstream compatibility only covers tested versions.** Every field in ccline 2.0.1's `input.rs` is `Option` with lenient deserialization, so a field changing shape within the same major version usually just makes that segment disappear; but required fields have changed across majors before (1.1.x → 2.0.x), so re-run `scripts/render-once.ts` after upgrading ccline.
135
143
  - **Nerd Font v3+ required.** Missing icon fonts render as boxes; Windows is untested.
@@ -150,6 +158,7 @@ extensions/pi-ccline/
150
158
  binary.ts path resolution/caching, spawn with timeout and abort, --version probing
151
159
  render.ts output normalization: line splitting, truncation by visible width, stale marker
152
160
  refresh.ts single-flight + throttle + latest-wins scheduler, and the timer that drives `duration`
161
+ turn.ts per-request timing from the stream and the running-tool list, plus the ccline bridge file
153
162
  config.ts config read/write
154
163
  *.test.ts node --test
155
164
  scripts/render-once.ts run the real ccline once against a fixture
package/README_zh.md CHANGED
@@ -18,12 +18,15 @@ pi 会话状态 → buildCclineInput() → JSON → ccline(spawn,COLUMNS=当
18
18
  | 推理强度 | `ctx.thinkingLevel` → `effort.level` |
19
19
  | 工作目录 | `ctx.cwd` → `cwd` / `workspace.current_dir` |
20
20
  | 上下文百分比 / token 数 | `ctx.getContextUsage()` → 当前上下文(聚合估计值);**不发会话累计 token** |
21
+ | 缓存命中率 | assistant 消息的 `usage.cacheRead` 除以 `input + cacheRead + cacheWrite`,在分支上累加 → `prompt_cache.hit_ratio`;从第二次请求起才发送 |
22
+ | 输出速度(`tok/s`) | 由 `message_start` → 首个 `message_update` → `message_end` 实测,写入 ccline 的 `bridge/<session>.json` 供 `timing.tps` 读取 |
23
+ | 运行中的工具 | `tool_execution_start` / `tool_execution_end` → 同一个 bridge 文件的 `tools` 数组,供 `activity.tools` 读取 |
21
24
  | 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
22
25
  | 会话 id / 名称 | `ctx.sessionManager` |
23
26
  | git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
24
27
  | 其他扩展的 status | `footerData.getExtensionStatuses()`,按 key 排序拼成一行放在 ccline 帧下方 |
25
28
 
26
- 不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失)。
29
+ 不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失);缓存冷热 / TTL 倒计时 / miss 次数与原因(pi 既不知道 provider 的缓存过期时间,也不知道缓存为何重建,所以只发命中率,其余字段交给 ccline 自己隐藏)。
27
30
 
28
31
  ## 安装
29
32
 
@@ -102,6 +105,8 @@ pi install .
102
105
  - 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
103
106
  - **provider 只能拼进模型段。** ccline 2.0.1 的输入 schema 没有 provider 字段,`model` 段只渲染 `display_name`,所以 provider 以 `名称 (provider)` 的形式追加在模型名后;名字里已经带同样的后缀时不重复追加。`showProvider: false` 可关掉。**窄宽度下 provider 可能看不到**:ccline 按 `COLUMNS` 自己折行/截断,实测宽度 40 时模型段只剩 `GPT-5.6`,这是上游布局行为,本扩展只保证把 provider 写进 `display_name`。
104
107
  - 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
108
+ - **缓存命中率:** `prompt_cache` 从第二条 assistant 消息起才发。这是 ccline 自己的显示门槛(`requests > 1`),它的理由是一次请求通常只是在写缓存——但首个请求确实可能命中之前会话已缓存的 prefix,所以这是显示行为,不是对「第一次请求」的事实断言。若在门槛前就发 `hit_ratio`,ccline 还会回退到 `cold` 文案,而那是 pi 无法断言的。`current_usage` 仍然只填 `input_tokens`:pi 的上下文估计值本就包含缓存 token,而缺少 `total_input_tokens` 时 ccline 会把 `current_usage` 各项相加,把 cache 数填进去会让它们被重复计入上下文进度条。
109
+ - **输出速度与运行中的工具走 ccline 的 bridge,不走 stdin。** ccline 的 `timing.tps` / `timing.ttft` 与 `activity.tools` 读的是其数据目录(`$CCLINE_HOME` 或 `~/.claude/ccline`)下的 `bridge/<session>.json`,也就是它家在 Claude Code 里的伴生 mod 写的文件。statusline 载荷里既没有单次请求耗时,也没有运行中工具列表,所以本扩展自己实测、自己跟踪,并按同样的 `v: 1` 结构原子写入。因为两者共用一个文件,每次写入都带全量:最后答完那次请求的计时 + 当前运行中的工具,所以工具起止不会把 `tok/s` 清掉,新回答也不会把已结束的工具留在那里。ttft = 流开始 → 首个内容块;pi 是在 provider 开始响应后才发 `message_start`,所以这个数不含建立连接的等待。tps = 该回答的 output token 除以「首个内容块 → `message_end`」的时长,所以等首字的耗时不算进生成速度。计时用单调时钟(`performance.now()`),只有 bridge 的新鲜度标记用墙钟。`tools` 会跳过带 `parentToolCallId` 的调用(codemode 脚本、工具内部再调工具),所以这段只列用户真正发起的调用。`session_shutdown` 时删除该文件;ccline 也会忽略超过 6 小时的 bridge。
105
110
  - 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
106
111
  - 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
107
112
  - 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
@@ -129,7 +134,10 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
129
134
  ## 已知限制
130
135
 
131
136
  - **5h / 7d 配额段永远为空。** pi 不提供 `rate_limits`,字段整段省略,ccline 自然不显示。
132
- - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。
137
+ - **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。另外本扩展会写 `~/.claude/ccline/bridge/<session>.json` 供 `timing.tps` 段使用,会话关闭时删掉。
138
+ - **缓存段只显示命中率。** 冷热状态、TTL 倒计时、miss 次数与原因需要 pi 没有的数据(provider 的缓存过期时间、重建原因),这些字段一律不发;配置里用 `format = "{hit}% hit"` 就只剩这一个数字,而第二次请求之前整段直接消失,而不是谎称 `cold`。
139
+ - **`tok/s` 描述的是最后一次答完的请求,不是滚动平均。** 它按请求实测,只在回答结束时更新(非流式 provider、或只有几个 token 的回答不会产生样本,上一轮的数值会继续显示到下一次更新)。`duration` 和 `tok/s` 没有关系:前者是会话墙钟时长,后者是流式速度。
140
+ - **工具段只显示「此刻在跑」的调用。** ccline 只点名最老的两个、其余的折成 `+N`,elapsed 取最老那个的耗时;没有历史、没有单工具耗时、没有输出。带 `parentToolCallId` 的调用(codemode 脚本、工具内部再调工具)不列;子 agent 内部的调用只有 pi 把它交给主扩展时才会出现,目前不会。
133
141
  - **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
134
142
  - **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
135
143
  - **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
@@ -150,6 +158,7 @@ extensions/pi-ccline/
150
158
  binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
151
159
  render.ts 输出归一化:分行、按可见宽度截断、stale 标记
152
160
  refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
161
+ turn.ts 从流事件实测单次请求耗时、跟踪运行中的工具,并写 ccline 的 bridge 文件
153
162
  config.ts 配置读写
154
163
  *.test.ts node --test
155
164
  scripts/render-once.ts 用 fixture 跑一次真实 ccline
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { afterEach, test } from "node:test";
@@ -318,7 +318,7 @@ function fakePi() {
318
318
  },
319
319
  registerCommand: () => {},
320
320
  };
321
- const dispatch = (name: string, ctx: unknown) => handlers.get(name)?.({ type: name }, ctx);
321
+ const dispatch = (name: string, ctx: unknown, event: Record<string, unknown> = {}) => handlers.get(name)?.({ type: name, ...event }, ctx);
322
322
  return { pi, handlers, dispatch };
323
323
  }
324
324
 
@@ -383,6 +383,118 @@ test("agent_start ticks the footer and agent_end leaves no periodic refresh behi
383
383
  await dispatch("session_shutdown", ctx);
384
384
  });
385
385
 
386
+ test("writes ccline's timing bridge from the streamed answer", async () => {
387
+ const cclineHome = tempDir("pi-ccline-bridge-");
388
+ const previousHome = process.env.CCLINE_HOME;
389
+ process.env.CCLINE_HOME = cclineHome;
390
+ try {
391
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
392
+ const { pi, handlers, dispatch } = fakePi();
393
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
394
+ for (const event of ["message_start", "message_update", "message_end"]) {
395
+ assert.ok(handlers.has(event), `${event} is wired`);
396
+ }
397
+
398
+ const { ctx } = fakeSession({ idle: true, binary });
399
+ await dispatch("session_start", ctx);
400
+
401
+ const assistant = { role: "assistant", model: "claude-sonnet-4-5", usage: { output: 500 } };
402
+ await dispatch("message_start", ctx, { message: assistant });
403
+ await sleep(120);
404
+ await dispatch("message_update", ctx, { message: assistant });
405
+ await sleep(220);
406
+ await dispatch("message_end", ctx, { message: assistant });
407
+
408
+ const bridge = JSON.parse(readFileSync(join(cclineHome, "bridge", "sess-1.json"), "utf8"));
409
+ assert.equal(bridge.v, 1);
410
+ assert.equal(bridge.session, "sess-1");
411
+ assert.equal(bridge.turn.model, "claude-sonnet-4-5");
412
+ assert.equal(bridge.turn.output_tokens, 500);
413
+ assert.ok(bridge.turn.ttft_ms >= 100, "ttft covers the wait before the first token");
414
+ assert.ok(bridge.turn.tps > 0 && Number.isFinite(bridge.turn.tps));
415
+
416
+ await dispatch("session_shutdown", ctx);
417
+ assert.equal(existsSync(join(cclineHome, "bridge", "sess-1.json")), false, "a closed session leaves no bridge");
418
+ } finally {
419
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
420
+ else process.env.CCLINE_HOME = previousHome;
421
+ }
422
+ });
423
+
424
+ test("publishes running tools in the same bridge as the timing", async () => {
425
+ const cclineHome = tempDir("pi-ccline-bridge-tools-");
426
+ const previousHome = process.env.CCLINE_HOME;
427
+ process.env.CCLINE_HOME = cclineHome;
428
+ try {
429
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
430
+ const { pi, dispatch } = fakePi();
431
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
432
+ const { ctx } = fakeSession({ idle: true, binary });
433
+ await dispatch("session_start", ctx);
434
+
435
+ // One finished answer, so the bridge already carries a turn.
436
+ const assistant = { role: "assistant", model: "m", usage: { output: 400 } };
437
+ await dispatch("message_start", ctx, { message: assistant });
438
+ await sleep(120);
439
+ await dispatch("message_update", ctx, { message: assistant });
440
+ await sleep(220);
441
+ await dispatch("message_end", ctx, { message: assistant });
442
+
443
+ const read = () => JSON.parse(readFileSync(join(cclineHome, "bridge", "sess-1.json"), "utf8"));
444
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t1", toolName: "Bash" });
445
+ const running = read();
446
+ assert.deepEqual(running.tools.map((tool: { name: string }) => tool.name), ["Bash"]);
447
+ assert.ok(running.tools[0].since > 0);
448
+ assert.ok(running.turn.tps > 0, "a tools update must not blank the timing segment");
449
+
450
+ // A nested call from another tool is not a tool the user issued.
451
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t2", toolName: "Read", parentToolCallId: "t1" });
452
+ assert.equal(read().tools.length, 1);
453
+
454
+ await dispatch("tool_execution_end", ctx, { toolCallId: "t1" });
455
+ const finished = read();
456
+ assert.equal("tools" in finished, false, "a finished tool leaves no row behind");
457
+ assert.ok(finished.turn.tps > 0);
458
+
459
+ // An aborted run may never send the end event; the run boundary clears it.
460
+ await dispatch("tool_execution_start", ctx, { toolCallId: "t3", toolName: "Grep" });
461
+ assert.equal(read().tools.length, 1);
462
+ await dispatch("agent_end", ctx);
463
+ assert.equal("tools" in read(), false, "no tool survives the run that owned it");
464
+
465
+ await dispatch("session_shutdown", ctx);
466
+ } finally {
467
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
468
+ else process.env.CCLINE_HOME = previousHome;
469
+ }
470
+ });
471
+
472
+ test("ignores message events that are not the assistant's answer", async () => {
473
+ const cclineHome = tempDir("pi-ccline-bridge-role-");
474
+ const previousHome = process.env.CCLINE_HOME;
475
+ process.env.CCLINE_HOME = cclineHome;
476
+ try {
477
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
478
+ const { pi, dispatch } = fakePi();
479
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary }) });
480
+ const { ctx } = fakeSession({ idle: true, binary });
481
+ await dispatch("session_start", ctx);
482
+
483
+ // A user message or tool result carries no model timing.
484
+ for (const role of ["user", "toolResult"]) {
485
+ const message = { role, usage: { output: 500 } };
486
+ await dispatch("message_start", ctx, { message });
487
+ await dispatch("message_update", ctx, { message });
488
+ await dispatch("message_end", ctx, { message });
489
+ }
490
+ assert.equal(existsSync(join(cclineHome, "bridge")), false, "no timing is attributed to non-answers");
491
+ await dispatch("session_shutdown", ctx);
492
+ } finally {
493
+ if (previousHome === undefined) delete process.env.CCLINE_HOME;
494
+ else process.env.CCLINE_HOME = previousHome;
495
+ }
496
+ });
497
+
386
498
  test("a footer installed mid-run ticks without waiting for the next turn", async () => {
387
499
  const counter = join(tempDir("pi-ccline-lifecycle-midrun-"), "count");
388
500
  const binary = fakeBinary(
@@ -6,6 +6,7 @@ import { loadConfig, saveConfig, type PiCclineConfig } from "./config.ts";
6
6
  import { buildCclineInput, collectSnapshot } from "./input.ts";
7
7
  import { RefreshScheduler, RefreshTicker } from "./refresh.ts";
8
8
  import { extensionStatusLine, loadingNotice, missingBinaryNotice, normalizeFrame, noticeFrame, withStaleMarker } from "./render.ts";
9
+ import { removeTurnBridge, TurnMeter, writeBridge, type BridgeTool, type TurnTiming } from "./turn.ts";
9
10
 
10
11
  /** Structural subsets so the footer does not depend on pi-tui internals. */
11
12
  type TuiLike = { requestRender(force?: boolean): void };
@@ -16,6 +17,19 @@ type FooterDataLike = {
16
17
  getExtensionStatuses?(): ReadonlyMap<string, string>;
17
18
  };
18
19
 
20
+ /** The slice of a pi message event this extension reads. */
21
+ type MessageEventLike = {
22
+ message?: { role?: string | undefined; model?: string | undefined; usage?: { output?: number | undefined } | undefined } | undefined;
23
+ };
24
+
25
+ /** The slice of a pi tool event this extension reads. */
26
+ type ToolEventLike = {
27
+ toolCallId?: string | undefined;
28
+ toolName?: string | undefined;
29
+ /** Set when another tool (a codemode script, an agent) made the call. */
30
+ parentToolCallId?: string | undefined;
31
+ };
32
+
19
33
  const DEFAULT_WIDTH = 120;
20
34
 
21
35
  /**
@@ -57,12 +71,20 @@ export class CclineFooter {
57
71
  private footerData: FooterDataLike | null = null;
58
72
  private requestRenderFn: (() => void) | null = null;
59
73
  private branchUnsubscribe: (() => void) | null = null;
74
+ /** Session whose ccline bridge this footer wrote, removed again on dispose. */
75
+ private bridgeSessionId: string | null = null;
76
+ /** The last finished answer, re-sent with every tools update (one file). */
77
+ private lastTiming: TurnTiming | null = null;
78
+ /** Tool calls running now, keyed by pi's tool call id. */
79
+ private readonly runningTools = new Map<string, BridgeTool>();
80
+ private readonly meter = new TurnMeter();
60
81
  private disposed = false;
61
82
 
62
83
  readonly stats = {
63
84
  refreshes: 0,
64
85
  lastRefreshAt: null as number | null,
65
86
  lastDurationMs: null as number | null,
87
+ lastTps: null as number | null,
66
88
  };
67
89
  resolved: ResolvedBinary = { path: null, source: "missing" };
68
90
 
@@ -136,6 +158,100 @@ export class CclineFooter {
136
158
  return this.lastError;
137
159
  }
138
160
 
161
+ /** The session pi reports, or null when it cannot be read or is unusable. */
162
+ private sessionId(): string | null {
163
+ try {
164
+ const id = this.ctx.sessionManager.getSessionId?.();
165
+ return typeof id === "string" && id.trim() ? id.trim() : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /** A model request went out: start timing until its answer finishes. */
172
+ onMessageStart(event: MessageEventLike): void {
173
+ const message = event.message;
174
+ if (!message || message.role !== "assistant") return;
175
+ // Stream start, not send time: pi begins the stream once the provider has
176
+ // answered, so this ttft stops short of the connect wait by design.
177
+ this.meter.start(message.model, performance.now());
178
+ }
179
+
180
+ /**
181
+ * A content block of that answer arrived (`text_start` / `*_delta` /
182
+ * `toolcall_*`; pi emits no metadata through this event), so the clock for
183
+ * the streaming rate starts at the first sign of content.
184
+ */
185
+ onMessageUpdate(event: MessageEventLike): void {
186
+ if (event.message?.role !== "assistant") return;
187
+ this.meter.firstDelta(performance.now());
188
+ }
189
+
190
+ /**
191
+ * Rewrite the bridge with everything it has to carry: the last answer's
192
+ * timing and the tools running now (ccline reads both from one file, so a
193
+ * writer that sent only one of them would blank the other segment).
194
+ */
195
+ private flushBridge(): void {
196
+ const sessionId = this.sessionId();
197
+ if (!sessionId) return;
198
+ const tools = [...this.runningTools.values()];
199
+ if (!this.lastTiming && tools.length === 0) return;
200
+ if (!writeBridge({ sessionId, timing: this.lastTiming ?? undefined, tools, nowMs: Date.now() })) return;
201
+ if (this.bridgeSessionId && this.bridgeSessionId !== sessionId) removeTurnBridge(this.bridgeSessionId);
202
+ this.bridgeSessionId = sessionId;
203
+ }
204
+
205
+ /**
206
+ * The answer finished: hand ccline its timing through the bridge file it
207
+ * reads for `timing.tps`. No speed, no file.
208
+ */
209
+ onMessageEnd(event: MessageEventLike): void {
210
+ const message = event.message;
211
+ if (!message || message.role !== "assistant") return;
212
+ // Measurement uses the monotonic clock; only the bridge's freshness
213
+ // stamp is wall-clock, and the two are never mixed.
214
+ const timing = this.meter.finish(message.usage?.output, performance.now());
215
+ if (!timing) return;
216
+ this.lastTiming = timing;
217
+ this.stats.lastTps = timing.tps;
218
+ this.flushBridge();
219
+ // The new speed only shows up in the next frame; let the throttle coalesce it.
220
+ this.requestRefresh();
221
+ }
222
+
223
+ /**
224
+ * A tool started: `activity.tools` shows it until `onToolEnd`. Nested calls
225
+ * (codemode, tools calling tools) are skipped — a script would otherwise
226
+ * flood the segment with names the user never issued.
227
+ */
228
+ onToolStart(event: ToolEventLike): void {
229
+ const id = event.toolCallId;
230
+ const name = event.toolName;
231
+ if (!id || !name || event.parentToolCallId) return;
232
+ this.runningTools.set(id, { name, since: Date.now() });
233
+ this.flushBridge();
234
+ this.requestRefresh();
235
+ }
236
+
237
+ onToolEnd(event: ToolEventLike): void {
238
+ const id = event.toolCallId;
239
+ if (!id || !this.runningTools.delete(id)) return;
240
+ this.flushBridge();
241
+ this.requestRefresh();
242
+ }
243
+
244
+ /**
245
+ * An aborted run can end without a matching `tool_execution_end`, and no
246
+ * tool can outlive the run, so drop the rest rather than show a tool that
247
+ * keeps counting up forever.
248
+ */
249
+ clearRunningTools(): void {
250
+ if (this.runningTools.size === 0) return;
251
+ this.runningTools.clear();
252
+ this.flushBridge();
253
+ }
254
+
139
255
  get hasFrame(): boolean {
140
256
  return this.frame !== null;
141
257
  }
@@ -253,6 +369,9 @@ export class CclineFooter {
253
369
  this.branchUnsubscribe = null;
254
370
  this.footerData = null;
255
371
  this.requestRenderFn = null;
372
+ this.runningTools.clear();
373
+ if (this.bridgeSessionId) removeTurnBridge(this.bridgeSessionId);
374
+ this.bridgeSessionId = null;
256
375
  }
257
376
  }
258
377
 
@@ -302,11 +421,24 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
302
421
  pi.on("agent_end", () => {
303
422
  requestRefresh();
304
423
  footer?.stopLive();
424
+ // No tool can survive the run, even when an abort skipped its end event.
425
+ footer?.clearRunningTools();
426
+ });
427
+ pi.on("agent_settled", () => {
428
+ footer?.stopLive();
429
+ footer?.clearRunningTools();
305
430
  });
306
- pi.on("agent_settled", () => footer?.stopLive());
307
431
  pi.on("model_select", () => requestRefresh());
308
432
  pi.on("thinking_level_select", () => requestRefresh());
309
433
  pi.on("session_info_changed", () => requestRefresh());
434
+ // Per-request timing is not in the statusline payload, so it is measured
435
+ // from the stream and written to ccline's bridge file when an answer ends.
436
+ pi.on("message_start", (event) => footer?.onMessageStart(event as MessageEventLike));
437
+ pi.on("message_update", (event) => footer?.onMessageUpdate(event as MessageEventLike));
438
+ pi.on("message_end", (event) => footer?.onMessageEnd(event as MessageEventLike));
439
+ // Same bridge: `activity.tools` lists what is still running.
440
+ pi.on("tool_execution_start", (event) => footer?.onToolStart(event as ToolEventLike));
441
+ pi.on("tool_execution_end", (event) => footer?.onToolEnd(event as ToolEventLike));
310
442
  pi.on("session_shutdown", () => teardown());
311
443
 
312
444
  pi.registerCommand("pi-ccline", {
@@ -364,6 +496,7 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
364
496
  const { refreshes, lastRefreshAt, lastDurationMs } = footer.stats;
365
497
  lines.push(`refreshes: ${refreshes} · last: ${lastRefreshAt === null ? "never" : `${Math.round((Date.now() - lastRefreshAt) / 1000)}s ago`}`);
366
498
  lines.push(`duration: ${lastDurationMs === null ? "n/a" : `${lastDurationMs}ms`} · frame: ${footer.hasFrame ? "cached" : "none"}`);
499
+ lines.push(`last answer: ${footer.stats.lastTps === null ? "not measured yet" : `${footer.stats.lastTps.toFixed(1)} tok/s`}`);
367
500
  lines.push(`state: ${footer.error ? `stale — ${footer.error}` : "ok"}`);
368
501
  } else {
369
502
  lines.push("state: footer not installed");
@@ -185,9 +185,31 @@ test("collectSnapshot sums only assistant usage on the branch", () => {
185
185
  test("collectSnapshot reports no totals for an empty branch", () => {
186
186
  const snapshotValue = collectSnapshot(fakeContext([]), Date.now());
187
187
  assert.equal(snapshotValue.totals, undefined);
188
+ assert.equal(snapshotValue.cache, undefined);
188
189
  assert.equal(snapshotValue.session?.name, "named");
189
190
  });
190
191
 
192
+ test("collectSnapshot sums prompt-cache reuse over the branch", () => {
193
+ const branch: BranchEntryLike[] = [
194
+ entry({ type: "message", message: { role: "user" } }),
195
+ entry({ type: "message", message: { role: "assistant", usage: { input: 100, cacheRead: 300, cacheWrite: 100 } } }),
196
+ entry({ type: "message", message: { role: "assistant", usage: { input: 100, cacheRead: 0, cacheWrite: 0 } } }),
197
+ ];
198
+
199
+ // 300 of 600 billed input tokens came from the cache.
200
+ assert.deepEqual(collectSnapshot(fakeContext(branch), Date.now()).cache, { requests: 2, hitRatio: 0.5 });
201
+ });
202
+
203
+ test("sends the cache hit share only once it means something", () => {
204
+ // ccline shows the share from the second request on and falls back to a
205
+ // misleading "cold" label when hit_ratio is missing, so nothing is sent yet.
206
+ assert.equal("prompt_cache" in buildCclineInput(snapshot({ cache: { requests: 1, hitRatio: 0 } })), false);
207
+ assert.deepEqual(buildCclineInput(snapshot({ cache: { requests: 41, hitRatio: 0.94123 } })).prompt_cache, {
208
+ requests: 41,
209
+ hit_ratio: 0.9412,
210
+ });
211
+ });
212
+
191
213
  test("collectSnapshot carries the provider from ctx.model", () => {
192
214
  const ctx = fakeContext([], { model: { id: "m", name: "M", provider: "anthropic", contextWindow: 200_000 } });
193
215
  const snapshotValue = collectSnapshot(ctx, Date.now());
@@ -28,6 +28,14 @@ export interface PiSnapshotTotals {
28
28
  durationMs?: number | undefined;
29
29
  }
30
30
 
31
+ /** Prompt-cache reuse summed over the visible branch. */
32
+ export interface PiSnapshotCache {
33
+ /** Assistant messages seen, each one a model request. */
34
+ requests: number;
35
+ /** cacheRead / (input + cacheRead + cacheWrite); the share read from cache. */
36
+ hitRatio: number;
37
+ }
38
+
31
39
  export interface PiSnapshotSession {
32
40
  id?: string | undefined;
33
41
  name?: string | undefined;
@@ -39,6 +47,7 @@ export interface PiSnapshot {
39
47
  thinkingLevel?: string | undefined;
40
48
  contextUsage?: PiSnapshotContextUsage | undefined;
41
49
  totals?: PiSnapshotTotals | undefined;
50
+ cache?: PiSnapshotCache | undefined;
42
51
  session?: PiSnapshotSession | undefined;
43
52
  }
44
53
 
@@ -95,6 +104,11 @@ function round1(value: number): number {
95
104
  return Math.round(value * 10) / 10;
96
105
  }
97
106
 
107
+ /** A ratio ccline renders as a whole percent, so four decimals are plenty. */
108
+ function round4(value: number): number {
109
+ return Math.round(value * 10_000) / 10_000;
110
+ }
111
+
98
112
  function trimmed(value: unknown): string | undefined {
99
113
  if (typeof value !== "string") return undefined;
100
114
  const text = value.trim();
@@ -120,6 +134,10 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
120
134
  let costUsd = 0;
121
135
  let sawAssistantUsage = false;
122
136
  let firstTimestamp: number | undefined;
137
+ let requests = 0;
138
+ let inputTokens = 0;
139
+ let cacheReadTokens = 0;
140
+ let cacheWriteTokens = 0;
123
141
 
124
142
  for (const entry of branch) {
125
143
  const timestamp = entry.timestamp ? Date.parse(entry.timestamp) : Number.NaN;
@@ -131,6 +149,10 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
131
149
  if (!entryUsage) continue;
132
150
  sawAssistantUsage = true;
133
151
  costUsd += num(entryUsage.cost?.total);
152
+ requests += 1;
153
+ inputTokens += num(entryUsage.input);
154
+ cacheReadTokens += num(entryUsage.cacheRead);
155
+ cacheWriteTokens += num(entryUsage.cacheWrite);
134
156
  }
135
157
 
136
158
  const totals: PiSnapshotTotals | undefined = sawAssistantUsage
@@ -142,6 +164,9 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
142
164
 
143
165
  const sessionId = attempt(() => ctx.sessionManager.getSessionId?.());
144
166
  const sessionName = attempt(() => ctx.sessionManager.getSessionName?.());
167
+ // The denominator is every input token the branch was billed for; without
168
+ // one there is no ratio to report (a session that never sent a prompt yet).
169
+ const seenInput = inputTokens + cacheReadTokens + cacheWriteTokens;
145
170
 
146
171
  return {
147
172
  cwd: ctx.cwd,
@@ -155,6 +180,7 @@ export function collectSnapshot(ctx: SnapshotContextLike, nowMs: number): PiSnap
155
180
  }
156
181
  : undefined,
157
182
  totals,
183
+ cache: requests > 0 && seenInput > 0 ? { requests, hitRatio: cacheReadTokens / seenInput } : undefined,
158
184
  session: { id: trimmed(sessionId), name: trimmed(sessionName) },
159
185
  };
160
186
  }
@@ -236,6 +262,14 @@ export function buildCclineInput(snapshot: PiSnapshot, options: BuildCclineInput
236
262
  const sessionName = trimmed(snapshot.session?.name);
237
263
  if (sessionName) input.session_name = sessionName;
238
264
 
265
+ // ccline only renders the hit share from the second request on, and falls
266
+ // back to its "cold" wording whenever `hit_ratio` is missing. So the block
267
+ // is sent only once it can say something pi actually measured.
268
+ const cache = snapshot.cache;
269
+ if (cache && cache.requests >= 2) {
270
+ input.prompt_cache = { requests: cache.requests, hit_ratio: round4(cache.hitRatio) };
271
+ }
272
+
239
273
  // rate_limits has no pi data source; omitting it removes the quota segment.
240
274
  return input;
241
275
  }
@@ -0,0 +1,161 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, test } from "node:test";
6
+
7
+ import { bridgeDir, bridgeFileName, removeTurnBridge, TurnMeter, writeBridge } from "./turn.ts";
8
+
9
+ const createdDirs: string[] = [];
10
+ afterEach(() => {
11
+ for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
12
+ });
13
+
14
+ function tempDir(): string {
15
+ const dir = mkdtempSync(join(tmpdir(), "pi-ccline-turn-"));
16
+ createdDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ test("measures streaming speed without counting the wait for the first token", () => {
21
+ const meter = new TurnMeter();
22
+ meter.start("claude-sonnet-4-5", 1_000);
23
+ meter.firstDelta(1_800);
24
+ assert.deepEqual(meter.finish(500, 6_800), {
25
+ model: "claude-sonnet-4-5",
26
+ ttftMs: 800,
27
+ tps: 100,
28
+ durationMs: 5_800,
29
+ outputTokens: 500,
30
+ });
31
+ });
32
+
33
+ test("keeps only the first delta of an answer", () => {
34
+ const meter = new TurnMeter();
35
+ meter.start("m", 0);
36
+ meter.firstDelta(500);
37
+ meter.firstDelta(900);
38
+ meter.firstDelta(1_200);
39
+ // 200 tokens over the 1500ms after the first token, not over 1000ms.
40
+ assert.equal(meter.finish(200, 2_000)?.tps, 200 / 1.5);
41
+ });
42
+
43
+ test("reports nothing without a first delta, output or a measurable span", () => {
44
+ const neverStreamed = new TurnMeter();
45
+ neverStreamed.start("m", 0);
46
+ assert.equal(neverStreamed.finish(100, 5_000), null, "a non-streaming answer has no speed");
47
+
48
+ const empty = new TurnMeter();
49
+ empty.start("m", 0);
50
+ empty.firstDelta(100);
51
+ assert.equal(empty.finish(0, 5_000), null, "no output tokens means no rate");
52
+
53
+ const tooShort = new TurnMeter();
54
+ tooShort.start("m", 0);
55
+ tooShort.firstDelta(100);
56
+ assert.equal(tooShort.finish(5, 150), null, "a sub-100ms span is jitter, not speed");
57
+
58
+ const unmeasured = new TurnMeter();
59
+ unmeasured.firstDelta(100);
60
+ assert.equal(unmeasured.finish(100, 5_000), null, "finish before start is ignored");
61
+ });
62
+
63
+ test("a finished sample does not leak into the next request", () => {
64
+ const meter = new TurnMeter();
65
+ meter.start("first", 0);
66
+ meter.firstDelta(100);
67
+ meter.finish(10, 1_100);
68
+
69
+ meter.start("second", 2_000);
70
+ meter.firstDelta(2_400);
71
+ const second = meter.finish(80, 3_400);
72
+ assert.equal(second?.model, "second");
73
+ assert.equal(second?.ttftMs, 400);
74
+ assert.equal(second?.tps, 80);
75
+ });
76
+
77
+ test("writes the bridge shape ccline reads", () => {
78
+ const dir = tempDir();
79
+ assert.equal(
80
+ writeBridge({
81
+ sessionId: "sess-1",
82
+ timing: { model: "m", ttftMs: 820, tps: 74.234, durationMs: 41_000, outputTokens: 1_400 },
83
+ tools: [{ name: "Bash", since: 1_790_808_755_000 }],
84
+ nowMs: 1_790_808_780_000,
85
+ dir,
86
+ }),
87
+ true,
88
+ );
89
+ assert.deepEqual(JSON.parse(readFileSync(join(dir, "sess-1.json"), "utf8")), {
90
+ v: 1,
91
+ session: "sess-1",
92
+ updated_at: 1_790_808_780_000,
93
+ turn: { model: "m", ttft_ms: 820, tps: 74.234, duration_ms: 41_000, output_tokens: 1_400 },
94
+ tools: [{ name: "Bash", since: 1_790_808_755_000 }],
95
+ });
96
+ });
97
+
98
+ test("keeps the turn when tools come and go, and vice versa", () => {
99
+ const dir = tempDir();
100
+ const timing = { tps: 60, durationMs: 2_000, outputTokens: 120 };
101
+
102
+ // A tools-only write must not blank timing.tps, and a timing-only write must
103
+ // not leave a stale tool behind: both live in the same file.
104
+ writeBridge({ sessionId: "s4", timing, nowMs: 1, dir });
105
+ writeBridge({ sessionId: "s4", timing, tools: [{ name: "Read", since: 5 }], nowMs: 2, dir });
106
+ const withTool = JSON.parse(readFileSync(join(dir, "s4.json"), "utf8"));
107
+ assert.equal(withTool.turn.tps, 60);
108
+ assert.deepEqual(withTool.tools, [{ name: "Read", since: 5 }]);
109
+
110
+ writeBridge({ sessionId: "s4", timing, tools: [], nowMs: 3, dir });
111
+ const afterTool = JSON.parse(readFileSync(join(dir, "s4.json"), "utf8"));
112
+ assert.deepEqual(Object.keys(afterTool).sort(), ["session", "turn", "updated_at", "v"]);
113
+ });
114
+
115
+ test("writes nothing when there is neither timing nor a running tool", () => {
116
+ const dir = tempDir();
117
+ assert.equal(writeBridge({ sessionId: "s5", nowMs: 1, dir }), false);
118
+ assert.equal(writeBridge({ sessionId: "s5", tools: [], nowMs: 1, dir }), false);
119
+ assert.deepEqual(readdirSync(dir), []);
120
+ });
121
+
122
+ test("omits timing fields it did not measure", () => {
123
+ const dir = tempDir();
124
+ writeBridge({ sessionId: "s2", timing: { tps: 12, durationMs: 1_000, outputTokens: 12 }, nowMs: 1, dir });
125
+ const bridge = JSON.parse(readFileSync(join(dir, "s2.json"), "utf8"));
126
+ assert.deepEqual(Object.keys(bridge.turn).sort(), ["duration_ms", "output_tokens", "tps"]);
127
+ });
128
+
129
+ test("refuses session ids that are not plain file names", () => {
130
+ const dir = tempDir();
131
+ assert.equal(bridgeFileName("../escape"), null);
132
+ assert.equal(bridgeFileName("a/b"), null);
133
+ assert.equal(bridgeFileName(""), null);
134
+ assert.equal(writeBridge({ sessionId: "../escape", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
135
+ assert.deepEqual(readdirSync(dir), [], "nothing escaped or landed in the bridge dir");
136
+ });
137
+
138
+ test("a failed write leaves no temp file behind", () => {
139
+ const dir = tempDir();
140
+ // A directory in the target's place makes the rename fail after the temp
141
+ // file was already written.
142
+ mkdirSync(join(dir, "blocked.json"));
143
+ assert.equal(writeBridge({ sessionId: "blocked", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
144
+ assert.deepEqual(readdirSync(dir), ["blocked.json"]);
145
+ });
146
+
147
+ test("removes a session bridge and tolerates a missing one", () => {
148
+ const dir = tempDir();
149
+ writeBridge({ sessionId: "s3", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir });
150
+ assert.equal(existsSync(join(dir, "s3.json")), true);
151
+ removeTurnBridge("s3", dir);
152
+ assert.equal(existsSync(join(dir, "s3.json")), false);
153
+ removeTurnBridge("s3", dir);
154
+ removeTurnBridge("../escape", dir);
155
+ });
156
+
157
+ test("follows ccline's own data directory resolution", () => {
158
+ assert.equal(bridgeDir({ CCLINE_HOME: "/tmp/ccline-home" }, "/home/u"), "/tmp/ccline-home/bridge");
159
+ assert.equal(bridgeDir({ CCLINE_HOME: " " }, "/home/u"), "/home/u/.claude/ccline/bridge");
160
+ assert.equal(bridgeDir({}, "/home/u"), "/home/u/.claude/ccline/bridge");
161
+ });
@@ -0,0 +1,171 @@
1
+ // Measures the main loop's last model request and hands it to ccline through
2
+ // the bridge file its companion mod normally writes.
3
+ //
4
+ // ccline's `timing.ttft` / `timing.tps` metrics read `bridge/<session>.json`
5
+ // under ccline's data dir, not the statusline JSON: per-request timing is the
6
+ // one thing the Claude Code payload leaves out. pi has the same gap, so the
7
+ // numbers are measured here from streaming events and written in the same
8
+ // documented v1 shape (the file is rewritten on every finished request, which
9
+ // also keeps it inside ccline's 6h freshness window).
10
+
11
+ import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** One finished model request, in the units ccline's bridge expects. */
16
+ export interface TurnTiming {
17
+ model?: string | undefined;
18
+ /** Stream start to the first text, thinking or tool-call delta. */
19
+ ttftMs?: number | undefined;
20
+ /** Output tokens per second while the answer streamed (ttft excluded). */
21
+ tps: number;
22
+ durationMs: number;
23
+ outputTokens: number;
24
+ }
25
+
26
+ /** Below this the sample says more about jitter than about speed. */
27
+ const MIN_STREAM_MS = 100;
28
+
29
+ /**
30
+ * ccline resolves its data dir from `CCLINE_HOME`, falling back to
31
+ * `~/.claude/ccline`; the bridge lives in a `bridge` subdirectory of either.
32
+ */
33
+ export function bridgeDir(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
34
+ const override = env.CCLINE_HOME?.trim();
35
+ return join(override ? override : join(home, ".claude", "ccline"), "bridge");
36
+ }
37
+
38
+ /**
39
+ * ccline's own name check rejects anything that could climb out of the bridge
40
+ * directory; pi session ids are UUIDs, so a strict allowlist costs nothing.
41
+ */
42
+ export function bridgeFileName(sessionId: string): string | null {
43
+ return /^[A-Za-z0-9._-]+$/.test(sessionId) ? `${sessionId}.json` : null;
44
+ }
45
+
46
+ /** A tool call that is still running, as ccline's `activity.tools` reads it. */
47
+ export interface BridgeTool {
48
+ name: string;
49
+ /** Unix epoch milliseconds it started; ccline derives the elapsed time. */
50
+ since: number;
51
+ }
52
+
53
+ export interface BridgeOptions {
54
+ sessionId: string;
55
+ /** The last finished answer, kept while tools come and go (same file). */
56
+ timing?: TurnTiming | undefined;
57
+ /** Tool calls running right now, oldest first is not required: ccline sorts. */
58
+ tools?: readonly BridgeTool[] | undefined;
59
+ /** Unix epoch milliseconds of the write; ccline drops bridges older than 6h. */
60
+ nowMs: number;
61
+ dir?: string;
62
+ }
63
+
64
+ /**
65
+ * Write the bridge for one session. `turn` and `tools` share one file, so every
66
+ * writer passes both: dropping `turn` would make `timing.tps` disappear.
67
+ * Returns false instead of throwing.
68
+ */
69
+ export function writeBridge(options: BridgeOptions): boolean {
70
+ const name = bridgeFileName(options.sessionId);
71
+ if (!name) return false;
72
+ const tools = options.tools ?? [];
73
+ if (!options.timing && tools.length === 0) return false;
74
+ const dir = options.dir ?? bridgeDir();
75
+ const path = join(dir, name);
76
+ const payload: Record<string, unknown> = {
77
+ v: 1,
78
+ session: options.sessionId,
79
+ updated_at: Math.round(options.nowMs),
80
+ };
81
+
82
+ const timing = options.timing;
83
+ if (timing) {
84
+ const { model, ttftMs, tps, durationMs, outputTokens } = timing;
85
+ const turn: Record<string, unknown> = {
86
+ tps,
87
+ duration_ms: Math.round(durationMs),
88
+ output_tokens: Math.round(outputTokens),
89
+ };
90
+ if (model) turn.model = model;
91
+ if (ttftMs !== undefined) turn.ttft_ms = Math.round(ttftMs);
92
+ payload.turn = turn;
93
+ }
94
+ if (tools.length > 0) {
95
+ payload.tools = tools.map((tool) => ({ name: tool.name, since: Math.round(tool.since) }));
96
+ }
97
+
98
+ try {
99
+ mkdirSync(dir, { recursive: true });
100
+ // ccline tolerates a half-written bridge by retrying once, but an atomic
101
+ // rename means it never has to.
102
+ const temp = `${path}.${process.pid}.tmp`;
103
+ writeFileSync(temp, `${JSON.stringify(payload)}\n`, "utf8");
104
+ renameSync(temp, path);
105
+ return true;
106
+ } catch {
107
+ // A failed write must not leave a temp file behind next to the bridges
108
+ // ccline scans (and must never throw into the session).
109
+ try {
110
+ rmSync(`${path}.${process.pid}.tmp`, { force: true });
111
+ } catch {
112
+ // nothing left to do; ccline prunes the directory itself
113
+ }
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /** Drop a session's bridge, so a closed session leaves no stale speed behind. */
119
+ export function removeTurnBridge(sessionId: string, dir: string = bridgeDir()): void {
120
+ const name = bridgeFileName(sessionId);
121
+ if (!name) return;
122
+ try {
123
+ rmSync(join(dir, name), { force: true });
124
+ } catch {
125
+ // A bridge that cannot be removed is pruned by ccline after a week.
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Times one request from three streaming events: `message_start` (the answer
131
+ * began), the first `message_update` (first token) and `message_end`.
132
+ * A request without a first delta (a non-streaming provider) yields no speed.
133
+ */
134
+ export class TurnMeter {
135
+ private startedAt: number | null = null;
136
+ private firstDeltaAt: number | null = null;
137
+ private model: string | undefined;
138
+
139
+ start(model: string | undefined, nowMs: number): void {
140
+ this.startedAt = nowMs;
141
+ this.firstDeltaAt = null;
142
+ this.model = model;
143
+ }
144
+
145
+ firstDelta(nowMs: number): void {
146
+ if (this.startedAt === null || this.firstDeltaAt !== null) return;
147
+ this.firstDeltaAt = nowMs;
148
+ }
149
+
150
+ /** The answer finished; null when the sample cannot describe a speed. */
151
+ finish(outputTokens: unknown, nowMs: number): TurnTiming | null {
152
+ const startedAt = this.startedAt;
153
+ const firstDeltaAt = this.firstDeltaAt;
154
+ const model = this.model;
155
+ this.startedAt = null;
156
+ this.firstDeltaAt = null;
157
+ this.model = undefined;
158
+ if (startedAt === null || firstDeltaAt === null) return null;
159
+
160
+ const output = typeof outputTokens === "number" && Number.isFinite(outputTokens) ? outputTokens : 0;
161
+ const streamMs = nowMs - firstDeltaAt;
162
+ if (output <= 0 || streamMs < MIN_STREAM_MS) return null;
163
+
164
+ const tps = output / (streamMs / 1000);
165
+ if (!Number.isFinite(tps) || tps <= 0) return null;
166
+
167
+ const ttftMs = Math.max(0, firstDeltaAt - startedAt);
168
+ return { model, tps, durationMs: Math.max(0, nowMs - startedAt), outputTokens: output, ttftMs };
169
+ }
170
+ }
171
+
@@ -18,9 +18,13 @@
18
18
  "used_percentage": 15.4
19
19
  },
20
20
  "cost": {
21
- "total_cost_usd": 1.2345,
22
- "total_duration_ms": 1830000
21
+ "total_cost_usd": 0.54,
22
+ "total_duration_ms": 1800000
23
23
  },
24
- "session_id": "4f8f2c1a-0000-4000-8000-000000000000",
25
- "session_name": "pi-ccline demo"
26
- }
24
+ "session_id": "sess-demo",
25
+ "session_name": "pi-ccline demo",
26
+ "prompt_cache": {
27
+ "requests": 2,
28
+ "hit_ratio": 0.9863
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caph42/pi-ccline",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Pi extension that renders the footer by delegating to the CCometixLine (ccline) statusline binary",
5
5
  "keywords": [
6
6
  "pi-package",