pi-crew 0.9.65 → 0.9.67

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 (54) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -3
  3. package/agents/executor.md +1 -1
  4. package/agents/test-engineer.md +1 -1
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +4526 -4100
  7. package/package.json +7 -4
  8. package/skills/real-test-pi-crew/SKILL.md +10 -9
  9. package/src/config/config.ts +19 -3
  10. package/src/config/role-tools.ts +2 -2
  11. package/src/config/types.ts +2 -0
  12. package/src/extension/knowledge-injection.ts +17 -0
  13. package/src/extension/pi-api.ts +0 -16
  14. package/src/extension/team-tool/api/agent-control.ts +358 -0
  15. package/src/extension/team-tool/api/handler-context.ts +57 -0
  16. package/src/extension/team-tool/api/heartbeat.ts +75 -0
  17. package/src/extension/team-tool/api/mailbox.ts +242 -0
  18. package/src/extension/team-tool/api/plan-approval.ts +190 -0
  19. package/src/extension/team-tool/api/read.ts +443 -0
  20. package/src/extension/team-tool/api/task-claims.ts +207 -0
  21. package/src/extension/team-tool/api.ts +56 -1301
  22. package/src/extension/team-tool/cancel.ts +49 -4
  23. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  24. package/src/extension/team-tool/explain.ts +3 -1
  25. package/src/extension/team-tool/goal-wrap.ts +2 -2
  26. package/src/extension/team-tool/goal.ts +4 -4
  27. package/src/extension/team-tool/lifecycle-actions.ts +9 -6
  28. package/src/extension/team-tool/parallel-dispatch.ts +2 -2
  29. package/src/extension/team-tool/respond.ts +11 -3
  30. package/src/extension/team-tool/run-intent.ts +357 -0
  31. package/src/extension/team-tool/run.ts +8 -285
  32. package/src/extension/team-tool/status.ts +56 -21
  33. package/src/extension/team-tool-types.ts +2 -0
  34. package/src/extension/team-tool.ts +10 -8
  35. package/src/prompt/scratchpad-lifecycle.ts +80 -5
  36. package/src/runtime/child-pi/child-pi-spawn.ts +13 -7
  37. package/src/runtime/child-pi/child-pi.ts +22 -144
  38. package/src/runtime/child-pi/mock-fixtures.ts +171 -0
  39. package/src/runtime/crew-agent-records.ts +18 -1
  40. package/src/runtime/output/output-validator.ts +34 -6
  41. package/src/runtime/scheduling/scheduler.ts +67 -19
  42. package/src/runtime/scratchpad/README.md +6 -0
  43. package/src/runtime/scratchpad/guest.ts +54 -5
  44. package/src/runtime/scratchpad/snapshot-hmac.ts +7 -1
  45. package/src/runtime/supervisor-contact.ts +0 -16
  46. package/src/runtime/task-runner/child-executor.ts +1 -0
  47. package/src/runtime/task-runner/state-helpers.ts +9 -1
  48. package/src/runtime/team-runner.ts +39 -2
  49. package/src/state/contracts.ts +3 -0
  50. package/src/state/event-log/event-log.ts +19 -27
  51. package/src/state/gitignore-manager.ts +61 -7
  52. package/src/utils/glob-match.ts +29 -0
  53. package/types/dwf.d.ts +1 -1
  54. package/src/types/new-api-types.ts +0 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.65",
3
+ "version": "0.9.67",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -67,22 +67,25 @@
67
67
  ],
68
68
  "scripts": {
69
69
  "check": "npm run ci",
70
- "ci": "npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
70
+ "ci": "npm run typecheck && npm run lint && npm run format:check && npm run check:conflict-markers && npm run check:decision-drift && npm run check:event-types && npm run check:lazy-imports && npm run check:bundle-staleness && npm run build:bundle && npm run check:bundle-size && npm run test:bundle && npm test && npm pack --dry-run",
71
71
  "check:lazy-imports": "node scripts/check-lazy-imports.mjs",
72
72
  "check:bundle-staleness": "node scripts/check-bundle-staleness.mjs",
73
73
  "check:bundle-size": "node scripts/check-bundle-size.mjs",
74
74
  "check:conflict-markers": "node scripts/check-conflict-markers.mjs",
75
75
  "check:decision-drift": "node scripts/check-decision-drift.mjs",
76
+ "check:event-types": "node scripts/check-event-types-registry.mjs",
76
77
  "typecheck": "tsc --noEmit && node --experimental-strip-types -e \"await import('./index.ts'); console.log('strip-types import ok')\"",
77
78
  "lint": "biome check --linter-enabled=true --formatter-enabled=false --max-diagnostics=50 .",
78
79
  "format:check": "biome format .",
79
80
  "test": "npm run test:unit && npm run test:integration",
80
81
  "test:unit": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=180000 --test-force-exit 'test/unit/**/*.test.ts'",
81
82
  "test:critical": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=30000 --test-force-exit test/unit/runtime/broker/crew-broker-handshake.test.ts test/unit/runtime/broker/crew-broker-stale-socket.test.ts test/unit/runtime/broker/crew-broker-feature-flag.test.ts test/unit/runtime/broker/crew-broker-server-gate.test.ts test/unit/runtime/broker/crew-broker-client-fallback.test.ts test/unit/runtime/broker/crew-broker-mailbox-observer.test.ts test/unit/runtime/broker/crew-broker-close-during-reconnect.test.ts test/unit/runtime/broker/crew-broker-steer-dedup.test.ts test/unit/runtime/broker/crew-broker-symlink-steering.test.ts test/unit/ui/keybinding-map.parity.test.ts test/unit/ui/pi-tui-dispatch-probe.test.ts test/unit/utils/session-utils-extract.test.ts test/unit/config/config-schema-sync.test.ts test/unit/runtime/child-pi/child-pi-env-spread.test.ts",
82
- "test:watch": "tsx --watch --test --test-concurrency=4 --test-timeout=30000 --test-force-exit 'test/unit/**/*.test.ts'",
83
+ "test:watch": "node scripts/test-runner.mjs --watch --test-concurrency=4 --test-timeout=30000 'test/unit/**/*.test.ts'",
83
84
  "test:integration": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=300000 test/integration/*.test.ts",
84
85
  "test:smoke": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=180000 test/smoke/*.smoke.ts",
85
86
  "test:spike": "node scripts/test-runner.mjs --test-concurrency=2 --test-timeout=120000 --test-force-exit test/runtime/scratchpad/*.test.ts",
87
+ "test:functional": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=120000 test/functional/pi-crew-functional.test.ts",
88
+ "test:platform": "node scripts/test-runner.mjs --test-concurrency=1 --test-timeout=60000 test/platform/*.test.ts",
86
89
  "build:bundle": "node scripts/build-bundle.mjs",
87
90
  "test:bundle": "node --experimental-strip-types --test --test-force-exit test/unit/bundle-load.test.ts",
88
91
  "watch:bundle": "node scripts/watch-bundle.mjs",
@@ -93,7 +96,7 @@
93
96
  "build:crew-font": "python scripts/build-crew-vibes-font.py",
94
97
  "bench": "node scripts/run-bench.mjs",
95
98
  "bench:check": "node scripts/bench-check.mjs",
96
- "test:new": "tsx --test --test-concurrency=4 --test-timeout=30000 --test-force-exit 'test/unit/**/*.test.ts'",
99
+ "test:new": "node scripts/test-runner.mjs --test-concurrency=4 --test-timeout=30000 'test/unit/**/*.test.ts'",
97
100
  "test:changed": "node scripts/test-changed.mjs",
98
101
  "bench:capture": "node scripts/run-bench.mjs && node -e \"require('node:fs').copyFileSync('test/bench/results.json','test/bench/baseline.json')\"",
99
102
  "profile:startup": "node scripts/profile-startup.mjs",
@@ -110,7 +110,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
110
110
 
111
111
  ---
112
112
 
113
- ## Tier 1 — Critical unit tests (~21s, 97 tests, the only suite you need for broker/UI changes)
113
+ ## Tier 1 — Critical unit tests (~25s, 101 tests, the only suite you need for broker/UI changes)
114
114
 
115
115
  **What**: run the curated 14-file fast subset.
116
116
 
@@ -122,7 +122,7 @@ To add Tier 1 to CI as a fast-feedback gate (under 30s):
122
122
  time npm run test:critical
123
123
  ```
124
124
 
125
- Expected output: `# tests 97 # pass 97 # fail 0 # duration_ms ~21000`.
125
+ Expected output: `# tests 101 # pass 101 # fail 0 # duration_ms ~26000`. (Count was 97 at v0.9.46; 101 since the model-routing merge — verify with the actual run; the skill's hard-coded numbers drift between releases.)
126
126
 
127
127
  **References**:
128
128
 
@@ -162,7 +162,7 @@ PI_CREW_BROKER=0 npm run test:critical
162
162
  PI_CREW_BROKER=1 npm run test:critical
163
163
  ```
164
164
 
165
- All three must show `# pass 97 # fail 0`. Measured times in this session: ~20s for default and `PI_CREW_BROKER=0`, ~21s for `PI_CREW_BROKER=1` (varies ±1s run-to-run).
165
+ All three must show `# pass 101 # fail 0`. Measured times in this session (2026-08-11): ~26s for default, ~26s for `PI_CREW_BROKER=0`, ~26s for `PI_CREW_BROKER=1` (varies ±1-2s run-to-run).
166
166
 
167
167
  **References**:
168
168
 
@@ -202,7 +202,7 @@ Compare the printed md5 against what the user's Pi session loaded. If they diffe
202
202
  | Bundle builder | `scripts/build-bundle.mjs` (esbuild-based, bundles `index.bundle.ts` → `dist/index.mjs`) |
203
203
  | Bundle resolution rule | `index.ts:5-22` (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** |
204
204
  | Postinstall hook | `scripts/postinstall.mjs:43` — best-effort bundle rebuild; falls back to strip-types if esbuild missing |
205
- | Bundle md5 after Phase-4 commit | `1cc4d55e18add7b9a036c569143320b6` (~2.78 MB at the time; bundle size drifts ±5% between releases, check current `ls -la dist/index.mjs`) |
205
+ | Bundle md5 after Phase-4 commit | `1cc4d55e18add7b9a036c569143320b6` (~2.78 MB at the time; **check current**: `md5sum dist/index.mjs`. As of v0.9.66 I-batch 2026-08-11: `16e29d053bd370e24f40df147dadcb79` ~2.81 MB) |
206
206
 
207
207
  ---
208
208
 
@@ -454,7 +454,7 @@ If the two md5s match → session is on the latest code. If not → user must `/
454
454
 
455
455
  **Real measured outcome** (this session, after the v0.9.57 schema fix): 9a (15 team actions) + 9b (4 subagent tools / 3 run paths) exercised; all green; the two silent-failure modes that motivated this tier (`Unknown type` from `Type.Unsafe` without Kind, and `Validation failed for tool team` from empty-string-strict schema) were caught ONLY by this battery — Tier 1-8 all passed while the team tool was broken live. The session also surfaced the unauthorized-agent-edit anti-pattern (a chain-run agent edited `chain-runner.ts` mid-smoke) — see Anti-patterns.
456
456
 
457
- **Not covered by the cheap battery above** — the actions below need extra setup, cost, or user confirmation. Run them only when the change touches their code path, and prefer a throwaway cwd / config so you don't mutate the user's real state. Organised by cost/safety:
457
+ **Not covered by the cheap battery above** — the actions below need extra setup, cost, or user confirmation. Run them only when the change touches their code path, and prefer a throwaway cwd / config so you don't mutate the user's real state. Organised by cost/safety. **As of 2026-08-11 (extended battery, run report `real-test-2026-08-11-scratchpad-I-batch.md`), 9c/9e/9f have been exercised live once each — they are no longer unproven, but still require explicit scope+confirmation to re-run.**
458
458
 
459
459
  **9c. Lifecycle / recovery** (needs a *running* run — start an async run, then exercise these against its runId):
460
460
  - `team action='wait' runId='...'` — block until completion
@@ -508,9 +508,10 @@ If the two md5s match → session is on the latest code. If not → user must `/
508
508
  | `makeFakeCtx({ flagOn: false })` without `brokerEnv: "0"` | `makeFakeCtx` deletes `PI_CREW_BROKER` env if `brokerEnv` is undefined | `612e18b` (test fix) | `test/unit/crew-broker-server-gate.test.ts:78` — pass `brokerEnv: "0"` to preserve env |
509
509
  | Trust green CI on one OS | macOS/Windows regressions slip through | n/a (permanent) | `.crew/knowledge.md` — "CI runs 3 OSes ... A flake on one OS IS a real bug" |
510
510
  | Trusting a team-run agent not to edit the repo under test | Agents spawned by `team`/`Agent`/`crew_agent` inherit the session cwd and have `edit`/`write` tools — a proactive LLM (observed with deepseek) will make **unauthorized source edits** to pi-crew during a trivial smoke run (e.g. "improving" `chain-runner.ts` while parsing a chain string). The edit can be correct + green-tested yet still be unintended scope creep that silently lands in your commit. | n/a (permanent) | After EVERY team/subagent run: `git status` and verify each changed file was authored by you. Diff + review any surprise change before staging. Consider `workspaceMode: 'worktree'` for parallel/risky runs to isolate mutations. |
511
+ | **Armed-role tool-surface bug (found live 2026-08-11)**: an opt-in tool (e.g. `scratchpad`) is armed via `ROLE_TOOL_CONFIGS[role].scratchpad=true` AND env `PI_CREW_SCRATCHPAD=1`, but NEVER appears in the worker surface. Root cause: `resolveToolPolicy` (`src/agents/agent-config.ts:165`) falls back `roleConfig.tools ?? agent.tools` when the role has no `tools` allowlist, and the builtin `agents/{executor,verifier,test-engineer}.md` frontmatter `tools:` did not list `scratchpad` → pi got `--tools read,grep,find,ls,bash,edit,write` and **hard-filtered** scratchpad. Env was correct; the tool was silently dropped by the `--tools` allowlist. Reproduce: `pi -p --tools read,grep,find,ls,bash,edit,write "list tools"` → no scratchpad; with scratchpad added → present. | `f753be30` | **Fix**: keep armed-role `agents/*.md` frontmatter `tools:` lists in sync with `ROLE_TOOL_CONFIGS` (QW17 pins the pinned roles; add the new tool to BOTH frontmatter AND role config for pinned roles, frontmatter-only for vacuous roles). A smoke run that claims the tool is "not available" in the worker is a REAL signal — verify the worker's actual `--tools` allowlist, not just env vars. |
511
512
  | `Type.Unsafe({ anyOf/type })` schema field **without** `[TypeBox.Kind]` symbol | `Value.Check` throws `Unknown type` the first time a model emits that field (e.g. `skill`, `config`) — every team action returns `isError:true` text `"Unknown type"`. Tier 1-8 stay green because unit tests never send the offending field. | v0.9.57 | `src/schema/team-tool-schema.ts` — `SkillOverride`/`FreeformConfig` switched from `Type.Unsafe` to TypeBox-native `Type.Union`/`Type.Record`. See Tier 9. |
512
513
  | Schema too strict for model-emitted empty strings (`runId:""`, `workspaceMode:""`, `budgetTotal:0`) | pi-ai `validateToolArguments` runs BEFORE the pi-crew handler and rejects `""` against Literal unions / patterns → `Validation failed for tool team` → model loops. | v0.9.57 | `src/schema/team-tool-schema.ts` — added `Literal("")` to unions, `^$|` pattern for runId, `""` to action enum, `0`/Boolean allowances. Handler-side `normalizeTeamParams` drops the empties. |
513
- | Claiming "all 9 tiers pass" while 9c–9f were never run | Overclaim — once reported "9 tiers pass" when only 9a (8/10) + 9b (4/5) had actually run; 9c–9f were skipped. Past runs then become unverifiable ("did it really pass 9 tiers?"). | n/a (process) | Fill `REPORT-TEMPLATE.md` per-tier DURING the run. "Tier 9 pass" = 9a AND 9b AND the applicable 9c–9f, each with evidence. Round-up-to-pass is the anti-pattern this row exists to prevent. |
514
+ | Claiming "all 9 tiers pass" while 9c–9f were never run | Overclaim — once reported "9 tiers pass" when only 9a (8/10) + 9b (4/5) had actually run; 9c–9f were skipped. Past runs then become unverifiable ("did it really pass 9 tiers?"). **2026-08-11 repeat**: an initial report said "9c–9f skipped" yet the summary read as full coverage until the gap was called out. | n/a (process) | Fill `REPORT-TEMPLATE.md` per-tier DURING the run. "Tier 9 pass" = 9a AND 9b AND the applicable 9c–9f, each with evidence. Round-up-to-pass is the anti-pattern this row exists to prevent. If 9c–9f are skipped, SAY SO in the verdict and do not phrase it as "all pass". |
514
515
  | chain run with `workflow:"chain"` forwarded to steps | Every chain step fails in ~58ms with an EMPTY error string — looks like a parse failure but isn't. `chain-dispatch` forwards `params.workflow` ("chain") into executor overrides; each step then runs the "chain" workflow via the normal `executeTeamRun` path and fails fast + silently. | Open (issue #44) | Omit `workflow` when invoking `action:'run' chain=...` — chain then runs 2/2 success (~308s). See `docs/bugs/chain-workflow-forward-quirk.md`. |
515
516
 
516
517
  ---
@@ -648,7 +649,7 @@ The skill does NOT need to be updated for every commit — only when the cited l
648
649
  ## Quick reference — exact commands
649
650
 
650
651
  ```bash
651
- # Tier 1 (critical unit, ~21s, 97 tests)
652
+ # Tier 1 (critical unit, ~25s, 101 tests)
652
653
  npm run test:critical
653
654
  # Tier 2 (3-path proof, broker changes only)
654
655
  PI_CREW_BROKER=0 npm run test:critical
@@ -687,14 +688,14 @@ md5sum "$(npm root -g)"/pi-crew/dist/index.mjs 2>/dev/null \
687
688
 
688
689
  Before claiming "tested":
689
690
 
690
- - [ ] Tier 1: `test:critical` fresh-run, all pass (<25s). Count varies by release — was 97 at v0.9.46, 101 after the model-routing merge; record the actual count in the report.
691
+ - [ ] Tier 1: `test:critical` fresh-run, all pass (<25s). Count varies by release — was 97 at v0.9.46, **101 since the model-routing merge (v0.9.66)**; record the actual count in the report.
691
692
  - [ ] Tier 2: 3-path proof all pass — **required if you touched `src/config/defaults.ts` or `src/extension/registration/lifecycle-handlers.ts`**
692
693
  - [ ] Tier 3: `npm run typecheck` exit 0, `npm run build:bundle` exit 0
693
694
  - [ ] Tier 4: bundle md5 matches what the session loaded (or user has `/quit`-ed + reopened)
694
695
  - [ ] Tier 5/6: live TUI smoke for any `src/ui/` change — keystroke reached `handleInput`
695
696
  - [ ] Tier 7: smoke team run for any `src/runtime/plan-templates.ts` or `workflows/*.workflow.md` change — completed, no hang, verifier output under 60s
696
697
  - [ ] Tier 8: final md5 sync check passed
697
- - [ ] Tier 9: feature battery — **required if you touched `src/schema/team-tool-schema.ts`, `src/extension/registration/team-tool.ts`, or any `Type.Unsafe({...})` schema**. 9a read-only batch all return clean; one probe per 9b spawn path (sync / async / chain / `Agent` / `crew_agent`+`get_subagent_result`) completes with `consistency=1`. Run 9c–9f only when the change touches their code path; 9d (destructive) requires explicit user confirmation. **After every run: `git status` to catch unauthorized agent edits.**
698
+ - [ ] Tier 9: feature battery — **required if you touched `src/schema/team-tool-schema.ts`, `src/extension/registration/team-tool.ts`, any `Type.Unsafe({...})` schema, or any armed-role tool list (`agents/*.md` / `src/config/role-tools.ts`)**. 9a read-only batch all return clean; one probe per 9b spawn path (sync / async / chain / `Agent` / `crew_agent`+`get_subagent_result`) completes with `consistency=1`. Run 9c–9f only when the change touches their code path; **at least one full 9c/9e/9f sweep per release is recommended so the battery stays proven** (see `real-test-2026-08-11-scratchpad-I-batch.md`); 9d (destructive) requires explicit user confirmation. **After every run: `git status` to catch unauthorized agent edits.**
698
699
  - [ ] **Output report**: save `docs/real-test/reports/real-test-<YYYY-MM-DD>-<slug>.md` from `skills/real-test-pi-crew/REPORT-TEMPLATE.md`, filled DURING the run with per-tier evidence (counts/md5/runId) — not reconstructed from memory afterward. This is what makes past runs verifiable instead of trust-the-summary.
699
700
 
700
701
  **"All 9 tiers pass" is a claim that needs per-row evidence.** Tier 9 means 9a **and** 9b **and** whichever of 9c–9f applies to the change — not "9a passed, therefore 9 passed". If any required item above is unchecked or lacks concrete evidence (a number, an md5, a runId), the answer to "is it tested?" is **no** — say so explicitly instead of rounding up to "pass".
@@ -1249,12 +1249,20 @@ export function updateConfig(patch: PiTeamsConfig, options: UpdateConfigOptions
1249
1249
  for (const unset of options.unsetPaths) unsetPath(raw, unset);
1250
1250
  merged = parseConfig(raw);
1251
1251
  }
1252
+ // Skip-if-unchanged: an empty/identical patch must not rewrite the file
1253
+ // (e.g. `team action='config'` with an empty patch — read-only path).
1254
+ // Both sides are parseConfig-normalized, so JSON.stringify key order is
1255
+ // deterministic (same construction path); no key sorting needed.
1256
+ const normalizedCurrent = parseConfig(current);
1257
+ if (JSON.stringify(merged) === JSON.stringify(normalizedCurrent)) {
1258
+ return { path: filePath, config: merged, written: false }; // unchanged — skip write
1259
+ }
1252
1260
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
1253
1261
  atomicWriteFile(filePath, `${JSON.stringify(merged, null, 2)}\n`);
1254
1262
  // (F16) Invalidate the loadConfig cache after a write — the next
1255
1263
  // caller must see the new value, not a 0-2s stale snapshot.
1256
1264
  invalidateConfigCache();
1257
- return { path: filePath, config: merged };
1265
+ return { path: filePath, config: merged, written: true };
1258
1266
  });
1259
1267
  }
1260
1268
 
@@ -1273,10 +1281,18 @@ export function updateAutonomousConfig(patch: PiTeamsAutonomousConfig): SavedPiT
1273
1281
  current.autonomous && typeof current.autonomous === "object" && !Array.isArray(current.autonomous)
1274
1282
  ? (current.autonomous as Record<string, unknown>)
1275
1283
  : {};
1276
- current.autonomous = { ...currentAutonomous, ...patch };
1284
+ // Skip-if-unchanged (raw shape): a no-op autonomous patch must not
1285
+ // rewrite the file. NOTE: compare the RAW on-disk record, NOT the
1286
+ // parseConfig-normalized shape — normalizing would add default keys and
1287
+ // false-positive the equality check.
1288
+ const next = { ...current, autonomous: { ...currentAutonomous, ...patch } };
1289
+ if (JSON.stringify(next) === JSON.stringify(current)) {
1290
+ return { path: filePath, config: parseConfig(current), written: false }; // unchanged — skip write
1291
+ }
1292
+ current.autonomous = next.autonomous;
1277
1293
  atomicWriteFile(filePath, `${JSON.stringify(current, null, 2)}\n`);
1278
1294
  // (F16) Invalidate the loadConfig cache after a write — see updateConfig.
1279
1295
  invalidateConfigCache();
1280
- return { path: filePath, config: parseConfig(current) };
1296
+ return { path: filePath, config: parseConfig(current), written: true };
1281
1297
  });
1282
1298
  }
@@ -84,7 +84,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
84
84
  // agents/verifier.md). Tool-set keeps bash but excludes edit/write so source
85
85
  // integrity is preserved during verification. Mirrors cold-verifier behavior.
86
86
  verifier: {
87
- tools: ["read", "grep", "find", "ls", "bash"],
87
+ tools: ["read", "grep", "find", "ls", "bash", "scratchpad"],
88
88
  excludeTools: ["edit", "write", "web"],
89
89
  // Phase 1 scratchpad: multi-cell test/verify flows reuse parsed state.
90
90
  scratchpad: true,
@@ -92,7 +92,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
92
92
 
93
93
  // Test Engineer - Can write tests (F1: hyphenated key)
94
94
  "test-engineer": {
95
- tools: ["read", "edit", "write", "bash", "ls"],
95
+ tools: ["read", "edit", "write", "bash", "ls", "scratchpad"],
96
96
  excludeTools: ["web"],
97
97
  // Phase 1 scratchpad: build/run test suites with state across cells.
98
98
  scratchpad: true,
@@ -312,6 +312,8 @@ export interface ConfigValidationResult {
312
312
  export interface SavedPiTeamsConfig {
313
313
  config: PiTeamsConfig;
314
314
  path: string;
315
+ /** Whether the file was actually rewritten. `false` when a no-op patch hit the skip-write guard. */
316
+ written: boolean;
315
317
  }
316
318
 
317
319
  export interface UpdateConfigOptions {
@@ -301,6 +301,7 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
301
301
  content = `${content.slice(0, MAX_KNOWLEDGE_HEAD_BYTES)}\n\n<!-- knowledge.md truncated at ${MAX_KNOWLEDGE_HEAD_BYTES} bytes (head shown). Full file: ${p} — use the \`read\` tool if you need sections beyond the head. -->`;
302
302
  }
303
303
  knowledgeCache.set(p, { key: cacheKey, content });
304
+ enforceKnowledgeCacheCap(knowledgeCache);
304
305
  return content;
305
306
  }
306
307
 
@@ -325,6 +326,7 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
325
326
  conventions: parsed.conventions,
326
327
  sessionLog: parsed.sessionLog,
327
328
  });
329
+ enforceKnowledgeCacheCap(sectionCache);
328
330
  }
329
331
 
330
332
  const queryText = [query.goal, query.taskText].filter(Boolean).join(" \n ");
@@ -382,6 +384,21 @@ interface CachedSections {
382
384
  }
383
385
  const sectionCache = new Map<string, CachedSections>();
384
386
 
387
+ // H6 (2026-08-10): FIFO cap — mirrors the discover-* caches
388
+ // (TEAM_DISCOVERY_MAX_ENTRIES = 32, WORKFLOW_DISCOVERY_MAX_ENTRIES = 32).
389
+ // Without this, the caches grow with every distinct project root seen by the
390
+ // process; each entry can be KB-scale (full knowledge.md content + parsed
391
+ // sections). Eviction is safe: a cache miss falls through to a fresh
392
+ // readFileSync + parse, same as the first call for that path.
393
+ const KNOWLEDGE_CACHE_MAX_ENTRIES = 64;
394
+
395
+ function enforceKnowledgeCacheCap(cache: Map<string, unknown>): void {
396
+ if (cache.size > KNOWLEDGE_CACHE_MAX_ENTRIES) {
397
+ const oldest = cache.keys().next().value;
398
+ if (oldest !== undefined) cache.delete(oldest);
399
+ }
400
+ }
401
+
385
402
  /** Head cap for the no-query (legacy / main-session) path. */
386
403
  const MAX_KNOWLEDGE_HEAD_BYTES = 2_000;
387
404
 
@@ -38,19 +38,3 @@ export type {
38
38
  } from "@earendil-works/pi-coding-agent";
39
39
 
40
40
  export { createBashTool, defineTool } from "@earendil-works/pi-coding-agent";
41
-
42
- /**
43
- * @deprecated Drift detector removed in Phase 5 follow-up to H1.
44
- *
45
- * History: this constant was meant to surface version-drift between
46
- * dev-time type-check and runtime peer-dep version. pi-crew declares
47
- * peer deps as `*` so the runtime version is set by whatever host pi
48
- * install the user has — pinning this constant to a specific devDep
49
- * range was a false invariant that did not reflect how extensions
50
- * actually consume peer packages.
51
- *
52
- * Kept exported (now `"0.0.0-unset"`) only for any downstream consumers
53
- * that may import it via re-exports. No diagnostic uses it anymore.
54
- * Will be removed in a future major version.
55
- */
56
- export const BUILT_AGAINST_PI_VERSION = "0.0.0-unset";
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Extracted `api` operation handlers — live-agent control group (H3 phase 2).
3
+ *
4
+ * Extracted from `src/extension/team-tool/api.ts` on 2026-08-10. Behaviour is
5
+ * byte-identical to the inline `if` blocks they replace.
6
+ */
7
+
8
+ import { readCrewAgents } from "../../../runtime/crew-agent-records.ts";
9
+ import { appendLiveAgentControlRequest } from "../../../runtime/live-session/live-agent-control.ts";
10
+ import {
11
+ followUpLiveAgent,
12
+ getLiveAgent,
13
+ listActiveLiveAgents,
14
+ resumeLiveAgent,
15
+ steerLiveAgent,
16
+ stopLiveAgent,
17
+ } from "../../../runtime/live-session/live-agent-manager.ts";
18
+ import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../../../runtime/live-session/live-control-realtime.ts";
19
+ import {
20
+ appendFollowUpMessage,
21
+ appendFollowUpMessageAsync,
22
+ appendSteeringMessage,
23
+ appendSteeringMessageAsync,
24
+ } from "../../../state/coordination/mailbox.ts";
25
+ import { appendEvent } from "../../../state/event-log/event-log.ts";
26
+ import type { ApiOperationHandler } from "./handler-context.ts";
27
+
28
+ export const handleNudgeAgent: ApiOperationHandler = (hctx) => {
29
+ const { cfg, loaded, result, paramRequired, ctx } = hctx;
30
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
31
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
32
+ if (!agent)
33
+ return result(
34
+ paramRequired(
35
+ "API nudge-agent",
36
+ "config.agentId matching an agent id or task id",
37
+ "{ action: 'api', runId: 'team_...', config: { operation: 'nudge-agent', agentId: 'agent-1' } }",
38
+ ),
39
+ {
40
+ action: "api",
41
+ status: "error",
42
+ runId: loaded.manifest.runId,
43
+ },
44
+ true,
45
+ );
46
+ const messageText =
47
+ typeof cfg.message === "string" && cfg.message.trim()
48
+ ? cfg.message.trim()
49
+ : "Please report your current status, blocker, or smallest next step.";
50
+ const message = appendSteeringMessage(loaded.manifest, {
51
+ taskId: agent.taskId,
52
+ to: agent.taskId,
53
+ body: messageText,
54
+ priority: "normal",
55
+ data: { source: "nudge-agent" },
56
+ });
57
+ appendEvent(loaded.manifest.eventsPath, {
58
+ type: "agent.nudged",
59
+ runId: loaded.manifest.runId,
60
+ taskId: agent.taskId,
61
+ message: messageText,
62
+ data: { agentId: agent.id, mailboxMessageId: message.id },
63
+ });
64
+ ctx.events?.emit?.("crew.mailbox.message", {
65
+ runId: loaded.manifest.runId,
66
+ id: message.id,
67
+ direction: message.direction,
68
+ from: message.from,
69
+ to: message.to,
70
+ taskId: message.taskId,
71
+ source: "nudge-agent",
72
+ });
73
+ return result(JSON.stringify({ agentId: agent.id, mailboxMessage: message }, null, 2), {
74
+ action: "api",
75
+ status: "ok",
76
+ runId: loaded.manifest.runId,
77
+ artifactsRoot: loaded.manifest.artifactsRoot,
78
+ });
79
+ };
80
+
81
+ export const handleListLiveAgents: ApiOperationHandler = (hctx) => {
82
+ const { loaded, result } = hctx;
83
+ return result(
84
+ JSON.stringify(
85
+ listActiveLiveAgents().filter((agent) => agent.runId === loaded.manifest.runId),
86
+ null,
87
+ 2,
88
+ ),
89
+ {
90
+ action: "api",
91
+ status: "ok",
92
+ runId: loaded.manifest.runId,
93
+ artifactsRoot: loaded.manifest.artifactsRoot,
94
+ },
95
+ );
96
+ };
97
+
98
+ /**
99
+ * Handles steer-agent / follow-up-agent / stop-agent / resume-agent /
100
+ * interrupt-agent — the live-agent control cluster (shared body in the
101
+ * original handleApi; interrupt falls through to stop).
102
+ */
103
+ export const handleLiveAgentControl: ApiOperationHandler = async (hctx) => {
104
+ const { cfg, loaded, result, paramRequired, ctx } = hctx;
105
+ const operation = typeof cfg.operation === "string" ? cfg.operation : "";
106
+ const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
107
+ if (!agentId)
108
+ return result(
109
+ paramRequired(
110
+ `API ${operation}`,
111
+ "config.agentId",
112
+ `{ action: 'api', runId: 'team_...', config: { operation: '${operation}', agentId: 'agent-1' } }`,
113
+ ),
114
+ {
115
+ action: "api",
116
+ status: "error",
117
+ runId: loaded.manifest.runId,
118
+ },
119
+ true,
120
+ );
121
+ const message = typeof cfg.message === "string" && cfg.message.trim() ? cfg.message.trim() : undefined;
122
+ const prompt = typeof cfg.prompt === "string" && cfg.prompt.trim() ? cfg.prompt.trim() : message;
123
+ try {
124
+ const live = getLiveAgent(agentId);
125
+ if (live && live.runId !== loaded.manifest.runId)
126
+ return result(
127
+ `Live agent '${agentId}' does not belong to run ${loaded.manifest.runId}.`,
128
+ {
129
+ action: "api",
130
+ status: "error",
131
+ runId: loaded.manifest.runId,
132
+ },
133
+ true,
134
+ );
135
+ if (live && live.workspaceId !== loaded.manifest.cwd)
136
+ return result(
137
+ `Live agent '${agentId}' does not belong to workspace ${loaded.manifest.cwd}.`,
138
+ {
139
+ action: "api",
140
+ status: "error",
141
+ runId: loaded.manifest.runId,
142
+ },
143
+ true,
144
+ );
145
+ if (!live && (operation === "steer-agent" || operation === "follow-up-agent"))
146
+ throw new Error(`Live agent '${agentId}' not found.`);
147
+ const liveTaskId = live?.taskId;
148
+ if ((operation === "steer-agent" || operation === "follow-up-agent") && !liveTaskId)
149
+ throw new Error(`Live agent '${agentId}' not found.`);
150
+ const targetTaskId = liveTaskId ?? agentId;
151
+ if (operation === "steer-agent") {
152
+ const text = message ?? "Please report current status and wrap up if possible.";
153
+ const realtime = await steerLiveAgent(agentId, text);
154
+ const mailboxMessage = await appendSteeringMessageAsync(loaded.manifest, {
155
+ taskId: targetTaskId,
156
+ body: text,
157
+ status: "delivered",
158
+ data: { source: "steer-agent", realtime: true },
159
+ });
160
+ return result(JSON.stringify({ realtime, mailboxMessage }, null, 2), {
161
+ action: "api",
162
+ status: "ok",
163
+ runId: loaded.manifest.runId,
164
+ artifactsRoot: loaded.manifest.artifactsRoot,
165
+ });
166
+ }
167
+ if (operation === "follow-up-agent") {
168
+ if (!prompt)
169
+ return result(
170
+ paramRequired(
171
+ "API follow-up-agent",
172
+ "config.prompt or config.message",
173
+ "{ action: 'api', runId: 'team_...', config: { operation: 'follow-up-agent', agentId: 'agent-1', prompt: '<next step>' } }",
174
+ ),
175
+ {
176
+ action: "api",
177
+ status: "error",
178
+ runId: loaded.manifest.runId,
179
+ },
180
+ true,
181
+ );
182
+ const realtime = await followUpLiveAgent(agentId, prompt);
183
+ const mailboxMessage = await appendFollowUpMessageAsync(loaded.manifest, {
184
+ taskId: targetTaskId,
185
+ body: prompt,
186
+ status: "delivered",
187
+ data: { source: "follow-up-agent", realtime: true },
188
+ });
189
+ return result(JSON.stringify({ realtime, mailboxMessage }, null, 2), {
190
+ action: "api",
191
+ status: "ok",
192
+ runId: loaded.manifest.runId,
193
+ artifactsRoot: loaded.manifest.artifactsRoot,
194
+ });
195
+ }
196
+ if (operation === "resume-agent") {
197
+ if (!prompt)
198
+ return result(
199
+ paramRequired(
200
+ "API resume-agent",
201
+ "config.prompt or config.message",
202
+ "{ action: 'api', runId: 'team_...', config: { operation: 'resume-agent', agentId: 'agent-1', prompt: '<resume instruction>' } }",
203
+ ),
204
+ {
205
+ action: "api",
206
+ status: "error",
207
+ runId: loaded.manifest.runId,
208
+ },
209
+ true,
210
+ );
211
+ return result(JSON.stringify(await resumeLiveAgent(agentId, prompt), null, 2), {
212
+ action: "api",
213
+ status: "ok",
214
+ runId: loaded.manifest.runId,
215
+ artifactsRoot: loaded.manifest.artifactsRoot,
216
+ });
217
+ }
218
+ return result(JSON.stringify(await stopLiveAgent(agentId), null, 2), {
219
+ action: "api",
220
+ status: "ok",
221
+ runId: loaded.manifest.runId,
222
+ artifactsRoot: loaded.manifest.artifactsRoot,
223
+ });
224
+ } catch (error) {
225
+ const agent = readCrewAgents(loaded.manifest).find((item) => item.id === agentId || item.taskId === agentId);
226
+ if (!agent) {
227
+ const err = error instanceof Error ? error.message : String(error);
228
+ return result(
229
+ err,
230
+ {
231
+ action: "api",
232
+ status: "error",
233
+ runId: loaded.manifest.runId,
234
+ },
235
+ true,
236
+ );
237
+ }
238
+ const task = loaded.tasks.find((item) => item.id === agent.taskId);
239
+ if (!task)
240
+ return result(
241
+ `API ${operation} agent '${agentId}' does not match a run task.`,
242
+ {
243
+ action: "api",
244
+ status: "error",
245
+ runId: loaded.manifest.runId,
246
+ },
247
+ true,
248
+ );
249
+ if (operation === "resume-agent" && !prompt)
250
+ return result(
251
+ paramRequired(
252
+ "API resume-agent",
253
+ "config.prompt or config.message",
254
+ "{ action: 'api', runId: 'team_...', config: { operation: 'resume-agent', agentId: 'agent-1', prompt: '<resume instruction>' } }",
255
+ ),
256
+ {
257
+ action: "api",
258
+ status: "error",
259
+ runId: loaded.manifest.runId,
260
+ },
261
+ true,
262
+ );
263
+ if (operation === "follow-up-agent" && !prompt)
264
+ return result(
265
+ paramRequired(
266
+ "API follow-up-agent",
267
+ "config.prompt or config.message",
268
+ "{ action: 'api', runId: 'team_...', config: { operation: 'follow-up-agent', agentId: 'agent-1', prompt: '<next step>' } }",
269
+ ),
270
+ {
271
+ action: "api",
272
+ status: "error",
273
+ runId: loaded.manifest.runId,
274
+ },
275
+ true,
276
+ );
277
+ try {
278
+ const request = appendLiveAgentControlRequest(loaded.manifest, {
279
+ taskId: task.id,
280
+ agentId: agent.id,
281
+ operation:
282
+ operation === "resume-agent"
283
+ ? "resume"
284
+ : operation === "follow-up-agent"
285
+ ? "follow-up"
286
+ : operation === "steer-agent"
287
+ ? "steer"
288
+ : "stop",
289
+ message: operation === "resume-agent" || operation === "follow-up-agent" ? prompt : message,
290
+ });
291
+ const mailboxMessage =
292
+ operation === "steer-agent"
293
+ ? appendSteeringMessage(loaded.manifest, {
294
+ taskId: task.id,
295
+ to: agent.id,
296
+ body: message ?? "Please report current status and wrap up if possible.",
297
+ status: "delivered",
298
+ data: {
299
+ source: "steer-agent",
300
+ liveControlRequestId: request.id,
301
+ },
302
+ })
303
+ : operation === "follow-up-agent" && prompt
304
+ ? appendFollowUpMessage(loaded.manifest, {
305
+ taskId: task.id,
306
+ to: agent.id,
307
+ body: prompt,
308
+ status: "delivered",
309
+ data: {
310
+ source: "follow-up-agent",
311
+ liveControlRequestId: request.id,
312
+ },
313
+ })
314
+ : undefined;
315
+ publishLiveControlRealtime(request);
316
+ ctx.events?.emit?.("pi-crew:live-control", liveControlRealtimeMessage(request));
317
+ appendEvent(loaded.manifest.eventsPath, {
318
+ type: "agent.control.queued",
319
+ runId: loaded.manifest.runId,
320
+ taskId: agent.taskId,
321
+ message: `Queued ${request.operation} control request for live agent.`,
322
+ data: {
323
+ request,
324
+ mailboxMessageId: mailboxMessage?.id,
325
+ realtime: true,
326
+ },
327
+ });
328
+ return result(JSON.stringify({ queued: true, request, mailboxMessage }, null, 2), {
329
+ action: "api",
330
+ status: "ok",
331
+ runId: loaded.manifest.runId,
332
+ artifactsRoot: loaded.manifest.artifactsRoot,
333
+ });
334
+ } catch (queueError) {
335
+ const message = queueError instanceof Error ? queueError.message : String(queueError);
336
+ return result(
337
+ message,
338
+ {
339
+ action: "api",
340
+ status: "error",
341
+ runId: loaded.manifest.runId,
342
+ },
343
+ true,
344
+ );
345
+ }
346
+ }
347
+ };
348
+
349
+ /** Dispatcher map for the live-agent control group. Consumed by handleApi. */
350
+ export const AGENT_CONTROL_OPERATIONS: Record<string, ApiOperationHandler> = {
351
+ "nudge-agent": handleNudgeAgent,
352
+ "list-live-agents": handleListLiveAgents,
353
+ "steer-agent": handleLiveAgentControl,
354
+ "follow-up-agent": handleLiveAgentControl,
355
+ "stop-agent": handleLiveAgentControl,
356
+ "resume-agent": handleLiveAgentControl,
357
+ "interrupt-agent": handleLiveAgentControl,
358
+ };