pi-crew 0.11.19 → 0.11.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.19",
3
+ "version": "0.11.21",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -182,6 +182,10 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
182
182
  >
183
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
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.
188
+
185
189
  ## Core principle: disk ≠ live Pi
186
190
 
187
191
  Two locations hold pi-crew state:
@@ -1252,6 +1256,27 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
1252
1256
  | `team action='schedule' interval=<ms>` always says `Invalid schedule "<N>ms". Use "5m", …` | handle-schedule builds `${interval}ms`; both interval parsers lacked an `ms` unit — every numeric interval died. Fixed 2026-09-21 | The suggested "5m" formats only work via `cron`/`once` params, not `interval` (interval is schema-typed as a plain ms number) |
1253
1257
  | Runs keep appearing under one scheduled job; children of YOUR pi process; machine thrashing | **Interval ≥ 2^31-1 ms (≥24.8 days) overflowed the Node timer to a 1ms hot loop** (fixed 2026-09-22 — clamped hops). Check `.crew/audit/prune.jsonl` for interleaved auto-prunes. Contain: remove job, then `pkill -f '[b]ackground-runner.ts --cwd <root>'` | Any new timer scheduling must clamp below MAX_TIMER_DELAY_MS — grep for `setInterval(`/`setTimeout(` with computed delays |
1254
1258
 
1259
+ ## Release gate — battery → push → GitHub Actions GREEN → publish (canonical, 2026-10-14)
1260
+
1261
+ Local `npm run ci` là LINUX-ONLY — không thay được matrix 3-OS của GitHub Actions.
1262
+ Live-proven 2026-10-14: **0.11.19 và 0.11.20 đều publish khi CI GitHub ĐỎ** (local ci + pre-push
1263
+ hook xanh hết) — windows fail do child-env thiếu `SYSTEMROOT` (node con không khởi tạo Winsock được,
1264
+ stdout rỗng); macOS fail do transient ECONNREFUSED khiến accepting-probe 1 lần coi holder SỐNG là
1265
+ corpse → xóa file socket + rebind (STEAL lock) — fix bằng 3-strike takeover (`process-lock.ts`).
1266
+
1267
+ **Quy trình BẮT BUỘC cho mọi release (theo thứ tự, không bỏ bước):**
1268
+
1269
+ 1. Cập nhật skill (wave entry) + CHANGELOG heading cho version sắp release — commit.
1270
+ 2. Chạy battery theo decision table (đủ tier cho paths đổi) — xanh hết.
1271
+ 3. `npm version patch` (tạo release commit + tag).
1272
+ 4. `git push origin main --follow-tags` — pre-push hook tự chạy full unit local.
1273
+ 5. **`gh run watch <runId> --exit-status` (hoặc poll `gh run list`) — CHỜ matrix 3-OS XANH TOÀN BỘ.**
1274
+ CI đỏ ⇒ KHÔNG publish — fix, push lại, chờ lại. (Check nhanh fail: `gh run view <id>` +
1275
+ `gh api repos/<o>/<r>/actions/jobs/<jobId>//logs` — KHÔNG grep "not ok" mù: tên test có thể
1276
+ chứa literal "not ok" ("malformed JSON is not ok with a message") — grep kèm assertion block.)
1277
+ 6. CHỈ KHI xanh: `npm publish` → poll `npm view <pkg> dist-tags.latest` → `gh release create`.
1278
+ 7. Post-release: knowledge.md entry SHIPPED + dọn branch/scratch.
1279
+
1255
1280
  ## Performance budget (per-tier soft limits)
1256
1281
 
1257
1282
  | Tier | Soft limit | Hard limit | What happens over hard limit |
@@ -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
 
@@ -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,8 +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 { usageFromValue } from "../session-usage.ts";
47
- import { trackTaskUsage } from "../usage-tracker.ts";
48
46
  import {
49
47
  buildYieldReminder,
50
48
  DEFAULT_YIELD_CONFIG,
@@ -89,6 +87,7 @@ import {
89
87
  import { hasLiveControlRealtimeListeners, subscribeLiveControlRealtime } from "./live-control-realtime.ts";
90
88
  import { buildExtensionBridge } from "./live-extension-bridge.ts";
91
89
  import { collectLiveSessionHealth, formatLiveSessionDiagnostics } from "./live-session-health.ts";
90
+ import { type SessionStatsLike, trackUsageFromMessageEndEvent, usageFromSessionStats } from "./usage-bridge.ts";
92
91
 
93
92
  /**
94
93
  * Module-scoped latch for the optional peer dependency import. When N
@@ -235,13 +234,8 @@ type LiveSessionLike = {
235
234
  steer?: (text: string) => Promise<void>;
236
235
  abort?: () => Promise<void> | void;
237
236
  dispose?: () => void;
238
- /** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost);
239
- * shaped like live-agent-manager's LiveSessionHandle so the session can be
240
- * registered there without a cast (extra SDK fields like cost pass structurally). */
241
- getSessionStats?: () => {
242
- tokens?: { input?: number; output?: number; cacheWrite?: number; cacheRead?: number };
243
- contextUsage?: { percent?: number | null; window?: number };
244
- };
237
+ /** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost) — shape: usage-bridge.ts (SessionStatsLike). */
238
+ getSessionStats?: () => SessionStatsLike;
245
239
  bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
246
240
  getActiveToolNames?: () => string[];
247
241
  setActiveToolsByName?: (names: string[]) => void;
@@ -469,24 +463,6 @@ function applyModelProviderOverride(session: LiveSessionLike | undefined, provid
469
463
  }
470
464
  }
471
465
 
472
- /**
473
- * Type-safe extractor for message role from streaming events.
474
- * Handles the case where message may be a Record with a role field.
475
- */
476
- function extractMessageRole(obj: Record<string, unknown> | undefined): string | undefined {
477
- const message = obj?.message ? asRecord(obj.message) : undefined;
478
- return isString(message?.role) ? message.role : undefined;
479
- }
480
-
481
- /**
482
- * Type-safe extractor for message usage from streaming events.
483
- * Handles the case where message may be a Record with usage data.
484
- */
485
- function extractMessageUsage(obj: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
486
- const message = obj?.message ? asRecord(obj.message) : undefined;
487
- return message?.usage && typeof message.usage === "object" ? (message.usage as Record<string, unknown>) : undefined;
488
- }
489
-
490
466
  /**
491
467
  * Type-safe extractor for tool name from streaming events.
492
468
  * Handles various event structures: { tool: { name } }, { toolName }, { name }.
@@ -791,13 +767,9 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
791
767
  session.setActiveToolsByName(active);
792
768
  }
793
769
 
794
- /** U16 (Fix B): thin adapter — session-stats parsing is delegated to the single
795
- * shared parser (`usageFromValue`), which accepts BOTH the nested SDK
796
- * `SessionStats` shape (`tokens.{input,output,cacheRead,cacheWrite}` + `cost`)
797
- * and flat legacy shapes. Exported for unit tests. */
798
- export function usageFromStats(stats: unknown): UsageState | undefined {
799
- return usageFromValue(stats);
800
- }
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";
801
773
 
802
774
  /**
803
775
  * U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
@@ -1531,17 +1503,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1531
1503
  }
1532
1504
  }
1533
1505
  }
1534
- // Accumulate lifetime usage that survives compaction
1535
- if (obj?.type === "message_end" && extractMessageRole(obj) === "assistant") {
1536
- const u = extractMessageUsage(obj);
1537
- if (u) {
1538
- trackTaskUsage(input.task.id, {
1539
- input: typeof u.input === "number" ? u.input : 0,
1540
- output: typeof u.output === "number" ? u.output : 0,
1541
- cacheWrite: typeof u.cacheWrite === "number" ? u.cacheWrite : 0,
1542
- });
1543
- }
1544
- }
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);
1545
1509
  input.onEvent?.(event);
1546
1510
  const text = [...eventText(event), ...finalAssistantText(event)].join("\n");
1547
1511
  if (text.trim()) {
@@ -1789,9 +1753,8 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1789
1753
  emit: emitEmbedded,
1790
1754
  })
1791
1755
  : undefined;
1792
- // U16 (Fix A): the SDK surface is getSessionStats() — the old getStats()
1793
- // name and session.stats fallback were both dead against the real SDK.
1794
- const usage = usageFromStats(session.getSessionStats?.());
1756
+ // U16 (Fix A): the real SDK surface is getSessionStats() — see usage-bridge.
1757
+ const usage = usageFromSessionStats(session);
1795
1758
  updateLiveAgentStatus(agentId, "completed");
1796
1759
  markLiveAgentCompleted(agentId);
1797
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
+ }
@@ -83,6 +83,10 @@ export interface RetryHooks {
83
83
  * logInternalError (the repo's console.error wrapper) and treated as false —
84
84
  * a broken detector must never change retry behavior. Unregistered ⇒
85
85
  * behaviour is bit-for-bit pre-U9-ADJ.
86
+ * F4 (follow-up 2026-10-14 §P3): the hook must settle within
87
+ * NO_PROGRESS_HOOK_TIMEOUT_MS (5s) — anything slower (or never-settling)
88
+ * is treated as false and logged (retry-executor.noProgressTimeout), so a
89
+ * hung detector can never hang the retry loop itself.
86
90
  */
87
91
  noProgress?: (context: {
88
92
  attempt: number;
@@ -130,6 +134,52 @@ function retryAttemptInfo(attempt: number, hooks: RetryHooks): RetryAttemptInfo
130
134
  };
131
135
  }
132
136
 
137
+ /**
138
+ * F4 (follow-up SPEC 2026-10-14 §P3): wall-clock budget for ONE `noProgress`
139
+ * hook invocation. Deliberately NOT configurable (no env var / no param): the
140
+ * hook is dormant (no production caller yet) and a new env-allowlist entry
141
+ * would be pure surface. Exceeded budget ⇒ hook treated as false (progress
142
+ * assumed, retry stays queued) + logInternalError("retry-executor.noProgressTimeout").
143
+ */
144
+ const NO_PROGRESS_HOOK_TIMEOUT_MS = 5000;
145
+
146
+ /**
147
+ * F4: race the `noProgress` hook against NO_PROGRESS_HOOK_TIMEOUT_MS. The
148
+ * timer RESOLVES false (never rejects); hook throws/rejections propagate
149
+ * unchanged so the existing soft-fail catch in executeWithRetry stays
150
+ * bit-for-bit. The timer handle is cleared the moment the race settles —
151
+ * fast hooks leave no pending handle behind, and a never-settling hook is
152
+ * abandoned once its budget resolved (no dangling handle keeps the loop alive).
153
+ */
154
+ async function settleNoProgressHook(
155
+ hook: NonNullable<RetryHooks["noProgress"]>,
156
+ context: { attempt: number; lastResult: unknown; previousResult: unknown; durationMsLast: number },
157
+ ): Promise<boolean> {
158
+ let timedOut = false;
159
+ let timer: ReturnType<typeof setTimeout> | undefined;
160
+ try {
161
+ return await Promise.race([
162
+ Promise.resolve(hook(context)),
163
+ new Promise<false>((resolve) => {
164
+ timer = setTimeout(() => {
165
+ timedOut = true;
166
+ resolve(false);
167
+ }, NO_PROGRESS_HOOK_TIMEOUT_MS);
168
+ }),
169
+ ]);
170
+ } finally {
171
+ if (timer !== undefined) clearTimeout(timer);
172
+ if (timedOut) {
173
+ logInternalError(
174
+ "retry-executor.noProgressTimeout",
175
+ { attempt: context.attempt, timeoutMs: NO_PROGRESS_HOOK_TIMEOUT_MS },
176
+ "soft-fail: treating as progress, retry stays queued",
177
+ "error",
178
+ );
179
+ }
180
+ }
181
+ }
182
+
133
183
  export async function executeWithRetry<T>(
134
184
  fn: (attempt: number, info: RetryAttemptInfo) => Promise<T>,
135
185
  policy: RetryPolicy = DEFAULT_RETRY_POLICY,
@@ -185,10 +235,12 @@ export async function executeWithRetry<T>(
185
235
  // attempt against the previous one and decides. true ⇒ stop the loop
186
236
  // with the distinct no_progress reason (not ordinary give-up exhaustion).
187
237
  // SOFT: hook errors are logged (logInternalError) and treated as false.
238
+ // F4: the hook races NO_PROGRESS_HOOK_TIMEOUT_MS — slow/never-settling
239
+ // hooks count as false (see settleNoProgressHook).
188
240
  if (hooks.noProgress) {
189
241
  let noProgressDecision = false;
190
242
  try {
191
- noProgressDecision = await hooks.noProgress({
243
+ noProgressDecision = await settleNoProgressHook(hooks.noProgress, {
192
244
  attempt,
193
245
  lastResult: lastError,
194
246
  previousResult: previousFailedResult,
@@ -1,28 +1,57 @@
1
1
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
2
2
 
3
+ /**
4
+ * P1 (U16 follow-up) — 2-tier usage tracking. `LifetimeUsage` now carries all
5
+ * five ledger fields. The change is ADDITIVE: callers that still pass only
6
+ * input/output/cacheWrite deltas behave exactly as before.
7
+ */
3
8
  export interface LifetimeUsage {
4
9
  input: number;
5
10
  output: number;
6
11
  cacheWrite: number;
12
+ /** P1: cumulative cache-read tokens (dropped from the ledger pre-P1). */
13
+ cacheRead: number;
14
+ /** P1: D2 aggregate cost — always a NUMBER, never the message-level cost object. */
15
+ cost: number;
7
16
  }
8
17
 
9
18
  export function emptyLifetimeUsage(): LifetimeUsage {
10
- return { input: 0, output: 0, cacheWrite: 0 };
19
+ return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
11
20
  }
12
21
 
13
- export function addUsage(into: LifetimeUsage, delta: { input?: number; output?: number; cacheWrite?: number }): void {
22
+ /**
23
+ * Usage delta for `addUsage`/`trackTaskUsage` — every field optional, absent
24
+ * fields are skipped. `cost` MUST be a NUMBER (the D2 aggregate tier):
25
+ * message-level cost OBJECTS (`{input, output, cacheRead, cacheWrite, total}`)
26
+ * are NOT handled here — callers must first pass them through the shared
27
+ * parser (`usageFromValue` in session-usage.ts), which extracts `cost.total`.
28
+ */
29
+ export interface UsageDelta {
30
+ input?: number;
31
+ output?: number;
32
+ cacheWrite?: number;
33
+ cacheRead?: number;
34
+ cost?: number;
35
+ }
36
+
37
+ export function addUsage(into: LifetimeUsage, delta: UsageDelta): void {
14
38
  if (typeof delta.input === "number") into.input += delta.input;
15
39
  if (typeof delta.output === "number") into.output += delta.output;
16
40
  if (typeof delta.cacheWrite === "number") into.cacheWrite += delta.cacheWrite;
41
+ if (typeof delta.cacheRead === "number") into.cacheRead += delta.cacheRead;
42
+ if (typeof delta.cost === "number") into.cost += delta.cost;
17
43
  }
18
44
 
19
- /** @internal */
20
- function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
45
+ /** @internal — exported for unit tests. */
46
+ export function lifetimeUsageFromState(state: UsageState | undefined): LifetimeUsage {
21
47
  if (!state) return emptyLifetimeUsage();
22
48
  return {
23
49
  input: state.input ?? 0,
24
50
  output: state.output ?? 0,
25
51
  cacheWrite: state.cacheWrite ?? 0,
52
+ // P1: legacy persisted states have no cacheRead/cost — both read as 0.
53
+ cacheRead: state.cacheRead ?? 0,
54
+ cost: state.cost ?? 0,
26
55
  };
27
56
  }
28
57
 
@@ -31,13 +60,17 @@ export function usageStateFromLifetime(lifetime: LifetimeUsage): UsageState {
31
60
  input: lifetime.input,
32
61
  output: lifetime.output,
33
62
  cacheWrite: lifetime.cacheWrite,
34
- cacheRead: 0,
63
+ // P1: REAL values — the hard-coded `cacheRead: 0` (and the missing
64
+ // `cost` key) are gone. `cost` stays always-present-as-number, matching
65
+ // the stable-ledger convention (session-usage.ts compactUsage Fix D).
66
+ cacheRead: lifetime.cacheRead,
67
+ cost: lifetime.cost,
35
68
  };
36
69
  }
37
70
 
38
71
  const taskUsageMap = new Map<string, LifetimeUsage>();
39
72
 
40
- export function trackTaskUsage(taskId: string, delta: { input?: number; output?: number; cacheWrite?: number }): void {
73
+ export function trackTaskUsage(taskId: string, delta: UsageDelta): void {
41
74
  const existing = taskUsageMap.get(taskId) ?? emptyLifetimeUsage();
42
75
  addUsage(existing, delta);
43
76
  taskUsageMap.set(taskId, existing);
@@ -60,8 +93,8 @@ export const getTaskUsage = getTrackedTaskUsage;
60
93
  export const getRunUsage = getTrackedTaskUsage;
61
94
  export const clearAllTaskUsage = clearAllTrackedTaskUsage;
62
95
 
63
- /** @internal */
64
- function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
96
+ /** @internal — exported for unit tests. */
97
+ export function aggregateTrackedUsageForRun(manifest: TeamRunManifest, tasks: TeamTaskState[]): UsageState {
65
98
  const total = emptyLifetimeUsage();
66
99
  for (const task of tasks) {
67
100
  const tracked = getTrackedTaskUsage(task.id);