@caph42/pi-ccline 0.1.1 → 0.2.0
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 +9 -2
- package/README_zh.md +9 -2
- package/extensions/pi-ccline/footer.test.ts +66 -2
- package/extensions/pi-ccline/index.ts +68 -0
- package/extensions/pi-ccline/input.test.ts +22 -0
- package/extensions/pi-ccline/input.ts +34 -0
- package/extensions/pi-ccline/turn.test.ts +135 -0
- package/extensions/pi-ccline/turn.ts +148 -0
- package/fixtures/basic.json +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,12 +18,14 @@ 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 |
|
|
21
23
|
| Cost, session duration | assistant messages' `usage.cost.total` summed; duration = now − earliest entry on the branch |
|
|
22
24
|
| Session id / name | `ctx.sessionManager` |
|
|
23
25
|
| git branch and anything else ccline probes itself | ccline reads the disk itself |
|
|
24
26
|
| Status lines from other extensions | `footerData.getExtensionStatuses()`, sorted by key into one line below the ccline frame |
|
|
25
27
|
|
|
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).
|
|
28
|
+
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
29
|
|
|
28
30
|
## Installation
|
|
29
31
|
|
|
@@ -102,6 +104,8 @@ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccli
|
|
|
102
104
|
- 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
105
|
- **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
106
|
- The input JSON **omits the `version` field**: ccline 2.0.1 validates it as a Claude Code version and rejects pi's version outright.
|
|
107
|
+
- **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.
|
|
108
|
+
- **Output speed goes through ccline's bridge, not stdin.** ccline's `timing.tps` / `timing.ttft` 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, so this extension measures it from `message_start` / `message_update` / `message_end` and writes the same documented `v: 1` shape, atomically, once per finished answer. 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. The file is removed on `session_shutdown`, and ccline ignores bridges older than 6h.
|
|
105
109
|
- No runtime dependencies: only `node:child_process` / `node:fs` / `node:os` / `node:path` plus the pi peer package `@earendil-works/pi-tui`.
|
|
106
110
|
- Non-TUI modes (`-p`, json, rpc) install no footer and spawn nothing.
|
|
107
111
|
- The extension's own config lives at `~/.pi/agent/pi-ccline/config.json`; pi's `settings.json` is never touched.
|
|
@@ -129,7 +133,9 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
|
|
|
129
133
|
## Known limitations
|
|
130
134
|
|
|
131
135
|
- **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.
|
|
136
|
+
- **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.
|
|
137
|
+
- **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".
|
|
138
|
+
- **`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.
|
|
133
139
|
- **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
140
|
- **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
141
|
- **Nerd Font v3+ required.** Missing icon fonts render as boxes; Windows is untested.
|
|
@@ -150,6 +156,7 @@ extensions/pi-ccline/
|
|
|
150
156
|
binary.ts path resolution/caching, spawn with timeout and abort, --version probing
|
|
151
157
|
render.ts output normalization: line splitting, truncation by visible width, stale marker
|
|
152
158
|
refresh.ts single-flight + throttle + latest-wins scheduler, and the timer that drives `duration`
|
|
159
|
+
turn.ts per-request timing from the stream, plus the ccline bridge file for `timing.tps`
|
|
153
160
|
config.ts config read/write
|
|
154
161
|
*.test.ts node --test
|
|
155
162
|
scripts/render-once.ts run the real ccline once against a fixture
|
package/README_zh.md
CHANGED
|
@@ -18,12 +18,14 @@ 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` 读取 |
|
|
21
23
|
| 成本、会话时长 | assistant 消息 `usage.cost.total` 累加;时长 = 当前时间 − 分支最早 entry |
|
|
22
24
|
| 会话 id / 名称 | `ctx.sessionManager` |
|
|
23
25
|
| git 分支等 ccline 自行探测的信息 | ccline 自己读磁盘 |
|
|
24
26
|
| 其他扩展的 status | `footerData.getExtensionStatuses()`,按 key 排序拼成一行放在 ccline 帧下方 |
|
|
25
27
|
|
|
26
|
-
不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits`
|
|
28
|
+
不显示、也不会伪造的:5h / 7d 配额段(pi 没有 `rate_limits` 数据源,字段不传,该段自动消失);缓存冷热 / TTL 倒计时 / miss 次数与原因(pi 既不知道 provider 的缓存过期时间,也不知道缓存为何重建,所以只发命中率,其余字段交给 ccline 自己隐藏)。
|
|
27
29
|
|
|
28
30
|
## 安装
|
|
29
31
|
|
|
@@ -102,6 +104,8 @@ pi install .
|
|
|
102
104
|
- 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
|
|
103
105
|
- **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
106
|
- 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
|
|
107
|
+
- **缓存命中率:** `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 数填进去会让它们被重复计入上下文进度条。
|
|
108
|
+
- **输出速度走 ccline 的 bridge,不走 stdin。** ccline 的 `timing.tps` / `timing.ttft` 读的是其数据目录(`$CCLINE_HOME` 或 `~/.claude/ccline`)下的 `bridge/<session>.json`,也就是它家在 Claude Code 里的伴生 mod 写的文件。statusline 载荷里没有单次请求耗时,所以本扩展用 `message_start` / `message_update` / `message_end` 自己计时,并按同样的 `v: 1` 结构原子写入,每个答完的请求写一次。ttft = 流开始 → 首个内容块;pi 是在 provider 开始响应后才发 `message_start`,所以这个数不含建立连接的等待。tps = 该回答的 output token 除以「首个内容块 → `message_end`」的时长,所以等首字的耗时不算进生成速度。计时用单调时钟(`performance.now()`),只有 bridge 的新鲜度标记用墙钟。`session_shutdown` 时删除该文件;ccline 也会忽略超过 6 小时的 bridge。
|
|
105
109
|
- 没有运行时依赖,只用 `node:child_process` / `node:fs` / `node:os` / `node:path` 和 pi 提供的 peer 包 `@earendil-works/pi-tui`。
|
|
106
110
|
- 非 TUI 模式(`-p`、json、rpc)不安装 footer、不 spawn。
|
|
107
111
|
- 扩展自己的配置写在 `~/.pi/agent/pi-ccline/config.json`,不碰 pi 的 `settings.json`。
|
|
@@ -129,7 +133,9 @@ node --experimental-strip-types scripts/render-once.ts --home "$(mktemp -d)" --b
|
|
|
129
133
|
## 已知限制
|
|
130
134
|
|
|
131
135
|
- **5h / 7d 配额段永远为空。** pi 不提供 `rate_limits`,字段整段省略,ccline 自然不显示。
|
|
132
|
-
- **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock
|
|
136
|
+
- **ccline 会写 `~/.claude/ccline/state/`**(会话状态、模型缓存、update.lock)。这是上游行为,扩展无法关闭。另外本扩展会写 `~/.claude/ccline/bridge/<session>.json` 供 `timing.tps` 段使用,会话关闭时删掉。
|
|
137
|
+
- **缓存段只显示命中率。** 冷热状态、TTL 倒计时、miss 次数与原因需要 pi 没有的数据(provider 的缓存过期时间、重建原因),这些字段一律不发;配置里用 `format = "{hit}% hit"` 就只剩这一个数字,而第二次请求之前整段直接消失,而不是谎称 `cold`。
|
|
138
|
+
- **`tok/s` 描述的是最后一次答完的请求,不是滚动平均。** 它按请求实测,只在回答结束时更新(非流式 provider、或只有几个 token 的回答不会产生样本,上一轮的数值会继续显示到下一次更新)。`duration` 和 `tok/s` 没有关系:前者是会话墙钟时长,后者是流式速度。
|
|
133
139
|
- **依赖外部二进制。** 没有 `ccline` 时 footer 只有一行安装提示;升级 `ccline` 后无需重启扩展,但二进制路径会被缓存到下一次解析(`/pi-ccline on` 或新会话会重算)。
|
|
134
140
|
- **上游兼容性只覆盖已测版本。** ccline 2.0.1 的 `input.rs` 每个字段都是 `Option` + lenient 反序列化,同一大版本内字段换形状一般只会让对应段消失;但大版本之间已经发生过必填字段变化(1.1.x → 2.0.x),升级 ccline 后请用 `scripts/render-once.ts` 复跑一次。
|
|
135
141
|
- **需要 Nerd Font v3+。** 图标字体缺失时会显示成方块;Windows 未测试。
|
|
@@ -150,6 +156,7 @@ extensions/pi-ccline/
|
|
|
150
156
|
binary.ts 路径解析/缓存、带超时与中止的 spawn、--version 探测
|
|
151
157
|
render.ts 输出归一化:分行、按可见宽度截断、stale 标记
|
|
152
158
|
refresh.ts 单飞 + 节流 + latest-wins 调度器,以及驱动 `耗时` 的定时器
|
|
159
|
+
turn.ts 从流事件实测单次请求耗时,并写 ccline 的 bridge 文件供 `timing.tps`
|
|
153
160
|
config.ts 配置读写
|
|
154
161
|
*.test.ts node --test
|
|
155
162
|
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,70 @@ 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("ignores message events that are not the assistant's answer", async () => {
|
|
425
|
+
const cclineHome = tempDir("pi-ccline-bridge-role-");
|
|
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
|
+
// A user message or tool result carries no model timing.
|
|
436
|
+
for (const role of ["user", "toolResult"]) {
|
|
437
|
+
const message = { role, usage: { output: 500 } };
|
|
438
|
+
await dispatch("message_start", ctx, { message });
|
|
439
|
+
await dispatch("message_update", ctx, { message });
|
|
440
|
+
await dispatch("message_end", ctx, { message });
|
|
441
|
+
}
|
|
442
|
+
assert.equal(existsSync(join(cclineHome, "bridge")), false, "no timing is attributed to non-answers");
|
|
443
|
+
await dispatch("session_shutdown", ctx);
|
|
444
|
+
} finally {
|
|
445
|
+
if (previousHome === undefined) delete process.env.CCLINE_HOME;
|
|
446
|
+
else process.env.CCLINE_HOME = previousHome;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
386
450
|
test("a footer installed mid-run ticks without waiting for the next turn", async () => {
|
|
387
451
|
const counter = join(tempDir("pi-ccline-lifecycle-midrun-"), "count");
|
|
388
452
|
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, writeTurnBridge } 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,11 @@ 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
|
+
|
|
19
25
|
const DEFAULT_WIDTH = 120;
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -57,12 +63,16 @@ export class CclineFooter {
|
|
|
57
63
|
private footerData: FooterDataLike | null = null;
|
|
58
64
|
private requestRenderFn: (() => void) | null = null;
|
|
59
65
|
private branchUnsubscribe: (() => void) | null = null;
|
|
66
|
+
/** Session whose ccline bridge this footer wrote, removed again on dispose. */
|
|
67
|
+
private bridgeSessionId: string | null = null;
|
|
68
|
+
private readonly meter = new TurnMeter();
|
|
60
69
|
private disposed = false;
|
|
61
70
|
|
|
62
71
|
readonly stats = {
|
|
63
72
|
refreshes: 0,
|
|
64
73
|
lastRefreshAt: null as number | null,
|
|
65
74
|
lastDurationMs: null as number | null,
|
|
75
|
+
lastTps: null as number | null,
|
|
66
76
|
};
|
|
67
77
|
resolved: ResolvedBinary = { path: null, source: "missing" };
|
|
68
78
|
|
|
@@ -136,6 +146,56 @@ export class CclineFooter {
|
|
|
136
146
|
return this.lastError;
|
|
137
147
|
}
|
|
138
148
|
|
|
149
|
+
/** The session pi reports, or null when it cannot be read or is unusable. */
|
|
150
|
+
private sessionId(): string | null {
|
|
151
|
+
try {
|
|
152
|
+
const id = this.ctx.sessionManager.getSessionId?.();
|
|
153
|
+
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A model request went out: start timing until its answer finishes. */
|
|
160
|
+
onMessageStart(event: MessageEventLike): void {
|
|
161
|
+
const message = event.message;
|
|
162
|
+
if (!message || message.role !== "assistant") return;
|
|
163
|
+
// Stream start, not send time: pi begins the stream once the provider has
|
|
164
|
+
// answered, so this ttft stops short of the connect wait by design.
|
|
165
|
+
this.meter.start(message.model, performance.now());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A content block of that answer arrived (`text_start` / `*_delta` /
|
|
170
|
+
* `toolcall_*`; pi emits no metadata through this event), so the clock for
|
|
171
|
+
* the streaming rate starts at the first sign of content.
|
|
172
|
+
*/
|
|
173
|
+
onMessageUpdate(event: MessageEventLike): void {
|
|
174
|
+
if (event.message?.role !== "assistant") return;
|
|
175
|
+
this.meter.firstDelta(performance.now());
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The answer finished: hand ccline its timing through the bridge file it
|
|
180
|
+
* reads for `timing.tps`. No speed, no file.
|
|
181
|
+
*/
|
|
182
|
+
onMessageEnd(event: MessageEventLike): void {
|
|
183
|
+
const message = event.message;
|
|
184
|
+
if (!message || message.role !== "assistant") return;
|
|
185
|
+
// Measurement uses the monotonic clock; only the bridge's freshness
|
|
186
|
+
// stamp is wall-clock, and the two are never mixed.
|
|
187
|
+
const timing = this.meter.finish(message.usage?.output, performance.now());
|
|
188
|
+
if (!timing) return;
|
|
189
|
+
const sessionId = this.sessionId();
|
|
190
|
+
if (!sessionId) return;
|
|
191
|
+
if (!writeTurnBridge({ sessionId, timing, nowMs: Date.now() })) return;
|
|
192
|
+
if (this.bridgeSessionId && this.bridgeSessionId !== sessionId) removeTurnBridge(this.bridgeSessionId);
|
|
193
|
+
this.bridgeSessionId = sessionId;
|
|
194
|
+
this.stats.lastTps = timing.tps;
|
|
195
|
+
// The new speed only shows up in the next frame; let the throttle coalesce it.
|
|
196
|
+
this.requestRefresh();
|
|
197
|
+
}
|
|
198
|
+
|
|
139
199
|
get hasFrame(): boolean {
|
|
140
200
|
return this.frame !== null;
|
|
141
201
|
}
|
|
@@ -253,6 +313,8 @@ export class CclineFooter {
|
|
|
253
313
|
this.branchUnsubscribe = null;
|
|
254
314
|
this.footerData = null;
|
|
255
315
|
this.requestRenderFn = null;
|
|
316
|
+
if (this.bridgeSessionId) removeTurnBridge(this.bridgeSessionId);
|
|
317
|
+
this.bridgeSessionId = null;
|
|
256
318
|
}
|
|
257
319
|
}
|
|
258
320
|
|
|
@@ -307,6 +369,11 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
|
|
|
307
369
|
pi.on("model_select", () => requestRefresh());
|
|
308
370
|
pi.on("thinking_level_select", () => requestRefresh());
|
|
309
371
|
pi.on("session_info_changed", () => requestRefresh());
|
|
372
|
+
// Per-request timing is not in the statusline payload, so it is measured
|
|
373
|
+
// from the stream and written to ccline's bridge file when an answer ends.
|
|
374
|
+
pi.on("message_start", (event) => footer?.onMessageStart(event as MessageEventLike));
|
|
375
|
+
pi.on("message_update", (event) => footer?.onMessageUpdate(event as MessageEventLike));
|
|
376
|
+
pi.on("message_end", (event) => footer?.onMessageEnd(event as MessageEventLike));
|
|
310
377
|
pi.on("session_shutdown", () => teardown());
|
|
311
378
|
|
|
312
379
|
pi.registerCommand("pi-ccline", {
|
|
@@ -364,6 +431,7 @@ export default function piCcline(pi: ExtensionAPI, deps: PiCclineDeps = {}) {
|
|
|
364
431
|
const { refreshes, lastRefreshAt, lastDurationMs } = footer.stats;
|
|
365
432
|
lines.push(`refreshes: ${refreshes} · last: ${lastRefreshAt === null ? "never" : `${Math.round((Date.now() - lastRefreshAt) / 1000)}s ago`}`);
|
|
366
433
|
lines.push(`duration: ${lastDurationMs === null ? "n/a" : `${lastDurationMs}ms`} · frame: ${footer.hasFrame ? "cached" : "none"}`);
|
|
434
|
+
lines.push(`last answer: ${footer.stats.lastTps === null ? "not measured yet" : `${footer.stats.lastTps.toFixed(1)} tok/s`}`);
|
|
367
435
|
lines.push(`state: ${footer.error ? `stale — ${footer.error}` : "ok"}`);
|
|
368
436
|
} else {
|
|
369
437
|
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,135 @@
|
|
|
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, writeTurnBridge } 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
|
+
writeTurnBridge({
|
|
81
|
+
sessionId: "sess-1",
|
|
82
|
+
timing: { model: "m", ttftMs: 820, tps: 74.234, durationMs: 41_000, outputTokens: 1_400 },
|
|
83
|
+
nowMs: 1_790_808_780_000,
|
|
84
|
+
dir,
|
|
85
|
+
}),
|
|
86
|
+
true,
|
|
87
|
+
);
|
|
88
|
+
assert.deepEqual(JSON.parse(readFileSync(join(dir, "sess-1.json"), "utf8")), {
|
|
89
|
+
v: 1,
|
|
90
|
+
session: "sess-1",
|
|
91
|
+
updated_at: 1_790_808_780_000,
|
|
92
|
+
turn: { model: "m", ttft_ms: 820, tps: 74.234, duration_ms: 41_000, output_tokens: 1_400 },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("omits timing fields it did not measure", () => {
|
|
97
|
+
const dir = tempDir();
|
|
98
|
+
writeTurnBridge({ sessionId: "s2", timing: { tps: 12, durationMs: 1_000, outputTokens: 12 }, nowMs: 1, dir });
|
|
99
|
+
const bridge = JSON.parse(readFileSync(join(dir, "s2.json"), "utf8"));
|
|
100
|
+
assert.deepEqual(Object.keys(bridge.turn).sort(), ["duration_ms", "output_tokens", "tps"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("refuses session ids that are not plain file names", () => {
|
|
104
|
+
const dir = tempDir();
|
|
105
|
+
assert.equal(bridgeFileName("../escape"), null);
|
|
106
|
+
assert.equal(bridgeFileName("a/b"), null);
|
|
107
|
+
assert.equal(bridgeFileName(""), null);
|
|
108
|
+
assert.equal(writeTurnBridge({ sessionId: "../escape", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
|
|
109
|
+
assert.deepEqual(readdirSync(dir), [], "nothing escaped or landed in the bridge dir");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a failed write leaves no temp file behind", () => {
|
|
113
|
+
const dir = tempDir();
|
|
114
|
+
// A directory in the target's place makes the rename fail after the temp
|
|
115
|
+
// file was already written.
|
|
116
|
+
mkdirSync(join(dir, "blocked.json"));
|
|
117
|
+
assert.equal(writeTurnBridge({ sessionId: "blocked", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir }), false);
|
|
118
|
+
assert.deepEqual(readdirSync(dir), ["blocked.json"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("removes a session bridge and tolerates a missing one", () => {
|
|
122
|
+
const dir = tempDir();
|
|
123
|
+
writeTurnBridge({ sessionId: "s3", timing: { tps: 1, durationMs: 1, outputTokens: 1 }, nowMs: 1, dir });
|
|
124
|
+
assert.equal(existsSync(join(dir, "s3.json")), true);
|
|
125
|
+
removeTurnBridge("s3", dir);
|
|
126
|
+
assert.equal(existsSync(join(dir, "s3.json")), false);
|
|
127
|
+
removeTurnBridge("s3", dir);
|
|
128
|
+
removeTurnBridge("../escape", dir);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("follows ccline's own data directory resolution", () => {
|
|
132
|
+
assert.equal(bridgeDir({ CCLINE_HOME: "/tmp/ccline-home" }, "/home/u"), "/tmp/ccline-home/bridge");
|
|
133
|
+
assert.equal(bridgeDir({ CCLINE_HOME: " " }, "/home/u"), "/home/u/.claude/ccline/bridge");
|
|
134
|
+
assert.equal(bridgeDir({}, "/home/u"), "/home/u/.claude/ccline/bridge");
|
|
135
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
export interface TurnBridgeOptions {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
timing: TurnTiming;
|
|
49
|
+
/** Unix epoch milliseconds of the write; ccline drops bridges older than 6h. */
|
|
50
|
+
nowMs: number;
|
|
51
|
+
dir?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Write the bridge for one session. Returns false instead of throwing. */
|
|
55
|
+
export function writeTurnBridge(options: TurnBridgeOptions): boolean {
|
|
56
|
+
const name = bridgeFileName(options.sessionId);
|
|
57
|
+
if (!name) return false;
|
|
58
|
+
const dir = options.dir ?? bridgeDir();
|
|
59
|
+
const path = join(dir, name);
|
|
60
|
+
const { model, ttftMs, tps, durationMs, outputTokens } = options.timing;
|
|
61
|
+
const turn: Record<string, unknown> = {
|
|
62
|
+
tps,
|
|
63
|
+
duration_ms: Math.round(durationMs),
|
|
64
|
+
output_tokens: Math.round(outputTokens),
|
|
65
|
+
};
|
|
66
|
+
if (model) turn.model = model;
|
|
67
|
+
if (ttftMs !== undefined) turn.ttft_ms = Math.round(ttftMs);
|
|
68
|
+
const payload = {
|
|
69
|
+
v: 1,
|
|
70
|
+
session: options.sessionId,
|
|
71
|
+
updated_at: Math.round(options.nowMs),
|
|
72
|
+
turn,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
// ccline tolerates a half-written bridge by retrying once, but an atomic
|
|
78
|
+
// rename means it never has to.
|
|
79
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
80
|
+
writeFileSync(temp, `${JSON.stringify(payload)}\n`, "utf8");
|
|
81
|
+
renameSync(temp, path);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
// A failed write must not leave a temp file behind next to the bridges
|
|
85
|
+
// ccline scans (and must never throw into the session).
|
|
86
|
+
try {
|
|
87
|
+
rmSync(`${path}.${process.pid}.tmp`, { force: true });
|
|
88
|
+
} catch {
|
|
89
|
+
// nothing left to do; ccline prunes the directory itself
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drop a session's bridge, so a closed session leaves no stale speed behind. */
|
|
96
|
+
export function removeTurnBridge(sessionId: string, dir: string = bridgeDir()): void {
|
|
97
|
+
const name = bridgeFileName(sessionId);
|
|
98
|
+
if (!name) return;
|
|
99
|
+
try {
|
|
100
|
+
rmSync(join(dir, name), { force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// A bridge that cannot be removed is pruned by ccline after a week.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Times one request from three streaming events: `message_start` (the answer
|
|
108
|
+
* began), the first `message_update` (first token) and `message_end`.
|
|
109
|
+
* A request without a first delta (a non-streaming provider) yields no speed.
|
|
110
|
+
*/
|
|
111
|
+
export class TurnMeter {
|
|
112
|
+
private startedAt: number | null = null;
|
|
113
|
+
private firstDeltaAt: number | null = null;
|
|
114
|
+
private model: string | undefined;
|
|
115
|
+
|
|
116
|
+
start(model: string | undefined, nowMs: number): void {
|
|
117
|
+
this.startedAt = nowMs;
|
|
118
|
+
this.firstDeltaAt = null;
|
|
119
|
+
this.model = model;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
firstDelta(nowMs: number): void {
|
|
123
|
+
if (this.startedAt === null || this.firstDeltaAt !== null) return;
|
|
124
|
+
this.firstDeltaAt = nowMs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The answer finished; null when the sample cannot describe a speed. */
|
|
128
|
+
finish(outputTokens: unknown, nowMs: number): TurnTiming | null {
|
|
129
|
+
const startedAt = this.startedAt;
|
|
130
|
+
const firstDeltaAt = this.firstDeltaAt;
|
|
131
|
+
const model = this.model;
|
|
132
|
+
this.startedAt = null;
|
|
133
|
+
this.firstDeltaAt = null;
|
|
134
|
+
this.model = undefined;
|
|
135
|
+
if (startedAt === null || firstDeltaAt === null) return null;
|
|
136
|
+
|
|
137
|
+
const output = typeof outputTokens === "number" && Number.isFinite(outputTokens) ? outputTokens : 0;
|
|
138
|
+
const streamMs = nowMs - firstDeltaAt;
|
|
139
|
+
if (output <= 0 || streamMs < MIN_STREAM_MS) return null;
|
|
140
|
+
|
|
141
|
+
const tps = output / (streamMs / 1000);
|
|
142
|
+
if (!Number.isFinite(tps) || tps <= 0) return null;
|
|
143
|
+
|
|
144
|
+
const ttftMs = Math.max(0, firstDeltaAt - startedAt);
|
|
145
|
+
return { model, tps, durationMs: Math.max(0, nowMs - startedAt), outputTokens: output, ttftMs };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
package/fixtures/basic.json
CHANGED
|
@@ -18,9 +18,13 @@
|
|
|
18
18
|
"used_percentage": 15.4
|
|
19
19
|
},
|
|
20
20
|
"cost": {
|
|
21
|
-
"total_cost_usd":
|
|
22
|
-
"total_duration_ms":
|
|
21
|
+
"total_cost_usd": 0.54,
|
|
22
|
+
"total_duration_ms": 1800000
|
|
23
23
|
},
|
|
24
|
-
"session_id": "
|
|
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
|
+
}
|