pi-crew 0.9.10 → 0.9.12

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,95 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.12] — TUI UI/UX polish (21 findings) (2026-06-27)
4
+
5
+ Comprehensive TUI UX review of pi-crew's UI layer (`src/ui/` + `src/utils/visual.ts`). 21 findings (5×P1, 10×P2, 6×P3), all addressed. Full review with evidence-backed file:line citations: `research-findings/pi-crew-uiux-review.md`.
6
+
7
+ ### Bug fixes
8
+
9
+ - **F-3 (P1)** — `src/ui/live-conversation-overlay.ts`: local `pad` used `s.length` (counted ANSI escape bytes) and `content.slice` (UTF-16 units) → border drift on every colored or CJK line. Replaced with `pad`/`truncate` from `utils/visual.ts` (reference impl: `transcript-viewer.ts`).
10
+ - **F-1 / F-2 / V-3 (P1/P2)** — `src/ui/status-colors.ts` (new shared `colorizeStatusGlyphs()`): unified status-glyph colorization covers ⏳ (waiting), ⚠ (needs_attention), and the braille spinner range ⠁–⣿ — the two most attention-demanding states were previously uncolored. Replaces duplicated per-module glyph maps/regexes in `widget-renderer.ts`, `live-run-sidebar.ts`, and `run-dashboard.ts`.
11
+ - **L-1 (P1)** — `src/ui/run-dashboard.ts`: windowed run list with `scrollOffset`; selection can no longer escape the rendered 8-row window. ↓ past row 7 previously hid the highlight and Enter acted on an invisible run. Brute-force verified for 0–30 runs.
12
+ - **L-2 (P1)** — cancellation/failure reason now shown in default detail row (`run-dashboard.ts`) and `live-run-sidebar.ts`. Previously rendered only in `progress-pane` (pane `2`). `cancellation-pane.ts` wired in via `summarizeTerminalReason()` (D-1).
13
+ - **V-1 (P2)** — tabular-aligned numeric metrics across `run-dashboard` footer, `dashboard-panes/agents-pane.ts`, and `widget/widget-formatters.ts` via width-aware `alignMetric` (visibleWidth-based). Eliminates per-tick column jitter on transitions like 9.9s→10.0s and 950→1.0k.
14
+ - **F-5 (P2)** — `src/runtime/process-status.ts`: `ERROR_VISIBILITY_GRACE_MS = 10 * 60_000` (was the 8s `COMPLETED_VISIBILITY_GRACE_MS` shared with completed runs). Failed/cancelled runs now linger 10 min in the crew widget — the run-level header (✗ team/workflow · X/Y agents) is the "one-line trace". Successful completions still vanish in 8s.
15
+ - **K-1 (P2)** — new `src/ui/overlays/help-overlay.ts`: `?` opens the HelpOverlay rendering `BINDINGS[]` grouped by scope. ~16/20 keybindings were previously undiscoverable (no `?` cheatsheet existed). Header hint updated.
16
+ - **F-6 (P2)** — `src/ui/live-run-sidebar.ts`: auto-close countdown moved inside the bordered box (was rendered below the bottom border).
17
+ - **V-2 (P2)** — `src/ui/crew-footer.ts`, `src/ui/crew-select-list.ts`: dropped ASCII `'...'` 3rd arg from `truncate(...)` so they use the default `'…'` (U+2026) consistently with the rest of the UI. Test updated.
18
+ - **L-3 / L-4 / L-5 (P2/P3)** — width-aware `runLabel` keeps the goal visible (the most meaningful field); widget prioritizes running > queued > waiting with `finishedSlots` so finished rows only fill leftover budget and never push a live agent's activity line off-screen; run-list separator unified to ` · `.
19
+ - **F-4 / F-7 (P2/P3)** — stale-snapshot hint in the default dashboard view when manifest reads are flaky; actionable empty/error states (no more "Dashboard error — see logs").
20
+ - **T-1 (P3)** — `src/utils/visual.ts`: ZWJ (`U+200D`) removed from `WIDE_RANGES` (now correctly width-0; was inflating compound-emoji width and over-truncating).
21
+ - **T-2 (P3)** — `src/ui/widget/index.ts`: debounced (~120ms) SIGWINCH + stdout-resize listener busts the render cache and requests a repaint. Guarded against double-registration across widget reinstalls.
22
+ - **D-1 (P3)** — `src/ui/dashboard-panes/cancellation-pane.ts` wired in via `summarizeTerminalReason()` (was dead-imported by `test/unit/cancellation-pane.test.ts`, so kept and given a real consumer instead of deleted).
23
+
24
+ ### Shortcut collision fix
25
+
26
+ - **Extension-load warning**: `alt+d` collided with `tui.editor.deleteWordForward` (verified in pi-tui `TUI_KEYBINDINGS`). Pi resolved in favor of the extension, *stripping* the editor's delete-word-forward binding. Moved dashboard shortcut to **`alt+c`** (mnemonic: **C**rew — verified free against the full built-in `alt+` keymap: occupied letters are `b, d, f, v, y`).
27
+ - **Test guard hardened**: `test/unit/crew-shortcuts.test.ts` collision set now includes the editor keys (`alt+b/d/f/y`, `alt+backspace`, `alt+delete`). The previous set was incomplete (`alt+v, alt+enter, alt+arrows` only), which is why the bug slipped past tests. This class of regression is now caught at test time.
28
+
29
+ ### Decisions (deviations from the initial review)
30
+
31
+ - **K-3 `KEY_RESERVED`**: NOT dead code — consumed by `test/unit/keybinding-map.parity.test.ts:29,185-203` and `test/manual/l2-keybinding-dispatch-smoke.mjs`. Corrected the misleading "dead code" doc instead of deleting (deleting would have broken the parity test).
32
+ - **K-2 `alt+m` / `alt+t`**: blocked — mailbox overlay is run-scoped (requires a runId; reached via the dashboard); `team-status` is a text command (`handleTeamTool({action:"status"})`), not an overlay opener. `alt+d → dashboard` is the only wired shortcut.
33
+ - **F-5 location**: fixed at the correct layer (`process-status.ts` run-level grace) rather than the report's suggested `widget-renderer.ts` agent-row linger; the run-level header line is the right "one-line trace" per the finding.
34
+
35
+ ### Verification
36
+
37
+ - `npx tsc --noEmit` + `npm run typecheck`: 0 errors (incl. strip-types import smoke).
38
+ - Focused UI cluster: **74/74 pass** across 8 suites — `crew-shortcuts` (incl. strengthened collision assertion), `crew-footer` (incl. renamed V-2 test), `keybinding-map parity`, `cancellation-pane`, `process-status ×3`, `agents-pane-cost`.
39
+ - Full suite baseline (before this batch): 5642 / 5639 pass / 0 fail. Two post-change full runs hit `ETIMEDOUT` on `spawnSync` inside integration child-spawn tests (`worktree-run`, `cleanup-full-flow`) under load (0 assertion failures — environmental flake). To be reconfirmed against CI.
40
+
41
+ ## [v0.9.11] — Per-run lock path for background-runner (parallel-spawn race) (2026-06-27)
42
+
43
+ Bug caught by an E2E parallel-spawn test in this session, NOT by unit tests (which cannot spawn multiple real processes). Independent of the F1-F5/redaction batches.
44
+
45
+ ### Bug fix
46
+
47
+ - **Shared `run.lock` killed concurrent background runners** (`src/runtime/background-runner.ts:417`). The bootstrap call passed a fake manifest `{ stateRoot: "", runId, cwd }` to `withRunLockSync` because the real manifest was not loaded yet. `lockPath()` = `path.join(manifest.stateRoot, "run.lock")` = `path.join("", "run.lock")` = `"run.lock"` — a RELATIVE path at cwd, SHARED across every run regardless of runId. When multiple background agents spawned in the same instant (e.g. parallel `Agent` calls), they raced on the single shared lock: one acquired, the rest failed fast ("Run 'run.lock' is locked by another operation") and exited within 3s. The existing "FIX Issue #3" comment claimed to prevent concurrent runners "for the same runId", but the lock path never contained the runId. Fix: compute the real per-run stateRoot via `createRunPaths(cwd, runId).stateRoot` before locking, so each run locks its own `<cwd>/.crew/state/runs/<runId>/run.lock`. Matches `locks-race.test.ts`.
48
+
49
+ ### Verification
50
+
51
+ - `npx tsc --noEmit` EXIT 0
52
+ - 7 lock-related suites pass (locks-race 10, background-runner-console-redirect 4, async-runner 13, api-locks 1, orphan-worker-registry 15, locks-untested 11, team-runner-heartbeat 2)
53
+ - E2E reproduce (decisive): BEFORE the fix, 3 parallel background explorers → 1 pass + 2 fail (background.log: "Failed to acquire lock"). AFTER the fix, same scenario → 3/3 pass, 0 lock errors.
54
+
55
+ ### Lesson
56
+
57
+ Concurrency/lock bugs only reproduce when multiple real processes spawn simultaneously — unit tests mocking a single process can never catch them. E2E parallel-spawn smoke tests are the only way to verify. (Reinforces the v0.9.9 lesson: E2E with real extension load is decisive.)
58
+
59
+ ## [v0.9.11] — Read-only permission model fixes F1-F5 (2026-06-27)
60
+
61
+ Review of the role permission model (question: "do read-only workflows still persist their task output?") confirmed output persistence is runner-driven and correct, but found 5 findings — one the same defect class as the v0.9.10 writer incident (Fix 5), in the opposite direction.
62
+
63
+ ### Bug fixes
64
+
65
+ - **`security-reviewer`/`test-engineer` tool config unreachable (F1, HIGH)** (`src/config/role-tools.ts`). Map keys were `security_reviewer`/`test_engineer` (underscore) while the runtime role strings are hyphenated (`agents/security-reviewer.md` → `security-reviewer`). `getToolConfig` did not normalize, so it returned `{}` and the strictest tool restrictions in the codebase silently never applied. Same defect class as the writer incident, opposite direction (under-enforce vs over-enforce). Tests masked it: they queried only the underscore forms. Fix: quote+hyphen the keys (a bare `security-reviewer:` key parses as subtraction — must be quoted) and normalize in `getToolConfig` (`role.replaceAll("_","-")`); added a regression test that derives role names from the runtime sets and asserts each resolves its intended config.
66
+ - **`critic`/`planner` tool-config gaps (F2)** (`src/config/role-tools.ts`). `critic` had no entry (a custom critic agent had no tool-level read-only enforcement); `planner`'s entry only excluded `ask_question` and did not enforce read-only. Added a `critic` entry and strengthened `planner` to a read-only tool-set.
67
+ - **`planner` kept read-only with deliverable guidance (F3)** (`src/runtime/task-runner/prompt-builder.ts`). `planner` emits deliverables (`output: plan.md`) but moving it to WRITE_ROLES would fire the plan-approval gate BEFORE planning (breaking default/implementation workflows — `team-runner.ts:399` relies on planner being read-only). Fix: keep planner read-only and add a prompt line telling read-only roles their RESULT TEXT is persisted by the runner, so they emit deliverables as text instead of attempting file writes.
68
+ - **`verifier` reclassified read-only → write (F4)** (`src/runtime/role-permission.ts` + `src/config/role-tools.ts`). `verifier`'s task runs tests via bash with redirects/cache writes (`npm test | tee`, `mkdir`, `rm`), all forbidden by the read-only prompt gate — a direct contradiction with `agents/verifier.md`. Moved verifier to WRITE_ROLES; tool-config keeps bash but excludes edit/write so source integrity is preserved. Mirrors `cold-verifier`.
69
+ - **Dead command-enforcement removed (F5)** (`src/runtime/role-permission.ts`). `isReadOnlyCommand`/`checkRolePermission`/`READ_ONLY_COMMANDS` had zero runtime callers (only tests). Real protection lives in the role tool-config + `safe-paths.ts`/`resolveRealContainedPath` (10+ runtime callers). Deleted the dead code.
70
+
71
+ ### Verification
72
+
73
+ - `npx tsc --noEmit` EXIT 0
74
+ - 124 tests pass / 0 fail across 13 suites + 1 integration (role-tools 15, role-permission-cov 23, role-permission 2, role-permission.spawn 3, prompt-builder-cov 15, v0-8-0-tool-policy-unification 10, skill-instructions 16, plan-approval-boundary 7, crew-contracts 6, goal-loop-team-roles 5, t9-cold-verifier 5, completion-guard 7, verification-gates 10, role-tools-integration 3)
75
+ - E2E: `research` workflow 3/3 tasks — explorer+analyst (read-only) persisted findings, writer wrote the deliverable file
76
+
77
+ ## [v0.9.11] — Secret redaction & env hardening (2026-06-27)
78
+
79
+ Independent security review (review team, 3/4 tasks, ~360K tokens) flagged 3 Medium findings in the secret-redaction and env-passthrough surfaces. All verified by live `npx tsx` repro + source trace before fixing.
80
+
81
+ ### Bug fixes
82
+
83
+ - **`redactAuthHeader` leaked credential values (L3/L5)** (`src/utils/redaction.ts`). Two defects: (1) `indexOf` matched only the FIRST `authorization:` occurrence per call, so a second header on a later line leaked verbatim; (2) the word-boundary allow-list excluded `-` and `\t`, so `Proxy-Authorization:` / `X-Authorization:` and tab-indented headers were not recognized. Fix: loop over all occurrences and add `-`/`\t` to a shared `AUTH_HEADER_BOUNDARY_CHARS` set (used by both `redactAuthHeader` and `redactBearerTokens`). Latent weakness caught by repro (NOT by the reviewer's proposed fix): the old code only APPENDED a ` ***` marker without removing the value — `"authorization: Basic abc123"` became `"authorization: Basic abc123 ***"` (credential still visible). The redact branch now blanks the value: `line.substring(authIdx, authIdx+14) + " ***"` → `"authorization: ***"`. Consistent with `redactInlineSecrets`.
84
+ - **`writeArtifact` flat-redaction only (M2)** (`src/state/artifact-store.ts:130`). Applied only `redactSecretString` (flat regex scan), so quoted-JSON secrets (`"api_key":"sk-..."`) and nested keys survived into persisted artifacts (e.g. `startup-evidence.json` holds up to 500 chars of raw child stderr). Fix: structural-then-flat — when content parses as JSON, run `redactSecrets` (recursive) first, then flat `redactSecretString`. Order matters: structural catches quoted keys, flat still catches Bearer/JWT/Auth headers inside JSON string values. Formatting is preserved: the input is re-stringified with the SAME indentation (pretty → indent 2, compact → compact), so pretty-printed artifacts like group-join metadata keep their `"partial": false` whitespace (caught by `test/integration/phase4-runtime.test.ts` regression on CI after the first attempt shipped a compact re-stringify).
85
+ - **Provider API keys leaked into the detached background runner (M1)** (`src/runtime/async-runner.ts:162`). The env allowlist forwarded 14 provider keys (MINIMAX/OPENAI/ANTHROPIC/...) to the background runner, contradicting `child-pi.ts:275` ("API keys are NOT needed — config file"). Keys leaked into V8 fatal-error reports (`--report-on-fatalerror` writes `environmentVariables` unredacted). The inline comment "same as child-pi.ts" was false. Fix: extracted `BACKGROUND_RUNNER_ENV_ALLOWLIST` (exported, unit-testable) and removed the 14 provider keys. Prereq verified: `background-runner.ts` does not read provider keys directly.
86
+
87
+ ### Verification
88
+
89
+ - `npx tsc --noEmit` EXIT 0
90
+ - 21 targeted suites pass (~130 tests): redaction-cov (32), redaction-p1f (18), redaction-transcript-roundtrip (3), child-pi-sec1-redaction (8), artifact-store (4), async-runner (13), env-filter (4), env-filter-cov (9), security-hardening (8), round28-otlp-crlf (4), child-pi-compaction-real (9), + others
91
+ - Live repro: `redactSecretString("Proxy-Authorization: Basic c2VjcmV0")` → `"Proxy-Authorization: ***"` (was: unchanged leak)
92
+
3
93
  ## [v0.9.10 (continued)] — Round 29 follow-ups: BG2 sweep bug fixes, test optimization, E2E verification (2026-06-26)
4
94
 
5
95
  A full-suite verify run (`verify-full2`, 5502 tests, 774 suites) surfaced 4 file-level timeouts and 2 real correctness bugs. This release fixes the 2 real bugs, the underlying cause of 2 of the 4 timeouts (chain-runner + orphan-worker-registry + cleanup-full-flow self-deadlock + HandoffManager interval leak), and adds E2E verification artifacts to prove all fixes hold against the live runtime, not just static analysis.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -22,9 +22,23 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
22
22
  excludeTools: ["edit", "write", "ask_question"],
23
23
  },
24
24
 
25
- // Planner - Planning and documentation
25
+ // Planner - Read-only planning; emits plans as TEXT (runner persists result).
26
+ // F2/F3: strengthened to a read-only tool-set matching its READ_ONLY_ROLES
27
+ // classification. Deliverables are emitted as RESULT TEXT (consumed by
28
+ // adaptive-plan.ts / runner shared-output), NOT file writes — so the
29
+ // plan-approval gate boundary (planner = read-only) is preserved. Moving
30
+ // planner to WRITE_ROLES would fire the gate before planning, breaking the
31
+ // default/implementation workflows.
26
32
  planner: {
27
- excludeTools: ["ask_question"],
33
+ tools: ["read", "grep", "find", "ls", "glob"],
34
+ excludeTools: ["edit", "write", "bash", "web", "ask_question"],
35
+ },
36
+
37
+ // Critic - Read-only plan/design critique (F2: was missing from the map,
38
+ // so a custom critic agent had no tool-level read-only enforcement).
39
+ critic: {
40
+ tools: ["read", "grep", "find", "ls", "glob"],
41
+ excludeTools: ["edit", "write", "bash", "web"],
28
42
  },
29
43
 
30
44
  // Executor - Full access (default)
@@ -45,13 +59,26 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
45
59
  },
46
60
 
47
61
  // Security Reviewer - Strict restrictions
48
- security_reviewer: {
62
+ // F1: key is hyphenated to match the runtime role string (agents/
63
+ // security-reviewer.md → "security-reviewer"). The underscore form never
64
+ // resolved at runtime (returned {}), silently dropping enforcement.
65
+ "security-reviewer": {
49
66
  tools: ["read", "grep", "find"],
50
67
  excludeTools: ["edit", "write", "bash", "web", "ask_question"],
51
68
  },
52
69
 
53
- // Test Engineer - Can write tests
54
- test_engineer: {
70
+ // Verifier - Runs tests (needs bash) but must NOT edit source (F4: moved
71
+ // from READ_ONLY_ROLES to WRITE_ROLES — the read-only prompt gate forbids
72
+ // the test-running redirects / cache writes its task requires, contradicting
73
+ // agents/verifier.md). Tool-set keeps bash but excludes edit/write so source
74
+ // integrity is preserved during verification. Mirrors cold-verifier behavior.
75
+ verifier: {
76
+ tools: ["read", "grep", "find", "ls", "bash"],
77
+ excludeTools: ["edit", "write", "web"],
78
+ },
79
+
80
+ // Test Engineer - Can write tests (F1: hyphenated key)
81
+ "test-engineer": {
55
82
  tools: ["read", "edit", "write", "bash", "ls"],
56
83
  excludeTools: ["web"],
57
84
  },
@@ -61,7 +88,13 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
61
88
  * Get tool configuration for a specific role.
62
89
  */
63
90
  export function getToolConfig(role: string): RoleToolConfig {
64
- return ROLE_TOOL_CONFIGS[role] ?? {};
91
+ // F1: normalize hyphen/underscore. Runtime role strings are hyphenated
92
+ // (agents/security-reviewer.md → "security-reviewer") but map keys were
93
+ // historically underscored, silently returning {} at runtime — the same
94
+ // defect class as the v0.9.10 writer incident (opposite direction:
95
+ // under-enforce instead of over-enforce). Accept both forms.
96
+ const key = role.includes("_") ? role.replaceAll("_", "-") : role;
97
+ return ROLE_TOOL_CONFIGS[key] ?? ROLE_TOOL_CONFIGS[role] ?? {};
65
98
  }
66
99
 
67
100
  /**
@@ -6,9 +6,25 @@
6
6
  * built-in keymap (see analysis of pi-tui core/keybindings defaults):
7
7
  *
8
8
  * alt+s → open the pi-crew settings overlay (config + theme picker)
9
+ * alt+c → open the pi-crew run dashboard overlay (mnemonic: **C**rew)
9
10
  *
10
- * `alt+<letter>` combos are safe: Pi only binds `alt+v`, `alt+enter`, and the
11
- * alt+arrow navigation keys. `alt+s` is mnemonic (settings) and free.
11
+ * OCCUPIED alt+ keys in the built-in keymap (must NOT reuse verified against
12
+ * pi-tui TUI_KEYBINDINGS + pi core keybindings.js):
13
+ * alt+b (cursor word left) alt+f (cursor word right)
14
+ * alt+d (delete word forward) alt+y (yank pop)
15
+ * alt+v (paste) alt+s (crew settings — this module)
16
+ * alt+enter / alt+up/down/left/right / alt+backspace / alt+delete
17
+ * Free alt+<letter> keys include: a, c, e, g, h, i, j, k, l, m, n, o, p, q,
18
+ * r, t, u, w, x, z.
19
+ *
20
+ * NOTE: an earlier revision used `alt+d` for the dashboard; that collided
21
+ * with `tui.editor.deleteWordForward` and Pi's conflict detector stripped the
22
+ * editor binding. `alt+c` is free AND mnemonic.
23
+ *
24
+ * NOTE: alt+m (mailbox) and alt+t (status) were considered but are NOT wired
25
+ * — the mailbox overlay is run-scoped (requires a runId; reached via the
26
+ * dashboard) and there is no standalone status overlay (status is a text
27
+ * command). See the K-2 note accompanying openTeamDashboard in commands.ts.
12
28
  *
13
29
  * Shortcuts are guarded by `hasUI` so they never fire in print/RPC mode, and
14
30
  * by the optional `registerShortcut` API so older Pi versions degrade
@@ -39,6 +55,17 @@ const CREW_SHORTCUTS: ReadonlyArray<ShortcutRegistration> = [
39
55
  await openTeamSettingsOverlay(ctx);
40
56
  },
41
57
  },
58
+ {
59
+ key: "alt+c",
60
+ description: "pi-crew: open run dashboard (Crew)",
61
+ // Lazy-import so the heavy UI module chain (RunDashboard etc.) is only
62
+ // loaded on first use, not at extension load.
63
+ handler: async (ctx) => {
64
+ // LAZY: defer dynamic import of ./registration/commands.ts to its call site.
65
+ const { openTeamDashboard } = await import("./registration/commands.ts");
66
+ await openTeamDashboard(ctx);
67
+ },
68
+ },
42
69
  ];
43
70
 
44
71
  /**
@@ -260,6 +260,66 @@ async function handleHealthDashboardAction(ctx: ExtensionCommandContext, selecti
260
260
 
261
261
  let depsRef: RegisterTeamCommandsDeps | undefined;
262
262
 
263
+ /**
264
+ * Open the pi-crew run dashboard overlay and run its action loop.
265
+ *
266
+ * Extracted verbatim from the `team-dashboard` command so it is reusable from
267
+ * a keyboard shortcut (alt+c, see crew-shortcuts.ts). Takes the base
268
+ * `ExtensionContext` (the shortcut handler's context) — uses only `hasUI`,
269
+ * `cwd`, `ui`, and `sessionManager` fields, so both `ExtensionContext` and
270
+ * `ExtensionCommandContext` satisfy it. Reads run caches via the module-level
271
+ * `depsRef` (set by `registerTeamCommands`), so it is a no-op if commands
272
+ * have not been registered yet. `deps` is captured into a local const so the
273
+ * non-undefined narrowing survives the awaited overlay call.
274
+ *
275
+ * The dashboard action helpers (handleMailboxDashboardAction, the viewers,
276
+ * notifyCommandResult, teamCommandContext/handleTeamTool) are declared for
277
+ * `ExtensionCommandContext` but only ever read base `ExtensionContext` fields
278
+ * (verified). The keyboard-shortcut path supplies an `ExtensionContext`, so we
279
+ * bridge those over-typed helpers with a single cast rather than relaxing
280
+ * signatures across several modules (some outside this file's scope).
281
+ */
282
+ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
283
+ if (!ctx.hasUI) return;
284
+ const deps = depsRef;
285
+ if (!deps) return;
286
+ const cmdCtx = ctx as ExtensionCommandContext;
287
+ for (;;) {
288
+ // Extract sessionId for workspace-scoped filtering
289
+ const sessionId = cmdCtx.sessionManager?.getSessionId?.();
290
+ const runs = deps.getManifestCache(cmdCtx.cwd).list(50);
291
+ const uiConfig = loadConfig(cmdCtx.cwd).config.ui;
292
+ const rightPanel = (uiConfig?.dashboardPlacement ?? DEFAULT_UI.dashboardPlacement) === "right";
293
+ const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth)) : "90%";
294
+ const { RunDashboard } = await ui();
295
+ const selection = await cmdCtx.ui.custom<RunDashboardSelection | undefined>((tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(cmdCtx.cwd), runProvider: () => deps.getManifestCache(cmdCtx.cwd).list(50), registry: deps.getMetricRegistry?.(), workspaceId: sessionId, requestRender: () => requestRenderTarget(tui) }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
296
+ if (!selection) return;
297
+ if (selection.action === "reload") continue;
298
+ if (selection.action === "notifications-dismiss") {
299
+ deps.dismissNotifications?.();
300
+ cmdCtx.ui.notify("pi-crew notifications dismissed.", "info");
301
+ continue;
302
+ }
303
+ if (selection.action === "mailbox-detail") {
304
+ await handleMailboxDashboardAction(cmdCtx, selection.runId);
305
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
306
+ continue;
307
+ }
308
+ if (selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export") {
309
+ await handleHealthDashboardAction(cmdCtx, selection);
310
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
311
+ continue;
312
+ }
313
+ if (selection.action === "agent-transcript" && await openTranscriptViewer(cmdCtx, selection.runId)) continue;
314
+ if (selection.action === "agent-live" && await openLiveConversation(cmdCtx, selection.runId)) continue;
315
+ if (selection.action === "agent-live") { await notifyCommandResult(cmdCtx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
316
+ const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(cmdCtx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(cmdCtx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(cmdCtx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(cmdCtx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(cmdCtx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(cmdCtx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
317
+ await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(cmdCtx));
318
+ await notifyCommandResult(cmdCtx, commandText(result));
319
+ return;
320
+ }
321
+ }
322
+
263
323
  export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
264
324
  depsRef = deps;
265
325
  pi.registerCommand("teams", {
@@ -497,40 +557,7 @@ export function registerTeamCommands(pi: ExtensionAPI, deps: RegisterTeamCommand
497
557
  } });
498
558
 
499
559
  pi.registerCommand("team-dashboard", { description: "Open a pi-crew run dashboard overlay", handler: async (_args: string, ctx: ExtensionCommandContext) => {
500
- for (;;) {
501
- // Extract sessionId for workspace-scoped filtering
502
- const sessionId = ctx.sessionManager?.getSessionId?.();
503
- const runs = deps.getManifestCache(ctx.cwd).list(50);
504
- const uiConfig = loadConfig(ctx.cwd).config.ui;
505
- const rightPanel = (uiConfig?.dashboardPlacement ?? DEFAULT_UI.dashboardPlacement) === "right";
506
- const width = rightPanel ? Math.min(90, Math.max(40, uiConfig?.dashboardWidth ?? DEFAULT_UI.dashboardWidth)) : "90%";
507
- const { RunDashboard } = await ui();
508
- const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme, { placement: rightPanel ? "right" : "center", showModel: uiConfig?.showModel, showTokens: uiConfig?.showTokens, showTools: uiConfig?.showTools, snapshotCache: deps.getRunSnapshotCache?.(ctx.cwd), runProvider: () => deps.getManifestCache(ctx.cwd).list(50), registry: deps.getMetricRegistry?.(), workspaceId: sessionId, requestRender: () => requestRenderTarget(tui) }), { overlay: true, overlayOptions: rightPanel ? { width, minWidth: 40, maxHeight: "100%", anchor: "top-right", offsetX: 0, offsetY: 0, margin: { top: 0, right: 0, bottom: 0, left: 0 } } : { width, maxHeight: "90%", anchor: "center", margin: 2 } });
509
- if (!selection) return;
510
- if (selection.action === "reload") continue;
511
- if (selection.action === "notifications-dismiss") {
512
- deps.dismissNotifications?.();
513
- ctx.ui.notify("pi-crew notifications dismissed.", "info");
514
- continue;
515
- }
516
- if (selection.action === "mailbox-detail") {
517
- await handleMailboxDashboardAction(ctx, selection.runId);
518
- deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
519
- continue;
520
- }
521
- if (selection.action === "health-recovery" || selection.action === "health-kill-stale" || selection.action === "health-diagnostic-export") {
522
- await handleHealthDashboardAction(ctx, selection);
523
- deps.getRunSnapshotCache?.(ctx.cwd).invalidate(selection.runId);
524
- continue;
525
- }
526
- if (selection.action === "agent-transcript" && await openTranscriptViewer(ctx, selection.runId)) continue;
527
- if (selection.action === "agent-live" && await openLiveConversation(ctx, selection.runId)) continue;
528
- if (selection.action === "agent-live") { await notifyCommandResult(ctx, commandText({ content: [{ type: "text", text: "No live agent found for this run." }] })); continue; }
529
- const result = selection.action === "api" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-manifest" } }, teamCommandContext(ctx)) : selection.action === "agents" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "agent-dashboard" } }, teamCommandContext(ctx)) : selection.action === "mailbox" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-mailbox" } }, teamCommandContext(ctx)) : selection.action === "agent-events" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-events", limit: 50 } }, teamCommandContext(ctx)) : selection.action === "agent-output" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-output", maxBytes: 32_000 } }, teamCommandContext(ctx)) : selection.action === "agent-transcript" ? await handleTeamTool({ action: "api", runId: selection.runId, config: { operation: "read-agent-transcript" } }, teamCommandContext(ctx)) : // eslint-disable-next-line @typescript-eslint/no-explicit-any
530
- await handleTeamTool({ action: selection.action as any, runId: selection.runId }, teamCommandContext(ctx));
531
- await notifyCommandResult(ctx, commandText(result));
532
- return;
533
- }
560
+ await openTeamDashboard(ctx);
534
561
  } });
535
562
 
536
563
  pi.registerCommand("team-mascot", { description: "Show an animated mascot splash", handler: async (args: string, ctx: ExtensionCommandContext) => {
@@ -150,6 +150,75 @@ export interface SpawnBackgroundTeamRunResult {
150
150
  logPath: string;
151
151
  }
152
152
 
153
+ /**
154
+ * Env vars explicitly forwarded to the detached background runner.
155
+ *
156
+ * Provider API keys (MINIMAX/OPENAI/ANTHROPIC/...) are INTENTIONALLY OMITTED
157
+ * (security review M1): the background runner only spawns child Pi workers,
158
+ * which read keys from the Pi config file (not env). Passing keys via env
159
+ * leaks them into V8 fatal-error reports (--report-on-fatalerror writes the
160
+ * `environmentVariables` section unredacted). Matches child-pi.ts policy.
161
+ * Exported so the invariant is unit-testable (test/unit/async-runner.test.ts).
162
+ */
163
+ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
164
+ // Essential non-secret vars
165
+ "PATH",
166
+ "HOME",
167
+ "USER",
168
+ "SHELL",
169
+ "TERM",
170
+ "LANG",
171
+ "LC_ALL",
172
+ "LC_COLLATE",
173
+ "LC_CTYPE",
174
+ "LC_MESSAGES",
175
+ "LC_MONETARY",
176
+ "LC_NUMERIC",
177
+ "LC_TIME",
178
+ "XDG_CONFIG_HOME",
179
+ "XDG_DATA_HOME",
180
+ "XDG_CACHE_HOME",
181
+ "XDG_RUNTIME_DIR",
182
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
183
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
184
+ "NVM_BIN",
185
+ "NVM_DIR",
186
+ "NVM_INC",
187
+ "NODE_PATH",
188
+ "NODE_DISABLE_COLORS",
189
+ "NODE_EXTRA_CA_CERTS",
190
+ "NPM_CONFIG_REGISTRY",
191
+ "NPM_CONFIG_USERCONFIG",
192
+ "NPM_CONFIG_GLOBALCONFIG",
193
+ // PI_CREW_PARENT_PID is needed for parent-guard (liveness check).
194
+ "PI_CREW_DEPTH",
195
+ "PI_CREW_MAX_DEPTH",
196
+ "PI_CREW_INHERIT_PROJECT_CONTEXT",
197
+ "PI_CREW_INHERIT_SKILLS",
198
+ "PI_CREW_PARENT_PID",
199
+ "PI_TEAMS_DEPTH",
200
+ "PI_TEAMS_MAX_DEPTH",
201
+ "PI_TEAMS_INHERIT_PROJECT_CONTEXT",
202
+ "PI_TEAMS_INHERIT_SKILLS",
203
+ "PI_TEAMS_PI_BIN",
204
+ "PI_TEAMS_MOCK_CHILD_PI",
205
+ "PI_CREW_ALLOW_MOCK",
206
+ // Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
207
+ "PI_CREW_WORKER_ATOMIC_WRITER",
208
+ "PI_TEAMS_WORKER_ATOMIC_WRITER",
209
+ // Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
210
+ "PI_CREW_VERIFICATION_SANITIZE_ENV",
211
+ "PI_TEAMS_VERIFICATION_SANITIZE_ENV",
212
+ "PI_CREW_VERIFICATION_PRESERVE_ENV",
213
+ "PI_TEAMS_VERIFICATION_PRESERVE_ENV",
214
+ // Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16).
215
+ "PI_CREW_VERIFICATION_WORKTREE",
216
+ "PI_TEAMS_VERIFICATION_WORKTREE",
217
+ // Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
218
+ "PI_CREW_BG_REPORT_ON_FATAL",
219
+ "PI_TEAMS_BG_REPORT_ON_FATAL",
220
+ ];
221
+
153
222
  export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise<SpawnBackgroundTeamRunResult> {
154
223
  const runnerPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "background-runner.ts");
155
224
  const logPath = path.join(manifest.stateRoot, "background.log");
@@ -159,80 +228,7 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
159
228
  // to prevent leaking all env vars (including secrets) to detached background runner.
160
229
  // Previously, destructuring only removed PI_CREW_PARENT_PID but kept everything else.
161
230
  const filteredEnv = sanitizeEnvSecrets(process.env, {
162
- allowList: [
163
- // Model provider API keys (same as child-pi.ts)
164
- "MINIMAX_API_KEY",
165
- "MINIMAX_GROUP_ID",
166
- "OPENAI_API_KEY",
167
- "OPENAI_ORG_ID",
168
- "ANTHROPIC_API_KEY",
169
- "GOOGLE_API_KEY",
170
- "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
171
- "AZURE_OPENAI_API_KEY",
172
- "AZURE_OPENAI_ENDPOINT",
173
- "AWS_ACCESS_KEY_ID",
174
- "AWS_SECRET_ACCESS_KEY",
175
- "AWS_REGION",
176
- "ZEU_API_KEY",
177
- "ZERODEV_API_KEY",
178
- // Essential non-secret vars
179
- "PATH",
180
- "HOME",
181
- "USER",
182
- "SHELL",
183
- "TERM",
184
- "LANG",
185
- "LC_ALL",
186
- "LC_COLLATE",
187
- "LC_CTYPE",
188
- "LC_MESSAGES",
189
- "LC_MONETARY",
190
- "LC_NUMERIC",
191
- "LC_TIME",
192
- "XDG_CONFIG_HOME",
193
- "XDG_DATA_HOME",
194
- "XDG_CACHE_HOME",
195
- "XDG_RUNTIME_DIR",
196
- // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
197
- ...WINDOWS_ESSENTIAL_ENV_VARS,
198
- "NVM_BIN",
199
- "NVM_DIR",
200
- "NVM_INC",
201
- "NODE_PATH",
202
- "NODE_DISABLE_COLORS",
203
- "NODE_EXTRA_CA_CERTS",
204
- "NPM_CONFIG_REGISTRY",
205
- "NPM_CONFIG_USERCONFIG",
206
- "NPM_CONFIG_GLOBALCONFIG",
207
- // FIX: explicit list matches child-pi.ts to prevent regression.
208
- // PI_CREW_PARENT_PID is needed for parent-guard (liveness check).
209
- "PI_CREW_DEPTH",
210
- "PI_CREW_MAX_DEPTH",
211
- "PI_CREW_INHERIT_PROJECT_CONTEXT",
212
- "PI_CREW_INHERIT_SKILLS",
213
- "PI_CREW_PARENT_PID",
214
- "PI_TEAMS_DEPTH",
215
- "PI_TEAMS_MAX_DEPTH",
216
- "PI_TEAMS_INHERIT_PROJECT_CONTEXT",
217
- "PI_TEAMS_INHERIT_SKILLS",
218
- "PI_TEAMS_PI_BIN",
219
- "PI_TEAMS_MOCK_CHILD_PI",
220
- "PI_CREW_ALLOW_MOCK",
221
- // Phase 1.5: worker-thread atomic writer opt-in (RFC 15).
222
- "PI_CREW_WORKER_ATOMIC_WRITER",
223
- "PI_TEAMS_WORKER_ATOMIC_WRITER",
224
- // Phase 1.5 #1: verification env sanitization opt-in (RFC 13 §6).
225
- "PI_CREW_VERIFICATION_SANITIZE_ENV",
226
- "PI_TEAMS_VERIFICATION_SANITIZE_ENV",
227
- "PI_CREW_VERIFICATION_PRESERVE_ENV",
228
- "PI_TEAMS_VERIFICATION_PRESERVE_ENV",
229
- // Phase 1.5 #2: verification git-worktree sandbox opt-in (RFC 16).
230
- "PI_CREW_VERIFICATION_WORKTREE",
231
- "PI_TEAMS_VERIFICATION_WORKTREE",
232
- // Phase 1.5 #3: V8 diagnostic report on fatal error (RFC 17 — investigation).
233
- "PI_CREW_BG_REPORT_ON_FATAL",
234
- "PI_TEAMS_BG_REPORT_ON_FATAL",
235
- ],
231
+ allowList: BACKGROUND_RUNNER_ENV_ALLOWLIST,
236
232
  });
237
233
  // FIX: removed delete workarounds — with explicit allowlist, these vars
238
234
  // are no longer auto-leaked. Matches child-pi.ts.
@@ -7,6 +7,7 @@ import {
7
7
  withRunLockSync,
8
8
  } from "../state/locks.ts";
9
9
  import {
10
+ createRunPaths,
10
11
  loadRunManifestById,
11
12
  saveRunManifest,
12
13
  updateRunStatus,
@@ -411,11 +412,21 @@ async function main(): Promise<void> {
411
412
  );
412
413
  // FIX Issue #3: Wrap in withRunLockSync to prevent concurrent background-runners
413
414
  // for the same runId from reading stale manifest state. If lock cannot be
414
- // acquired within 5s, fail immediately rather than proceeding with stale data.
415
+ // be acquired within 5s, fail immediately rather than proceeding with stale data.
416
+ //
417
+ // BUGFIX (caught by E2E parallel-spawn, 2026-06-27): the lock manifest must
418
+ // carry the REAL per-run stateRoot, NOT an empty string. lockPath() derives
419
+ // `<stateRoot>/run.lock`, so `stateRoot: ""` collapses every concurrent
420
+ // background-runner (different runIds, same spawn instant) onto a SINGLE
421
+ // shared `run.lock` at cwd — 1 acquires, the rest fail-fast and die. Compute
422
+ // the per-run stateRoot from (cwd, runId) via createRunPaths (same helper
423
+ // resolveRunStateRoot uses internally), so each run locks its own
424
+ // `<cwd>/.crew/state/runs/<runId>/run.lock`. Matches locks-race.test.ts.
425
+ const bootstrapStateRoot = createRunPaths(cwd, runId).stateRoot;
415
426
  let loaded: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
416
427
  try {
417
428
  loaded = withRunLockSync(
418
- { stateRoot: "", runId, cwd } as TeamRunManifest,
429
+ { stateRoot: bootstrapStateRoot, runId, cwd } as TeamRunManifest,
419
430
  () => loadRunManifestById(cwd, runId),
420
431
  { staleMs: 30_000 },
421
432
  );
@@ -16,7 +16,11 @@ export interface ProcessLiveness {
16
16
  */
17
17
  const ORPHANED_ACTIVE_RUN_MS = 2 * 60 * 1000;
18
18
  /** How long a completed run stays visible in the widget after completion. */
19
- const COMPLETED_VISIBILITY_GRACE_MS = 8000;
19
+ const COMPLETED_VISIBILITY_GRACE_MS = 8_000;
20
+ /** Errors (failed/cancelled) linger far longer so a failed run leaves a visible
21
+ * trace in the crew widget for ~10 min (F-5). Successful completions vanish
22
+ * quickly to keep the widget quiet. */
23
+ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
20
24
  /** Maximum age (ms) for an active run before it's considered stale.
21
25
  * After this time, PID-only liveness is unreliable due to PID recycling. */
22
26
  const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
@@ -120,12 +124,13 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
120
124
  }
121
125
  // Grace period: show completed runs for a few seconds so users see the result.
122
126
  if (run.status === "completed" || run.status === "failed" || run.status === "cancelled") {
127
+ const grace = run.status === "completed" ? COMPLETED_VISIBILITY_GRACE_MS : ERROR_VISIBILITY_GRACE_MS;
123
128
  const lastAgentActivity = agents.reduce<number>((max, agent) => {
124
129
  const ts = agent.completedAt ?? agent.startedAt;
125
130
  const parsed = ts ? new Date(ts).getTime() : 0;
126
131
  return Number.isFinite(parsed) && parsed > max ? parsed : max;
127
132
  }, new Date(run.updatedAt).getTime());
128
- if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < COMPLETED_VISIBILITY_GRACE_MS) return true;
133
+ if (Number.isFinite(lastAgentActivity) && now - lastAgentActivity < grace) return true;
129
134
  return false;
130
135
  }
131
136
  if (!isActiveRunStatus(run.status)) return false;
@@ -1,11 +1,10 @@
1
- import { isSensitivePath } from "./sensitive-paths.ts";
2
-
3
1
  export type RolePermissionMode = "read_only" | "workspace_write" | "danger_full_access" | "explicit_confirm";
4
2
 
5
- const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "verifier", "analyst", "critic", "planner"]);
6
- const WRITE_ROLES = new Set(["executor", "test-engineer", "writer"]);
7
- const READ_ONLY_COMMANDS = new Set(["cat", "head", "tail", "less", "more", "wc", "ls", "find", "grep", "rg", "awk", "sed", "echo", "printf", "which", "where", "whoami", "pwd", "env", "printenv", "date", "df", "du", "uname", "file", "stat", "diff", "sort", "uniq", "tr", "cut", "paste", "test", "true", "false", "type", "readlink", "realpath", "basename", "dirname", "sha256sum", "md5sum", "xxd", "hexdump", "od", "strings", "tree", "jq", "git", "gh"]);
8
-
3
+ // Read-only roles: cannot mutate files/source. `verifier` is NOT here — it runs
4
+ // tests (bash + cache writes) so it is a WRITE role (F4). `planner` stays
5
+ // read-only to preserve the plan-approval gate boundary (F3).
6
+ const READ_ONLY_ROLES = new Set(["explorer", "reviewer", "security-reviewer", "analyst", "critic", "planner"]);
7
+ const WRITE_ROLES = new Set(["executor", "test-engineer", "writer", "verifier"]);
9
8
  export interface PermissionCheckResult {
10
9
  allowed: boolean;
11
10
  mode: RolePermissionMode;
@@ -18,21 +17,6 @@ export function permissionForRole(role: string): RolePermissionMode {
18
17
  return "workspace_write";
19
18
  }
20
19
 
21
- export function isReadOnlyCommand(command: string): boolean {
22
- const first = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop() ?? "";
23
- return READ_ONLY_COMMANDS.has(first) && !/\s(-i|--in-place)\b|\s>{1,2}\s|\brm\b|\bmv\b|\bcp\b|\b(?:npm|pnpm|yarn|bun)\s+(install|add|ci|remove)\b|\bgit\s+(commit|push|merge|rebase|reset|checkout|clean)\b/.test(command);
24
- }
25
-
26
- export function checkRolePermission(role: string, command: string, filePath?: string): PermissionCheckResult {
27
- const mode = permissionForRole(role);
28
- // Also block access to known sensitive paths even for read-only commands
29
- if (filePath && isSensitivePath(filePath)) {
30
- return { allowed: false, mode, reason: `Path '${filePath}' is sensitive (credentials, SSH keys, etc.) — access denied for all roles.` };
31
- }
32
- if (mode === "read_only" && !isReadOnlyCommand(command)) return { allowed: false, mode, reason: `Role '${role}' is read-only and command may modify state.` };
33
- return { allowed: true, mode };
34
- }
35
-
36
20
  export function currentCrewRole(env: NodeJS.ProcessEnv = process.env): string | undefined {
37
21
  return env.PI_CREW_ROLE?.trim() || env.PI_TEAMS_ROLE?.trim() || undefined;
38
22
  }
@@ -30,6 +30,7 @@ function readOnlyRoleInstructions(role: string): string {
30
30
  "- Do not use shell redirects, heredocs, in-place edits, package installs, git commit/merge/rebase/reset/checkout, or other state-mutating commands.",
31
31
  "- If implementation changes are needed, report exact recommendations instead of applying them.",
32
32
  "- Prefer read/grep/find/listing tools and read-only git inspection commands.",
33
+ "- Your final RESULT TEXT is persisted automatically by the runner (as a result artifact and, if the step declares `output:`, to a shared file). To deliver a plan, report, or findings, EMIT THEM AS TEXT in your final result — do NOT try to write a file yourself.",
33
34
  ].join("\n");
34
35
  }
35
36