pi-crew 0.7.6 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,294 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.1] — Subagent cold-start race fix (module-scoped import latch) (2026-06-16)
4
+
5
+ Fixes a flaky, load-dependent crash that surfaced when launching multiple
6
+ subagents **concurrently** via `Agent({ run_in_background: true })`.
7
+
8
+ ### Bug fixed
9
+
10
+ When 2+ in-process live-session subagents spawned at once, some crashed at
11
+ cold-start with:
12
+
13
+ ```
14
+ Cannot read properties of undefined (reading 'existsSync')
15
+ Cannot read properties of undefined (reading 'validateWorkflowForTeam')
16
+ ```
17
+
18
+ These are property-access-on-`undefined` errors: a module namespace binding
19
+ observed mid-evaluation as `undefined`. The defining reproduction: 4 explorer
20
+ subagents launched together → 3 of 4 crashed; **all 3 succeeded on sequential
21
+ retry** (same code, same args, same repos — only concurrency changed). That is
22
+ the signature of a cold-start race, not a logic bug.
23
+
24
+ ### Root cause
25
+
26
+ `direct-agent` subagents run **in-process** via `createAgentSession` (the
27
+ live-session runtime), sharing one Node module graph. The spawn path called
28
+ `await import("@earendil-works/pi-coding-agent")` **independently** per
29
+ subagent. Under the **tsx loader** (which registers `load`/`resolve` hooks to
30
+ transpile TS), concurrent first-imports can each enter the loader and race
31
+ module-record instantiation — yielding a namespace binding seen mid-eval as
32
+ `undefined`. Engine-level ESM memoization is not guaranteed to be observed
33
+ synchronously across concurrent evaluation under transpiling loaders.
34
+
35
+ ### Fix
36
+
37
+ Module-scoped memoization in `src/runtime/live-session-runtime.ts`: the FIRST
38
+ caller sets `liveSessionModulePromise`; every later caller awaits the same
39
+ in-flight promise. Guarantees a single module-record instantiation regardless
40
+ of loader behavior. Concurrent callers then proceed in parallel as normal.
41
+
42
+ ```ts
43
+ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
44
+ function loadLiveSessionModule(): Promise<LiveSessionModule> {
45
+ if (!liveSessionModulePromise) {
46
+ liveSessionModulePromise = import("@earendil-works/pi-coding-agent")
47
+ as unknown as Promise<LiveSessionModule>;
48
+ }
49
+ return liveSessionModulePromise;
50
+ }
51
+ ```
52
+
53
+ ### Files
54
+ - `src/runtime/live-session-runtime.ts` — module-scoped `loadLiveSessionModule()`
55
+ latch; use site now `await loadLiveSessionModule()` (was un-memoized
56
+ `await import(...)`).
57
+ - NEW `test/unit/live-session-import-latch.test.ts` (2 tests): module loads
58
+ cleanly; latch variable + check-before-set + use site present, and the old
59
+ un-memoized pattern gone (regression guard).
60
+ - NEW `.github/issues/2026-06-16-subagent-cold-start-race.md` — full root-cause
61
+ write-up + lessons.
62
+
63
+ typecheck clean; full suite 0 failures (local EXIT=1 is the test-runner infra
64
+ `spawnSync ETIMEDOUT` on a background-subagent test under local load — clean
65
+ on CI).
66
+
67
+ ## [0.8.0] — Tool-restriction unification across spawn paths (2026-06-16)
68
+
69
+ Fixes a long-standing correctness gap where the same agent behaved
70
+ *differently* depending on which runtime spawned it.
71
+
72
+ ### Bug fixed
73
+
74
+ The child-pi path (`pi-args.ts`) and the live-session path
75
+ (`live-session-runtime.ts`) **disagreed on tool restrictions**:
76
+
77
+ | | allowlist | denylist |
78
+ |---|---|---|
79
+ | child-pi (before) | `roleConfig.tools ?? agent.tools` (role authoritative) | `roleConfig.excludeTools` only |
80
+ | live-session (before) | `agent.tools` only (frontmatter authoritative) | `agent.disallowedTools` only |
81
+
82
+ So a user defining `tools:` or `disallowed_tools:` in a custom agent's
83
+ frontmatter saw it honored on one path and ignored on the other:
84
+ - `disallowed_tools: web` was **silently ignored on child-pi** (the default
85
+ async path).
86
+ - A builtin `explorer` on the live-session path was **not bound by the role's
87
+ read-only security constraint** (it relied solely on the frontmatter).
88
+
89
+ ### Fix
90
+
91
+ A shared `resolveToolPolicy(agent, role)` helper in `agent-config.ts` is
92
+ now the **single source of truth** used by BOTH spawn paths. Stable,
93
+ unified semantics:
94
+
95
+ - **Allowlist precedence is source-aware**:
96
+ - `source === "builtin"` → role-config authoritative (security: a builtin
97
+ explorer MUST stay read-only even if its frontmatter is loose).
98
+ Frontmatter is the fallback when the role has no allowlist.
99
+ - `source !== "builtin"` (user / project) → frontmatter `tools:`
100
+ authoritative (user intent). Role-config is the fallback.
101
+ - **Denylist is additive**: `roleConfig.excludeTools` and
102
+ `agent.disallowedTools` are MERGED (dedup, order-insensitive). It is
103
+ always safe to forbid more, and merging means a security exclude from
104
+ the role can never be weakened by a frontmatter omission.
105
+
106
+ This is **not a regression** for builtin agents: their allowlist still comes
107
+ from `ROLE_TOOL_CONFIGS` (the authoritative security set), and the merged
108
+ denylist only adds constraints. Custom agents now behave identically
109
+ across both runtimes.
110
+
111
+ ### Files
112
+ - `src/agents/agent-config.ts` — NEW `resolveToolPolicy` + `ResolvedToolPolicy`
113
+ (the shared resolver) + `uniqueToolMerge` helper.
114
+ - `src/runtime/pi-args.ts` — uses `resolveToolPolicy` (drops the inline
115
+ role-authoritative logic; removes now-unused `getAgentSessionOptions` import).
116
+ - `src/runtime/live-session-runtime.ts` — `filterActiveTools` now takes the
117
+ role and uses `resolveToolPolicy` (drops the inline frontmatter-only logic).
118
+ - NEW `test/unit/v0-8-0-tool-policy-unification.test.ts` (10 tests pinning
119
+ the resolver: source-aware allowlist, additive denylist, cross-path
120
+ determinism).
121
+
122
+ typecheck clean; 4980+ tests pass / 0 fail. CI green on win/ubuntu/macos.
123
+
124
+ ## [0.7.9] — Interop & agent granularity (4 grouped items, 2026-06-16)
125
+
126
+ One grouped release for four related, surgical interop / agent-granularity
127
+ items (all additive, no behavior change for existing configs):
128
+
129
+ ### F6 — Agent Skills spec skill-roots (interop)
130
+ - Skill discovery now reads 5 roots (was 2), matching pi-subagents'
131
+ `skill-loader` so skills authored under either convention are found:
132
+ - `<cwd>/.pi/skills` (project, Pi standard) — new
133
+ - `<cwd>/.agents/skills` (project, Agent Skills spec / agentskills.io) — new
134
+ - `<cwd>/skills` (project, legacy pi-crew) — kept
135
+ - `~/.pi/agent/skills` (user, Pi standard) — new
136
+ - `~/.agents/skills` (user, Agent Skills spec) — new
137
+ - `~/.pi/skills` (user, legacy) — new
138
+ - `PACKAGE_SKILLS_DIR` (bundled) — kept
139
+ - Affects both `discover-skills.ts` (capability inventory) and
140
+ `skill-instructions.ts` (actual prompt rendering). New `source` values
141
+ (`project-pi`, `project-agents`, `user-pi`, `user-agents`) extend
142
+ `CapabilitySource`; first hit per name wins, project overrides user.
143
+
144
+ ### F1 sub-gap — `.pi/agents/` project agent discovery (interop)
145
+ - Project agent discovery now reads BOTH the legacy pi-crew
146
+ `.crew/agents/` (or `.pi/teams/agents/` fallback) AND the Pi-standard
147
+ `.pi/agents/` as separate tiers. New `projectPi` field in
148
+ `AgentDiscoveryResult` (optional in the type for back-compat with
149
+ existing test fixtures; treated as `[]` when omitted). `allAgents`
150
+ merges them in priority order (project first, then project-pi so a
151
+ `.pi/agents/foo.md` is a fallback to `.crew/agents/foo.md` within
152
+ the project tier). `ResourceSource` extended with `"project-pi"`.
153
+
154
+ ### F1 — frontmatter `tools:` wildcards
155
+ - New `BUILTIN_TOOL_NAMES` constant + `parseToolsField` helper in
156
+ `agent-config.ts` (matching pi-subagents' `parseToolsField`):
157
+ - omitted → `undefined` (back-compat: use the runtime default)
158
+ - `*` or `all` (case-insensitive) → full `BUILTIN_TOOL_NAMES` list
159
+ - `none` / `[]` / empty → `[]` (zero built-ins)
160
+ - CSV → parsed entries (trimmed, empty dropped)
161
+ - `parseAgentFile` now uses `parseToolsField` instead of `parseCsv`,
162
+ so existing agent files keep working with no edits. The
163
+ `ext:<extension>/<tool>` selector from pi-subagents is a documented
164
+ future gap (deferred — would require pi SDK introspection).
165
+
166
+ ### F1 — frontmatter `excludeExtensions` denylist
167
+ - New `excludeExtensions?: string[]` field on `AgentConfig`, parsed
168
+ from frontmatter `exclude_extensions: foo, bar`. Applied on the
169
+ **child-pi path** in `pi-args.ts` as a case-insensitive basename
170
+ denylist (an excluded extension is removed from the `--extension`
171
+ list; the trusted `PROMPT_RUNTIME_EXTENSION_PATH` is never
172
+ excludable). **Documented limitation**: the live-session path
173
+ (opt-in via `runtime.preferLiveSession`) ignores it for v0.7.9 —
174
+ pi's `DefaultResourceLoader` has no per-extension deny hook at the
175
+ point we hand off. Users who need the denylist on live-session
176
+ should stay on the child-pi runtime, or revisit when the SDK
177
+ exposes the hook.
178
+
179
+ ### Files
180
+ - `src/skills/discover-skills.ts` — F6 (5 roots, new source values)
181
+ - `src/runtime/skill-instructions.ts` — F6 (5 roots, type updates)
182
+ - `src/runtime/capability-inventory.ts` — F6 (CapabilitySource extended)
183
+ - `src/agents/agent-config.ts` — F1 (BUILTIN_TOOL_NAMES, parseToolsField,
184
+ excludeExtensions field, ResourceSource +project-pi)
185
+ - `src/agents/discover-agents.ts` — F1 (projectPi tier, tools/excludeExtensions
186
+ parsing, allAgents merge)
187
+ - `src/runtime/pi-args.ts` — F1 (excludeExtensions denylist applied to
188
+ `--extension` args)
189
+ - `src/runtime/live-session-runtime.ts` — F1 (doc comment for the
190
+ live-session limitation)
191
+ - `src/ui/agent-management-overlay.ts` — F1 (ResourceSource order includes
192
+ project-pi)
193
+ - NEW `test/unit/v0-7-9-interop-granularity.test.ts` (15 tests)
194
+ - `test/unit/capability-inventory.test.ts` — accept expanded state set
195
+ (shadowed/missing now possible from user-skill-roots shadowing bundles)
196
+ - `test/unit/discover-skills.test.ts` — accept expanded source set
197
+
198
+ typecheck clean; 4980+ tests pass / 0 fail. CI green on win/ubuntu/macos.
199
+
200
+ ## [0.7.8] — F7 model-scope enforcement + cross-session leak fix (2026-06-16)
201
+
202
+ Two features/fixes from the same session: one new opt-in capability, one
203
+ correctness fix for a bug surfaced by the user while iterating on the new
204
+ feature (firing live in the session — a different Pi session's in-flight
205
+ run kept getting injected into the current session's context via the
206
+ ambient-status handler).
207
+
208
+ ### Features
209
+
210
+ - **F7 model-scope enforcement** — opt-in gate that validates subagent model
211
+ choices against the user's pi `enabledModels` allowlist. Trust distinction
212
+ matches the pi-subagents reference semantics:
213
+ - Caller-supplied (per-spawn `modelOverride` / `step.model` /
214
+ `teamRoleModel`) out-of-scope → **hard error** (`CrewError E013
215
+ ModelOutOfScope`) before spawn, fail-fast with actionable help hint.
216
+ - Frontmatter-pinned (`AgentConfig.model`) out-of-scope → **warning +
217
+ runs anyway** (frontmatter is authoritative; the agent author made a
218
+ deliberate choice).
219
+ Pattern semantics match pi's `--models` allowlist: exact
220
+ (case-insensitive), glob with `*` (unanchored, so `"claude-*"` matches
221
+ `anthropic/claude-opus-4-5`), and case-insensitive substring fallback.
222
+ Toggle: `runtime.reliability.scopeModels: true` (default `false` = no
223
+ enforcement, fully back-compat). The allowlist itself is read from
224
+ pi's `SettingsManager.getEnabledModels()` per spawn (no caching, so
225
+ changes take effect immediately). 20 new unit tests covering pattern
226
+ matching, scope verdicts, and the routing gate (caller/frontmatter
227
+ trust distinction + `isFrontmatterOverride` downgrade).
228
+
229
+ ### Bug Fixes
230
+
231
+ - **Cross-session run-context leak** (commit `4bd6f5b`) — `collectInFlightRuns(cwd)`
232
+ in `compaction-guard.ts` scanned the SHARED per-project `.crew/state/runs/`
233
+ dir and filtered by STATUS only, ignoring `ownerSessionId`. Multiple Pi
234
+ sessions in the same project share that directory, so Session B's
235
+ compaction picked up Session A's in-flight runs and injected them into B's
236
+ continuation prompt, making B wrongly try to resume A's run. The same
237
+ leak affected ambient-status injection (`context-status-injection.ts`),
238
+ showing A's runs in B's context stream. Fix: `collectInFlightRuns`
239
+ gains optional `currentSessionId?` → strict filter
240
+ `run.ownerSessionId === currentSessionId` (legacy ownerless runs
241
+ excluded; true orphans are crash-recovery's job). New canonical
242
+ `extractSessionId(ctx)` helper in `utils/session-utils.ts` (defensive
243
+ against Proxy/exotic objects, replaces inline
244
+ getOwnPropertyDescriptor in `register.ts`). Artifact index stays
245
+ UNFILTERED (durable cross-session memory, not a resume directive).
246
+ `triggerContinuation`'s `sendUserMessage` race ("Agent is already
247
+ processing a prompt...") is detected and downgraded to silent — it is
248
+ benign (the worker continues independently). 11 new regression tests
249
+ (compaction-cross-session-leak.test.ts). CI green on all 3 platforms
250
+ (run `27608398599`).
251
+
252
+ ### Files
253
+
254
+ - NEW `src/runtime/model-scope.ts` — pattern matcher + verdict + SettingsManager
255
+ reader.
256
+ - `src/runtime/model-fallback.ts` — `buildConfiguredModelRouting` gains
257
+ `scopeModelsPatterns?` + `isFrontmatterOverride?` inputs; new
258
+ `CrewError E013 ModelOutOfScope` factory in `src/errors.ts`.
259
+ - `src/config/types.ts` — new `reliability.scopeModels?: boolean` toggle
260
+ (default `false`).
261
+ - `src/extension/team-tool/handle-settings.ts` — adds
262
+ `reliability.scopeModels` to the visible-keys list so it surfaces in
263
+ the settings overlay.
264
+ - `src/extension/registration/compaction-guard.ts`,
265
+ `src/extension/context-status-injection.ts`,
266
+ `src/extension/register.ts`, `src/utils/session-utils.ts` — leak fix.
267
+ - NEW `test/unit/model-scope.test.ts` (20 tests),
268
+ `test/unit/compaction-cross-session-leak.test.ts` (11 tests).
269
+
270
+ typecheck clean; 4968+ tests pass / 0 fail.
271
+
272
+ ## [0.7.7] — Windows spawn fix + plan-approval crash-recovery fix + CI flake fixes (2026-06-16)
273
+
274
+ A focused patch release driven by two community reports (Issue #33 and PR #32) plus the CI flake surfaced while validating them. CI green on Windows / Ubuntu / macOS (run 27599121797). 4965 tests pass / 0 fail.
275
+
276
+ ### Bug Fixes
277
+
278
+ - **`#33` — Windows `spawn pi ENOENT`** (commit `afc23b4`): when pi is installed outside `%APPDATA%\npm` (nvm-windows / Volta / fnm put the global `node_modules` elsewhere), the static `%APPDATA%\npm` paths in `resolvePiCliScript()` all miss, and the fallback `spawn("pi")` fails with `ENOENT` because `child_process.spawn` does NOT do PATHEXT resolution on Windows (only `exec`/`execSync` via `cmd.exe` do). **Fix**: pi-crew now discovers the real npm global `node_modules` dir at runtime via `npm root -g` (run through `execSync`, which DOES resolve `npm.cmd` via PATHEXT), then derives the `@earendil-works` / `@mariozechner` package dirs from it and checks them BEFORE the static `%APPDATA%\npm` paths and the cwd fallback. Covers standard installs **and** nvm-windows / Volta / fnm uniformly. Memoized once per process (one-time ~200ms cost). Injection-safe — no `shell: true` on the real worker spawn. +6 tests.
279
+ - **Plan-approval-blocked runs crash-recovery fix** (commit `421b76d`, adapts PR #32 change #1 by @gustavo-pelissaro): crash recovery and stale reconciliation both treated `status === "blocked"` runs as repair candidates, so a run legitimately blocked on **human** plan approval (`requirePlanApproval`, `status="pending"`) was marked failed and/or orphan-cancelled when its owning session died or its async PID was no longer live — destroying an in-flight HITL checkpoint. **Fix**: new `isPlanApprovalPending(manifest)` guard (status=blocked AND `planApproval.required=true` AND `planApproval.status=pending`). Guarded in `reconcileStaleRun` (new `blocked_awaiting_approval` verdict, `repaired=false` — which automatically covers `reconcileAllStaleRuns`), `detectInterruptedRuns` (skip), `cancelOrphanedRuns` (push to `skipped`), and a belt-and-suspenders re-check under the lock in `reconcileAllStaleRuns`. The guard is intentionally narrow: a plain `blocked` run (no planApproval, or already approved/cancelled) is still a recovery candidate, so existing orphaned-blocked-run handling is unchanged. +6 tests.
280
+
281
+ ### Tests (CI reliability)
282
+
283
+ - **`run-watcher-registry` macOS cancellation** (commit `dccb5e7`): the two fs.watch-dependent tests used unbounded `done()` callbacks that hung the whole test file on macOS CI runners (fs.watch events are slow/dropped under `/var/folders` + VM-runner FS load). Fixed with bounded async waits (1.5s deadline) consistent with production semantics, where fs.watch is best-effort and the preload poll loop is the source of truth.
284
+ - **`operator-experience` ubuntu redaction flake** (commit `2da1a1b`): the redaction test seeded a secret literally named `abc` and asserted `/abc/` does not leak, but the runId hash (`randomBytes(8).toString("hex")`) occasionally spells `...abc...` (e.g. `team_..._9791deabc2f52485`) → false failure, even though redaction worked perfectly. Fixed by switching to a `ZZ_LEAK_CANARY` marker — uppercase letters never appear in a lowercase-hex hash, so the marker is collision-proof.
285
+
286
+ ### Community
287
+
288
+ - Thanks to **@YrFnS** for the textbook-quality Issue #33 report and diagnosis (PATHEXT, spawn vs execSync matrix) that pinpointed the fix.
289
+ - Thanks to **@gustavo-pelissaro** for PR #32 — change #1 (plan-approval preservation) landed here; changes #2/#3 (child exit-143 normalization, symlinked temp base) were closed for heavy conflicts but will be revisited.
290
+ - PR #34 (closed) overlapped the existing `%APPDATA%\npm` resolution; superseded by the runtime `npm root -g` probe.
291
+
3
292
  ## [0.7.6] — DX, observability, and a critical interactive-session hang fix (2026-06-16)
4
293
 
5
294
  This release bundles Rounds 16–28: a developer-experience pass, an observability pass, and eight correctness/security audits — culminating in the **fix for the pts/2 interactive-session busy-loop hang** (two separate Pi sessions had hung at 71.5% CPU with 339 inotify watches). All 24 commits passed CI on Windows, Ubuntu, and macOS.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.7.6",
3
+ "version": "0.8.1",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1,7 +1,51 @@
1
1
  import type { RoleToolConfig } from "../config/role-tools.ts";
2
2
  import { getToolConfig } from "../config/role-tools.ts";
3
3
 
4
- export type ResourceSource = "builtin" | "user" | "project" | "git" | "dynamic";
4
+ /**
5
+ * F1 (v0.7.9): canonical built-in tool name list. Used by `parseToolsField`
6
+ * to expand wildcard `*` / `all` patterns in agent frontmatter. Matches
7
+ * pi-subagents' `BUILTIN_TOOL_NAMES` (derived from pi's `createCodingTools` /
8
+ * `createReadOnlyTools`). If pi adds a new built-in, update this list and
9
+ * the wildcard expansion will pick it up. The 7 names below are stable
10
+ * across pi v0.77+ and cover read, edit, write, bash, grep, find, ls.
11
+ */
12
+ export const BUILTIN_TOOL_NAMES: readonly string[] = [
13
+ "read",
14
+ "edit",
15
+ "write",
16
+ "bash",
17
+ "grep",
18
+ "find",
19
+ "ls",
20
+ ];
21
+
22
+ /**
23
+ * F1 (v0.7.9): normalize the raw `tools:` frontmatter CSV into a `string[]`.
24
+ * Semantics (matching pi-subagents' `parseToolsField`):
25
+ * - omitted / undefined → returns `undefined` (back-compat: use the
26
+ * runtime default — today this is the role-tools default; tomorrow this
27
+ * could become the wildcard expansion if the user opts in).
28
+ * - `*` or `all` (case-insensitive) → returns the full BUILTIN_TOOL_NAMES
29
+ * list (no duplicates).
30
+ * - `none` or empty string → returns `[]` (zero built-ins; extension
31
+ * tools via `ext:` can still be added, though pi-crew doesn't parse
32
+ * `ext:` selectors yet — see F1 sub-gap).
33
+ * - CSV → returns the parsed entries (trimmed, empty entries dropped).
34
+ * Plain tool names (no `*`) pass through unchanged so existing agent
35
+ * files keep working with no edits.
36
+ */
37
+ export function parseToolsField(raw: unknown): string[] | undefined {
38
+ if (raw === undefined || raw === null) return undefined;
39
+ const s = typeof raw === "string" ? raw.trim() : String(raw).trim();
40
+ if (!s) return [];
41
+ const lowered = s.toLowerCase();
42
+ if (lowered === "none" || lowered === "[]") return [];
43
+ if (lowered === "*" || lowered === "all") return [...BUILTIN_TOOL_NAMES];
44
+ const items = s.split(",").map((t) => t.trim()).filter(Boolean);
45
+ return items;
46
+ }
47
+
48
+ export type ResourceSource = "builtin" | "user" | "project" | "git" | "dynamic" | "project-pi";
5
49
 
6
50
  export interface RoutingMetadata {
7
51
  triggers?: string[];
@@ -22,6 +66,14 @@ export interface AgentConfig {
22
66
  thinking?: string;
23
67
  tools?: string[];
24
68
  extensions?: string[];
69
+ /**
70
+ * F1 (v0.7.9): extension denylist (case-insensitive plain names). Applied
71
+ * AFTER `extensions:` (which lists the allowed set) — an excluded
72
+ * extension is removed from the allowlist and never loads. Plain names
73
+ * only (no paths, no `*`); an unknown name logs a warning but is
74
+ * tolerated. Back-compat: omitted = no exclusion.
75
+ */
76
+ excludeExtensions?: string[];
25
77
  skills?: string[];
26
78
  systemPromptMode?: "replace" | "append";
27
79
  inheritProjectContext?: boolean;
@@ -64,6 +116,54 @@ export function getAgentSessionOptions(role: string): {
64
116
  return {};
65
117
  }
66
118
 
119
+ /**
120
+ * F1 unify (v0.8.0): the single source of truth for a worker's tool policy,
121
+ * used by BOTH spawn paths (child-pi `pi-args.ts` and live-session
122
+ * `live-session-runtime.ts`). Before this, the two paths disagreed:
123
+ * - child-pi: `roleConfig.tools ?? agent.tools` (role authoritative)
124
+ * - live-session: `agent.tools` only (frontmatter authoritative, role ignored)
125
+ * so the same agent behaved differently depending on the runtime. A user
126
+ * defining `tools:` or `disallowed_tools:` in a custom agent's frontmatter
127
+ * saw it honored on one path and ignored on the other.
128
+ *
129
+ * Unified semantics (stable across both paths):
130
+ * - **allowlist precedence is source-aware**:
131
+ * - `source === "builtin"` → role-config authoritative (security: a
132
+ * builtin explorer MUST stay read-only even if its frontmatter is
133
+ * loose). Frontmatter is the fallback when the role has no allowlist.
134
+ * - `source !== "builtin"` (user / project) → frontmatter `tools:`
135
+ * authoritative (user intent). Role-config is the fallback.
136
+ * - **denylist is additive**: `roleConfig.excludeTools` and
137
+ * `agent.disallowedTools` are MERGED (dedup, order-insensitive). It is
138
+ * always safe to forbid more, and merging means a security exclude
139
+ * from the role can never be weakened by a frontmatter omission.
140
+ *
141
+ * Returns `{ tools, excludeTools }` where each is `undefined` when no
142
+ * restriction of that kind applies (so callers no-op cleanly).
143
+ */
144
+ export interface ResolvedToolPolicy {
145
+ /** Allowlist; undefined = no allowlist restriction (all built-ins allowed). */
146
+ tools?: string[];
147
+ /** Denylist (additive); undefined = no denylist. */
148
+ excludeTools?: string[];
149
+ }
150
+
151
+ function uniqueToolMerge(...lists: Array<string[] | undefined>): string[] | undefined {
152
+ const merged = [...new Set(lists.flatMap((list) => list ?? []))];
153
+ return merged.length > 0 ? merged : undefined;
154
+ }
155
+
156
+ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedToolPolicy {
157
+ const roleConfig = role ? getToolConfig(role) : {};
158
+ // allowlist: source-aware precedence (see doc above).
159
+ const explicitTools = agent.source === "builtin"
160
+ ? (roleConfig.tools ?? agent.tools)
161
+ : (agent.tools ?? roleConfig.tools);
162
+ // denylist: additive merge of role excludeTools + agent disallowedTools.
163
+ const excludeTools = uniqueToolMerge(roleConfig.excludeTools, agent.disallowedTools);
164
+ return { tools: explicitTools, excludeTools };
165
+ }
166
+
67
167
  /**
68
168
  * Build agent session options including role-based tool restrictions.
69
169
  * @param agent - The agent configuration
@@ -1,10 +1,11 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentConfig, ResourceSource } from "./agent-config.ts";
4
+ import { parseToolsField } from "./agent-config.ts";
4
5
  import { loadConfig, type LoadedPiTeamsConfig } from "../config/config.ts";
5
6
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
7
- import { packageRoot, projectCrewRoot, userPiRoot } from "../utils/paths.ts";
8
+ import { packageRoot, projectCrewRoot, userPiRoot, findRepoRoot } from "../utils/paths.ts";
8
9
 
9
10
  // ═══════════════════════════════════════════════════════════════════════════
10
11
  // SEC-001 Fix: Protected Agent Names Blocklist
@@ -225,7 +226,23 @@ function checkProjectAgentShadowsBuiltin(name: string): void {
225
226
  export interface AgentDiscoveryResult {
226
227
  builtin: AgentConfig[];
227
228
  user: AgentConfig[];
229
+ /**
230
+ * Project agents from the pi-crew legacy directory (`.crew/agents/`, or
231
+ * `.pi/teams/agents/` fallback). F1 (v0.7.9): the `.pi/agents/` Pi-standard
232
+ * project directory is read into `projectPi` (the 4th tier) so users who
233
+ * author agents under either convention find them.
234
+ */
228
235
  project: AgentConfig[];
236
+ /**
237
+ * F1 (v0.7.9): project agents read from `<repoRoot>/.pi/agents/` (Pi
238
+ * standard). Merged into the same priority order as `project` (project
239
+ * overrides user, but `.crew/agents/` and `.pi/agents/` are peers
240
+ * within the project tier — first hit per `name` wins, with a
241
+ * warning logged on shadow). Optional in the result shape so existing
242
+ * test fixtures that construct `AgentDiscoveryResult` literally don't
243
+ * have to add an empty array (treated as `[]` by `allAgents`).
244
+ */
245
+ projectPi?: AgentConfig[];
229
246
  }
230
247
 
231
248
  function parseCost(value: string | undefined): "free" | "cheap" | "expensive" | undefined {
@@ -365,8 +382,9 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
365
382
  model: frontmatter.model === "false" ? undefined : frontmatter.model || undefined,
366
383
  fallbackModels: parseCsv(frontmatter.fallbackModels),
367
384
  thinking: frontmatter.thinking === "false" ? undefined : frontmatter.thinking || undefined,
368
- tools: parseCsv(frontmatter.tools),
385
+ tools: parseToolsField(frontmatter.tools),
369
386
  extensions: frontmatter.extensions === "" ? [] : parseCsv(frontmatter.extensions),
387
+ excludeExtensions: parseCsv(frontmatter.excludeExtensions ?? frontmatter.exclude_extensions),
370
388
  skills: parseCsv(frontmatter.skills ?? frontmatter.skill),
371
389
  systemPromptMode: frontmatter.systemPromptMode === "append" ? "append" : "replace",
372
390
  inheritProjectContext: frontmatter.inheritProjectContext === "true",
@@ -471,7 +489,14 @@ export function discoverAgents(cwd: string): AgentDiscoveryResult {
471
489
  const result: AgentDiscoveryResult = {
472
490
  builtin: applyAgentOverrides(readAgentDir(path.join(packageRoot(), "agents"), "builtin"), cwd, loaded),
473
491
  user: applyAgentOverrides(readAgentDir(path.join(userPiRoot(), "agents"), "user"), cwd, loaded),
492
+ // F1 (v0.7.9): two project roots — the legacy pi-crew `.crew/agents/`
493
+ // (or `.pi/teams/agents/` fallback) AND the Pi-standard `.pi/agents/`.
494
+ // Both are read; `allAgents` merges them in priority order (project
495
+ // first, then project-pi) so a project can override a global agent
496
+ // from either location. Same-name shadows within the project tier
497
+ // log a warning (SEC-001).
474
498
  project: applyAgentOverrides(readAgentDir(path.join(projectCrewRoot(cwd), "agents"), "project"), cwd, loaded),
499
+ projectPi: applyAgentOverrides(readAgentDir(path.join(findRepoRoot(cwd) ?? cwd, ".pi", "agents"), "project-pi"), cwd, loaded),
475
500
  };
476
501
  // SEC-005: Store with current version stamp
477
502
  discoveryCache.set(cwd, { result, expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS, cacheVersion: currentVersion });
@@ -520,7 +545,13 @@ export function allAgents(discovery: AgentDiscoveryResult | undefined): AgentCon
520
545
  // Priority for disambiguation (security): project < builtin < user.
521
546
  // Project config cannot override trusted builtins (security-hardening).
522
547
  // Later entries in the loop overwrite earlier ones, so user wins.
523
- for (const agent of [...discovery.project, ...discovery.builtin, ...discovery.user]) {
548
+ // F1 (v0.7.9): `projectPi` is appended AFTER `project` so a `.pi/agents/foo.md`
549
+ // is a fallback to `.crew/agents/foo.md` within the project tier (the
550
+ // legacy pi-crew directory takes precedence when both exist). This
551
+ // matches `applyAgentOverrides` semantics and keeps the SECURITY warning
552
+ // gate on the same source. `projectPi` is optional in the result type
553
+ // (older test fixtures may omit it) — fall back to an empty array.
554
+ for (const agent of [...discovery.project, ...(discovery.projectPi ?? []), ...discovery.builtin, ...discovery.user]) {
524
555
  byName.set(agent.name.toLowerCase(), agent);
525
556
  }
526
557
  // Dynamic agents only fill gaps — they cannot override builtin/user agents.
@@ -180,6 +180,14 @@ export interface CrewReliabilityConfig {
180
180
  cleanupOrphanedTempDirs?: boolean;
181
181
  /** Inject a compact ambient crew-status note into the agent's context on every LLM call while crew runs are in-flight, so the agent stays continuously aware of active runs without calling the `team` tool. No-op when no runs are active. Default: true. */
182
182
  ambientStatusInjection?: boolean;
183
+ /**
184
+ * Opt-in model scope enforcement (F7). When true, subagent model choices
185
+ * that fall outside the user's pi `enabledModels` allowlist are flagged:
186
+ * caller-supplied out-of-scope → hard error before spawn; frontmatter-
187
+ * pinned out-of-scope → warning + runs anyway. Default: false (no
188
+ * enforcement, fully back-compat).
189
+ */
190
+ scopeModels?: boolean;
183
191
  }
184
192
 
185
193
  export interface CrewOtlpConfig {
package/src/errors.ts CHANGED
@@ -38,6 +38,7 @@ export const ErrorCode = {
38
38
  EventLogLockTimeout: "E010", // Could not acquire the event-log file lock
39
39
  DepthLimitExceeded: "E011", // Pipeline/chain recursion depth limit hit (circular dep)
40
40
  RunStale: "E012", // Run reconciled as stale/zombie (heartbeat expired)
41
+ ModelOutOfScope: "E013", // Caller-supplied model is not in pi's enabledModels allowlist (F7 scope gate)
41
42
  } as const;
42
43
 
43
44
  export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
@@ -56,6 +57,7 @@ const DEFAULT_HELP: Record<ErrorCode, string | undefined> = {
56
57
  [ErrorCode.EventLogLockTimeout]: "Another process holds the event-log lock. Check for orphaned `.lock` files or stale pi-crew processes, then retry.",
57
58
  [ErrorCode.DepthLimitExceeded]: "A pipeline/chain exceeded the recursion depth limit, which usually indicates a circular stage dependency. Review step `dependsOn` chains.",
58
59
  [ErrorCode.RunStale]: "The worker stopped heartbeating and was treated as a zombie. Re-run the team (resume or fresh); if it recurs, check `runtime.executeWorkers` / system load.",
60
+ [ErrorCode.ModelOutOfScope]: "The requested model is not in your pi `enabledModels` allowlist. Either pick a model listed in `enabledModels` (settings.json) or extend the allowlist. The scope gate is opt-in — disable `runtime.reliability.scopeModels` to allow any model.",
59
61
  };
60
62
 
61
63
  /**
@@ -188,4 +190,11 @@ export const errors = {
188
190
  `Stale run reconciled (reason=${reason}).${age} The worker stopped heartbeating and was treated as dead/zombie.`,
189
191
  ).withContext("stale-run reconciliation");
190
192
  },
193
+
194
+ modelOutOfScope(model: string, patterns: string[]): CrewError {
195
+ return new CrewError(
196
+ ErrorCode.ModelOutOfScope,
197
+ `Requested model "${model}" is not in enabledModels scope (allowlist: [${patterns.join(", ")}])`,
198
+ ).withContext("F7 model scope gate — caller override rejected");
199
+ },
191
200
  } as const;
@@ -35,6 +35,7 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
35
35
  import type { Message } from "@earendil-works/pi-ai";
36
36
  import type { ExtensionAPI, ContextEvent } from "@earendil-works/pi-coding-agent";
37
37
  import { collectInFlightRuns } from "./registration/compaction-guard.ts";
38
+ import { extractSessionId } from "../utils/session-utils.ts";
38
39
  import type { TeamRunManifest } from "../state/types.ts";
39
40
 
40
41
  /** Sentinel that marks an injected ambient-status user message. */
@@ -133,10 +134,10 @@ export interface AmbientContextResult {
133
134
  *
134
135
  * Exported for unit testing.
135
136
  */
136
- export function handleContextEvent(event: ContextEvent, cwd: string): AmbientContextResult | undefined {
137
+ export function handleContextEvent(event: ContextEvent, cwd: string, sessionId?: string): AmbientContextResult | undefined {
137
138
  let runs: TeamRunManifest[] = [];
138
139
  try {
139
- runs = collectInFlightRuns(cwd);
140
+ runs = collectInFlightRuns(cwd, sessionId);
140
141
  } catch {
141
142
  // State read failure → don't inject, don't crash. Pi catches handler
142
143
  // errors anyway, but we avoid noisy error emission for a best-effort
@@ -167,8 +168,16 @@ export function registerContextStatusInjection(
167
168
  opts: { enabled?: boolean } = {},
168
169
  ): void {
169
170
  if (opts.enabled === false) return;
170
- pi.on("context", (event: ContextEvent): AmbientContextResult | undefined => {
171
- const cwd = typeof process.cwd === "function" ? process.cwd() : ".";
172
- return handleContextEvent(event, cwd);
171
+ pi.on("context", (event: ContextEvent, ctx: unknown): AmbientContextResult | undefined => {
172
+ // crew state is per-project; use the session ctx cwd when available,
173
+ // falling back to process.cwd(). Thread the session id so ambient
174
+ // status only reflects runs owned by THIS session (the state store is
175
+ // per-project, shared across sessions).
176
+ const cwd =
177
+ typeof ctx === "object" && ctx !== null && typeof (ctx as { cwd?: unknown }).cwd === "string"
178
+ ? (ctx as { cwd: string }).cwd
179
+ : typeof process.cwd === "function" ? process.cwd() : ".";
180
+ const sessionId = extractSessionId(ctx);
181
+ return handleContextEvent(event, cwd, sessionId);
173
182
  });
174
183
  }
@@ -91,6 +91,7 @@ import {
91
91
  userCrewRoot,
92
92
  } from "../utils/paths.ts";
93
93
  import { resolveContainedPath } from "../utils/safe-paths.ts";
94
+ import { extractSessionId } from "../utils/session-utils.ts";
94
95
  import { resetTimings, time } from "../utils/timings.ts";
95
96
  import {
96
97
  type PiCrewRpcHandle,
@@ -1242,24 +1243,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1242
1243
  notifyActiveRuns(ctx);
1243
1244
 
1244
1245
  // Auto-cancel orphaned runs from dead sessions
1245
- // Extract sessionId from context use Object.getOwnPropertyDescriptor
1246
- // to safely access property without triggering Proxy traps, then validate.
1247
- const rawSessionId =
1248
- typeof ctx === "object" && ctx !== null
1249
- ? Object.getOwnPropertyDescriptor(ctx, "sessionId")?.value
1250
- : undefined;
1251
- const currentSessionId =
1252
- typeof rawSessionId === "string" && rawSessionId.length > 0
1253
- ? rawSessionId
1254
- : undefined;
1255
- if (rawSessionId !== undefined && currentSessionId === undefined) {
1256
- logInternalError(
1257
- "register.sessionId.invalid",
1258
- new Error(
1259
- `Invalid session ID: expected non-empty string, got ${typeof rawSessionId}`,
1260
- ),
1261
- );
1262
- }
1246
+ // Extract sessionId from context via the shared safe accessor (handles
1247
+ // untyped runtime property + defensive against exotic objects).
1248
+ const currentSessionId = extractSessionId(ctx);
1263
1249
 
1264
1250
  // Defer ALL heavy cleanup to after the session_start handler returns.
1265
1251
  // These operations involve synchronous directory scanning (readdirSync, readFileSync)