pi-crew 0.11.2 → 0.11.4

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 (58) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/index.mjs +491 -493
  3. package/package.json +5 -2
  4. package/skills/real-test-pi-crew/SKILL.md +19 -11
  5. package/src/extension/async-notifier.ts +32 -8
  6. package/src/extension/crew-input-router.ts +9 -2
  7. package/src/extension/plan-orchestrate.ts +19 -3
  8. package/src/extension/registration/commands/shared.ts +11 -10
  9. package/src/extension/registration/foreground-run-controller.ts +2 -2
  10. package/src/extension/registration/lazy-configurers.ts +4 -1
  11. package/src/extension/registration/lifecycle-handlers.ts +16 -10
  12. package/src/extension/registration/lifecycle.ts +2 -0
  13. package/src/extension/registration/observability.ts +27 -18
  14. package/src/extension/registration/team-tool.ts +9 -2
  15. package/src/extension/registration/wire-cross-extension.ts +2 -0
  16. package/src/extension/team-manager-command.ts +3 -2
  17. package/src/extension/team-tool/goal-wrap.ts +7 -0
  18. package/src/extension/team-tool/goal.ts +12 -0
  19. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  20. package/src/extension/team-tool/run-intent.ts +1 -1
  21. package/src/extension/team-tool/run.ts +1 -1
  22. package/src/runtime/async-runner.ts +36 -0
  23. package/src/runtime/broker/crew-broker.ts +61 -62
  24. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  25. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  26. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  27. package/src/runtime/delegate-spawn.ts +16 -2
  28. package/src/runtime/deterministic-ast.ts +14 -2
  29. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  30. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  31. package/src/runtime/goal-workflow/goal-loop-runner.ts +32 -0
  32. package/src/runtime/group-join.ts +11 -3
  33. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  34. package/src/runtime/model/runtime-warmup.ts +2 -0
  35. package/src/runtime/output/output-validator.ts +10 -5
  36. package/src/runtime/plan-approval.ts +14 -1
  37. package/src/runtime/recovery/crash-recovery.ts +75 -52
  38. package/src/runtime/subagent-manager.ts +43 -13
  39. package/src/runtime/task-runner/child-executor.ts +17 -6
  40. package/src/runtime/task-runner/post-execution.ts +44 -14
  41. package/src/runtime/task-runner/prompt-builder.ts +8 -0
  42. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  43. package/src/runtime/task-runner.ts +7 -0
  44. package/src/runtime/team-runner.ts +4 -1
  45. package/src/runtime/verification/verification-worktree.ts +3 -0
  46. package/src/state/coordination/mailbox.ts +7 -2
  47. package/src/state/stores/state-store.ts +8 -1
  48. package/src/ui/dashboard-panes/agents-pane.ts +11 -8
  49. package/src/ui/inline-panel/index.ts +1 -1
  50. package/src/ui/render-diff.ts +19 -6
  51. package/src/ui/run-dashboard.ts +11 -1
  52. package/src/ui/run-snapshot-cache.ts +9 -3
  53. package/src/ui/widget/task-list.ts +8 -5
  54. package/src/ui/widget/widget-model.ts +3 -1
  55. package/tsconfig.json +2 -0
  56. package/src/ui/card-colors.ts +0 -168
  57. package/src/utils/completion-dedupe.ts +0 -63
  58. package/src/utils/resolve-shell.ts +0 -36
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
7
10
  "engines": {
8
11
  "node": ">=22.0.0"
9
12
  },
@@ -93,7 +96,7 @@
93
96
  "test:functional": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=120000 test/functional/pi-crew-functional.test.ts",
94
97
  "test:platform": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=60000 test/platform/*.test.ts",
95
98
  "build:bundle": "node scripts/build-bundle.mjs",
96
- "test:bundle": "node --experimental-strip-types --test --test-force-exit test/unit/bundle-load.test.ts",
99
+ "test:bundle": "node scripts/test-runner.mjs --test-timeout=60000 --test-force-exit test/unit/bundle-load.test.ts",
97
100
  "watch:bundle": "node scripts/watch-bundle.mjs",
98
101
  "build:bundle:once": "node scripts/watch-bundle.mjs --once",
99
102
  "dev": "node scripts/dev-runner.mjs",
@@ -119,6 +119,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
119
119
  | `workflows/*.workflow.md` | T7 |
120
120
  | `.github/workflows/ci.yml` | T11e |
121
121
  | `scripts/wc-gate.mjs` | T11b |
122
+ | `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>`). Dev script — NOT bundled, no T3/T4 needed |
123
+ | `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). NOT bundled |
122
124
  | `dist/` (bundle) | T3 → T4 → T8 → T11j |
123
125
  | bất kỳ đường dẫn trên, trước khi commit | gates đầy đủ: critical + unit + tsc + biome + md5 |
124
126
 
@@ -134,6 +136,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
134
136
 
135
137
  > **2026-09-26 update (ambient-noise triage + temp-workspace hygiene + inline-async seam, commits `73790eaa`→`0006ddd5`, reports `real-test-2026-09-26-full-battery-manual.md` + addenda 1-5, ADRs `2026-09-26-inline-async-test-seam.md` / `2026-09-26-temp-workspace-hygiene.md`)**: (1) **Guest `gc-*` skip** — delegate-owned guest tasks have NO heartbeat channel; watcher + aggregator skip them at two independent layers (`agent==="delegate"` marker), guard-tests prove a genuinely-missing REAL worker is still flagged. (2) **Reconciler hygiene** — sweep batch rotates statelessly (`floor(now/60s) % batches`, starvation từ cluster kẹt đầu alphabet đã chết), `.cleanup-in-progress` sentinels cũ >10 phút được reclaim, test-runner dọn `pi-crew-*` pre-existing (mtime < suiteStart−30') sau mỗi suite — battery probe dirs trong /tmp phải KHÔNG đặt tên `pi-crew-*` hoặc phải tạo <30' trước T11. (3) **Inline-async test seam** — `PI_CREW_TEST_ASYNC_INLINE=1` + `PI_CREW_ALLOW_MOCK=1` chạy async run IN-PROCESS (`executeBackgroundRun` core shared với runner detached; `main()` giờ entry-guarded bằng `PI_CREW_BACKGROUND_RUNNER_ENTRY` hoặc argv check). Seam là công cụ UNIT TEST cho heavy async files (subagent-tools-integration: 5+min stall-prone → 14/14 trong 37s) — **battery T7/T9b/T10 TUYỆT ĐỐI không set** (xem row troubleshooting "seam leak"). New pinned suites: `stale-reconciler-rotation` (mutation-checked), `stale-reconciler-sentinel-reclaim`, `sweep-test-tmp` ×2, `async-runner-inline-seam` (mutation-checked), `heartbeat-watcher-guest-skip` ×2, `heartbeat-overlay` guest tests ×2. Suite anchors @ 2026-09-26: `npm test` **EXIT=0** — 8379 tests / 8372 pass / 0 fail / 7 skipped (~14 min), `test:critical` 116 (~25s), bundle **1677.8 KB** md5 `3860c0264940e7df91511da493fa375e`; CI flake Windows `subagent-tools-integration` spawn-stall **RESOLVED** bởi seam (đã từng tốn rerun ×4 trong một ngày).
136
138
 
139
+ > **2026-09-29 update (RR-021 optimization wave + 4 review rounds, baseline `79a758d2`..`4393858c`, 37 commits; story `docs/stories/RR-021.md`; audit `pi-crew-optimization-audit.md`)**: (1) **Correctness/hang-risks** — `classifyReviewOutcome` word-boundary fix (the `\baccept\b` string-literal bug + negation guard), git/rg probe timeouts, `withRunLockSync`→`withRunLock` rerouting (lock family v0.9.26 intact), `reconcileAllStaleRuns` concurrent (`mapConcurrent` bound 4, per-run error isolation), sleeps→waitFor. (2) **Bundle hygiene** — esbuild/acorn/diff load via lazy sync-createRequire shims, ajv external, dead code −767 lines: bundle 3360 KB → **1575.2 KB**; NEW gates `check:lazy-imports` (ci.yml:142; src-side `// LAZY:` marker enforcement + dist-side no-hoisted-heavy-import assertion) and `check:bundle-size` (ci.yml:169). (3) **Test-gate integrity (review rounds 2–4)** — the runner now fails LOUDLY on `# cancelled N>0` (dual reporter: stdout UX unchanged, full TAP to a temp file, scraped in BOTH single-run and batch paths — `parseCancelledCount` + 21-test `test-runner-exit.test.ts`), on explicitly-listed files that do not exist (node:test v22 silently skipped them → exit 0; round-3 MAJOR B1), and `test:bundle` routes through the guarded runner (was the only CI gate bypassing it). Incremental tsc: typecheck ~20s → **~3.5s warm**. Suite anchors @ 2026-09-29: unit **8204 tests / 8201 pass / 0 fail / 3 skipped** (909 files), integration **136 / 132 pass / 4 skipped** (35 files incl. 3 slow), `test:critical` **116** (~18s), bundle **1575.2 KB** md5 `35ca335a90da01ac631493f220aaf121` (`--committed-hash` OK). Line refs moved: `PI_CREW_TEST_RUNNER_TIMEOUT_MS` now `scripts/test-runner.mjs:419`; crash-recovery design comment `:228`.
140
+
137
141
  > **2026-09-21 update (F-L2 — steer boot-window loss, found by the live feature battery)**: a steer written to `<artifactsRoot>/steering/<taskId>.jsonl` while the worker extension is still LOADING was silently lost forever — `registerPiTeamsPromptRuntime` starts the file poll at load time, pi's loader gates action methods until bind (`sendMessage: notInitialized` THROWS), and the old poll advanced `lastOffset` to `stat.size` BEFORE the line loop while its per-line catch swallowed the throw as "malformed line" (run `team_20260921035840`, root-caused by watcher-driven bisection probes R7-R9: +0.65s post-boot = lost, +2.5s+ = delivered). Fix (`src/prompt/prompt-runtime.ts`): walk the buffer by byte offset, advance only past terminal verdicts (delivered/non-steer/rejected/malformed), rewind to the failing line on a DELIVERY failure, `createSeenSteerIdSet.unmark()` releases the dedup id so the retry is not skipped. Regression suite `test/unit/prompt/prompt-runtime-steer-boot-window.test.ts` (4 tests) — mutation-checked both layers (offset-revert → 4 fail; unmark-removal → exactly the id-bearing test fails). Note: `dist/index.mjs` is UNCHANGED by this fix — child workers load prompt-runtime from the SOURCE path (`pi-args.ts PROMPT_RUNTIME_EXTENSION_PATH`), so the fix is live without a bundle bump.
138
142
  >
139
143
  > **F-L2 live re-verification (post-restart, 2026-09-21 afternoon)** — two independent proofs: (a) **lab**: pre-write the steer, then `PI_CREW_STEERING_FILE=<file> pi --extension <repo>/src/prompt/prompt-runtime.ts -p '<long task>'` — stderr shows `[pi-crew:prompt-runtime.steer-delivery-failed] Extension runtime not initialized…` ×2 (pre-bind ticks now LOGGED instead of swallowed) then the post-bind tick delivers and the model complies; (b) **real team run**: watcher writes at **+0.51s** after the steering dir appears (the previously-lost window) → worker session log gains the `custom_message` and the probe token lands in `results/<taskId>.txt`. Suite anchors @ 2026-09-21 (post-fix): unit **8006 tests / 8003 pass / 0 fail / 3 skipped** (875 files; 1088s under load), integration **134 / 130 pass / 4 skipped**, `test:critical` **116**, bundle **3360.1 KB** md5 `2f45df91ed0580ee5bb5b9b57a8a8c3f` (UNCHANGED — see the source-path note above).
@@ -194,14 +198,16 @@ The skill maps to existing CI gates as follows:
194
198
  | `npm run typecheck` | Tier 3 | `.github/workflows/*.yml` (every PR) |
195
199
  | `npm run check:wc-gate` | Tier 11b | **in BOTH `ci` and `ci:fast` scripts** (`package.json:71-72`) + explicit step in `.github/workflows/ci.yml:66-71` (since `09dda842` — was `ci:fast`-only, i.e. advisory) |
196
200
  | Bundle-staleness check (incl. **ARCH-7 path-leak scan** since `7d18508b` — line-scans `dist/index.mjs` + structural sourcemap check for tracked-source leaks) | Tier 3 last step | `scripts/check-bundle-staleness.mjs`; `--committed-hash` mode = Tier 11j release gate |
197
- | `npm run test:bundle` (bundle import smoke, 2 tests) | Tier 3 post-build sanity | `test/unit/bundle-load.test.ts` |
201
+ | `npm run test:bundle` (bundle import smoke, 2 tests) | Tier 3 post-build sanity | `test/unit/bundle-load.test.ts` — routed through the guarded `scripts/test-runner.mjs` since RR-021 round-3 (was a bare `node --test` bypass, the only CI gate without the wrappers) |
198
202
  | `node scripts/release-smoke.mjs` (manual, release cut) | Tier 3/11j companion | ARCH-6: installs pi-* peer deps, `import()`s the tarball-installed bundle (`:77`), shape-checks exports |
199
- | Full `npm test` (= unit 869 files + integration 35 @ 2026-09-17) | n/a — too slow for in-loop | CI only; slow tier (3 files) is a SEPARATE glob `test:integration:slow` — only `npm run test:full` includes it |
203
+ | Full `npm test` (= unit 909 files + integration 35 incl. 3 slow @ 2026-09-29) | n/a — too slow for in-loop | CI only; slow tier (3 files) is a SEPARATE glob `test:integration:slow` — only `npm run test:full` includes it |
200
204
  | `PI_CREW_SMOKE=1` env | Tier 11e | set ONLY in `weekly-smoke.yml` (auth-gated); nightly.yml deliberately does NOT (comment at `:24`) |
201
205
  | `npm run check:decision-drift` | Tier 11 (doc↔code) | **build-blocking in `.github/workflows/ci.yml` since 2026-09-17** (review-remediation baseline): every `PI_CREW_*` token cited in `docs/decisions/*.md` must exist in `src/` |
202
206
  | `npm run check:env-vars` | Tier 11 | **build-blocking in `ci.yml` since 2026-09-17**: every `PI_CREW_*` read must be routed via `getCrewEnv` + registered |
203
207
  | `npm run check:event-types -- --enforce` | Tier 9a/11 | **build-blocking in `ci.yml` since 2026-09-17**: event registry (`src/state/contracts.ts`) must cover every emitted type — 168 registered / 0 drift @ 2026-09-17 (was 89, drift silent) |
204
- | `PI_CREW_TEST_RUNNER_TIMEOUT_MS` env | Tier 1 companion | overrides the test-runner spawn deadline (`scripts/test-runner.mjs:206`); default **1_500_000 ms** since 2026-09-17 (was 900_000 — sized for a 5800-test suite; the full suite outgrew it and the ETIMEDOUT became a false green pre-F05) |
208
+ | `npm run check:lazy-imports` | Tier 3 companion (RR-021 WI-2.3 + review rounds 2-4) | `scripts/check-lazy-imports.mjs` — src-side: every runtime `import()` position needs a `// LAZY:` marker (known gaps: ternary-else / object-property / case-label / runtime member-access after same-line colon — documented in-script); dist-side: no module-scope hoisted heavy import (esbuild/acorn/diff/jiti/cli-highlight/yaml/ajv). Build-blocking at `ci.yml:142` |
209
+ | `npm run check:bundle-size` | Tier 3 companion | `scripts/check-bundle-size.mjs` — committed bundle ≤ budget (Sprint 6 gate, `ci.yml:169`; budget re-anchored when RR-021 shrank the bundle 3360→1575 KB) |
210
+ | `PI_CREW_TEST_RUNNER_TIMEOUT_MS` env | Tier 1 companion | overrides the test-runner spawn deadline (`scripts/test-runner.mjs:419` since RR-021; was :206); default **1_500_000 ms** since 2026-09-17 (was 900_000 — sized for a 5800-test suite; the full suite outgrew it and the ETIMEDOUT became a false green pre-F05). Since RR-021 rounds 2-3 the runner ALSO fail-closes on `# cancelled N>0` (TAP dual-reporter scrape) and on explicitly-listed missing files — see `test/unit/scripts/test-runner-exit.test.ts` (21 tests) |
205
211
 
206
212
  To add Tier 1 to a pre-commit hook:
207
213
 
@@ -231,7 +237,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
231
237
 
232
238
  **What**: run the curated 14-file fast subset.
233
239
 
234
- **Why this exists**: full `npm run test:unit` runs 875 files (was 642 at skill-writing time — it keeps growing), several minutes. Verifier worker response timeout would kill the worker mid-run → run = "hang". The fix (introduced in commit `1cb2dca`) splits out a `test:critical` subset covering exactly what changed in the broker/UI work.
240
+ **Why this exists**: full `npm run test:unit` runs 909 files (was 642 at skill-writing time — it keeps growing), several minutes. Verifier worker response timeout would kill the worker mid-run → run = "hang". The fix (introduced in commit `1cb2dca`) splits out a `test:critical` subset covering exactly what changed in the broker/UI work.
235
241
 
236
242
  **How**:
237
243
 
@@ -239,7 +245,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
239
245
  time npm run test:critical
240
246
  ```
241
247
 
242
- Expected output: `# tests 116 # pass 116 # fail 0 # duration_ms ~21000`. (Count was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, **116 @ 2026-09-20** — verify with the actual run; the skill's hard-coded numbers drift between releases.)
248
+ Expected output: `# tests 116 # pass 116 # fail 0 # cancelled 0 # duration_ms ~18000`. (Count was 97 at v0.9.46, 101 at v0.9.66, 102 at the waitMethodsEnabled flip, **116 since 2026-09-20** — verify with the actual run; the skill's hard-coded numbers drift between releases.) **Since RR-021 round-2 the runner itself fails loudly on `# cancelled N>0`** (dual-reporter TAP scrape — the round-1 unref incident shipped green with 2 cancelled tests), and **round-3 added the missing-file pre-check**: an explicitly-listed file that does not exist now exits 1 naming the path instead of being silently skipped by node:test.
243
249
 
244
250
  **References**:
245
251
 
@@ -303,7 +309,7 @@ All three must show `# pass 101 # fail 0`. Measured times in this session (2026-
303
309
  **How**:
304
310
 
305
311
  ```bash
306
- npm run typecheck # ~20s, exits 0 with "strip-types import ok"
312
+ npm run typecheck # ~3.5s warm / ~5s cold since RR-021 incremental tsc (was ~20s), exits 0 with "strip-types import ok"
307
313
  npm run build:bundle # <1s, prints "[build-bundle] dist/index.mjs NNNN KB in NNN ms"
308
314
  md5sum dist/index.mjs
309
315
  node scripts/check-bundle-staleness.mjs # ARCH-7: staleness + path-leak scan — exit 0
@@ -320,7 +326,7 @@ Compare the printed md5 against what the user's Pi session loaded. If they diffe
320
326
  | Bundle builder | `scripts/build-bundle.mjs` (esbuild-based, bundles `index.bundle.ts` → `dist/index.mjs`) |
321
327
  | Bundle resolution rule | `index.ts:1-25` (entrypoint docstring); also `scripts/build-bundle.mjs:14-20` (entrypoint preference); **symlink is live for source files but the bundled `dist/index.mjs` is loaded** |
322
328
  | Postinstall hook | `scripts/postinstall.mjs:43` — best-effort bundle rebuild; falls back to strip-types if esbuild missing |
323
- | Bundle md5 anchors | `1cc4d55e18add7b9a036c569143320b6` (Phase-4 flip, ~2.78 MB) → `16e29d053bd370e24f40df147dadcb79` (v0.9.66, 2026-08-11) → `9b557ac106b82e1ee33d39dd0d6c7dd7` (post-MuxSurface-A1 main, 2026-08-27) → `945720b1ad25673d86e263cdd834532f` (post-Batch-10 branch tip `aa899a1e`, 2026-09-11, ~3.30 MB) → `25ffc88e9a13611f61871b4c4a5b17ab` (review-remediation F01–F20 wave, 2026-09-17, 3357.3 KB / 3,437,867 B). **Always check current**: `md5sum dist/index.mjs` |
329
+ | Bundle md5 anchors | `1cc4d55e18add7b9a036c569143320b6` (Phase-4 flip, ~2.78 MB) → `16e29d053bd370e24f40df147dadcb79` (v0.9.66, 2026-08-11) → `9b557ac106b82e1ee33d39dd0d6c7dd7` (post-MuxSurface-A1 main, 2026-08-27) → `945720b1ad25673d86e263cdd834532f` (post-Batch-10 branch tip `aa899a1e`, 2026-09-11, ~3.30 MB) → `25ffc88e9a13611f61871b4c4a5b17ab` (review-remediation F01–F20 wave, 2026-09-17, 3357.3 KB) → `3860c0264940e7df91511da493fa375e` (2026-09-26, 1677.8 KB) → **`35ca335a90da01ac631493f220aaf121`** (RR-021 wave, 2026-09-29, **1575.2 KB** — lazy esbuild/acorn/diff shims + ajv external + −767 dead lines). **Always check current**: `md5sum dist/index.mjs` |
324
330
 
325
331
  ---
326
332
 
@@ -776,9 +782,9 @@ herdr chỉ được detect khi **chính pi session đang chạy trong một her
776
782
  grep -c "atomicWriteJson" src/state/stores/plan-store.ts src/state/stores/ownership-map.ts # >=1 each
777
783
  # 2. terminal-state events are sync appendEvent (crash-recovery design comment at :221
778
784
  # (file lives at src/runtime/recovery/crash-recovery.ts after the runtime reorg)
779
- grep -n "Log the event first" src/runtime/recovery/crash-recovery.ts # design intent: sync
785
+ grep -n "Log the event first" src/runtime/recovery/crash-recovery.ts # design intent: sync (:228 @ 2026-09-29; was :221 pre-RR-021)
780
786
  # 3. buffered-site census — snapshot & audit:
781
- grep -rln "appendEventBuffered" src/ | wc -l # 16 files / ~70 raw matches (incl. imports+definition) at v0.10.5; audited live conversions = 43; EVERY new site needs the reader-audit
787
+ grep -rln "appendEventBuffered" src/ | wc -l # 15 files @ 2026-09-29 (16 at v0.10.5; RR-021 WI-4.2 dead-code sweep removed one); audited live conversions = 43; EVERY new site needs the reader-audit
782
788
  # 4. the full gate — test:critical has NO stores/dwf/recovery coverage:
783
789
  npm run test:unit # 875 files, ~8006 tests, ~11-12 min (670-730s measured 2026-09-20; 1088s under load 2026-09-21) — MANDATORY after any delayed-write conversion program
784
790
  ```
@@ -1080,7 +1086,7 @@ PY
1080
1086
  | Test using real `loadConfig()` to mock config | Flaky when env / disk config changes | `612e18b` | `test/unit/runtime/broker/crew-broker-server-gate.test.ts:78` (use `brokerEnv: "0"` instead of `flagOn: false`) |
1081
1087
  | Source edit seen immediately | No, requires bundle rebuild + reload | n/a (permanent) | `index.ts:1-25` — bundle resolution rules |
1082
1088
  | Skip disabled-path proof | `effectiveEnabled()` regression slips through | n/a (permanent) | Tier 2 above |
1083
- | `npm run test:unit` against the full suite (823 files now, 642 then) | several minutes; mis-judges verifier runtime | n/a (permanent) | Tier 1 above |
1089
+ | `npm run test:unit` against the full suite (909 files now, 642 then) | several minutes; mis-judges verifier runtime | n/a (permanent) | Tier 1 above |
1084
1090
  | Skip typecheck | TS errors slip past `test:critical` (which uses `--test-timeout=30000`) | n/a (permanent) | Tier 3 above |
1085
1091
  | Run `pi` from a stale bundle | Session shows old behavior despite src/ edits | n/a (permanent) | `scripts/check-bundle-staleness.mjs` — CI gate |
1086
1092
  | Test by reading code | Proves nothing about runtime | n/a (permanent) | All tiers above |
@@ -1109,7 +1115,7 @@ PY
1109
1115
  | **Worker killed mid long-silent-bash** (Batch-1 postmortem): one >5–10 min command emits no output events → watchdog SIGTERM mid-run — exit 143 WHILE the command runs. Work was intact; manual re-run green — the kill WAS the "hang". **2026-09-20 sharpening**: the trigger is EVENT SILENCE, not duration — `\| tail`/`\| head`/`> file` buffering manufactured silence from a chatty suite (proven: `worker.response_timeout` at exactly cmd_start+600s, heartbeat fresh, 29 earlier streaming commands all survived); genuinely-silent commands die unpiped too. | n/a (quirk — `CONTEXT.md` Flagged #1; P2 candidate) | Never `\| tail` a long suite in a worker — stream or split it; if buffering stdout, keep stderr out of the pipe (`2>&1` removes the last update source); raise `PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS` for legit silent workloads. On exit-143-mid-command: re-run manually BEFORE diagnosing a code bug; mine `.crew/state/runs/<runId>/background.log` for the killed worker's findings. Postmortem: `postmortem-batch-1-sigterm.md`; full RCA: run `team_20260919150900_6af9ee2bcd57a7f3` |
1110
1116
  | **`pkill -f "test:unit"` kills your own shell** (2026-09-16): the pattern matches the `bash -c` cmdline of the tool call that runs it, so the command dies before it starts the replacement suite — and the log file simply never appears. | n/a (self-inflicted) | Kill by pid (`pgrep -f test-runner` → `kill <pid>`), or match a pattern the sink does not contain | Tier 13 "How" step 0 — detached start: `(setsid nohup npm run test:unit > /tmp/suite.log 2>&1 < /dev/null &)` |
1111
1117
  | **`pgrep -f test-runner` read as liveness** (2026-09-16): the same self-match makes it report `RUNNING` forever, so a suite that died 10 minutes ago looks alive and the "result" you read is a truncated log. | n/a (self-inflicted) | Liveness = `ps -eo pid,etime,cmd \| grep -E "test-runner\|node --test"` with the grep itself excluded | Tier 13 harness step 1 — a run is DONE only when the `# tests/# pass/# fail` block exists at EOF |
1112
- | **Reading a truncated suite log as a summary** (2026-09-16): the runner died with `Test runner error: spawnSync … ETIMEDOUT` at subtest 3946/7860 under load; the last numbers looked like a verdict. | n/a (runner) | The verdict is the `# tests/# pass/# fail` block at EOF — absent means the run did not finish; re-run in the foreground with nothing else heavy running. Since 2026-09-17 (F05) the runner itself fails CLOSED on this class — `resolveExitCode()` (`scripts/test-runner.mjs:44`) maps ETIMEDOUT/spawn-error/signal-kill to a non-zero exit, so wrappers/CI see red instead of a silent exit-0 false green; the spawn deadline also rose 900s→1500s (`PI_CREW_TEST_RUNNER_TIMEOUT_MS` override) | Failure symptoms row "Test runner error: spawnSync … ETIMEDOUT" |
1118
+ | **Reading a truncated suite log as a summary** (2026-09-16): the runner died with `Test runner error: spawnSync … ETIMEDOUT` at subtest 3946/7860 under load; the last numbers looked like a verdict. | n/a (runner) | The verdict is the `# tests/# pass/# fail` block at EOF — absent means the run did not finish; re-run in the foreground with nothing else heavy running. Since 2026-09-17 (F05) the runner itself fails CLOSED on this class — `resolveExitCode()` (`scripts/test-runner.mjs:45`) maps ETIMEDOUT/spawn-error/signal-kill to a non-zero exit, so wrappers/CI see red instead of a silent exit-0 false green; the spawn deadline also rose 900s→1500s (`PI_CREW_TEST_RUNNER_TIMEOUT_MS` override). RR-021 rounds 2-3 closed the LAST two silent-green classes in the wrapper: `# cancelled N>0` (TAP dual-reporter scrape, fails both single-run and batch paths) and explicitly-listed missing files (pre-check exits 1 naming paths — node:test v22 used to silently skip them) | Failure symptoms row "Test runner error: spawnSync … ETIMEDOUT" |
1113
1119
  | **Running the full suite on a tree you are still editing** (2026-09-16): a 12-minute suite started before the last fixes reports failures that no longer exist — and passes fixes that were not in yet. | n/a (process) | Freeze the tree first; if you must edit, the run is VOID — say so instead of quoting its numbers | Done-criteria: "Full `test:unit` fresh-run" |
1114
1120
  | **Grepping the bundle without context or escape-awareness** (2026-09-16): esbuild escapes non-ASCII as `\u250F` (uppercase hex) so glyph greps miss, while `"->"` (the progress **wire format**, `roleSeparator`) and `Crew agents` (error message + task-graph markdown) are permanent false positives. | n/a (permanent) | Grep the codepoint escape (`u250F`, `u258F`) and always print ±90 chars of context before concluding | Tier 3 bundle check + Tier 13 step 2 (escape-aware proof snippet) |
1115
1121
  | **Trusting a delegated UI claim** (2026-09-16): four parallel executors + a verifier reported "dedupe applied" and "hints canonical"; the rendered surfaces still showed `fast-fix/fast-fix` and an invented `· right default`. Summaries describe intent. | n/a (process) | Render the surface with REAL state before believing any UI claim — including your own | Tier 13 (this entire tier exists for this class) |
@@ -1153,6 +1159,8 @@ When a tier fails, the recovery is usually quick. Match the symptom to the cause
1153
1159
  | Steer written during worker BOOT never reaches the model (no error, no log; later steers work) | F-L2 (fixed 2026-09-21): poll advanced its offset past an entry whose `pi.sendMessage` threw pre-bind ("not initialized") and the per-line catch ate the throw — one-shot silent loss | Fixed in `prompt-runtime.ts` (offset rewind + dedup unmark); pinned by `prompt-runtime-steer-boot-window.test.ts`. On older builds: re-send the steer after ~5s — a steer that lands post-bind delivers fine |
1154
1160
  | Steer "lost" but the file you wrote is FULL while a 0-byte `<taskId>.jsonl` sits beside it | **Probe bug, not a product bug** — the steering filename depends on run kind: team runs poll `<taskId>.jsonl`, crew_agent/subagent runs use `<slot>-agent.jsonl`; you wrote the wrong channel (no component reads it) | Re-run writing every `*.jsonl` in the steering dir (Tier 9g), or derive the name from the run kind. Cross-check `results/<taskId>.txt` + the worker session's `custom_message` before calling it a loss |
1155
1161
  | Fix verified in unit tests but a live probe still fails | Live path ≠ unit path: wrong filename (above), wrong role/tool for the probe instruction, or the session did not cold-start the changed module | Confirm the loaded artifact (`--extension <path>` resolution for workers is the SOURCE file, so a restart is enough; parent-side needs bundle+restart), then re-run Tier 9g with text-only compliance |
1162
+ | `[test-runner] FAIL: N cancelled test(s)` (runner diagnostic, any suite size) | A test drains the event loop while pending (unref'd race timers — the RR-021 round-1 shape) — node:test cancels it and the runner now fails loudly instead of exiting 0 | Fix the TEST (ref the timer / gate-promise pattern), never ignore or re-run hoping it passes; `test-runner-exit.test.ts` has the canonical red-green fixture |
1163
+ | `[test-runner] FAIL: N explicitly-listed test file(s) do not exist` | An explicit-list script (`test:critical` & friends) or manual command references a renamed/deleted file — node:test v22 silently skipped it (exit 0); the RR-021 round-3 pre-check refuses to spawn | Update the invoking script/command — the named path is the file that moved. Glob args are exempt (node:test expands them) |
1156
1164
  | Full `test:unit` fails ONLY on `wait-request-broker.test.ts` under parallel load | Per-file 180s runner timeout vs the file's real runtime (passes isolated) | `node scripts/test-runner.mjs test/unit/runtime/broker/wait-request-broker.test.ts` — green in isolation = infra flake, not a regression |
1157
1165
  | `undefined` (or `undefined — …`) painted in a live surface | an optional segment interpolated into a template literal without a guard. **Check BOTH paths**: the pure builder and the component that composes the same row (2026-09-16: `widget-renderer.buildWidgetLines` guarded it, `src/ui/widget/index.ts` did not). | Guard before composing (`if (!x) return [] / return undefined`), add a regression lock that renders the zero-state through the COMPONENT, and sweep every `${optional}` on the surface. |
1158
1166
  | Spinner keeps spinning after the run finished (`⠹ … 0 running`) | the surface hardcodes a spinner frame instead of deriving the glyph from state | Derive it: spinner only while an agent/run is actually running, otherwise the outcome glyph (`✓`/`✗`). Assert in the battery that "0 running" never coexists with a braille glyph. |
@@ -1,13 +1,13 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readCrewAgents, saveCrewAgents } from "../runtime/crew-agent-records.ts";
3
3
  import { checkProcessLiveness, isActiveRunStatus } from "../runtime/process-status.ts";
4
- import { withRunLockSync } from "../state/coordination/locks.ts";
4
+ import { withRunLock } from "../state/coordination/locks.ts";
5
5
  import { appendEvent, readEventsCursor, type TeamEvent } from "../state/event-log/event-log.ts";
6
6
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/stores/state-store.ts";
7
7
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { logInternalError } from "../utils/internal-error.ts";
9
9
  import { extractSessionId } from "../utils/session-utils.ts";
10
- import { listRuns } from "./run-index.ts";
10
+ import { listRecentRuns, listRuns } from "./run-index.ts";
11
11
  import type { WebhookNotifier } from "./webhook-notify.ts";
12
12
 
13
13
  export interface AsyncNotifierState {
@@ -107,7 +107,11 @@ function markActiveTasksAndAgentsFailed(run: TeamRunManifest, message: string):
107
107
  }
108
108
  }
109
109
 
110
- export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(), quietMs = 30_000): TeamRunManifest | undefined {
110
+ export async function markDeadAsyncRunIfNeeded(
111
+ run: TeamRunManifest,
112
+ now = Date.now(),
113
+ quietMs = 30_000,
114
+ ): Promise<TeamRunManifest | undefined> {
111
115
  if (!run.async || !isActiveRunStatus(run.status)) return undefined;
112
116
  const liveness = checkProcessLiveness(run.async.pid);
113
117
  if (liveness.alive) return undefined;
@@ -116,8 +120,13 @@ export function markDeadAsyncRunIfNeeded(run: TeamRunManifest, now = Date.now(),
116
120
  if (latestEventAgeMs(events, now) < quietMs) return undefined;
117
121
  const asyncPid = run.async.pid;
118
122
  const message = `Background runner died unexpectedly; check background.log (${liveness.detail}).`;
119
- return withRunLockSync(run, () => {
120
- const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
123
+ // RR-021 WI-1.4: rerouted to the async run-lock (same v0.9.26 lock family as
124
+ // the sync helper — sync and async acquisitions interoperate via live-token
125
+ // registration, RR-011 F02). The notifier tick is async, so we must not hold
126
+ // the event loop with a blocking sync acquisition while other async contenders
127
+ // (background runner, stale reconciler) wait.
128
+ return withRunLock(run, async () => {
129
+ const fresh = loadRunManifestById(run.cwd, run.runId); // NOTE: best-effort only inside the lock; concurrent writes may cause inconsistency;
121
130
  if (!fresh || !isActiveRunStatus(fresh.manifest.status)) return undefined;
122
131
  const failed = updateRunStatus(fresh.manifest, "failed", message);
123
132
  markActiveTasksAndAgentsFailed(failed, message);
@@ -163,16 +172,25 @@ export function startAsyncRunNotifier(
163
172
  if (isFinished(run.status) && updatedAtMs < staleBeforeMs) addSeenFinishedRunId(state, run.runId);
164
173
  }
165
174
  let cachedRuns: TeamRunManifest[] | undefined;
166
- state.interval = setInterval(() => {
175
+ // RR-021 WI-1.4: the tick is async now (markDeadAsyncRunIfNeeded awaits the
176
+ // run-lock). `ticking` preserves the old no-overlap semantics — setInterval
177
+ // must not re-enter a tick that is still awaiting a lock.
178
+ let ticking = false;
179
+ const tick = async (): Promise<void> => {
180
+ if (ticking) return;
181
+ ticking = true;
167
182
  try {
168
183
  if (options.isCurrent && !options.isCurrent(generation)) return;
169
184
  const nowMs = Date.now();
170
185
  if (cachedRuns === undefined || nowMs - (state.lastListRunsMs ?? 0) > LIST_RUNS_DEBOUNCE_MS) {
171
- cachedRuns = listRuns(ctx.cwd).filter(ownsRun).slice(0, 20);
186
+ // RR-021 WI-4.1: bounded recent-runs read (40 = 2x the 20 we keep) instead
187
+ // of a full listRuns() index scan on every debounce window — the 2x bound
188
+ // survives the ownsRun filter still yielding 20 owned runs.
189
+ cachedRuns = listRecentRuns(ctx.cwd, 40).filter(ownsRun).slice(0, 20);
172
190
  state.lastListRunsMs = nowMs;
173
191
  }
174
192
  for (const run of cachedRuns) {
175
- const current = markDeadAsyncRunIfNeeded(run) ?? run;
193
+ const current = (await markDeadAsyncRunIfNeeded(run)) ?? run;
176
194
  if (!isFinished(current.status) || state.seenFinishedRunIds.has(current.runId)) continue;
177
195
  addSeenFinishedRunId(state, current.runId);
178
196
  // Suppress notifications for INTERNAL goal-loop sub-runs.
@@ -213,7 +231,13 @@ export function startAsyncRunNotifier(
213
231
  return;
214
232
  }
215
233
  logInternalError("async-notifier", error, `interval=${intervalMs}`);
234
+ } finally {
235
+ ticking = false;
216
236
  }
237
+ };
238
+ state.interval = setInterval(() => {
239
+ // tick() never rejects (it catches internally) — void is safe.
240
+ void tick();
217
241
  }, intervalMs);
218
242
  // Defense-in-depth: never let the notifier timer keep the event loop alive.
219
243
  // If stopAsyncRunNotifier is missed (session switch race), the next run of
@@ -55,6 +55,13 @@ function phraseToRegex(phrase: string): RegExp {
55
55
  return new RegExp(`^${escaped}\\b`, "i");
56
56
  }
57
57
 
58
+ // RR-021 WI-4.3k: precompile the per-phrase regexes ONCE — the router used to
59
+ // rebuild every phrase regex on every routed input line.
60
+ const CREW_PHRASE_MATCHERS: ReadonlyArray<{ command: string; regex: RegExp }> = CREW_PHRASES.map((entry) => ({
61
+ command: entry.command,
62
+ regex: phraseToRegex(entry.phrase),
63
+ }));
64
+
58
65
  /**
59
66
  * Try to rewrite a natural-language crew phrase into a slash command.
60
67
  * Returns the rewritten command string, or `null` if no rule matches.
@@ -67,8 +74,8 @@ export function rewriteCrewInput(text: string): string | null {
67
74
  // Never transform explicit slash commands or inputs that don't start with
68
75
  // a crew/team keyword phrase.
69
76
  if (trimmed.startsWith("/")) return null;
70
- for (const entry of CREW_PHRASES) {
71
- const match = trimmed.match(phraseToRegex(entry.phrase));
77
+ for (const entry of CREW_PHRASE_MATCHERS) {
78
+ const match = trimmed.match(entry.regex);
72
79
  if (!match) continue;
73
80
  // Carry any remaining args after the matched phrase forward.
74
81
  const rest = trimmed.slice(match[0].length).trim();
@@ -183,11 +183,27 @@ export function parsePlanDocumentSimple(planPath: string): OrchestratedStep[] {
183
183
  /**
184
184
  * Detect implicit tag from content keywords when no explicit tag is present.
185
185
  */
186
+ // RR-021 WI-4.3k: precompiled word-boundary regexes — hasWord used to compile
187
+ // a fresh RegExp for every word on every call.
188
+ const WORD_MATCHERS: Readonly<Record<string, RegExp>> = {
189
+ design: /\bdesign\b/,
190
+ architecture: /\barchitecture\b/,
191
+ implement: /\bimplement\b/,
192
+ coding: /\bcoding\b/,
193
+ security: /\bsecurity\b/,
194
+ audit: /\baudit\b/,
195
+ build: /\bbuild\b/,
196
+ compile: /\bcompile\b/,
197
+ test: /\btest\b/,
198
+ verify: /\bverify\b/,
199
+ review: /\breview\b/,
200
+ feedback: /\bfeedback\b/,
201
+ };
186
202
  function detectImplicitTag(content: string): string | undefined {
187
203
  const lowerContent = content.toLowerCase();
188
- // Use word-boundary regex to avoid substring false matches.
189
- // E.g., "implementation" must NOT trigger "impl" — only the word "implement" should.
190
- const hasWord = (word: string): boolean => new RegExp(`\\b${word}\\b`).test(lowerContent);
204
+ // Word-boundary match avoids substring false matches: "implementation"
205
+ // must NOT trigger "impl" — only the word "implement" should.
206
+ const hasWord = (word: keyof typeof WORD_MATCHERS): boolean => WORD_MATCHERS[word].test(lowerContent);
191
207
 
192
208
  if (hasWord("design") || hasWord("architecture")) return "design";
193
209
  if (hasWord("implement") || hasWord("coding")) return "impl";
@@ -46,6 +46,7 @@ export async function handleTeamTool(
46
46
  if (!_cachedHandleTeamTool) {
47
47
  if (!_handleTeamToolPromise) {
48
48
  _handleTeamToolPromise = import("../../team-tool.ts").then((mod) => {
49
+ // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
49
50
  _cachedHandleTeamTool = mod.handleTeamTool;
50
51
  return mod.handleTeamTool;
51
52
  });
@@ -109,13 +110,13 @@ export async function ui(): Promise<NonNullable<typeof _uiCache>> {
109
110
  if (!_uiCachePromise) {
110
111
  _uiCachePromise = (async () => {
111
112
  const [rd, tv, co, md, mc, ap, ma] = await Promise.all([
112
- import("../../../ui/run-dashboard.ts"),
113
- import("../../../ui/transcript-viewer.ts"),
114
- import("../../../ui/overlays/confirm-overlay.ts"),
115
- import("../../../ui/overlays/mailbox-detail-overlay.ts"),
116
- import("../../../ui/overlays/mailbox-compose-overlay.ts"),
117
- import("../../../ui/overlays/agent-picker-overlay.ts"),
118
- import("../../../ui/mascot.ts"),
113
+ import("../../../ui/run-dashboard.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
114
+ import("../../../ui/transcript-viewer.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
115
+ import("../../../ui/overlays/confirm-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
116
+ import("../../../ui/overlays/mailbox-detail-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
117
+ import("../../../ui/overlays/mailbox-compose-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
118
+ import("../../../ui/overlays/agent-picker-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
119
+ import("../../../ui/mascot.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
119
120
  ]);
120
121
  const cache = {
121
122
  RunDashboard: rd.RunDashboard,
@@ -249,9 +250,9 @@ export function teamCommandContext(ctx: ExtensionCommandContext): ExtensionComma
249
250
  export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise<void> {
250
251
  if (!ctx.hasUI) return;
251
252
  const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
252
- import("../../../config/config.ts"),
253
- import("../../../ui/theme-adapter.ts"),
254
- import("../../../ui/settings-overlay.ts"),
253
+ import("../../../config/config.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
254
+ import("../../../ui/theme-adapter.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
255
+ import("../../../ui/settings-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
255
256
  ]);
256
257
  const loaded = loadConfig(ctx.cwd);
257
258
  const config = loaded.config as Record<string, unknown>;
@@ -103,7 +103,7 @@ function startForegroundRunImpl(
103
103
  extensionCtx.ui.setWorkingMessage(runId ? `pi-crew foreground run ${runId}...` : "pi-crew foreground run...");
104
104
  }
105
105
  if (runId) {
106
- void import("../../runtime/foreground-watchdog.ts")
106
+ void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
107
107
  .then(({ startForegroundWatchdog }) => {
108
108
  startForegroundWatchdog({ pi, cwd: extensionCtx.cwd, runId });
109
109
  })
@@ -139,7 +139,7 @@ function startForegroundRunImpl(
139
139
  .finally(() => {
140
140
  ctx.foregroundTeamRunControllers.delete(key);
141
141
  if (runId) {
142
- void import("../../runtime/foreground-watchdog.ts")
142
+ void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
143
143
  .then(({ stopWatchdog }) => {
144
144
  stopWatchdog(runId);
145
145
  })
@@ -67,7 +67,10 @@ function installTurnReconcileHook(pi: ExtensionAPI, ctx: RegistrationContext): v
67
67
  const current = ctx.currentCtx;
68
68
  if (!current || ctx.cleanedUp) return;
69
69
  try {
70
- reconcileAllStaleRuns(current.cwd, ctx.getManifestCache(current.cwd), undefined, extractSessionId(current));
70
+ // RR-021 WI-1.5: async reconcile — fire-and-forget from the sync hook.
71
+ void reconcileAllStaleRuns(current.cwd, ctx.getManifestCache(current.cwd), undefined, extractSessionId(current)).catch(
72
+ (error) => logInternalError("register.autoRepair.turnHook", error),
73
+ );
71
74
  } catch (error) {
72
75
  logInternalError("register.autoRepair.turnHook", error);
73
76
  }
@@ -456,17 +456,23 @@ async function runDeferredSessionCleanup(
456
456
 
457
457
  // Reconcile stale runs found on disk
458
458
  try {
459
- const staleResults =
460
- reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId) ?? [];
461
- if (staleResults.length > 0) {
462
- ctx.notifyOperator({
463
- id: "stale_reconcile",
464
- severity: "info",
465
- source: "crash-recovery",
466
- title: `Reconciled ${staleResults.length} stale run(s)`,
467
- body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
459
+ // RR-021 WI-1.5: reconcile is async (mapConcurrent bound 4) — keep this
460
+ // session_start callback synchronous; notify when the result lands.
461
+ void reconcileAllStaleRuns(extensionCtx.cwd, ctx.getManifestCache(extensionCtx.cwd), Date.now(), currentSessionId)
462
+ .then((staleResults) => {
463
+ if ((staleResults ?? []).length > 0) {
464
+ ctx.notifyOperator({
465
+ id: "stale_reconcile",
466
+ severity: "info",
467
+ source: "crash-recovery",
468
+ title: `Reconciled ${staleResults.length} stale run(s)`,
469
+ body: `Found and repaired ghost runs from previous sessions: ${staleResults.map((r) => r.runId).join(", ")}`,
470
+ });
471
+ }
472
+ })
473
+ .catch((error) => {
474
+ logInternalError("register.sessionStart.reconcileStale", error);
468
475
  });
469
- }
470
476
  } catch (error) {
471
477
  logInternalError("register.sessionStart.reconcileStale", error);
472
478
  }
@@ -87,6 +87,7 @@ export function startLifecycleWatchers(ctx: ExtensionContext, state: LifecycleSt
87
87
  // LAZY: async-notifier pulls in debounce + cron helpers — defer
88
88
  // until the first lifecycle install (deferred import within module).
89
89
  void import("../async-notifier.ts").then(({ startAsyncRunNotifier }) => {
90
+ // LAZY: defer async-notifier until the lifecycle event fires
90
91
  if (deps.isCleanedUp()) return;
91
92
  startAsyncRunNotifier(ctx, deps.notifierState, loadedConfig.config.notifierIntervalMs ?? DEFAULT_UI.notifierIntervalMs, {
92
93
  generation: deps.ownerGeneration,
@@ -110,6 +111,7 @@ export function stopLifecycleWatchers(state: LifecycleState, deps: LifecycleDeps
110
111
  state.notifierStarted = false;
111
112
  try {
112
113
  void import("../async-notifier.ts").then(({ stopAsyncRunNotifier }) => {
114
+ // LAZY: defer async-notifier until the lifecycle event fires
113
115
  stopAsyncRunNotifier(deps.notifierState);
114
116
  });
115
117
  } catch (error) {
@@ -71,7 +71,11 @@ export interface ObservabilityDeps {
71
71
  * newer session. Reuse this counter — do not invent a parallel one.
72
72
  */
73
73
  getSessionGeneration: () => number;
74
- reconcileStaleRuns: (cwd: string, cache: ReturnType<ObservabilityDeps["getManifestCache"]>, currentSessionId?: string) => unknown[];
74
+ reconcileStaleRuns: (
75
+ cwd: string,
76
+ cache: ReturnType<ObservabilityDeps["getManifestCache"]>,
77
+ currentSessionId?: string,
78
+ ) => unknown[] | Promise<unknown[] | undefined>;
75
79
  reconcileOrphanedTempWorkspaces: (now: number, opts: { cleanupOrphanedTempDirs?: boolean }) => unknown;
76
80
  cleanupOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
77
81
  cleanupLegacyOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
@@ -253,26 +257,31 @@ async function configureObservabilityInternal(ctx: ExtensionContext, state: Obse
253
257
  if (autoRepairIntervalMs > 0) {
254
258
  state.autoRepairTimer = setInterval(() => {
255
259
  if (deps.isCleanedUp()) return;
256
- try {
257
- const staleResults = deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
258
- if (Array.isArray(staleResults) && staleResults.length > 0) {
259
- for (const result of staleResults) {
260
- const repaired = (result as { repaired?: boolean }).repaired;
261
- if (repaired) {
262
- deps.notifyOperator({
263
- id: `auto_repair_${(result as { runId: string }).runId}`,
264
- severity: "info",
265
- source: "auto-repair",
266
- runId: (result as { runId: string }).runId,
267
- title: `Auto-repaired stale run`,
268
- body: (result as { detail?: string }).detail ?? "",
269
- });
260
+ // RR-021 WI-1.5: reconcileStaleRuns may be async (mapConcurrent) —
261
+ // settle the result before inspecting, keep errors inside the log.
262
+ void (async () => {
263
+ try {
264
+ const settled = await deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
265
+ const staleResults = Array.isArray(settled) ? settled : [];
266
+ if (staleResults.length > 0) {
267
+ for (const result of staleResults) {
268
+ const repaired = (result as { repaired?: boolean }).repaired;
269
+ if (repaired) {
270
+ deps.notifyOperator({
271
+ id: `auto_repair_${(result as { runId: string }).runId}`,
272
+ severity: "info",
273
+ source: "auto-repair",
274
+ runId: (result as { runId: string }).runId,
275
+ title: `Auto-repaired stale run`,
276
+ body: (result as { detail?: string }).detail ?? "",
277
+ });
278
+ }
270
279
  }
271
280
  }
281
+ } catch (error) {
282
+ logInternalError("register.autoRepair", error);
272
283
  }
273
- } catch (error) {
274
- logInternalError("register.autoRepair", error);
275
- }
284
+ })();
276
285
  }, autoRepairIntervalMs);
277
286
  state.autoRepairTimer.unref();
278
287
 
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import type { TObject } from "@sinclair/typebox";
5
- import { Value } from "@sinclair/typebox/value";
5
+ import { TypeCompiler } from "@sinclair/typebox/compiler";
6
6
  import { loadConfig } from "../../config/config.ts";
7
7
  import { findClosestKey } from "../../config/suggestions.ts";
8
8
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
@@ -36,6 +36,12 @@ import { withSessionId } from "../team-tool/context.ts";
36
36
  import { formatTeamToolParamError } from "../team-tool/param-error.ts";
37
37
  import { toolResult } from "../tool-result.ts";
38
38
 
39
+ // RR-021 WI-4.3g: compile the team-tool schema ONCE. Value.Check re-walks the
40
+ // whole (large) schema object on every tool call; TypeCompiler.Compile (STATIC
41
+ // method on @sinclair/typebox/compiler) builds a specialized checker at module
42
+ // load. The error formatter keeps using the schema + Value.Errors (cold path).
43
+ const compiledTeamToolParams = TypeCompiler.Compile(TeamToolParams);
44
+
39
45
  /**
40
46
  * Normalize model-generated tool params before schema validation.
41
47
  *
@@ -228,7 +234,8 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
228
234
  // validation so strict unions/patterns accept "unset" as omitted.
229
235
  const normalized = normalizeTeamParams(params);
230
236
  // Defense-in-depth: validate params at runtime even though Pi framework already does
231
- if (!Value.Check(TeamToolParams, normalized)) {
237
+ // (RR-021 WI-4.3g: precompiled checker — same semantics as Value.Check.)
238
+ if (!compiledTeamToolParams.Check(normalized)) {
232
239
  return toolResult(formatTeamToolParamError(TeamToolParams, normalized), { action: "list", status: "error" }, true);
233
240
  }
234
241
  // EXT-1: additionalProperties:true lets unknown (typo'd) keys slip past
@@ -28,6 +28,7 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
28
28
  // in the entire runtime chain) out of the cold-start module graph. EXT-9:
29
29
  // the registry now lives in module-scoped state, not globalThis.
30
30
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
31
+ // LAZY: defer team-tool until cross-extension wiring fires
31
32
  const manifestCacheForRegistry = ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd());
32
33
  installCrewGlobalRegistry({
33
34
  manifestCache: manifestCacheForRegistry,
@@ -57,6 +58,7 @@ export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: Reg
57
58
  return;
58
59
  }
59
60
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
61
+ // LAZY: defer team-tool until cross-extension wiring fires
60
62
  installCrewGlobalRegistry({
61
63
  manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
62
64
  cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { listRuns } from "./run-index.ts";
2
+ import { listRecentRuns } from "./run-index.ts";
3
3
  // Lazy-loaded: team-tool.ts pulls in entire runtime chain.
4
4
  import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
5
5
 
@@ -122,7 +122,8 @@ export async function handleTeamManagerCommand(_args: string, ctx: ExtensionComm
122
122
  return;
123
123
  }
124
124
 
125
- const runs = listRuns(ctx.cwd).slice(0, 20);
125
+ // RR-021 WI-4.1: bounded recent-runs read instead of a full index scan.
126
+ const runs = listRecentRuns(ctx.cwd, 20);
126
127
  if (runs.length === 0) {
127
128
  ctx.ui.notify("No pi-crew runs found.", "info");
128
129
  return;
@@ -21,6 +21,7 @@ import { loadConfig } from "../../config/config.ts";
21
21
  import type { GoalWrapWorkflowConfig } from "../../config/types.ts";
22
22
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
23
23
  import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
24
+ import { captureRunModelContext } from "../../runtime/model/session-model.ts";
24
25
  import { snapshotManifests } from "../../runtime/verification/verification-integrity.ts";
25
26
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
26
27
  import { atomicWriteJson } from "../../state/atomic-write.ts";
@@ -248,6 +249,11 @@ export async function startGoalWrappedRun(
248
249
  store.save(goalState);
249
250
 
250
251
  const paths = createRunPaths(cwd, goalId);
252
+ // Finding #1 (battery 2026-09-29): persist the parent session's model
253
+ // routing on the goal-loop manifest — the DETACHED runner re-loads this
254
+ // manifest and has no ExtensionContext to re-resolve it. Without it,
255
+ // goal-wrap turns silently default-chain (minimax 402 family).
256
+ const modelContext = captureRunModelContext(ctx, typeof params.model === "string" ? params.model : undefined);
251
257
  const goalLoopManifest: TeamRunManifest = {
252
258
  schemaVersion: 1,
253
259
  runId: goalId,
@@ -267,6 +273,7 @@ export async function startGoalWrappedRun(
267
273
  artifacts: [],
268
274
  ownerSessionId,
269
275
  runKind: "goal-loop",
276
+ ...(modelContext ? { modelContext } : {}),
270
277
  };
271
278
  await saveRunManifestAsync(goalLoopManifest);
272
279
  await appendEventAsync(paths.eventsPath, {