pi-crew 0.11.23 → 0.11.25

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 (156) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/explorer.md +3 -3
  5. package/agents/librarian.md +3 -3
  6. package/agents/orchestrator.md +17 -0
  7. package/dist/index.mjs +567 -555
  8. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  9. package/docs/dynamic-workflows.md +37 -2
  10. package/docs/publishing.md +34 -5
  11. package/docs/troubleshooting.md +50 -2
  12. package/docs/usage.md +7 -2
  13. package/install.mjs +2 -1
  14. package/package.json +6 -4
  15. package/skills/read-only-explorer/SKILL.md +15 -5
  16. package/skills/real-test-pi-crew/SKILL.md +7 -7
  17. package/src/agents/discover-agents.ts +8 -2
  18. package/src/config/config-validation.ts +61 -1
  19. package/src/config/config.ts +90 -5
  20. package/src/config/env-allowlist-registry.ts +5 -0
  21. package/src/config/env-vars.ts +23 -13
  22. package/src/config/role-tools.ts +14 -11
  23. package/src/config/types.ts +4 -1
  24. package/src/extension/crew-cleanup.ts +6 -0
  25. package/src/extension/management.ts +7 -0
  26. package/src/extension/project-init.ts +19 -1
  27. package/src/extension/register.ts +83 -30
  28. package/src/extension/registration/artifact-cleanup.ts +52 -11
  29. package/src/extension/registration/commands/manage.ts +9 -2
  30. package/src/extension/registration/hook-registration.ts +3 -0
  31. package/src/extension/registration/lazy-configurers.ts +2 -0
  32. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  33. package/src/extension/registration/render-loop.ts +27 -2
  34. package/src/extension/registration/tool-loop-guard.ts +5 -3
  35. package/src/extension/registration/verify-gate-tool.ts +7 -3
  36. package/src/extension/run-index.ts +24 -1
  37. package/src/extension/team-tool/api/read.ts +12 -3
  38. package/src/extension/team-tool/cancel.ts +11 -0
  39. package/src/extension/team-tool/config-patch.ts +56 -11
  40. package/src/extension/team-tool/destructive-gate.ts +268 -0
  41. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  42. package/src/extension/team-tool/goal-wrap.ts +11 -1
  43. package/src/extension/team-tool/goal.ts +13 -1
  44. package/src/extension/team-tool/handle-schedule.ts +38 -4
  45. package/src/extension/team-tool/handle-settings.ts +76 -6
  46. package/src/extension/team-tool/health-monitor.ts +14 -2
  47. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  48. package/src/extension/team-tool/respond.ts +12 -5
  49. package/src/extension/team-tool/run-deadline.ts +29 -2
  50. package/src/extension/team-tool/run.ts +11 -0
  51. package/src/extension/team-tool/status.ts +64 -8
  52. package/src/extension/team-tool/workflow-manage.ts +5 -1
  53. package/src/extension/team-tool.ts +13 -0
  54. package/src/i18n.ts +18 -14
  55. package/src/observability/exporters/otlp-exporter.ts +11 -1
  56. package/src/prompt/inbox-poll.ts +14 -2
  57. package/src/prompt/message-tool.ts +1 -1
  58. package/src/prompt/prompt-runtime.ts +127 -21
  59. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  60. package/src/prompt/surface-worker.ts +35 -9
  61. package/src/runtime/broker/crew-broker-client.ts +7 -0
  62. package/src/runtime/broker/crew-broker.ts +216 -242
  63. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  64. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  65. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  66. package/src/runtime/broker/protocol/connection-state.ts +159 -1
  67. package/src/runtime/broker/protocol/events-replay.ts +50 -10
  68. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  69. package/src/runtime/budget-enforcement.ts +26 -6
  70. package/src/runtime/chain-parser.ts +5 -3
  71. package/src/runtime/chain-runner.ts +41 -22
  72. package/src/runtime/child-pi/child-pi-constants.ts +9 -0
  73. package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
  74. package/src/runtime/child-pi/child-pi-streams.ts +19 -3
  75. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  76. package/src/runtime/child-pi/postmortem-registry.ts +8 -2
  77. package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
  78. package/src/runtime/compaction/tool-output-pruner.ts +28 -5
  79. package/src/runtime/crew-agent-records.ts +67 -4
  80. package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
  81. package/src/runtime/deadletter.ts +5 -1
  82. package/src/runtime/delegate-spawn.ts +61 -8
  83. package/src/runtime/dispatch-batch.ts +61 -14
  84. package/src/runtime/effectiveness.ts +55 -4
  85. package/src/runtime/finalize-run.ts +12 -1
  86. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
  87. package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
  88. package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
  89. package/src/runtime/live-session/live-agent-control.ts +123 -20
  90. package/src/runtime/live-session/live-agent-manager.ts +114 -15
  91. package/src/runtime/live-session/live-session-runtime.ts +75 -83
  92. package/src/runtime/live-session/usage-bridge.ts +66 -2
  93. package/src/runtime/mcp-proxy.ts +10 -0
  94. package/src/runtime/model/model-fallback.ts +74 -9
  95. package/src/runtime/model/pi-args.ts +3 -3
  96. package/src/runtime/model/runtime-policy.ts +22 -6
  97. package/src/runtime/model/runtime-resolver.ts +11 -5
  98. package/src/runtime/orphan-worker-registry.ts +64 -24
  99. package/src/runtime/pi-spawn.ts +4 -1
  100. package/src/runtime/process/orphan-reaper.ts +143 -0
  101. package/src/runtime/process-status.ts +38 -16
  102. package/src/runtime/recovery/crash-recovery.ts +35 -2
  103. package/src/runtime/resilient-edit.ts +170 -93
  104. package/src/runtime/role-permission.ts +3 -1
  105. package/src/runtime/run-tracker.ts +9 -1
  106. package/src/runtime/sanitize/worker-text.ts +98 -0
  107. package/src/runtime/scheduling/batch-barrier.ts +42 -5
  108. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  109. package/src/runtime/scheduling/scheduler.ts +74 -29
  110. package/src/runtime/spawn-policy.ts +190 -8
  111. package/src/runtime/stale-reconciler.ts +77 -6
  112. package/src/runtime/subagent-manager.ts +29 -0
  113. package/src/runtime/surface/herdr-provider.ts +85 -2
  114. package/src/runtime/surface/launch-script.ts +7 -1
  115. package/src/runtime/surface/resolve-surface.ts +4 -1
  116. package/src/runtime/surface/tmux-provider.ts +32 -2
  117. package/src/runtime/task-output-context.ts +58 -11
  118. package/src/runtime/task-runner/child-executor.ts +3 -2
  119. package/src/runtime/task-runner/post-execution.ts +144 -0
  120. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  121. package/src/runtime/task-runner.ts +16 -3
  122. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  123. package/src/runtime/verification/verify-gate.ts +153 -30
  124. package/src/runtime/workspace-lock.ts +52 -4
  125. package/src/state/atomic-write.ts +73 -4
  126. package/src/state/contracts.ts +9 -0
  127. package/src/state/coordination/mailbox.ts +40 -11
  128. package/src/state/coordination/process-lock.ts +50 -0
  129. package/src/state/event-log/cursor.ts +10 -1
  130. package/src/state/event-log/event-log-rotation.ts +54 -8
  131. package/src/state/event-log/poison-marker.ts +16 -1
  132. package/src/state/event-log/worker-atomic-writer.ts +5 -0
  133. package/src/state/stores/active-run-registry.ts +56 -5
  134. package/src/state/stores/artifact-store.ts +181 -6
  135. package/src/state/stores/manifest-io.ts +126 -8
  136. package/src/state/stores/ownership-map.ts +26 -7
  137. package/src/state/stores/plan-store.ts +30 -1
  138. package/src/state/stores/sqlite-run-state.ts +91 -1
  139. package/src/state/stores/state-store.ts +30 -31
  140. package/src/state/types.ts +18 -0
  141. package/src/state/usage.ts +41 -0
  142. package/src/ui/dashboard-panes/agents-pane.ts +4 -3
  143. package/src/ui/dashboard-panes/plan-pane.ts +5 -3
  144. package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
  145. package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
  146. package/src/ui/run-dashboard.ts +9 -7
  147. package/src/ui/run-snapshot-cache.ts +3 -2
  148. package/src/ui/settings-overlay.ts +87 -11
  149. package/src/ui/theme-discovery.ts +26 -7
  150. package/src/ui/transcript-viewer.ts +5 -3
  151. package/src/utils/internal-error.ts +5 -1
  152. package/src/utils/timer-limits.ts +30 -0
  153. package/src/utils/token-counter.ts +47 -0
  154. package/src/utils/visual.ts +84 -0
  155. package/src/workflows/discover-workflows.ts +9 -1
  156. package/src/worktree/worktree-manager.ts +128 -18
@@ -78,7 +78,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
78
78
  name: "PI_CREW_DEPTH",
79
79
  mirror: "PI_TEAMS_DEPTH",
80
80
  mirrorPrecedence: "crew",
81
- default: "0",
81
+ // wave-2b: default was the STRING "0" on an int parser — getCrewEnvParsed
82
+ // returned the raw string from the fallback, so typed readers (getCrewEnvInt)
83
+ // saw undefined instead of 0. Numeric default routes through the parser.
84
+ default: 0,
82
85
  parser: "int",
83
86
  doc: "current crew depth (pi-args.ts:72 reads CREW ?? TEAMS ?? '0')",
84
87
  },
@@ -86,7 +89,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
86
89
  name: "PI_TEAMS_DEPTH",
87
90
  mirror: "PI_CREW_DEPTH",
88
91
  mirrorPrecedence: "crew",
89
- default: "0",
92
+ default: 0, // wave-2b: numeric default on int parser (was string "0")
90
93
  parser: "int",
91
94
  doc: "crew depth mirror (fallback of PI_CREW_DEPTH)",
92
95
  },
@@ -274,7 +277,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
274
277
  },
275
278
  PI_CREW_DEBUG: {
276
279
  name: "PI_CREW_DEBUG",
277
- doc: "truthiness enables background debug logging (background-runner.ts:63)",
280
+ doc: "truthiness enables background debug logging (background-runner.ts:63); RAW truthiness — '0'/'false' still ENABLE, unset to disable (R45-F4)",
278
281
  },
279
282
  PI_CREW_DEBUG_BUDGET: {
280
283
  name: "PI_CREW_DEBUG_BUDGET",
@@ -368,7 +371,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
368
371
  },
369
372
  PI_CREW_TRUST_PROJECT_DWF: {
370
373
  name: "PI_CREW_TRUST_PROJECT_DWF",
371
- doc: "'1' allows project-sourced .dwf.ts workflows (dynamic-workflow-runner.ts:154)",
374
+ doc: "'1' allows project-sourced .dwf.ts workflows (dynamic-workflow-runner.ts:189)",
372
375
  },
373
376
  PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS: {
374
377
  name: "PI_TEAMS_CHILD_RESPONSE_TIMEOUT_MS",
@@ -377,7 +380,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
377
380
  },
378
381
  PI_TEAMS_DEBUG: {
379
382
  name: "PI_TEAMS_DEBUG",
380
- doc: "truthiness gates debug-severity internal-error logging (internal-error.ts:4)",
383
+ doc: "truthiness gates debug-severity internal-error logging (internal-error.ts:10); RAW truthiness — '0'/'false' still ENABLE, unset to disable (R45-F4)",
381
384
  },
382
385
  PI_TEAMS_MOCK_CHILD_PI: {
383
386
  name: "PI_TEAMS_MOCK_CHILD_PI",
@@ -508,7 +511,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
508
511
  PI_CREW_VERIFY_GATE_TIMEOUT_MS: {
509
512
  name: "PI_CREW_VERIFY_GATE_TIMEOUT_MS",
510
513
  parser: "int",
511
- default: "120000",
514
+ default: 120_000, // wave-2b: numeric default (was string "120000" — fallback bypassed the parser)
512
515
  doc: "U13 per-check timeout in ms for the deterministic verify_gate pre-gate commands. Invalid/≤0 → 120000, mirroring runPhaseGates (runtime/verification/verify-gate.ts resolveVerifyGateTimeoutMs)",
513
516
  },
514
517
  PI_CREW_VERIFY_DETERMINISM: {
@@ -523,7 +526,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
523
526
  PI_CREW_RPC_TURN_TIMEOUT_MS: {
524
527
  name: "PI_CREW_RPC_TURN_TIMEOUT_MS",
525
528
  parser: "int",
526
- default: "600000",
529
+ default: 600_000, // wave-2b: numeric default (was string "600000" — fallback bypassed the parser)
527
530
  doc: "W7/DR1 belt-and-suspenders turn timeout for the EXPERIMENTAL RPC worker transport: max ms a prompt-accepted turn may run without agent_settled before the transport fails with the early-failure shape (DR5 guard still blocks the stdio fallback once a session event was seen). Env-only while RPC stays experimental. Invalid/≤0 → 600000 (rpc-worker.ts resolveRpcTurnTimeoutMs)",
528
531
  },
529
532
  PI_CREW_AUTO_EXIT: {
@@ -533,13 +536,13 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
533
536
  PI_CREW_AUTO_PRUNE_KEEP: {
534
537
  name: "PI_CREW_AUTO_PRUNE_KEEP",
535
538
  parser: "int",
536
- default: "10",
539
+ default: 10, // wave-2b: numeric default (was string "10" — fallback bypassed the parser)
537
540
  doc: "DP-01: number of most-recent finished runs the session-start auto-prune retains (was hard-coded 10). Invalid/negative → 10 with a warning (run-maintenance.ts:resolveAutoPruneKeep)",
538
541
  },
539
542
  PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS: {
540
543
  name: "PI_CREW_AUTO_PRUNE_AGE_FLOOR_HOURS",
541
544
  parser: "int",
542
- default: "24",
545
+ default: 24, // wave-2b: numeric default (was string "24" — fallback bypassed the parser)
543
546
  doc: "DP-01: auto-prune never deletes a finished run younger than this many hours, even beyond top-keep — evidence/incident runs survive a session restart (0 disables; run-maintenance.ts:resolveAutoPruneAgeFloorMs)",
544
547
  },
545
548
  PI_CREW_SURFACE: {
@@ -763,18 +766,25 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
763
766
  * Raw crew-env lookup. Returns the raw string (undefined when unset) with
764
767
  * mirror-alias fallback applied per the entry's `mirrorPrecedence`. This is
765
768
  * the behavior-preserving replacement for `process.env.PI_CREW_X` reads.
769
+ *
770
+ * R26-F1 (deep-review round 26): `env` is an OPTIONAL source override. Reads
771
+ * that receive an env object as a parameter (`isRootSession(env)`,
772
+ * `currentCrewDepth(env)`, …) must still route through the registry — passing
773
+ * the object here keeps the registry invariant intact for those sites instead
774
+ * of leaving them as gate-blind `env.PI_CREW_X` dot reads. Defaults to
775
+ * `process.env`, so existing call sites are unchanged.
766
776
  */
767
- export function getCrewEnv(name: string): string | undefined {
777
+ export function getCrewEnv(name: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
768
778
  const spec = CREW_ENV_VARS[name];
769
779
  if (!spec?.mirror || spec.mirrorPrecedence === "or") {
770
- return process.env[name];
780
+ return env[name];
771
781
  }
772
782
  const crewName = name.startsWith("PI_TEAMS_") ? spec.mirror : name;
773
783
  const teamsName = name.startsWith("PI_TEAMS_") ? name : spec.mirror;
774
784
  if (spec.mirrorPrecedence === "teams") {
775
- return process.env[teamsName] ?? process.env[crewName];
785
+ return env[teamsName] ?? env[crewName];
776
786
  }
777
- return process.env[crewName] ?? process.env[teamsName];
787
+ return env[crewName] ?? env[teamsName];
778
788
  }
779
789
 
780
790
  /**
@@ -17,18 +17,21 @@ export interface RoleToolConfig {
17
17
  }
18
18
 
19
19
  export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
20
- // Explorer - Read-only exploration; bash is included for git log/show
21
- // (decisions stream needs commit-history mining) but edit/write stay
22
- // excluded. State-mutation safety is enforced separately by
23
- // READ_ONLY_ROLES in role-permission.ts.
20
+ // Explorer - Read-only exploration. Wave-3 (Chain A cheapest cut, deep review
21
+ // 2026-10-16/17): bash REMOVED from the enforced allowlist — the advertised
22
+ // read-only contract is now the enforceable one (READ_ONLY_ROLES state-mutation
23
+ // gating cannot stop host-level persistence via arbitrary commands, e.g.
24
+ // `git config --global` or writes to ~/.pi/agent). Frontmatter mirrors this.
24
25
  explorer: {
25
- tools: ["read", "grep", "find", "ls", "glob", "bash", "ask"],
26
- excludeTools: ["edit", "write", "web"],
26
+ tools: ["read", "grep", "find", "ls", "glob", "ask"],
27
+ excludeTools: ["edit", "write", "bash", "web"],
27
28
  },
28
29
 
29
- // Analyst - Read and analyze, limited execution
30
+ // Analyst - Read and analyze, limited execution. (ask_question removed
31
+ // 2026-10-18: the host tool is `ask` — excluding a nonexistent name was a
32
+ // dead entry; the live `ask` tool stays allowed for analyst.)
30
33
  analyst: {
31
- excludeTools: ["edit", "write", "ask_question"],
34
+ excludeTools: ["edit", "write"],
32
35
  },
33
36
 
34
37
  // Planner - Read-only planning; emits plans as TEXT (runner persists result).
@@ -40,7 +43,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
40
43
  // default/implementation workflows.
41
44
  planner: {
42
45
  tools: ["read", "grep", "find", "ls", "glob", "ask"],
43
- excludeTools: ["edit", "write", "bash", "web", "ask_question"],
46
+ excludeTools: ["edit", "write", "bash", "web"],
44
47
  },
45
48
 
46
49
  // Critic - Read-only plan/design critique (F2: was missing from the map,
@@ -66,7 +69,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
66
69
  // Writer - Documentation focused
67
70
  writer: {
68
71
  tools: ["read", "edit", "write", "ls", "ask"],
69
- excludeTools: ["bash", "web", "ask_question"],
72
+ excludeTools: ["bash", "web"],
70
73
  },
71
74
 
72
75
  // Security Reviewer - Strict restrictions
@@ -75,7 +78,7 @@ export const ROLE_TOOL_CONFIGS: Record<string, RoleToolConfig> = {
75
78
  // resolved at runtime (returned {}), silently dropping enforcement.
76
79
  "security-reviewer": {
77
80
  tools: ["read", "grep", "find", "ask"],
78
- excludeTools: ["edit", "write", "bash", "web", "ask_question"],
81
+ excludeTools: ["edit", "write", "bash", "web"],
79
82
  },
80
83
 
81
84
  // Verifier - Runs tests (needs bash) but must NOT edit source (F4: moved
@@ -386,7 +386,10 @@ export interface CrewReliabilityConfig {
386
386
  retryPolicy?: CrewRetryPolicyConfig;
387
387
  autoRecover?: boolean;
388
388
  deadletterThreshold?: number;
389
- /** Interval (ms) for periodic stale-run auto-repair. Default 60_000 (60s). Set to 0 to disable. */
389
+ /** Interval (ms) for periodic stale-run auto-repair. Default 300_000 (5min) —
390
+ * the canonical value lives in the `?? 300_000` fallback at
391
+ * `src/extension/registration/observability.ts:256` (RT-F2 raised it from 60s;
392
+ * R45-F2). Set to 0 to disable. */
390
393
  autoRepairIntervalMs?: number;
391
394
  /** Remove /tmp/pi-crew-* directories after their orphaned runs are reconciled. Default: true. */
392
395
  cleanupOrphanedTempDirs?: boolean;
@@ -79,6 +79,12 @@ export function registerCleanupHandler(pi: ExtensionAPI, opts?: { disposeTermina
79
79
  // suppression; on REAL shutdowns (quit/reload) the flag is unset
80
80
  // and this cleanup still drains the registry, as does the
81
81
  // process-exit hook (tryRegisterSessionCleanup) when pi dies.
82
+ // R49-F6: this gate is a SWITCH FLAG (`isSessionSwitchInFlight()`), not the
83
+ // reason-aware gate used by lifecycle-handlers.ts:165-166. On a `reload` shutdown
84
+ // the flag can still read true (it is cleared on the next session_start), so
85
+ // this handler may skip a cleanup the reason-gate would have run — the
86
+ // process-exit hook (tryRegisterSessionCleanup) is the backstop. Divergence
87
+ // is deliberate (never kill workers on a navigational switch) but not obvious.
82
88
  if (isSessionSwitchInFlight()) return;
83
89
  // Kill all child-pi processes
84
90
  await cleanupChildProcesses();
@@ -15,6 +15,7 @@ import { projectCrewRoot, userPiRoot } from "../utils/paths.ts";
15
15
  import { allWorkflows, discoverWorkflows, invalidateWorkflowDiscoveryCache } from "../workflows/discover-workflows.ts";
16
16
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
17
17
  import { serializeWorkflow } from "../workflows/workflow-serializer.ts";
18
+ import { assertRootOnlyAction } from "./team-tool/destructive-gate.ts";
18
19
  import { enforceDestructiveIntent } from "./team-tool/intent-policy.ts";
19
20
  import type { TeamToolDetails } from "./team-tool-types.ts";
20
21
  import { type PiTeamsToolResult, toolResult } from "./tool-result.ts";
@@ -415,6 +416,12 @@ export function handleUpdate(params: TeamToolParamsValue, ctx: ManagementContext
415
416
  if (parsed.error) return result(parsed.error, "error", true);
416
417
  const cfg = parsed.value!;
417
418
  const current = resolved.resource!;
419
+ // Wave-3 root-gate (R5-M-B2): the update path resolves its write scope from
420
+ // the MATCHED resource (params.scope may be undefined and match either
421
+ // tier), so the facade gate cannot decide — gate HERE on the resolved
422
+ // source. Non-root sessions may only rewrite project-tier resources.
423
+ const updateGate = assertRootOnlyAction("update", undefined, current.source === "project" ? "project" : "user");
424
+ if (updateGate) return result(updateGate.message, "error", true);
418
425
  const nextName = hasOwn(cfg, "name") ? sanitizeName(String(cfg.name ?? "")) : current.name;
419
426
  if (!nextName) return result("config.name is invalid after sanitization.", "error", true);
420
427
  const source = current.source === "project" ? "project" : "user";
@@ -21,6 +21,13 @@ export interface ProjectInitResult {
21
21
  configScope: "global" | "project" | "none";
22
22
  configCreated: boolean;
23
23
  configSkipped: boolean;
24
+ /**
25
+ * F13-A: set when an EXPLICIT `overwrite: true` replaced an existing config
26
+ * file. The previous content is preserved at this path — an overwrite of the
27
+ * global (`~/.pi/agent/pi-crew.json`) or project config is destructive and may
28
+ * discard user-customized settings, so it must never be silent.
29
+ */
30
+ configBackupPath?: string;
24
31
  }
25
32
 
26
33
  function ensureDir(dir: string, createdDirs: string[]): void {
@@ -112,10 +119,20 @@ export function initializeProject(cwd: string, options: ProjectInitOptions = {})
112
119
 
113
120
  let configCreated = false;
114
121
  let configSkipped = false;
122
+ let configBackupPath: string | undefined;
115
123
  if (configPath) {
116
124
  if (configScope === "project") ensureDir(path.dirname(configPath), createdDirs);
117
125
  else fs.mkdirSync(path.dirname(configPath), { recursive: true });
118
- if (!fs.existsSync(configPath) || options.overwrite === true) {
126
+ const configExists = fs.existsSync(configPath);
127
+ if (!configExists) {
128
+ fs.writeFileSync(configPath, `${JSON.stringify(DEFAULT_PI_CREW_CONFIG, null, 2)}\n`, "utf-8");
129
+ configCreated = true;
130
+ } else if (options.overwrite === true) {
131
+ // F13-A: overwriting an existing config is destructive — it may hold the
132
+ // user's own settings (global scope writes ~/.pi/agent/pi-crew.json).
133
+ // Keep the previous content and report it instead of replacing silently.
134
+ configBackupPath = `${configPath}.bak-${Date.now()}`;
135
+ fs.copyFileSync(configPath, configBackupPath);
119
136
  fs.writeFileSync(configPath, `${JSON.stringify(DEFAULT_PI_CREW_CONFIG, null, 2)}\n`, "utf-8");
120
137
  configCreated = true;
121
138
  } else {
@@ -166,5 +183,6 @@ export function initializeProject(cwd: string, options: ProjectInitOptions = {})
166
183
  configScope,
167
184
  configCreated,
168
185
  configSkipped,
186
+ ...(configBackupPath ? { configBackupPath } : {}),
169
187
  };
170
188
  }
@@ -24,6 +24,7 @@ import { startRuntimeWarmup } from "../runtime/model/runtime-warmup.ts";
24
24
  import { primePeerDep } from "../runtime/peer-dep.ts";
25
25
  import { deployBundledThemes } from "../ui/deploy-bundled-themes.ts";
26
26
  import { installChildProcessAbortShield } from "../utils/child-process-shield.ts";
27
+ import { logInternalError } from "../utils/internal-error.ts";
27
28
  import { resetTimings, time } from "../utils/timings.ts";
28
29
  import { registerAutonomousPolicy } from "./autonomous-policy.ts";
29
30
  import { registerContextStatusInjection } from "./context-status-injection.ts";
@@ -75,6 +76,21 @@ export async function registerPiTeams(pi: ExtensionAPI): Promise<void> {
75
76
  );
76
77
  }
77
78
 
79
+ // Wave-3 (attack-chain A / R2-M-B1 hardening): hermeticWorkers is DEFAULT
80
+ // ON; runtime.hermeticWorkers=false is a deliberate trust downgrade (worker
81
+ // pi sessions load ambient extensions/skills — the parity state where a
82
+ // repo-planted worker can reach the `team` tool's user-tier persistence).
83
+ // Emit ONE prominent warning at extension init so the operator sees it.
84
+ try {
85
+ if (loadConfig(process.cwd()).config.runtime?.hermeticWorkers === false) {
86
+ console.warn(
87
+ "[pi-crew] ⚠ SECURITY: runtime.hermeticWorkers=false — workers will load ambient extensions/skills (non-default trust posture). Worker sessions can then persist user-tier config/agents (every future session). Re-enable hermetic spawns unless parity mode is specifically required.",
88
+ );
89
+ }
90
+ } catch {
91
+ /* config read failures surface through their own channel */
92
+ }
93
+
78
94
  startRuntimeWarmup();
79
95
  primePeerDep().catch(() => undefined);
80
96
  deployBundledThemes();
@@ -82,56 +98,93 @@ export async function registerPiTeams(pi: ExtensionAPI): Promise<void> {
82
98
  const ctx = buildRegistrationContext(pi);
83
99
  ctx.importCrashRecovery = importCrashRecovery;
84
100
  ctx.purgeStaleActiveRunIndexSyncIfLoaded = purgeStaleActiveRunIndexSyncIfLoaded;
85
- installRuntimeCleanup(pi, ctx);
86
- installForegroundRunController(pi, ctx);
87
- installSubagentManager(pi, ctx);
88
- installLazyConfigurers(pi, ctx);
89
- installCrossExtensionWiring(pi, ctx);
101
+
102
+ // R49-F5 (wave-6 M4-R2) — registrar guard. The registrars below run in a
103
+ // flat sequence; before this guard ANY throw aborted the remaining
104
+ // registrars and left a PARTIAL extension with no diagnostic (Pi's loader
105
+ // discards the rejection). Guard each call: on throw, record the subsystem
106
+ // name, log it through the internal-error channel, and CONTINUE — extension
107
+ // load must never break the session, but a failed subsystem must be
108
+ // visible. ONE consolidated warning is surfaced before the skill check.
109
+ const registrarFailures: string[] = [];
110
+ const withRegistrar = <T>(name: string, fn: () => T): T | undefined => {
111
+ try {
112
+ return fn();
113
+ } catch (err) {
114
+ const detail = err instanceof Error ? err.message : String(err);
115
+ registrarFailures.push(`${name} (${detail})`);
116
+ logInternalError(`register.${name}`, err, "extension registration subsystem");
117
+ return undefined;
118
+ }
119
+ };
120
+
121
+ withRegistrar("runtimeCleanup", () => installRuntimeCleanup(pi, ctx));
122
+ withRegistrar("foregroundRunController", () => installForegroundRunController(pi, ctx));
123
+ withRegistrar("subagentManager", () => installSubagentManager(pi, ctx));
124
+ withRegistrar("lazyConfigurers", () => installLazyConfigurers(pi, ctx));
125
+ withRegistrar("crossExtensionWiring", () => installCrossExtensionWiring(pi, ctx));
90
126
 
91
127
  time("register.policy");
92
- registerAutonomousPolicy(pi);
93
- registerKnowledgeInjection(pi);
128
+ withRegistrar("autonomousPolicy", () => registerAutonomousPolicy(pi));
129
+ withRegistrar("knowledgeInjection", () => registerKnowledgeInjection(pi));
94
130
 
95
- registerPiTools(pi, ctx);
96
- registerPiCommands(pi, ctx);
97
- installPiHooks(pi, ctx);
98
- installSessionLifecycleHandlers(pi, ctx);
131
+ withRegistrar("piTools", () => registerPiTools(pi, ctx));
132
+ withRegistrar("piCommands", () => registerPiCommands(pi, ctx));
133
+ withRegistrar("piHooks", () => installPiHooks(pi, ctx));
134
+ withRegistrar("sessionLifecycleHandlers", () => installSessionLifecycleHandlers(pi, ctx));
99
135
  // Phase 0 inter-pi broker: install the lifecycle controller immediately
100
136
  // after the session handlers. The controller's gate (broker.enabled AND
101
137
  // root-session only) decides whether anything is actually done; for
102
138
  // subagents or when the flag is off, it returns a no-op controller.
103
- ctx.brokerController = installCrewBrokerLifecycleController(pi, ctx);
139
+ ctx.brokerController = withRegistrar("crewBrokerLifecycle", () => installCrewBrokerLifecycleController(pi, ctx));
104
140
 
105
141
  // M3-1 (UI-AUDIT P0-2/P1-1): wire the tab-title + Ghostty progress
106
142
  // controller to runEventBus. Before this the controller was never
107
143
  // constructed (dead since v0.8.3). Registers the dispose hook that the
108
144
  // SIGTERM/SIGHUP handler calls (crew-cleanup.ts).
109
- installTerminalStatus(pi, ctx);
110
- registerCompactionGuard(pi, {
111
- foregroundControllers: ctx.foregroundControllers,
112
- foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
113
- });
145
+ withRegistrar("terminalStatus", () => installTerminalStatus(pi, ctx));
146
+ withRegistrar("compactionGuard", () =>
147
+ registerCompactionGuard(pi, {
148
+ foregroundControllers: ctx.foregroundControllers,
149
+ foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
150
+ }),
151
+ );
114
152
 
115
153
  if (process.env.CREW_RESILIENT_EDIT === "1") {
154
+ // R49-F5: do NOT swallow the outcome. `wrapEditWithResilientReplace`
155
+ // returns false when the hook was NOT registered (pi-diff present or the
156
+ // host lacks the tool_result seam) and the dynamic import can reject;
157
+ // both were previously discarded, so an operator who set
158
+ // CREW_RESILIENT_EDIT=1 got a silent no-op.
116
159
  // LAZY: resilient-edit only loads when the opt-in env var is set.
117
160
  import("../runtime/resilient-edit.ts")
118
- .then(({ wrapEditWithResilientReplace }) => wrapEditWithResilientReplace(pi))
119
- .catch(() => {
120
- /* non-critical */
161
+ .then(({ wrapEditWithResilientReplace }) => {
162
+ if (!wrapEditWithResilientReplace(pi)) {
163
+ console.warn(
164
+ "[pi-crew] CREW_RESILIENT_EDIT=1 but the resilient-edit wrapper did NOT register " +
165
+ "(pi-diff detected or the host lacks the tool_result seam) — the opt-in is a no-op this session.",
166
+ );
167
+ }
168
+ })
169
+ .catch((err: unknown) => {
170
+ logInternalError("register.resilientEdit", err, "CREW_RESILIENT_EDIT=1");
171
+ console.warn(`[pi-crew] resilient-edit failed to load: ${err instanceof Error ? err.message : String(err)}`);
121
172
  });
122
173
  }
123
174
 
124
- registerCrewMessageRenderers(pi);
125
- registerCrewInputRouter(pi);
126
- registerCrewShortcuts(pi);
127
- registerContextStatusInjection(pi, {
128
- enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
129
- });
175
+ withRegistrar("crewMessageRenderers", () => registerCrewMessageRenderers(pi));
176
+ withRegistrar("crewInputRouter", () => registerCrewInputRouter(pi));
177
+ withRegistrar("crewShortcuts", () => registerCrewShortcuts(pi));
178
+ withRegistrar("contextStatusInjection", () =>
179
+ registerContextStatusInjection(pi, {
180
+ enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
181
+ }),
182
+ );
130
183
 
131
- try {
132
- registerCrewVibes(pi);
133
- } catch (err) {
134
- console.warn("[pi-crew] crew-vibes initialization failed:", err instanceof Error ? err.message : err);
184
+ withRegistrar("crewVibes", () => registerCrewVibes(pi));
185
+
186
+ if (registrarFailures.length > 0) {
187
+ console.warn(`[pi-crew] ${registrarFailures.length} subsystem(s) failed to register: ${registrarFailures.join(", ")}`);
135
188
  }
136
189
 
137
190
  const skillCheck = await runPostInitSkillCheck(process.cwd());
@@ -5,33 +5,69 @@ import { CLEANUP_MARKER_FILE, cleanupOldArtifacts, pruneExpiredArtifacts } from
5
5
  import type { ArtifactDescriptor, TeamRunManifest } from "../../state/types.ts";
6
6
  import { logInternalError } from "../../utils/internal-error.ts";
7
7
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
8
+ import { resolveContainedRelativePath } from "../../utils/safe-paths.ts";
8
9
 
9
10
  /**
10
- * ST-10: Load artifact descriptors from all manifests in a runs directory.
11
- * Returns a flat list of every ArtifactDescriptor across all runs.
12
- * Best-effort: unreadable or corrupt manifests are silently skipped.
11
+ * ST-10 + R44-N1: artifact descriptors for ONE run, paired with the run's
12
+ * HOST-DERIVED artifacts root (containment anchor). The anchor is computed
13
+ * from the runs directory being scanned (`<crewRoot>/state/runs/<runId>` →
14
+ * `<crewRoot>/artifacts/<runId>` — the same convention `createRunPaths`
15
+ * derives), NEVER from the worker-writable manifest itself: a malicious
16
+ * manifest must not be able to nominate its own prune boundary.
13
17
  */
14
- function collectArtifactDescriptors(runsDir: string): ArtifactDescriptor[] {
15
- const descriptors: ArtifactDescriptor[] = [];
18
+ export interface RunArtifactGroup {
19
+ runId: string;
20
+ /** Host-derived per-run artifacts root (prune containment anchor). */
21
+ artifactsRoot: string;
22
+ descriptors: ArtifactDescriptor[];
23
+ }
24
+
25
+ /**
26
+ * ST-10: Load artifact descriptors from all manifests in a runs directory,
27
+ * grouped per run with the host-derived artifacts root for each run.
28
+ * Best-effort: unreadable or corrupt manifests are silently skipped. Runs
29
+ * whose DIRECTORY NAME cannot form a safe artifacts root (path-escape) are
30
+ * skipped with a warn (fail-closed — their descriptors are never pruned).
31
+ */
32
+ export function collectArtifactDescriptors(runsDir: string): RunArtifactGroup[] {
33
+ const groups: RunArtifactGroup[] = [];
16
34
  let dirs: fs.Dirent[];
17
35
  try {
18
36
  dirs = fs.readdirSync(runsDir, { withFileTypes: true });
19
37
  } catch {
20
- return descriptors;
38
+ return groups;
21
39
  }
40
+ // runsDir = <crewRoot>/<state.runsSubdir> → the crew root sits two levels up
41
+ // (mirrors state-store.ts validateRunManifestPaths' baseRoot derivation).
42
+ const crewRoot = path.resolve(runsDir, "..", "..");
43
+ const artifactsParent = path.join(crewRoot, DEFAULT_PATHS.state.artifactsSubdir);
22
44
  for (const dir of dirs) {
23
45
  if (!dir.isDirectory()) continue;
46
+ let artifactsRoot: string;
47
+ try {
48
+ artifactsRoot = resolveContainedRelativePath(artifactsParent, dir.name, "runId");
49
+ } catch (error) {
50
+ // R44-N1 fail-closed: an unsafe run-dir name must not reach the prune
51
+ // path at all — its descriptors are dropped, not anchored elsewhere.
52
+ logInternalError(
53
+ "artifact-cleanup.run-anchor",
54
+ error instanceof Error ? error : new Error(String(error)),
55
+ `runsDir=${runsDir} runId=${dir.name}`,
56
+ "warn",
57
+ );
58
+ continue;
59
+ }
24
60
  const manifestPath = path.join(runsDir, dir.name, DEFAULT_PATHS.state.manifestFile);
25
61
  try {
26
62
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as TeamRunManifest;
27
63
  if (Array.isArray(manifest.artifacts)) {
28
- descriptors.push(...manifest.artifacts);
64
+ groups.push({ runId: dir.name, artifactsRoot, descriptors: manifest.artifacts });
29
65
  }
30
66
  } catch {
31
67
  // Skip unreadable/corrupt manifests — best-effort.
32
68
  }
33
69
  }
34
- return descriptors;
70
+ return groups;
35
71
  }
36
72
 
37
73
  export function runArtifactCleanup(cwd: string): void {
@@ -49,9 +85,14 @@ export function runArtifactCleanup(cwd: string): void {
49
85
  markerFile: CLEANUP_MARKER_FILE,
50
86
  });
51
87
 
52
- // ST-10: Enforce retention/expiresAt from manifest metadata.
53
- pruneExpiredArtifacts(collectArtifactDescriptors(path.join(userCrewRoot(), DEFAULT_PATHS.state.runsSubdir)));
54
- pruneExpiredArtifacts(collectArtifactDescriptors(path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir)));
88
+ // ST-10 + R44-N1: enforce retention/expiresAt from manifest metadata,
89
+ // per-run and containment-anchored — a run's descriptors can only prune
90
+ // files under that run's OWN host-derived artifacts root.
91
+ for (const scope of [userCrewRoot(), projectCrewRoot(cwd)]) {
92
+ for (const group of collectArtifactDescriptors(path.join(scope, DEFAULT_PATHS.state.runsSubdir))) {
93
+ pruneExpiredArtifacts(group.descriptors, { artifactsRoot: group.artifactsRoot });
94
+ }
95
+ }
55
96
  } catch (error) {
56
97
  logInternalError("register.artifact-cleanup", error, `cwd=${cwd}`);
57
98
  }
@@ -99,8 +99,10 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
99
99
  description: "Open the pi-crew interactive menu (list/run/status/cleanup/manage resources/doctor)",
100
100
  handler: handleTeamManagerCommand,
101
101
  });
102
- // Backward-compat alias: this command was originally registered as "team-cleanup"
103
- // (the interactive menu predates the runId-targeted cleanup action). Keep both so
102
+ // Backward-compat alias: the interactive menu originally shipped under a
103
+ // runId-targeted name; `/team-cleanup` itself was NEVER registered (R27-D1) —
104
+ // the live names are `/team-manager` and this `/team-cleanup-menu` alias, so
105
+ // docs must not advertise `/team-cleanup <runId>`. Keep both.
104
106
 
105
107
  pi.registerCommand("team-cleanup-menu", {
106
108
  description: "Alias for /team-manager (pi-crew interactive menu)",
@@ -218,6 +220,11 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
218
220
  // LAZY: load withSessionId only when needed for skill-create command
219
221
  const { withSessionId } = await import("../../team-tool/context.ts");
220
222
  const sessionId = withSessionId(ctx);
223
+ // R49-F7: `ctx.workspaceFolder` is a PHANTOM read — it is a VS-Code-era
224
+ // field that pi's ExtensionCommandContext does not define (absent in the
225
+ // resolved 0.77/1.0.4 api literal), so the optional chain always falls back
226
+ // to `process.cwd()`. Harmless (the fallback is correct) but do not treat
227
+ // this branch as a real cwd override.
221
228
  const cwd =
222
229
  (
223
230
  ctx as unknown as {
@@ -80,6 +80,9 @@ function installResourcesDiscoverHook(pi: ExtensionAPI, ctx: RegistrationContext
80
80
  return paths.length > 0 ? { skillPaths: paths } : {};
81
81
  });
82
82
  } catch {
83
+ // R49-F4: `pi.on()` does NOT throw for an unknown event name in pi 0.77/1.0.4
84
+ // (verified against the resolved api literal + dist) — this catch is defensive
85
+ // only. The real degradation on an older host is that the hook NEVER FIRES.
83
86
  /* older Pi without resources_discover */
84
87
  }
85
88
  }
@@ -76,6 +76,8 @@ function installTurnReconcileHook(pi: ExtensionAPI, ctx: RegistrationContext): v
76
76
  }
77
77
  });
78
78
  } catch {
79
+ // R49-F4: `pi.on()` never throws on an unknown event (pi 0.77/1.0.4) — this
80
+ // catch is defensive; the real older-host degradation is a NEVER-FIRING hook.
79
81
  /* older Pi without before_agent_start — rely on the interval alone */
80
82
  }
81
83
  }