pi-crew 0.11.18 → 0.11.20

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/NOTICE.md +13 -0
  3. package/dist/index.mjs +546 -498
  4. package/package.json +3 -3
  5. package/skills/real-test-pi-crew/SKILL.md +8 -1
  6. package/src/config/env-allowlist-registry.ts +8 -0
  7. package/src/config/env-vars.ts +11 -0
  8. package/src/extension/registration/subagent-manager-setup.ts +87 -17
  9. package/src/runtime/child-pi/mock-fixtures.ts +36 -0
  10. package/src/runtime/goal-workflow/goal-achievement.ts +47 -0
  11. package/src/runtime/live-session/live-session-runtime.ts +11 -55
  12. package/src/runtime/live-session/usage-bridge.ts +93 -0
  13. package/src/runtime/output/pi-json-output.ts +12 -25
  14. package/src/runtime/recovery/crash-recovery.ts +4 -0
  15. package/src/runtime/recovery/retry-executor.ts +142 -0
  16. package/src/runtime/session-usage.ts +48 -5
  17. package/src/runtime/task-runner/child-executor.ts +7 -2
  18. package/src/runtime/task-runner/pre-execution.ts +1 -1
  19. package/src/runtime/task-runner.ts +136 -81
  20. package/src/runtime/usage-tracker.ts +41 -8
  21. package/src/runtime/verification/verify-gate.ts +164 -7
  22. package/src/state/atomic-write.ts +10 -3
  23. package/src/state/coordination/locks.ts +132 -2
  24. package/src/state/coordination/process-lock.ts +204 -0
  25. package/src/state/event-log/cursor.ts +292 -84
  26. package/src/state/event-log/event-log-rotation.ts +30 -1
  27. package/src/state/event-log/poison-marker.ts +251 -0
  28. package/src/state/event-log/worker-atomic-writer.ts +282 -41
  29. package/src/state/types.ts +14 -3
  30. package/src/state/usage.ts +15 -21
  31. package/src/utils/incremental-reader.ts +316 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.18",
3
+ "version": "0.11.20",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -136,7 +136,7 @@
136
136
  "@earendil-works/pi-agent-core": "*",
137
137
  "@earendil-works/pi-ai": "*",
138
138
  "@earendil-works/pi-coding-agent": "*",
139
- "@earendil-works/pi-tui": "*",
139
+ "@earendil-works/pi-tui": ">=1.0.4 <2.0.0",
140
140
  "@sinclair/typebox": "*"
141
141
  },
142
142
  "dependencies": {
@@ -153,7 +153,7 @@
153
153
  "@earendil-works/pi-agent-core": "^1.0.4",
154
154
  "@earendil-works/pi-ai": "^1.0.4",
155
155
  "@earendil-works/pi-coding-agent": "^1.0.4",
156
- "@earendil-works/pi-tui": "^1.0.4",
156
+ "@earendil-works/pi-tui": "1.0.4",
157
157
  "@sinclair/typebox": "^0.34.52",
158
158
  "@types/node": "^25.9.8",
159
159
  "tsx": "^4.23.15",
@@ -133,7 +133,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
133
133
  | `scripts/wc-gate.mjs` | T11b |
134
134
  | `scripts/test-runner.mjs` (the runner itself) | T1 (exercises it) + `test/unit/scripts/test-runner-exit.test.ts` (21 tests: exit codes, cancelled-guard red-green, missing-file pre-check, batch shard empirical) + ONE batch-mode run (`PI_CREW_TEST_BATCH_SIZE=2 node scripts/test-runner.mjs --shard=0/1 <3 small files>`). **Home isolation per-suite từ 2026-10-11** (set CẢ `PI_CREW_HOME` + `PI_TEAMS_HOME` — mirror pair, đặt 1 tên là defeatable; sweep prefix `pi-crew-` cover orphan `pi-crew-test-home-*`; opt-out `PI_CREW_TEST_NO_HOME_ISOLATION=1`) → khi đụng isolation: leak detector = đếm run-dirs `~/.pi/agent/extensions/pi-crew/state/runs/` before/after một suite, KHÔNG được tăng. Dev script — NOT bundled, no T3/T4 needed |
135
135
  | `scripts/check-lazy-imports.mjs` | Run the gate on a clean tree (`node scripts/check-lazy-imports.mjs` exit 0) + re-probe the pattern battery vs the KNOWN GAPS comment (round-3 lesson: a skip-rule fix can OPEN a new false-skip hole — probe both directions; fixture suite `test/unit/scripts/check-lazy-imports-gate.test.ts` 4 tests probe cả 2 hướng). **AST-based từ Δ2 (2026-10-11, esbuild strip + acorn)**: per-entry budget `{maxFiles}` là RATCHET — bump chỉ khi chủ ý + justify trong commit; hosted-git-info allowlist ĐÃ xóa (2026-10-12, dead require dropped — gate strict trở lại). NOT bundled |
136
- | `src/runtime/verification/verify-gate.ts` + task-runner gate mount (U13) | T7 smoke ×2 — (a) goal whose checks PASS → run must reach `completed` (NOT `blocked`) + `task.verifier_pre_gate` event with `skipVerifier:true` + verifier result artifact carries gate summary; (b) goal whose checks FAIL (e.g. `false` command) → LLM verifier spawns WITH gate context. **Guards the U13×effectiveness interplay** (live-caught 2026-10-10: green-gated verifier = no usage/transcript by design → effectiveness guard false-blocked the run → sync calls hung forever; fixed by counting `diagnostics.verifyGate` as observable work — `effectiveness-guard.test.ts` pins it) |
136
+ | `src/runtime/verification/verify-gate.ts` + task-runner gate mount (U13) | T7 smoke ×2 — (a) goal whose checks PASS → run must reach `completed` (NOT `blocked`) + `task.verifier_pre_gate` event with `skipVerifier:true` + verifier result artifact carries gate summary; (b) goal whose checks FAIL (e.g. `false` command) → LLM verifier spawns WITH gate context. **Guards the U13×effectiveness interplay** (live-caught 2026-10-10: green-gated verifier = no usage/transcript by design → effectiveness guard false-blocked the run → sync calls hung forever; fixed by counting `diagnostics.verifyGate` as observable work — `effectiveness-guard.test.ts` pins it). U13-ADJ (2026-10-13): layer-3 determinism `PI_CREW_VERIFY_DETERMINISM` DEFAULT OFF — battery recipe KHÔNG đổi trừ khi flag bật; khi bật: rerun critical-check cuối cùng PASS, flaky/executor-death → INCONCLUSIVE (không FAILED), không rerun khi checks đã FAILED; belt never-fail-task giờ wrap cả writeArtifact skip-path + finalizer NGỒI NGOÀI try (throw lan truyền, không fall-through spawn) |
137
+ | `src/state/coordination/**` (run-lock socket U21, 2026-10-13) | T7 + pinned `test/unit/state/coordination/{process-lock,run-lock-socket}.test.ts` (U21-1..6) — escape hatch `PI_CREW_RUN_LOCK_SOCKET=0` (pre-U21 bit-for-bit). Semantics MỚI khi socket ON (default): stale-steal một LIVE holder giờ WAIT trong deadline `staleMs*2` thay vì dual-entry (test cũ `run-lock-steal-live-holder-warn.test.ts` giờ env-scoped socket=0). **Test-rule cho mọi lock-freeze tương lai**: PHẢI await acquired-signal deterministic từ body (pattern `team-tool-resume-liveness` F1 sau 2026-10-13) — socket-listen là MACROTASK, assumption "acquisition thắng race với await kế tiếp" đã sai 1 lần (RR-011 token từng vô hình với contender same-process trong cửa sổ file→socket) |
137
138
  | `PI_CREW_STATE_BACKEND=sqlite` (U8 state backend) | Unit: `sqlite-run-state.test.ts` + `state-store-sqlite-parity.test.ts` (incl. coalesced-mirror pin). LIVE: one fast-fix run with the env set → assert `state.sqlite` exists, **sqlite tasks reach terminal status** (live-caught 2026-10-10: `saveRunTasksCoalesced` hot path skipped the db — manifest+tasks.json completed while sqlite stuck at createRun `queued`), events.jsonl still written (audit spine), JSON mirrors present |
138
139
  | `PI_CREW_CHILD_PI_MODE=rpc` / `PI_CREW_CHILD_PI_POOL` (U14 transport) | Unit: `child-pi-rpc-mode.test.ts` (18) + `owned-process-u15.test.ts` (11). LIVE: one async run with env set → workers' status.json carry rpc transport markers, run green, `pgrep -f "mode rpc"` clean after completion (no orphan rpc processes), json default unaffected |
139
140
  | `src/runtime/live-session/**` shared-services rework (U4) | Unit: `live-session-u4` suite (10 — init<50ms via `session_created.elapsedMs`, recursion assert, D4 planning-window replay, hung-tool drop). LIVE: T8 md5 + one fast-fix run (live-session path opt-in) — session_created.elapsedMs < 50ms in events |
@@ -178,6 +179,12 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
178
179
  > **Steering channel filename is RUN-KIND-DEPENDENT — the #1 way to fake a failure** (cost this session a full false-negative investigation): **team workflow runs** poll `<artifactsRoot>/steering/<taskId>.jsonl` (`child-executor.ts:562` → env `PI_CREW_STEERING_FILE` → `prompt-runtime.ts:855`), e.g. `01_assess.jsonl`, `adaptive-01-executor.jsonl`; **crew_agent / direct subagent runs** use the agent-slot name `<slot>-agent.jsonl` (e.g. `01_01-agent.jsonl`). A watcher writing `01_01-agent.jsonl` into a team run's steering dir writes a file **no component reads** (0-byte `<taskId>.jsonl` sits untouched, the phantom file accumulates bytes) — it looks exactly like the boot-window loss but is a probe bug. Always derive the name from the run kind, or write every `*.jsonl` present in the dir (see Tier 9g).
179
180
  >
180
181
  > **2026-10-12 update (test-state leak fix + U11 hybrid worker + Δ-wave, commits `468fe609`..`a56129ec`, 8 commits)**: (1) **Unit-test state leak vào user root (fix 2 tầng)** — fixture chạy từ bare `/tmp` cwd (không repo marker) đẩy run-state vào `~/.pi/agent/extensions/pi-crew/` qua no-repo fallback của `scopeBaseRoot()`; async-notifier mỗi live session toast các fixture failure thành `Error: pi-crew run failed: team_… (inline-seam-fixture/none)` (104 run-dir rò từ tháng 6, dọn 2026-10-11). Tầng 1: runner isolation per-suite (`PI_CREW_HOME` + `PI_TEAMS_HOME` — CẢ HAI, mirror pair 1-tên defeatable); tầng 2: run-index temp-cwd guard (non-temp root × TERMINAL × bare-temp cwd không marker ⇒ ẩn khỏi listing; by-ID vẫn resolve). Probe rule: leak detector đếm run-dirs user-root before/after suite. (2) **U11 hybrid 2-tier worker (DEFAULT-OFF)** — embedded tier trên U4 stack, shared slot budget 2 tier, fallback 2-faults, events `tier.decision/fault/fallback`; LIVE acceptance `team_20261011150612`: embedded `session_created.elapsedMs` **9ms** vs subprocess spawn **317ms** (~35-60×); row decision-table mới ở trên kèm STALE-BUNDLE SIGNATURE. (3) **Δ-wave benches + gates**: `check-lazy-imports` AST hóa (budget ratchet 523, fixture 4 tests); **b14** run-index scan (readdir vô hại 1.4µs/dir — per-entry work 0.3ms/dir mới là chi phí; notifier recent-40 an toàn mọi scale; F4: scan-cache bucket TTL non-sliding + `set()` reset bucket rỗng — scan >1s tự roll bucket giữa chừng, cần decision record nếu bucket nào phục vụ scan >1s); **b15** broker scan-cost (Δ3 verdict NO-GO có bằng chứng: N≤11 tasks/run thực tế, scan 0.006-0.05ms/call vs I/O floor 8.1ms — candidate indexes parked tới khi run thật >50 tasks); `hosted-git-info` dead-require dropped (KHÔNG resolve ở đâu cả — nested `node_modules/@peer/...` không nằm resolution path của pi-crew; generic parser là de-facto path 100% thời gian). (4) **U12 track** — 7 blacklist items mới từ upstream `cdaf6c227`, triggers 1/4 → vẫn track-only (`docs/reviews/pi-upstream-tracking-u12.md`). Suite anchors @ 2026-10-12 (`a56129ec`): unit **9063 tests / 9060 pass / 0 fail / 3 skipped / 0 cancelled**, integration **144 / 140 pass / 0 fail / 4 skipped** (EXIT=0, env scrub), `test:critical` **120**, bundle **1,758,732 B (1717.5 KB)** md5 `472cb93254c69f7302b99e47269e02ea`, event registry **182 registered / 147 emitted** (`tier.*` registered; DRIFT 3 = mailbox cũ đã biết, owner broker lane).
182
+ >
183
+ > **2026-10-13 update (pi-durable wave U22/U16/U21/U13-ADJ/U9-ADJ, branch `feat/pi-durable-upgrade`, commits `85917a44`..`1acd01e7`, 15 commits; spec `sdd-pi-durable-2026-10-13/`)**: (1) **U22 events.jsonl durability** — worker-atomic-writer hardened (fsync file+dir, per-request timeout `PI_CREW_WORKER_ATOMIC_TIMEOUT_MS`, `on("exit")` drain, temp-dir cache; opt-in như cũ); torn-vs-poison unified scanner (`parseJsonlWindow`) trên CẢ 4 đường read + sidecar `<events>.poison.jsonl`. **Marker LIFETIME RULE (bài học M1 — 2 reviewer độc lập hội tụ, 9k-test suite KHÔNG bắt được)**: marker chỉ hợp lệ cho file instance nó sinh ra — rotate MOVE sidecar theo archive, compact DELETE sidecar, và mọi marker-hit được RE-PARSE 1 lần trước khi skip (dòng parse được = marker đã lành, prune ngay) — layer này tự chữa cả marker bẩn same-uid; read-path giờ CÓ write side-effect (trust note security review). `sweepOldArchives` quét cả `<archive>.poison.jsonl`. (2) **Parameter-property recurrence (US-011 = R3-1 tái diễn)**: `WorkerRequestBookkeeper` constructor(private …) giết strip-types child — bắt bởi gate của PHASE KẾ (không phải phase tác giả); reinforced rule: chạy 1 file test spawn-child sau mọi phase đụng constructor syntax. (3) **U21 run-lock dual-guard** (file→socket, release socket→file, WAIT giữ nguyên): RR-011 token đăng ký NGAY sau file-acquire (cửa sổ socket-listen macrotask từng để token "vô hình" — steal-corpse hazard); flake F1 `team-tool-resume-liveness` từng note 2026-10-07 thực chất là RACE ASSUMPTION — giờ fix bằng acquired-signal deterministic (class flaky đóng). Deferred: uid-namespace cho lock name (F2 LOW). (4) **U16 usage ledger**: SDK chỉ có `getSessionStats()` — cả `getStats` lẫn fallback `session.stats` đều chết (embedded usage luôn undefined trước fix); 1 parser duy nhất, cost number|object(`total`), lifetimeUsage đủ cacheRead+cost; LIVE evidence: `team action=summary` hiển thị cacheRead. (5) **U13-ADJ layer-3 + never-fail-task belt** (chi tiết row U13 ở decision table). (6) **U9-ADJ noProgress hook** (dormant — chưa có production caller) + `owner`/`intent` additive (validators permissive cả tasks.json lẫn sqlite). (7) **Process**: lazy-imports budget 523→525 (poison-marker + process-lock, ratchet justified); dist committed (T11j `--committed-hash` yêu cầu khớp fresh build); 2 MAJOR bắt bởi review ĐỘC LẬP sau khi mọi gate xanh — durability paths CẦN review round, unit xanh không đủ. Suite anchors @ 2026-10-13 (`1acd01e7`): unit **9147 tests / 9143 pass / 1 fail = `subagent-tools-integration` teardown-race flake (14/14 ×2 isolated — class đã biết 2026-09-26, KHÔNG phải regression) / 3 skipped**, integration **144 / 140 pass / 0 fail / 4 skipped**, `test:critical` **120**, bundle **1,776,600 B (1735.0 KB)** md5 `c197855f917997a404ae39111d8ccfdc` (`--committed-hash` OK).
184
+
185
+ > **2026-10-14 update (pi-durable FOLLOW-UP: P1 usage-tracker 5-field + m5 usage-bridge, P2 F2 uid-namespace lock name, P3 F4 noProgress budget; branch `feat/pi-durable-followup` 6 commits trên v0.11.19 `f10da4cc`)**: (1) **F2** — `runLockSocketName` prefix uid (`${uid}:run:${abs}`, "noid" fallback): abstract namespace là SYSTEM-WIDE — cùng path 2 user = collision; deploy-window an toàn vì file-lock vẫn guard chính. (2) **F4** — noProgress hook race `NO_PROGRESS_HOOK_TIMEOUT_MS=5000` (constant KHÔNG env — hook dormant, tránh allowlist surface); timer resolve false + log riêng; throw-path bit-for-bit (sync-throw trước timer tạo → timer undefined → propagates); Promise.race subscribe loser ⇒ late rejection không unhandled. (3) **P1** — tracker 5-field (cacheRead+cost NUMBER — object phải qua parser ở caller); m5 tách `live-session/usage-bridge.ts` (1963→1926, headroom 74; lazy-import ratchet 525→526); LIVE: tasks.json run thật `cacheRead:8512` + `cost:0.00743`. (4) **X1 audit cwd-oracle trust vs 0.11.12: PARTIAL hướng an toàn** — core byte-identical; 2 delta default-ON đều GIẢM over-trust; không đường nào tin layer-1 nhiều hơn baseline. (5) **PROBE QUIRK (durable)**: `timeout N pi -p` + sync team tool — wrapper có thể KHÔNG thoát sau run completed (12-10 treo 20h, 14-10 ~9' cho tới KILL) — run bên trong vẫn hoàn tất xanh; lấy evidence TỪ RUN-DIR (không phụ thuộc wrapper), dọn bằng kill PID trực tiếp (không pattern). (6) **Gate exit qua pipe**: `cmd | tail; echo $?` bắt exit của TAIL — lazy-imports từng 526>525 mà exit hiển thị 0; dùng log-file/pipefail cho mọi gate exit. Suite anchors @ 2026-10-14 (`b683c723`+skill): unit **9159 / 9155 pass / 1 fail = heartbeat-pulse timing flake (4/4 ×2 isolated, class timing-under-load) / 3 skipped**, integration **144 / 140 / 0 / 4**, `test:critical` **120**, bundle **1777312 B (1735.7 KB)** md5 `f1e5cc77608acdb6ef155f7adc4ebad6` (committed==fresh). Deferred mới: `state/usage.ts` LifetimeUsage 3-field lệch triết lý cacheRead vs tracker (m6 unify).
186
+
187
+ > **2026-10-14 update #2 (dup-noti idle-gate + release 0.11.20 follow-up, commits `afc4fe9c`..HEAD trên cùng wave)**: (1) **DUP-NOTI RCA + FIX (live-caught 6×: P3/X1/E5 + 3 shards)** — noti trùng KHÔNG phải do emit 2 lần mà do `sendUserMessage(deliverAs:"followUp")` KHÔNG retract được (SDK `clearQueue()` all-or-nothing — dùng là phá luôn steering của user): leader consume mid-turn (get_subagent_result wait=true) → message ĐÃ queue vẫn deliver ở turn boundary = 1 turn phí mỗi lần. Rule-2 re-check canh SAI cửa sổ (chặn emit mới, không unsend được). **Fix = idle-gate** trong `subagent-manager-setup.ts`: `leaderTurnActive(ctx.currentCtx.isIdle())` (capability-guarded — host thiếu = bit-for-bit, pinned bởi Rule1-3 integration chạy trên fallback) + `runWhenLeaderIdle` (retry 500ms, cap 10' — muộn hơn never nhưng re-check vẫn chặn consumed quá cap); coalescer flush idle-gate TRƯỚC splice; batch path NOTIFY_DEFER_MS bị subsume (đã xóa). **Probe-quirk mở rộng**: fresh `pi -p` + prompt nặng (đụng crew_agent/team tool) có thể treo 0-output trong khi prompt đơn giản OK (provider-side, không phải pi-crew) — idle-gate live-probe qua đường này có thể bị chặn bởi môi trường; khi đó dựa vào unit (5/5 pin đúng race semantics) + Rule1-3 14/14. (2) **Goal FALSE-GREEN fix**: `goalDeclaresReadOnly` (marker EN+VI literal, explicit-intent) + bước (1b) — live-proven run `14022151` ("goal declared read-only (no edits expected)"). (3) **m6**: state/usage.ts dead 3-field family xóa; RoleUsage +cacheRead; ledger-vs-display docblock (#38 chỉ đúng cho display/budget-proxy). (4) Suite anchors @ 0.11.20-tree (`8e70e66f`): unit **9146 / 9143 pass / 0 fail / 0 cancelled / 3 skipped** (composition đổi sau m6 −dead-describe), critical **120**, bundle **1,778,118 B (1736.4 KB)** md5 `8e70e66f543106b1ac34902bd4047302` (committed==fresh), census 15.
181
188
 
182
189
  ## Core principle: disk ≠ live Pi
183
190
 
@@ -257,6 +257,14 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
257
257
  PI_CREW_CLASSIFIER_MODEL: { kind: "gap", reason: "child-executor retry classifier (runner)" },
258
258
  PI_CREW_VERIFY_GATE: { kind: "gap", reason: "verify-gate pre-gate before verifier spawns (runner)" },
259
259
  PI_CREW_VERIFY_GATE_TIMEOUT_MS: { kind: "gap", reason: "verify-gate per-check timeout (runner)" },
260
+ PI_CREW_VERIFY_DETERMINISM: {
261
+ kind: "gap",
262
+ reason: "U13-ADJ layer-3 determinism rerun toggle — resolved in the runner before the verifier spawns; deliberately unforwarded (each process decides its own gate determinism tier, exactly like PI_CREW_VERIFY_GATE above)",
263
+ },
264
+ PI_CREW_RUN_LOCK_SOCKET: {
265
+ kind: "gap",
266
+ reason: "U21 run-lock dual-guard toggle — withRunLock also runs in the runner; deliberately unforwarded this wave (socket dies with each process, so a host-vs-runner toggle mismatch is harmless: each process just picks its own admission tier)",
267
+ },
260
268
  PI_CREW_RPC_DIALOG_ANSWER: { kind: "gap", reason: "rpc-worker dialog policy (runner)" },
261
269
  PI_CREW_RPC_TURN_TIMEOUT_MS: { kind: "gap", reason: "rpc-worker turn timeout (runner)" },
262
270
  PI_CREW_MAX_AUTO_FALLBACKS: { kind: "gap", reason: "model-fallback cap resolved in the runner" },
@@ -472,6 +472,12 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
472
472
  name: "PI_CREW_STATE_BACKEND",
473
473
  doc: "U8 (upgrade-spec 2026-10-09) run-state store backend select: 'sqlite' stores manifest+tasks+worker-status in <runId>/state.sqlite via node:sqlite with ONE transaction per batch write (all-or-nothing; manifest.json/tasks.json stay written as read-side mirrors for legacy consumers — loads read the db); unset or any other value keeps the default JSON file backend. events.jsonl audit spine unchanged in both modes (state-store.ts / sqlite-run-state.ts isSqliteStateBackend)",
474
474
  },
475
+ PI_CREW_RUN_LOCK_SOCKET: {
476
+ name: "PI_CREW_RUN_LOCK_SOCKET",
477
+ parser: "boolean",
478
+ default: true,
479
+ doc: "U21 (upgrade 2026-10-13) run-lock dual-guard toggle: '0'/'false'/'no' disables the kernel process-socket SUPPLEMENT on the ASYNC withRunLock path (pre-U21 file-lock-only behaviour, bit-for-bit); default ON. Host-process read only — never passed to children (the socket name is derived from the run dir path). The SYNC withRunLockSync path is always file-only by design (locks.ts acquireRunLockGuarded)",
480
+ },
475
481
  PI_CREW_CHILD_PI_MODE: {
476
482
  name: "PI_CREW_CHILD_PI_MODE",
477
483
  doc: "U14 (upgrade-spec 2026-10-09) child-pi protocol v2 transport select: 'rpc' spawns task workers over pi --mode rpc (SDK RpcClient wire — steer receipts, clear_queue hygiene, multi-prompt reuse; child-pi-rpc.ts), 'json' / unset keeps the --mode json -p stdio path (phase-1 default; both modes fully regression-tested). Surface TUI panes are unaffected",
@@ -505,6 +511,11 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
505
511
  default: "120000",
506
512
  doc: "U13 per-check timeout in ms for the deterministic verify_gate pre-gate commands. Invalid/≤0 → 120000, mirroring runPhaseGates (runtime/verification/verify-gate.ts resolveVerifyGateTimeoutMs)",
507
513
  },
514
+ PI_CREW_VERIFY_DETERMINISM: {
515
+ name: "PI_CREW_VERIFY_DETERMINISM",
516
+ parser: "boolean",
517
+ doc: "U13-ADJ (upgrade 2026-10-13, PHASE 4) layer-3 determinism rerun, DEFAULT OFF: '1' reruns the most critical default check (usually test:critical) once after a GREEN gate; disagreement (flaky) or a rerun executor failure downgrades the verdict to INCONCLUSIVE (never FAILED, never a task failure) with a 'deterministic-check: flaky <name>' note. Flag off keeps the gate bit-for-bit pre-U13-ADJ (runtime/verification/verify-gate.ts resolveVerifyDeterminismEnabled)",
518
+ },
508
519
  PI_CREW_RPC_DIALOG_ANSWER: {
509
520
  name: "PI_CREW_RPC_DIALOG_ANSWER",
510
521
  doc: "W7 EXPERIMENTAL dialog auto-answer policy for RPC-mode extension_ui_request dialogs (select/confirm/input/editor): 'cancel' (default) answers {cancelled:true} — universal safe answer; 'block' leaves the server promise pending (debug). Auto-confirm deliberately NOT implemented (security); invalid → 'cancel' (rpc-worker.ts resolveDialogAnswerPolicy)",
@@ -2,7 +2,7 @@
2
2
  * Subagent manager installer for pi-crew.
3
3
  *
4
4
  * Wires the SubagentManager singleton with:
5
- * • a terminal-status callback (Rules 1 + 2 + 3):
5
+ * • a terminal-status callback (Rules 1 + 2 + 3 + idle-gate):
6
6
  * - Rule 1 (batch coalescing): explicit batchId → ONE consolidated notify
7
7
  * when all members terminal.
8
8
  * - Rule 2 (consume-race fix): resultConsumed re-check so a leader that
@@ -12,6 +12,12 @@
12
12
  * produce 1 notice — not N drips delivered one-per-turn at turn
13
13
  * boundaries (the symptom: leader joins all, then redundant per-agent
14
14
  * "changed state" notices keep dripping in over later turns).
15
+ * - Idle-gate (2026-10-14, dup-close): a queued followUp wake-up cannot be
16
+ * retracted (clearQueue is all-or-nothing), so while the leader's turn
17
+ * is active the emit is DEFERRED and the resultConsumed re-check re-runs
18
+ * once the session is idle — the moment the message would actually
19
+ * deliver. A leader that proactively joins the result mid-turn
20
+ * (get_subagent_result wait=true) never sees the stale drip.
15
21
  * • an internal event forwarder (subagent.stuck-blocked → notification),
16
22
  * • a hard cap on concurrent subagents (4).
17
23
  *
@@ -27,13 +33,6 @@ import { sendAgentWakeUp } from "./subagent-helpers.ts";
27
33
  const MAX_CONCURRENT_SUBAGENTS = 4;
28
34
  const SUBAGENT_DEFAULT_TIMEOUT_MS = 1000;
29
35
 
30
- /**
31
- * Defer window for the BATCH path (explicit batchId). Gives the leader a chance
32
- * to consume batch members before the consolidated notify emits; the
33
- * resultConsumed re-check then suppresses.
34
- */
35
- const NOTIFY_DEFER_MS = 1500;
36
-
37
36
  /**
38
37
  * Coalesce window for NON-batch background completions (Rule 3). Completions
39
38
  * within this window (debounced — the timer resets on each new arrival) merge
@@ -46,6 +45,63 @@ const NOTIFY_DEFER_MS = 1500;
46
45
  */
47
46
  const NOTIFY_COALESCE_MS = 800;
48
47
 
48
+ /**
49
+ * Idle-gate retry cadence (dup-close, 2026-10-14). A queued followUp wake-up
50
+ * CANNOT be retracted once `sendUserMessage` has queued it (the SDK's
51
+ * `clearQueue()` is all-or-nothing — it would also destroy the user's own
52
+ * pending steering). So while the leader's turn is ACTIVE we keep DEFERRING
53
+ * the emit and re-running the resultConsumed check; the wake-up only queues
54
+ * once the session is idle — the moment it will actually be delivered — which
55
+ * closes the duplicate class where the leader proactively joins the result
56
+ * mid-turn and then gets a stale "changed state" drip at the turn boundary
57
+ * (live-caught 6× 2026-10-13/14: P3/X1/E5 + 3 research shards).
58
+ */
59
+ const IDLE_RETRY_MS = 500;
60
+
61
+ /**
62
+ * Hard cap for the idle-gate deferral. Leader turns can be long; after the
63
+ * cap the wake-up is emitted anyway (late beats never) — the resultConsumed
64
+ * re-check still runs first, so a consumed agent never re-notifies even past
65
+ * the cap.
66
+ */
67
+ const IDLE_EMIT_CAP_MS = 10 * 60_000;
68
+
69
+ /**
70
+ * True when the leader's own turn is ACTIVE (session streaming). Capability-
71
+ * guarded: an older host without `ctx.isIdle` (or a bare/absent currentCtx)
72
+ * reports "not active" so the emit path stays bit-for-bit with the pre-gate
73
+ * behavior.
74
+ * @internal — exported for unit tests.
75
+ */
76
+ export function leaderTurnActive(ctx: RegistrationContext): boolean {
77
+ const c = ctx.currentCtx;
78
+ if (!c || typeof c.isIdle !== "function") return false;
79
+ try {
80
+ return c.isIdle() === false;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Run `fn` once the leader turn is idle (or immediately when it already is /
88
+ * the host lacks the capability / the cap expires). `fn` must be safe to call
89
+ * late: it re-runs its own consume/owner re-checks first.
90
+ * @internal — exported for unit tests.
91
+ */
92
+ export function runWhenLeaderIdle(ctx: RegistrationContext, fn: () => void): void {
93
+ const deadline = Date.now() + IDLE_EMIT_CAP_MS;
94
+ const attempt = (): void => {
95
+ if (ctx.cleanedUp) return;
96
+ if (!leaderTurnActive(ctx) || Date.now() >= deadline) {
97
+ fn();
98
+ return;
99
+ }
100
+ setTimeout(attempt, IDLE_RETRY_MS);
101
+ };
102
+ attempt();
103
+ }
104
+
49
105
  /** A pending non-batch completion awaiting coalesced emit. */
50
106
  interface PendingCompletion {
51
107
  agentId: string;
@@ -64,6 +120,7 @@ interface CompletionCoalescer {
64
120
  function createCompletionCoalescer(pi: ExtensionAPI, ctx: RegistrationContext): CompletionCoalescer {
65
121
  let pending: PendingCompletion[] = [];
66
122
  let timer: ReturnType<typeof setTimeout> | null = null;
123
+ let cycleDeadline = 0;
67
124
 
68
125
  /** True if the completion is still deliverable (not consumed, current session). */
69
126
  const isLive = (c: PendingCompletion): boolean => {
@@ -80,17 +137,29 @@ function createCompletionCoalescer(pi: ExtensionAPI, ctx: RegistrationContext):
80
137
  pending = [];
81
138
  return;
82
139
  }
140
+ // Idle-gate (dup-close): defer the emit while the leader's own turn is
141
+ // active — a queued followUp cannot be retracted, so we only queue it at
142
+ // the moment it will actually be delivered. The consume re-check below
143
+ // then runs at THAT moment: a leader that joined the result mid-turn
144
+ // suppresses the notify entirely instead of receiving a stale drip.
145
+ // Past the cap we emit anyway (late beats never).
146
+ if (leaderTurnActive(ctx) && Date.now() < cycleDeadline) {
147
+ timer = setTimeout(flush, IDLE_RETRY_MS);
148
+ return;
149
+ }
83
150
  const batch = pending.splice(0);
84
- if (batch.length === 0) return;
85
- // Rule 2: drop agents the leader already consumed during the window.
86
- const live = batch.filter(isLive);
87
- if (live.length === 0) return;
88
- if (live.length === 1) emitIndividualCompletion(pi, ctx, live[0]!);
89
- else emitConsolidatedCompletions(pi, ctx, live);
151
+ if (batch.length > 0) {
152
+ // Rule 2: drop agents the leader already consumed during the window.
153
+ const live = batch.filter(isLive);
154
+ if (live.length === 1) emitIndividualCompletion(pi, ctx, live[0]!);
155
+ else if (live.length > 1) emitConsolidatedCompletions(pi, ctx, live);
156
+ }
157
+ if (pending.length === 0) cycleDeadline = 0;
90
158
  };
91
159
 
92
160
  return {
93
161
  enqueue(completion) {
162
+ if (pending.length === 0) cycleDeadline = Date.now() + IDLE_EMIT_CAP_MS;
94
163
  pending.push(completion);
95
164
  if (timer) clearTimeout(timer);
96
165
  timer = setTimeout(flush, NOTIFY_COALESCE_MS);
@@ -250,9 +319,10 @@ function onTerminalStatus(
250
319
  const agentRunId = record.runId;
251
320
  const agentBatchId = record.batchId;
252
321
 
253
- // Rule 1 (batch): defer + BatchBarrier consolidated emit.
322
+ // Rule 1 (batch): defer + BatchBarrier consolidated emit, idle-gated so the
323
+ // consume re-check runs at the moment the wake-up actually queues.
254
324
  if (agentBatchId) {
255
- setTimeout(() => {
325
+ runWhenLeaderIdle(ctx, () => {
256
326
  if (ctx.cleanedUp) return;
257
327
  const fresh = ctx.subagentManager.getRecord(agentId);
258
328
  const persisted = ctx.currentCtx ? readPersistedSubagentRecord(ctx.currentCtx.cwd, agentId) : undefined;
@@ -290,7 +360,7 @@ function onTerminalStatus(
290
360
  }
291
361
  // Either we just emitted the consolidated notify, or we are still
292
362
  // waiting for other members — in both cases do NOT emit individually.
293
- }, NOTIFY_DEFER_MS);
363
+ });
294
364
  return;
295
365
  }
296
366
 
@@ -131,6 +131,42 @@ export async function runMockChildPi(
131
131
  stderr: "[MOCK] rate limit: mock failure",
132
132
  };
133
133
 
134
+ // U16 (usage ledger): same happy-path shape as json-success, but the
135
+ // message_end events carry the REAL message-level usage shape — usage
136
+ // nested under message.role="assistant" with cost as an OBJECT
137
+ // ({input, output, cacheRead, cacheWrite, total} per @earendil-works/pi-ai
138
+ // Usage) — so the lifetimeUsage accumulator and the subprocess usage
139
+ // parsers see exactly what a real child pi emits. Two events so the
140
+ // accumulation (sum) across messages is asserted, not a single snapshot.
141
+ if (mock === "json-usage-ledger") {
142
+ const text = `[MOCK] JSON usage ledger for ${input.agent.name}`;
143
+ const usageEvent = (tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }, cost: number) =>
144
+ JSON.stringify({
145
+ type: "message_end",
146
+ message: {
147
+ role: "assistant",
148
+ usage: {
149
+ ...tokens,
150
+ totalTokens: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite,
151
+ cost: {
152
+ input: 0.01,
153
+ output: 0.01,
154
+ cacheRead: 0.005,
155
+ cacheWrite: 0.005,
156
+ total: cost,
157
+ },
158
+ },
159
+ },
160
+ });
161
+ const stdout = [
162
+ JSON.stringify({ type: "message", message: { role: "assistant", content: [{ type: "text", text }] } }),
163
+ usageEvent({ input: 10, output: 5, cacheRead: 40, cacheWrite: 3 }, 0.035),
164
+ usageEvent({ input: 7, output: 2, cacheRead: 20, cacheWrite: 1 }, 0.0203),
165
+ ].join("\n");
166
+ await observe(input, `${stdout}\n`);
167
+ return { exitCode: 0, stdout, stderr: "" };
168
+ }
169
+
134
170
  // RR-013 (F04) test seam: reproduce the MuxSurface A1 degrade result WITHOUT a
135
171
  // real multiplexer. child-executor's `childResult.surface?.degraded` branch is
136
172
  // the ONLY producer of TaskExecutionResult.surfaceLost, and runTeamTask offers
@@ -10,6 +10,9 @@
10
10
  * the false-green is no longer SILENT. It is deliberately conservative:
11
11
  * - read-only / doc-only workflows are never accused (they legitimately make
12
12
  * no project-code edits; their outputs land in gitignored .crew/),
13
+ * - a goal that EXPLICITLY declares read-only intent (marker phrase) is
14
+ * never accused either, even when the workflow shape is mutating —
15
+ * explicit intent beats workflow shape (see `goalDeclaresReadOnly`),
13
16
  * - a false-green is only flagged for CODE-MUTATING runs (executor /
14
17
  * test-engineer steps) in a git repo whose working tree is empty,
15
18
  * - status is downgraded to "failed" ONLY when a corroborating signal (a
@@ -59,6 +62,38 @@ export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot:
59
62
  }
60
63
  }
61
64
 
65
+ /**
66
+ * Goal-text markers that EXPLICITLY declare a read-only task. Each entry is
67
+ * matched as a substring, case-insensitively, against whitespace-collapsed
68
+ * lowercase goal text (`goalDeclaresReadOnly`). Keep this list short and
69
+ * literal — one phrase per line — so it is trivial to extend. Longer phrases
70
+ * already covered by a shorter entry are intentionally omitted (e.g.
71
+ * "do not modify, create, or delete" is covered by "do not modify").
72
+ */
73
+ const READ_ONLY_GOAL_MARKERS: readonly string[] = [
74
+ "read-only",
75
+ "read only",
76
+ "do not modify",
77
+ "don't modify",
78
+ "no file modifications",
79
+ "không sửa",
80
+ "không chỉnh sửa",
81
+ "chỉ đọc",
82
+ ];
83
+
84
+ /**
85
+ * Explicit-intent detector, deliberately conservative: returns true ONLY when
86
+ * the goal text contains one of the literal READ_ONLY_GOAL_MARKERS phrases
87
+ * (case-insensitive, whitespace-collapsed). No semantic guessing — a goal
88
+ * that merely *sounds* non-mutating is NOT suppressed. Battery/probe goal
89
+ * conventions use exactly these phrases.
90
+ */
91
+ export function goalDeclaresReadOnly(goal: string | undefined): boolean {
92
+ if (typeof goal !== "string" || goal.length === 0) return false;
93
+ const normalized = goal.toLowerCase().replace(/\s+/g, " ").trim();
94
+ return READ_ONLY_GOAL_MARKERS.some((marker) => normalized.includes(marker));
95
+ }
96
+
62
97
  /**
63
98
  * Assess whether a completed run achieved its goal. Pure function over manifest
64
99
  * + tasks + workflow — no side effects, easy to unit-test.
@@ -78,6 +113,18 @@ export function assessGoalAchievement(
78
113
  };
79
114
  }
80
115
 
116
+ // (1b) Explicit-intent detector: the WORKFLOW is mutating, but the GOAL TEXT
117
+ // explicitly declares a read-only task — a clean tree is then EXPECTED,
118
+ // never an accusation. Deliberately conservative (literal markers only,
119
+ // no semantic guessing); battery/probe conventions use these phrases.
120
+ if (goalDeclaresReadOnly(manifest.goal)) {
121
+ return {
122
+ achieved: "unknown",
123
+ reason: "goal declared read-only (no edits expected)",
124
+ signals: [],
125
+ };
126
+ }
127
+
81
128
  // (2) Need a git repo to inspect the working tree.
82
129
  const tree = isGitWorkingTreeClean(manifest.cwd);
83
130
  if (tree.clean === "unknown" || !tree.repoRoot) {
@@ -43,7 +43,6 @@ import {
43
43
  // in a single place: the shared user prompt. See G3 fix.
44
44
  import { createStreamingOutput, type StreamingOutputHandle } from "../output/streaming-output.ts";
45
45
  import { buildSensitivePathConstraint } from "../sensitive-paths.ts";
46
- import { trackTaskUsage } from "../usage-tracker.ts";
47
46
  import {
48
47
  buildYieldReminder,
49
48
  DEFAULT_YIELD_CONFIG,
@@ -88,6 +87,7 @@ import {
88
87
  import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
89
88
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
90
89
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
90
+ import { type SessionStatsLike, trackUsageFromMessageEndEvent, usageFromSessionStats } from "./usage-bridge.ts";
91
91
 
92
92
  /**
93
93
  * Module-scoped latch for the optional peer dependency import. When N
@@ -234,8 +234,8 @@ type LiveSessionLike = {
234
234
  steer?: (text: string) => Promise<void>;
235
235
  abort?: () => Promise<void> | void;
236
236
  dispose?: () => void;
237
- getStats?: () => unknown;
238
- stats?: unknown;
237
+ /** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost) — shape: usage-bridge.ts (SessionStatsLike). */
238
+ getSessionStats?: () => SessionStatsLike;
239
239
  bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
240
240
  getActiveToolNames?: () => string[];
241
241
  setActiveToolsByName?: (names: string[]) => void;
@@ -463,24 +463,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
463
463
  }
464
464
  }
465
465
 
466
- /**
467
- * Type-safe extractor for message role from streaming events.
468
- * Handles the case where message may be a Record with a role field.
469
- */
470
- function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
471
- const message = obj?.message ? asRecord(obj.message) : undefined;
472
- return isString(message?.role) ? message.role : undefined;
473
- }
474
-
475
- /**
476
- * Type-safe extractor for message usage from streaming events.
477
- * Handles the case where message may be a Record with usage data.
478
- */
479
- function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
480
- const message = obj?.message ? asRecord(obj.message) : undefined;
481
- return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
482
- }
483
-
484
466
  /**
485
467
  * Type-safe extractor for tool name from streaming events.
486
468
  * Handles various event structures: { tool: { name } }, { toolName }, { name }.
@@ -525,15 +507,6 @@ function finalAssistantText(event: unknown): string[] {
525
507
  return textFromContent(message.content);
526
508
  }
527
509
 
528
- function numberField(obj: Record<string, unknown> | undefined, keys: string[]): number | undefined {
529
- if (!obj) return undefined;
530
- for (const key of keys) {
531
- const value = obj[key];
532
- if (typeof value === "number" && Number.isFinite(value)) return value;
533
- }
534
- return undefined;
535
- }
536
-
537
510
  /**
538
511
  * F7: resolve the enabledModels allowlist for the current project, but only
539
512
  * if the `reliability.scopeModels` toggle is ON. Returns an empty
@@ -794,19 +767,9 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
794
767
  session.setActiveToolsByName(active);
795
768
  }
796
769
 
797
- function usageFromStats(stats: unknown): UsageState | undefined {
798
- const obj = asRecord(stats);
799
- if (!obj) return undefined;
800
- const input = numberField(obj, ["input", "inputTokens", "input_tokens"]);
801
- const output = numberField(obj, ["output", "outputTokens", "output_tokens"]);
802
- const cacheRead = numberField(obj, ["cacheRead", "cache_read"]);
803
- const cacheWrite = numberField(obj, ["cacheWrite", "cache_write"]);
804
- const cost = numberField(obj, ["cost"]);
805
- const turns = numberField(obj, ["turns", "turnCount", "turn_count"]);
806
- return [input, output, cacheRead, cacheWrite, cost, turns].some((value) => value !== undefined)
807
- ? { input, output, cacheRead, cacheWrite, cost, turns }
808
- : undefined;
809
- }
770
+ // U16 (Fix B): the usageFromStats thin adapter moved to usage-bridge.ts (m5);
771
+ // re-exported here so the historical import surface keeps working.
772
+ export { usageFromStats } from "./usage-bridge.ts";
810
773
 
811
774
  /**
812
775
  * U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
@@ -1540,17 +1503,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1540
1503
  }
1541
1504
  }
1542
1505
  }
1543
- // Accumulate lifetime usage that survives compaction
1544
- if (obj?.type === "message_end" && extractMessageRole(obj) === "assistant") {
1545
- const u = extractMessageUsage(obj);
1546
- if (u) {
1547
- trackTaskUsage(input.task.id, {
1548
- input: typeof u.input === "number" ? u.input : 0,
1549
- output: typeof u.output === "number" ? u.output : 0,
1550
- cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
1551
- });
1552
- }
1553
- }
1506
+ // P1: accumulate lifetime usage that survives compaction — message-level
1507
+ // deltas incl. cacheRead + numeric cost (parsed in usage-bridge).
1508
+ trackUsageFromMessageEndEvent(input.task.id, event);
1554
1509
  input.onEvent?.(event);
1555
1510
  const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
1556
1511
  if (text.trim()) {
@@ -1798,7 +1753,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1798
1753
  emit: emitEmbedded,
1799
1754
  })
1800
1755
  : undefined;
1801
- const usage = usageFromStats(typeof session.getStats === "function" ? session.getStats() : session.stats);
1756
+ // U16 (Fix A): the real SDK surface is getSessionStats() — see usage-bridge.
1757
+ const usage = usageFromSessionStats(session);
1802
1758
  updateLiveAgentStatus(agentId, "completed");
1803
1759
  markLiveAgentCompleted(agentId);
1804
1760
  return {
@@ -0,0 +1,93 @@
1
+ import type { UsageState } from "../../state/types.ts";
2
+ import { usageFromValue } from "../session-usage.ts";
3
+ import { trackTaskUsage } from "../usage-tracker.ts";
4
+
5
+ /**
6
+ * m5 (P1): the live-session usage bridge, extracted from
7
+ * live-session-runtime.ts (1963 → ≤2000-line wc-gate relief). Owns every pure
8
+ * shape/parser helper that maps pi SDK session surfaces onto pi-crew's usage
9
+ * ledger — the runtime module keeps only the call sites.
10
+ */
11
+
12
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
13
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
14
+ }
15
+
16
+ function isString(value: unknown): value is string {
17
+ return typeof value === "string";
18
+ }
19
+
20
+ /**
21
+ * U16: SDK AgentSession session-level stats surface (aggregate tokens + cost);
22
+ * shaped like live-agent-manager's LiveSessionHandle so the session can be
23
+ * registered there without a cast (extra SDK fields like cost pass structurally).
24
+ */
25
+ export interface SessionStatsLike {
26
+ tokens?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number };
27
+ contextUsage?: { percent?: number | null; window?: number };
28
+ }
29
+
30
+ /**
31
+ * Type-safe extractor for message role from streaming events.
32
+ * Handles the case where message may be a Record with a role field.
33
+ */
34
+ function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
35
+ const message = obj?.message ? asRecord(obj.message) : undefined;
36
+ return isString(message?.role) ? message.role : undefined;
37
+ }
38
+
39
+ /**
40
+ * Type-safe extractor for message usage from streaming events.
41
+ * Handles the case where message may be a Record with usage data.
42
+ */
43
+ function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
44
+ const message = obj?.message ? asRecord(obj.message) : undefined;
45
+ return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
46
+ }
47
+
48
+ /** U16 (Fix B): thin adapter — session-stats parsing is delegated to the single
49
+ * shared parser (`usageFromValue`), which accepts BOTH the nested SDK
50
+ * `SessionStats` shape (`tokens.{input,output,cacheRead,cacheWrite}` + `cost`)
51
+ * and flat legacy shapes. Re-exported by live-session-runtime.ts to keep its
52
+ * historical import surface green. */
53
+ export function usageFromStats(stats: unknown): UsageState | undefined {
54
+ return usageFromValue(stats);
55
+ }
56
+
57
+ /** Minimal session surface the completion site needs. */
58
+ interface SessionWithStats {
59
+ getSessionStats?: () => unknown;
60
+ }
61
+
62
+ /**
63
+ * U16 (Fix A): the SDK surface is getSessionStats() — the old getStats()
64
+ * name and session.stats fallback were both dead against the real SDK.
65
+ */
66
+ export function usageFromSessionStats(session: SessionWithStats | undefined): UsageState | undefined {
67
+ return usageFromStats(session?.getSessionStats?.());
68
+ }
69
+
70
+ /**
71
+ * P1 (AC4): accumulate lifetime usage that survives compaction from one
72
+ * streaming event. Only assistant `message_end` events contribute. The
73
+ * message-level usage object is parsed by the SHARED parser so the new
74
+ * `cacheRead` field and the message-level cost OBJECT (`{…, total}`) both land
75
+ * as NUMBERS in the tracker — usage-tracker deltas accept numeric cost only
76
+ * (D2 aggregate tier). Non-finite fields parse to undefined and are skipped
77
+ * (pre-P1 the raw `typeof` check passed 0 — same arithmetic result).
78
+ */
79
+ export function trackUsageFromMessageEndEvent(taskId: string, event: unknown): void {
80
+ const obj = asRecord(event);
81
+ if (obj?.type !== "message_end" || extractMessageRole(obj) !== "assistant") return;
82
+ const raw = extractMessageUsage(obj);
83
+ if (!raw) return;
84
+ const usage = usageFromValue(raw);
85
+ if (!usage) return;
86
+ trackTaskUsage(taskId, {
87
+ input: usage.input,
88
+ output: usage.output,
89
+ cacheWrite: usage.cacheWrite,
90
+ cacheRead: usage.cacheRead,
91
+ cost: usage.cost,
92
+ });
93
+ }