pi-crew 0.11.18 → 0.11.19

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.18",
3
+ "version": "0.11.19",
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,8 @@ 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).
181
184
 
182
185
  ## Core principle: disk ≠ live Pi
183
186
 
@@ -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)",
@@ -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
@@ -43,6 +43,7 @@ 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";
46
47
  import { trackTaskUsage } from "../usage-tracker.ts";
47
48
  import {
48
49
  buildYieldReminder,
@@ -234,8 +235,13 @@ type LiveSessionLike = {
234
235
  steer?: (text: string) => Promise<void>;
235
236
  abort?: () => Promise<void> | void;
236
237
  dispose?: () => void;
237
- getStats?: () => unknown;
238
- stats?: unknown;
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
+ };
239
245
  bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
240
246
  getActiveToolNames?: () => string[];
241
247
  setActiveToolsByName?: (names: string[]) => void;
@@ -525,15 +531,6 @@ function finalAssistantText(event: unknown): string[] {
525
531
  return textFromContent(message.content);
526
532
  }
527
533
 
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
534
  /**
538
535
  * F7: resolve the enabledModels allowlist for the current project, but only
539
536
  * if the `reliability.scopeModels` toggle is ON. Returns an empty
@@ -794,18 +791,12 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
794
791
  session.setActiveToolsByName(active);
795
792
  }
796
793
 
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;
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);
809
800
  }
810
801
 
811
802
  /**
@@ -1798,7 +1789,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1798
1789
  emit: emitEmbedded,
1799
1790
  })
1800
1791
  : undefined;
1801
- const usage = usageFromStats(typeof session.getStats === "function" ? session.getStats() : session.stats);
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?.());
1802
1795
  updateLiveAgentStatus(agentId, "completed");
1803
1796
  markLiveAgentCompleted(agentId);
1804
1797
  return {
@@ -1,3 +1,5 @@
1
+ import { usageFromValue } from "../session-usage.ts";
2
+
1
3
  export interface ParsedPiUsage {
2
4
  input?: number;
3
5
  output?: number;
@@ -24,14 +26,6 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
24
26
  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
25
27
  }
26
28
 
27
- function numberField(obj: Record<string, unknown>, keys: string[]): number | undefined {
28
- for (const key of keys) {
29
- const value = obj[key];
30
- if (typeof value === "number" && Number.isFinite(value)) return value;
31
- }
32
- return undefined;
33
- }
34
-
35
29
  function mergeUsage(target: ParsedPiUsage, source: ParsedPiUsage): ParsedPiUsage {
36
30
  return {
37
31
  input: source.input ?? target.input,
@@ -43,24 +37,17 @@ function mergeUsage(target: ParsedPiUsage, source: ParsedPiUsage): ParsedPiUsage
43
37
  };
44
38
  }
45
39
 
40
+ /**
41
+ * U16: usage extraction is delegated to the single shared parser
42
+ * (`usageFromValue` in session-usage.ts) so the subprocess tier accepts BOTH
43
+ * cost shapes — the session-level aggregate NUMBER and the message-level
44
+ * OBJECT with `total` (@earendil-works/pi-ai `Usage.cost`) — instead of
45
+ * silently dropping the object form. Same field aliases as before
46
+ * (inputTokens/input_tokens/cost_usd/…) and same nested fallbacks
47
+ * (usage/message/tokenUsage/tokens/stats).
48
+ */
46
49
  function extractUsage(value: unknown): ParsedPiUsage | undefined {
47
- const obj = asRecord(value);
48
- if (!obj) return undefined;
49
- const direct: ParsedPiUsage = {
50
- input: numberField(obj, ["input", "inputTokens", "input_tokens"]),
51
- output: numberField(obj, ["output", "outputTokens", "output_tokens"]),
52
- cacheRead: numberField(obj, ["cacheRead", "cache_read", "cacheReadTokens", "cache_read_tokens"]),
53
- cacheWrite: numberField(obj, ["cacheWrite", "cache_write", "cacheWriteTokens", "cache_write_tokens"]),
54
- cost: numberField(obj, ["cost", "costUsd", "cost_usd"]),
55
- turns: numberField(obj, ["turns", "turnCount", "turn_count"]),
56
- };
57
- if (Object.values(direct).some((entry) => entry !== undefined)) return direct;
58
- // Pi --mode json nests usage under message for message_end / turn_end events.
59
- for (const key of ["usage", "message", "tokenUsage", "tokens", "stats"]) {
60
- const nested = extractUsage(obj[key]);
61
- if (nested) return nested;
62
- }
63
- return undefined;
50
+ return usageFromValue(value);
64
51
  }
65
52
 
66
53
  function textFromContent(content: unknown): string[] {
@@ -959,6 +959,10 @@ export async function reconcileAllStaleRuns(
959
959
  // so locks whose holder died (kill -9 / crash) do not linger. The sweep is
960
960
  // strictly safer than acquire-time steal (stale AND holder-dead only), so a
961
961
  // live holder is never disturbed. Best-effort.
962
+ // U21 (2026-10-13): socket holders need no sweep — the kernel process socket
963
+ // of an async run-lock holder dies with its process. This sweep covers the
964
+ // SYNC path (withRunLockSync, file-only by design) + legacy file-only holders;
965
+ // remove after the file-lock migration window closes.
962
966
  try {
963
967
  const runsRoots = [
964
968
  path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir),
@@ -24,6 +24,33 @@ export interface RetryAttemptInfo {
24
24
  attemptId: string;
25
25
  }
26
26
 
27
+ /**
28
+ * U9-ADJ (upgrade 2026-10-13 §U9): terminal sentinel thrown when the retry
29
+ * loop stops because the `noProgress` hook detected a stuck loop. Distinct
30
+ * from ordinary give-up (exhaustion throws the ORIGINAL error untouched):
31
+ * this carries `reason: "no_progress"` plus the attempt info, with the
32
+ * original error preserved as `cause`. Callers distinguish the two exits via
33
+ * `error instanceof RetryNoProgressError` or `reason === "no_progress"`.
34
+ */
35
+ export class RetryNoProgressError extends Error {
36
+ readonly reason: "no_progress";
37
+ readonly attempt: number;
38
+ readonly attemptId: string;
39
+ readonly durationMsLast: number;
40
+
41
+ constructor(details: { attempt: number; attemptId: string; durationMsLast: number; error: Error }) {
42
+ super(
43
+ `Retry loop stopped after attempt ${details.attempt} (${details.attemptId}): no progress between attempts (last attempt took ${details.durationMsLast}ms). Original error: ${details.error.message}`,
44
+ { cause: details.error },
45
+ );
46
+ this.name = "RetryNoProgressError";
47
+ this.reason = "no_progress";
48
+ this.attempt = details.attempt;
49
+ this.attemptId = details.attemptId;
50
+ this.durationMsLast = details.durationMsLast;
51
+ }
52
+ }
53
+
27
54
  export interface RetryHooks {
28
55
  onAttemptFailed?: (attempt: number, error: Error, nextDelayMs: number, info: RetryAttemptInfo) => void;
29
56
  onRetryGivenUp?: (attempts: number, error: Error, info: RetryAttemptInfo) => void;
@@ -40,6 +67,29 @@ export interface RetryHooks {
40
67
  * (classifyAmbientNoiseGate), dormant unless runtime.classifierEnabled.
41
68
  */
42
69
  shouldRetry?: (attempt: number, error: Error, info: RetryAttemptInfo) => boolean | Promise<boolean>;
70
+ /**
71
+ * U9-ADJ (upgrade 2026-10-13 §U9): no-progress gate consulted after a
72
+ * failed attempt ONCE `shouldRetry` (if present) has approved queueing the
73
+ * next one, and BEFORE onAttemptFailed + backoff sleep. The CALLER owns the
74
+ * "no progress" semantics (e.g. compare checkpoint depth / error identity
75
+ * between attempts) — the executor stays generic and task-agnostic.
76
+ * `lastResult` / `previousResult` are the `Error`s thrown by the just-failed
77
+ * attempt and the one before it (`previousResult` is `undefined` on the
78
+ * first failure — nothing to compare yet); `durationMsLast` is the wall
79
+ * time of the just-failed attempt in ms. Returning true stops the loop with
80
+ * `RetryNoProgressError` (`reason: "no_progress"`) instead of spawning the
81
+ * next attempt — `onRetryGivenUp` still fires first (same terminal contract
82
+ * as the cancellation exit). SOFT: a throwing/rejecting hook is logged via
83
+ * logInternalError (the repo's console.error wrapper) and treated as false —
84
+ * a broken detector must never change retry behavior. Unregistered ⇒
85
+ * behaviour is bit-for-bit pre-U9-ADJ.
86
+ */
87
+ noProgress?: (context: {
88
+ attempt: number;
89
+ lastResult: unknown;
90
+ previousResult: unknown;
91
+ durationMsLast: number;
92
+ }) => boolean | Promise<boolean>;
43
93
  }
44
94
 
45
95
  export const DEFAULT_RETRY_POLICY: RetryPolicy = {
@@ -91,13 +141,18 @@ export async function executeWithRetry<T>(
91
141
  maxAttempts: Math.max(1, policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts),
92
142
  };
93
143
  let lastError: Error | undefined;
144
+ // U9-ADJ: result (thrown Error) of the PREVIOUS failed attempt, fed to the
145
+ // noProgress hook as `previousResult`. `undefined` until a first failure.
146
+ let previousFailedResult: unknown;
94
147
  for (let attempt = 1; attempt <= normalized.maxAttempts; attempt += 1) {
95
148
  throwIfCancelled(hooks.signal);
96
149
  const info = retryAttemptInfo(attempt, hooks);
150
+ const attemptStartedAtMs = Date.now();
97
151
  try {
98
152
  return await fn(attempt, info);
99
153
  } catch (error) {
100
154
  lastError = asError(error);
155
+ const durationMsLast = Date.now() - attemptStartedAtMs;
101
156
  // Never retry if aborted — sleep() would immediately reject on every attempt.
102
157
  if (hooks.signal?.aborted) {
103
158
  hooks.onRetryGivenUp?.(attempt, lastError, info);
@@ -124,6 +179,41 @@ export async function executeWithRetry<T>(
124
179
  throw lastError;
125
180
  }
126
181
  }
182
+ // U9-ADJ: no-progress gate — consulted once per failed attempt, after
183
+ // retryability + shouldRetry, BEFORE the backoff sleep + next spawn.
184
+ // Only the caller knows task semantics, so it compares the just-failed
185
+ // attempt against the previous one and decides. true ⇒ stop the loop
186
+ // with the distinct no_progress reason (not ordinary give-up exhaustion).
187
+ // SOFT: hook errors are logged (logInternalError) and treated as false.
188
+ if (hooks.noProgress) {
189
+ let noProgressDecision = false;
190
+ try {
191
+ noProgressDecision = await hooks.noProgress({
192
+ attempt,
193
+ lastResult: lastError,
194
+ previousResult: previousFailedResult,
195
+ durationMsLast,
196
+ });
197
+ } catch (noProgressError) {
198
+ logInternalError(
199
+ "retry-executor.noProgress",
200
+ noProgressError,
201
+ "soft-fail: treating as progress, retry stays queued",
202
+ "error",
203
+ );
204
+ noProgressDecision = false;
205
+ }
206
+ if (noProgressDecision === true) {
207
+ hooks.onRetryGivenUp?.(attempt, lastError, info);
208
+ throw new RetryNoProgressError({
209
+ attempt,
210
+ attemptId: info.attemptId,
211
+ durationMsLast,
212
+ error: lastError,
213
+ });
214
+ }
215
+ }
216
+ previousFailedResult = lastError;
127
217
  const delay = calculateRetryDelay(attempt, normalized);
128
218
  hooks.onAttemptFailed?.(attempt, lastError, delay, info);
129
219
  try {
@@ -13,7 +13,45 @@ function numberField(obj: Record<string, unknown>, keys: string[]): number | und
13
13
  return undefined;
14
14
  }
15
15
 
16
- function usageFromValue(value: unknown): UsageState | undefined {
16
+ /**
17
+ * U16 (D2 — two-tier cost): cost arrives EITHER as a session-level aggregate
18
+ * NUMBER (SDK `SessionStats.cost`) OR as a message-level OBJECT with a `total`
19
+ * field (`@earendil-works/pi-ai` `Usage.cost = {input, output, cacheRead,
20
+ * cacheWrite, total}`). Accept both; an object without a finite `total` yields
21
+ * undefined — never NaN.
22
+ */
23
+ function costField(obj: Record<string, unknown>): number | undefined {
24
+ for (const key of ["cost", "costUsd", "cost_usd"]) {
25
+ const raw = obj[key];
26
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
27
+ const record = asRecord(raw);
28
+ const total = record ? record.total : undefined;
29
+ if (typeof total === "number" && Number.isFinite(total)) return total;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ function mergeUsageFields(primary: UsageState, fallback: UsageState): UsageState {
35
+ return {
36
+ input: primary.input ?? fallback.input,
37
+ output: primary.output ?? fallback.output,
38
+ cacheRead: primary.cacheRead ?? fallback.cacheRead,
39
+ cacheWrite: primary.cacheWrite ?? fallback.cacheWrite,
40
+ cost: primary.cost ?? fallback.cost,
41
+ turns: primary.turns ?? fallback.turns,
42
+ };
43
+ }
44
+
45
+ /**
46
+ * U16: the single usage-shape parser (session-level `SessionStats` AND
47
+ * message-level `Usage`). Flat/direct fields win; nested payloads
48
+ * (`usage`/`message`/`tokenUsage`/`tokens`/`stats`) fill only the MISSING
49
+ * fields, so the SDK session shape `{tokens: {…}, cost}` maps completely while
50
+ * flat legacy shapes parse unchanged. `message` restores the legacy
51
+ * pi-json-output.ts nested key (git d29df7dd — Pi --mode json nests usage
52
+ * under `message` for message_end/turn_end events) for back-compat.
53
+ */
54
+ export function usageFromValue(value: unknown): UsageState | undefined {
17
55
  const obj = asRecord(value);
18
56
  if (!obj) return undefined;
19
57
  const direct: UsageState = {
@@ -21,14 +59,15 @@ function usageFromValue(value: unknown): UsageState | undefined {
21
59
  output: numberField(obj, ["output", "outputTokens", "output_tokens"]),
22
60
  cacheRead: numberField(obj, ["cacheRead", "cache_read", "cacheReadTokens", "cache_read_tokens"]),
23
61
  cacheWrite: numberField(obj, ["cacheWrite", "cache_write", "cacheWriteTokens", "cache_write_tokens"]),
24
- cost: numberField(obj, ["cost", "costUsd", "cost_usd"]),
62
+ cost: costField(obj),
25
63
  turns: numberField(obj, ["turns", "turnCount", "turn_count"]),
26
64
  };
27
- if (Object.values(direct).some((entry) => entry !== undefined)) return direct;
28
- for (const key of ["usage", "tokenUsage", "tokens", "stats"]) {
65
+ let merged: UsageState | undefined = Object.values(direct).some((entry) => entry !== undefined) ? direct : undefined;
66
+ for (const key of ["usage", "message", "tokenUsage", "tokens", "stats"]) {
29
67
  const nested = usageFromValue(obj[key]);
30
- if (nested) return nested;
68
+ if (nested) merged = merged ? mergeUsageFields(merged, nested) : nested;
31
69
  }
70
+ if (merged) return merged;
32
71
  const message = asRecord(obj.message);
33
72
  return message ? usageFromValue(message.usage) : undefined;
34
73
  }
@@ -48,6 +87,10 @@ function compactUsage(total: UsageState, foundKeys: Set<keyof UsageState>): Usag
48
87
  if (foundKeys.size === 0) return undefined;
49
88
  const compact: UsageState = {};
50
89
  for (const key of foundKeys) compact[key] = total[key];
90
+ // U16 (Fix D): stable ledger schema — once ANY usage was seen, `cost` is
91
+ // always present (0 when no source ever reported it) so downstream
92
+ // consumers can rely on `usage.cost` being a number.
93
+ compact.cost = compact.cost ?? 0;
51
94
  return compact;
52
95
  }
53
96
 
@@ -70,7 +70,7 @@ import { buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "..
70
70
  import { checkProcessLiveness } from "../process-status.ts";
71
71
  import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
72
72
  import { runWorker } from "../run-worker.ts";
73
- import { parseSessionUsage } from "../session-usage.ts";
73
+ import { parseSessionUsage, usageFromValue } from "../session-usage.ts";
74
74
  import { recordSupervisorContact, supervisorContactFromEvent } from "../supervisor-contact.ts";
75
75
  import type { ResultSource, TaskExecutionResult } from "./post-execution.ts";
76
76
  import type { StreamBridgeHandle, TaskExecutionContext } from "./pre-execution.ts";
@@ -937,7 +937,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
937
937
  if (event && typeof event === "object" && (event as Record<string, unknown>).type === "message_end") {
938
938
  const msg = (event as Record<string, unknown>).message as Record<string, unknown> | undefined;
939
939
  if (msg?.role === "assistant") {
940
- const usage = msg.usage as Record<string, number> | undefined;
940
+ // U16 (Fix D): parse via the shared parser so message-level usage
941
+ // (cost is an OBJECT {…, total} per @earendil-works/pi-ai Usage)
942
+ // contributes cost + cacheRead to the ledger too.
943
+ const usage = usageFromValue(msg.usage);
941
944
  if (usage) {
942
945
  // RT-8: spread before accumulating lifetimeUsage instead of
943
946
  // mutating in place — preserves the immutable-snapshot invariant.
@@ -946,7 +949,9 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
946
949
  lifetimeUsage: {
947
950
  input: (task.lifetimeUsage?.input ?? 0) + (usage.input ?? 0),
948
951
  output: (task.lifetimeUsage?.output ?? 0) + (usage.output ?? 0),
952
+ cacheRead: (task.lifetimeUsage?.cacheRead ?? 0) + (usage.cacheRead ?? 0),
949
953
  cacheWrite: (task.lifetimeUsage?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0),
954
+ cost: (task.lifetimeUsage?.cost ?? 0) + (usage.cost ?? 0),
950
955
  },
951
956
  };
952
957
  }
@@ -178,7 +178,7 @@ export async function prepareTaskExecutionContext(
178
178
  // it outlives the worker, which would blind the stale-reconciler's
179
179
  // PID-liveness gate forever.
180
180
  agentProgress: input.task.agentProgress ?? emptyCrewAgentProgress(),
181
- // Lifetime usage accumulator — survives compaction unlike session.stats
181
+ // Lifetime usage accumulator — survives compaction unlike session stats snapshots
182
182
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
183
183
  ...(dependencyContextText ? { dependencyContextText } : {}),
184
184
  } as TeamTaskState;