pi-crew 0.11.22 → 0.11.24

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 (85) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/agents/explorer.md +3 -3
  3. package/agents/librarian.md +3 -3
  4. package/dist/index.mjs +523 -520
  5. package/docs/dynamic-workflows.md +14 -2
  6. package/package.json +4 -4
  7. package/src/agents/discover-agents.ts +8 -2
  8. package/src/config/config-validation.ts +3 -1
  9. package/src/config/env-allowlist-registry.ts +5 -0
  10. package/src/config/env-vars.ts +9 -6
  11. package/src/config/role-tools.ts +7 -6
  12. package/src/extension/management.ts +7 -0
  13. package/src/extension/register.ts +15 -0
  14. package/src/extension/run-index.ts +24 -1
  15. package/src/extension/team-tool/destructive-gate.ts +208 -0
  16. package/src/extension/team-tool/goal-wrap.ts +11 -1
  17. package/src/extension/team-tool/goal.ts +13 -1
  18. package/src/extension/team-tool/respond.ts +8 -6
  19. package/src/extension/team-tool/run.ts +4 -4
  20. package/src/extension/team-tool.ts +10 -0
  21. package/src/observability/exporters/otlp-exporter.ts +11 -1
  22. package/src/prompt/prompt-runtime.ts +102 -18
  23. package/src/prompt/surface-worker.ts +13 -1
  24. package/src/runtime/broker/crew-broker-client.ts +7 -0
  25. package/src/runtime/broker/crew-broker.ts +113 -152
  26. package/src/runtime/broker/protocol/connection-state.ts +159 -1
  27. package/src/runtime/broker/protocol/events-replay.ts +50 -10
  28. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  29. package/src/runtime/child-pi/child-pi-constants.ts +9 -0
  30. package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
  31. package/src/runtime/child-pi/child-pi-streams.ts +19 -3
  32. package/src/runtime/child-pi/postmortem-registry.ts +8 -2
  33. package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
  34. package/src/runtime/compaction/tool-output-pruner.ts +20 -2
  35. package/src/runtime/crew-agent-records.ts +92 -17
  36. package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
  37. package/src/runtime/deadletter.ts +5 -1
  38. package/src/runtime/dispatch-batch.ts +60 -16
  39. package/src/runtime/effectiveness.ts +24 -1
  40. package/src/runtime/finalize-run.ts +12 -1
  41. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
  42. package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
  43. package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
  44. package/src/runtime/live-session/live-agent-control.ts +34 -14
  45. package/src/runtime/live-session/live-agent-manager.ts +20 -5
  46. package/src/runtime/live-session/live-session-runtime.ts +66 -81
  47. package/src/runtime/live-session/usage-bridge.ts +66 -2
  48. package/src/runtime/mcp-proxy.ts +10 -0
  49. package/src/runtime/model/model-fallback.ts +68 -0
  50. package/src/runtime/model/runtime-policy.ts +20 -6
  51. package/src/runtime/model/runtime-resolver.ts +5 -3
  52. package/src/runtime/orphan-worker-registry.ts +64 -24
  53. package/src/runtime/pi-spawn.ts +4 -1
  54. package/src/runtime/process/zombie-scanner.ts +6 -2
  55. package/src/runtime/recovery/crash-recovery.ts +35 -2
  56. package/src/runtime/scheduling/batch-barrier.ts +42 -5
  57. package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
  58. package/src/runtime/scheduling/scheduler.ts +74 -29
  59. package/src/runtime/stale-reconciler.ts +45 -6
  60. package/src/runtime/surface/herdr-provider.ts +54 -1
  61. package/src/runtime/surface/launch-script.ts +7 -1
  62. package/src/runtime/surface/resolve-surface.ts +4 -1
  63. package/src/runtime/surface/tmux-provider.ts +32 -2
  64. package/src/runtime/task-runner/child-executor.ts +3 -2
  65. package/src/runtime/workspace-lock.ts +52 -4
  66. package/src/state/contracts.ts +2 -0
  67. package/src/state/coordination/process-lock.ts +50 -0
  68. package/src/state/event-log/cursor.ts +24 -3
  69. package/src/state/event-log/event-log-rotation.ts +26 -2
  70. package/src/state/event-log/worker-atomic-writer.ts +5 -0
  71. package/src/state/stores/active-run-registry.ts +56 -5
  72. package/src/state/stores/ownership-map.ts +26 -7
  73. package/src/state/stores/plan-store.ts +30 -1
  74. package/src/state/stores/sqlite-run-state.ts +91 -1
  75. package/src/state/types.ts +5 -0
  76. package/src/ui/dashboard-panes/agents-pane.ts +4 -3
  77. package/src/ui/dashboard-panes/plan-pane.ts +5 -3
  78. package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
  79. package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
  80. package/src/ui/run-dashboard.ts +6 -4
  81. package/src/ui/run-snapshot-cache.ts +3 -2
  82. package/src/ui/settings-overlay.ts +68 -10
  83. package/src/ui/theme-discovery.ts +26 -7
  84. package/src/ui/transcript-viewer.ts +5 -3
  85. package/src/utils/visual.ts +84 -0
@@ -25,6 +25,9 @@ export default async function (ctx) {
25
25
  const synth = await ctx.agent({ role: "analyst", prompt: "Merge + dedupe findings", inputs: reports.map(r => r.artifactPath) });
26
26
 
27
27
  ctx.phase("Review");
28
+ // NOTE (R2-M-B5): the identical-call cache is RESUME-ONLY — every
29
+ // ctx.review / ctx.retry iteration really re-runs the task (fixed
30
+ // semantics; see the caveat under WorkflowCtx API below).
28
31
  for (let i = 0; i < 3; i++) {
29
32
  const review = await ctx.review(synth.taskId, "reviewer");
30
33
  if (review.outcome === "accept") break;
@@ -52,8 +55,8 @@ Slash command: `/workflows` lists all workflows (static + dynamic).
52
55
  | `ctx.agent({role, prompt, model?, skill?, maxTurns?, inputs?, schema?, worktree?})` | Spawn one agent, await `{ok, text, structured, artifactPath, usage}`. Concurrency enforced by `ctx.semaphore`. `schema?` (round-13) is a TypeBox schema — when set, output is validated and mismatch yields `ok:false`. `worktree?` (round-17) spawns the agent in an isolated git worktree (default false; falls back to normal cwd + warning in a non-git repo). |
53
56
  | `ctx.fanOut(items, limit, fn)` | Bounded parallel fan-out (wraps `mapConcurrent`). |
54
57
  | `ctx.pipeline(items, ...stages)` | **round-16.** Multi-stage pipeline: each item passes through all stages sequentially; different items run concurrently (bounded by `ctx.semaphore`). A failed stage yields `null` for that item (logged via `ctx.log`) and other items continue. Aborts propagate. Returns `(TResult\|null)[]`. |
55
- | `ctx.review(taskId, reviewerRole?)` | Run a reviewer; parse `{outcome, feedback}`. |
56
- | `ctx.retry(taskId, {feedback?})` | Re-run with feedback (wraps `executeWithRetry`). |
58
+ | `ctx.review(taskId, reviewerRole?)` | Run a reviewer; parse `{outcome, feedback}`. Re-runs the task every call (see the identical-call cache caveat below). |
59
+ | `ctx.retry(taskId, {feedback?})` | Re-run with feedback (wraps `executeWithRetry`). Always re-spawns (never cached). |
57
60
  | `ctx.mail(to, body, opts?)` | Mailbox message to another agent/leader. |
58
61
  | `ctx.gatherReplies(ids, deadlineMs)` | Block until N replies arrive or deadline. |
59
62
  | `ctx.renderTemplate(name, vars)` | Render a built-in plan template. |
@@ -68,6 +71,15 @@ Slash command: `/workflows` lists all workflows (static + dynamic).
68
71
  explicit `agent` name → `team.roles[].agent` → `discoverAgents` by name → synthesize
69
72
  minimal (`source:'dynamic'`).
70
73
 
74
+ > **Identical-call cache caveat (R2-M-B5, fixed semantics).** The
75
+ > completed-calls cache is consulted ONLY for calls hydrated from a resumed
76
+ > checkpoint — identical call options within a LIVE run always re-spawn.
77
+ > Historically the cache also hit within a run, which made
78
+ > `while(true){review; if accept break; retry}` loops replay round-1 verdicts
79
+ > forever (stale verdicts until the agent-call cap aborted the run). If you
80
+ > need true idempotency within a run, structure the workflow to call each
81
+ > distinct step once and rely on resume for crash recovery.
82
+
71
83
  ### Pipeline (round-16 P2-1)
72
84
 
73
85
  `ctx.pipeline(items, ...stages)` runs a **multi-stage transform** over a list of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.22",
3
+ "version": "0.11.24",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -134,9 +134,9 @@
134
134
  ]
135
135
  },
136
136
  "peerDependencies": {
137
- "@earendil-works/pi-agent-core": "*",
138
- "@earendil-works/pi-ai": "*",
139
- "@earendil-works/pi-coding-agent": "*",
137
+ "@earendil-works/pi-agent-core": ">=1.0.4",
138
+ "@earendil-works/pi-ai": ">=1.0.4",
139
+ "@earendil-works/pi-coding-agent": ">=1.0.4",
140
140
  "@earendil-works/pi-tui": ">=1.0.4 <2.0.0",
141
141
  "@sinclair/typebox": "*"
142
142
  },
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { type LoadedPiTeamsConfig, loadConfig } from "../config/config.ts";
4
+ import { LIMIT_CEILINGS } from "../config/config-validation.ts";
4
5
  import { getCrewEnv } from "../config/env-vars.ts";
5
6
  import { discoverProviderExtensionPaths } from "../runtime/model/provider-extensions.ts";
6
7
  import { parseCsv, parseFrontmatter } from "../utils/frontmatter.ts";
@@ -462,11 +463,16 @@ function parseAgentFile(filePath: string, source: ResourceSource): AgentConfig |
462
463
  contextMode,
463
464
  maxTurns: (() => {
464
465
  const n = Number.parseInt(frontmatter.maxTurns, 10);
465
- return Number.isFinite(n) && n > 0 ? n : undefined;
466
+ // R7-A3 (wave-2b): clamp to the config ceiling — a repo-sourced agent
467
+ // file with maxTurns: 1e9 became the effective run cap whenever
468
+ // runtime.maxTurns was unset (config paths already bound at 10k).
469
+ return Number.isFinite(n) && n > 0 ? Math.min(n, LIMIT_CEILINGS.runtimeMaxTurns) : undefined;
466
470
  })(),
467
471
  maxTokens: (() => {
468
472
  const n = Number.parseInt(frontmatter.maxTokens, 10);
469
- return Number.isFinite(n) && n > 0 ? n : undefined;
473
+ // R7-A3: same clamp class — budget-equivalent token counts use the
474
+ // handoff-budget ceiling (1M) as the family bound.
475
+ return Number.isFinite(n) && n > 0 ? Math.min(n, LIMIT_CEILINGS.runtimeHandoffBudgetTokens) : undefined;
470
476
  })(),
471
477
  effort:
472
478
  frontmatter.effort === "low" || frontmatter.effort === "medium" || frontmatter.effort === "high"
@@ -63,7 +63,9 @@ function validateConfigWithWarnings(raw: unknown): string[] {
63
63
  return [];
64
64
  }
65
65
 
66
- const LIMIT_CEILINGS = {
66
+ // wave-2b (R7-A3): exported so agent frontmatter parsing (discover-agents)
67
+ // clamps project-sourced values with the SAME ceilings the config paths use.
68
+ export const LIMIT_CEILINGS = {
67
69
  maxConcurrentWorkers: 1024,
68
70
  maxTaskDepth: 100,
69
71
  maxChildrenPerTask: 1000,
@@ -69,6 +69,11 @@ export const ESSENTIAL_PROCESS_ENV_VARS: readonly string[] = [
69
69
  "XDG_DATA_HOME",
70
70
  "XDG_CACHE_HOME",
71
71
  "XDG_RUNTIME_DIR",
72
+ // R1-M1 (wave-2b): Node's fs.realpathSync.native / os.tmpdir / sqlite WAL
73
+ // all consult TMPDIR; a host with a non-default TMPDIR spawned children that
74
+ // diverged from it (temp paths under /tmp vs $TMPDIR) — pass it through so
75
+ // parent and children agree on the temp root.
76
+ "TMPDIR",
72
77
  ];
73
78
 
74
79
  /** Node/npm toolchain vars (nvm prefixes, npm registry/config). */
@@ -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
  },
@@ -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: {
@@ -17,13 +17,14 @@ 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
30
  // Analyst - Read and analyze, limited execution
@@ -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";
@@ -75,6 +75,21 @@ export async function registerPiTeams(pi: ExtensionAPI): Promise<void> {
75
75
  );
76
76
  }
77
77
 
78
+ // Wave-3 (attack-chain A / R2-M-B1 hardening): hermeticWorkers is DEFAULT
79
+ // ON; runtime.hermeticWorkers=false is a deliberate trust downgrade (worker
80
+ // pi sessions load ambient extensions/skills — the parity state where a
81
+ // repo-planted worker can reach the `team` tool's user-tier persistence).
82
+ // Emit ONE prominent warning at extension init so the operator sees it.
83
+ try {
84
+ if (loadConfig(process.cwd()).config.runtime?.hermeticWorkers === false) {
85
+ console.warn(
86
+ "[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.",
87
+ );
88
+ }
89
+ } catch {
90
+ /* config read failures surface through their own channel */
91
+ }
92
+
78
93
  startRuntimeWarmup();
79
94
  primePeerDep().catch(() => undefined);
80
95
  deployBundledThemes();
@@ -27,9 +27,32 @@ function readManifest(filePath: string): TeamRunManifest | undefined {
27
27
  */
28
28
  const TEMP_DIR_ROOTS = [os.tmpdir(), "/var/tmp", "/tmp"];
29
29
 
30
+ /** R7-P2 (wave-2b): canonicalize BOTH sides of the temp-root comparison.
31
+ * macOS returns /var/... from one API and /private/var/... from another
32
+ * (getcwd is physical); the raw startsWith then misses and phantom test runs
33
+ * leak into production dashboards. realpath'd roots + realpath'd compared
34
+ * paths (best-effort — a vanished path simply doesn't match, as before). */
35
+ const TEMP_DIR_ROOTS_REAL = TEMP_DIR_ROOTS.map((root) => {
36
+ try {
37
+ return fs.realpathSync.native(root);
38
+ } catch {
39
+ return root;
40
+ }
41
+ });
42
+
43
+ function realPathOrNull(p: string): string | null {
44
+ try {
45
+ return fs.realpathSync.native(p);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
30
51
  function isTempWorkspacePath(p: string | undefined): boolean {
31
52
  if (!p) return false;
32
- return TEMP_DIR_ROOTS.some((t) => p.startsWith(t + path.sep));
53
+ const real = realPathOrNull(p);
54
+ if (!real) return false; // unreadable/vanished — do not guess
55
+ return TEMP_DIR_ROOTS_REAL.some((t) => real === t || real.startsWith(t + path.sep));
33
56
  }
34
57
 
35
58
  function collectRuns(root: string, maxEntries?: number, signal?: AbortSignal): TeamRunManifest[] {
@@ -21,6 +21,8 @@
21
21
  * 5. Otherwise → blocked with a reason telling the user what to pass.
22
22
  */
23
23
 
24
+ import { currentCrewDepth } from "../../runtime/model/pi-args.ts";
25
+
24
26
  export const DESTRUCTIVE_TEAM_ACTIONS = new Set([
25
27
  "delete",
26
28
  "forget",
@@ -56,3 +58,209 @@ export function shouldBlockDestructiveTeamAction(action: string | undefined, inp
56
58
  if (forceBypassesReferenceChecks) return undefined;
57
59
  return `Destructive action '${action}' requires confirm=true${action === "delete" ? " (or force=true to bypass reference checks)" : ""}.`;
58
60
  }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Root-session gate (merged from root-gate.ts — Wave-3; lazy-import budget)
64
+ // ---------------------------------------------------------------------------
65
+ /**
66
+ * Root-session gate for user-tier persistent team actions (Wave-3).
67
+ *
68
+ * Deep-audit findings R2-M-B1, R3-SEC-1, R5-M-B1, R5-M-B2 (pi-crew-deep-review
69
+ * 2026-10-16): eight team-tool entry points could persist state OUTSIDE the
70
+ * current workspace — user-tier config (`~/.pi/agent/pi-crew.json`), user-tier
71
+ * agents/teams/workflows (`~/.pi/agent/{agents,teams,workflows}/`), the
72
+ * project scheduler, autonomous goal loops, and the Pi UI theme — from ANY
73
+ * caller with the `team` tool, including nested workers and repo-planted
74
+ * parity sessions. A worker that persists there survives the task and
75
+ * affects every future pi session (attack chains A/D/E).
76
+ *
77
+ * OWNER-DECIDED policy (Wave-3): gate on WORKER ORIGIN — actions initiated
78
+ * from a NON-ROOT session (PI_CREW_KIND=subagent or crew depth > 0) are
79
+ * rejected when they would write outside the project tier; project-scope
80
+ * writes stay worker-reachable (project-tier agent files already get SEC-1
81
+ * extension stripping).
82
+ *
83
+ * Gated matrix (non-root sessions only):
84
+ * - `schedule` — ALL mutations (add is the default subAction; reads use
85
+ * `scheduled`, a different action).
86
+ * - `goal` — `start` (any), plus ANY subAction carrying force:true.
87
+ * - `config` — mutations whose resolved scope is `user` (project allowed).
88
+ * - `settings` — `set`/`unset` at scope `user` (project allowed);
89
+ * `theme` always (it always writes the user tier).
90
+ * - `autonomy` — mutations (the autonomous patch always writes user tier).
91
+ * - `create` / — when resolved scope is `user` (create defaults to user;
92
+ * - `update` update is gated at the resolved resource's scope — see
93
+ * the post-`resolveMutable` call in management.ts).
94
+ *
95
+ * Reads (config/status/list, settings list/get, schedule `scheduled`) are
96
+ * never gated. `dryRun` previews that write nothing pass through, mirroring
97
+ * destructive-gate.ts's dryRun convention.
98
+ */
99
+
100
+ /** Typed error so callers/tests can distinguish the gate from handler errors. */
101
+ export class RootSessionRequiredError extends Error {
102
+ constructor(message: string) {
103
+ super(message);
104
+ this.name = "RootSessionRequiredError";
105
+ }
106
+ }
107
+
108
+ /**
109
+ * True when this process is the user's ROOT pi session (not a pi-crew worker:
110
+ * PI_CREW_KIND !== "subagent" AND currentCrewDepth === 0). Mirrors the broker
111
+ * credential gate in lifecycle-handlers.ts — the depth-gate pattern proven
112
+ * in-repo (broker tests).
113
+ */
114
+ export function isRootSession(env: NodeJS.ProcessEnv = process.env): boolean {
115
+ if (env.PI_CREW_KIND === "subagent") return false;
116
+ try {
117
+ return currentCrewDepth(env) === 0;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ export type RootGateScope = "user" | "project";
124
+
125
+ /**
126
+ * Core predicate: does this (action, subcommand, scope) combination require a
127
+ * root session? Pure — callers resolve the subcommand/scope from their params
128
+ * (see {@link rootGateBlockReason} for the raw-params resolver used by the
129
+ * team-tool facade).
130
+ *
131
+ * @returns a {@link RootSessionRequiredError} when the action must be blocked
132
+ * for non-root sessions, or `undefined` when allowed.
133
+ */
134
+ export function assertRootOnlyAction(
135
+ action: string,
136
+ subcommand: string | undefined,
137
+ scope: RootGateScope | undefined,
138
+ env: NodeJS.ProcessEnv = process.env,
139
+ ): RootSessionRequiredError | undefined {
140
+ if (isRootSession(env)) return undefined;
141
+ const detail = (what: string): RootSessionRequiredError =>
142
+ new RootSessionRequiredError(
143
+ `root-session required: team action '${action}'${subcommand ? ` (${subcommand})` : ""} ${what}. ` +
144
+ "This session is a pi-crew worker (PI_CREW_KIND/subagent or depth>0); user-tier state persists beyond the task and is reserved for the user's root pi session. " +
145
+ (what.includes("project") ? "" : "Re-run with scope:'project' for project-local effects, or from the root session."),
146
+ );
147
+ switch (action) {
148
+ case "schedule":
149
+ // ALL mutations; reads go through the separate `scheduled` action.
150
+ return detail("creates/modifies persistent scheduled jobs");
151
+ case "goal":
152
+ if (subcommand === "start") return detail("starts a persistent autonomous goal loop");
153
+ if (subcommand === "force") return detail("bypasses goal-ownership via force:true (stop/resume/clear/pause)");
154
+ // other goal ops (status/pause/stop without force) stay open.
155
+ return undefined;
156
+ case "config":
157
+ if (scope === "user") return detail("writes user-scope config (all future sessions)");
158
+ return undefined;
159
+ case "settings":
160
+ // `theme` always writes the user tier; set/unset gated at user scope.
161
+ if (subcommand === "theme") return detail("switches the user-tier Pi UI theme");
162
+ if ((subcommand === "set" || subcommand === "unset") && scope === "user")
163
+ return detail("writes user-scope settings (all future sessions)");
164
+ return undefined;
165
+ case "autonomy":
166
+ // The autonomous patch ALWAYS writes the user tier.
167
+ return detail("writes the user-tier autonomy config (all future sessions)");
168
+ case "create":
169
+ if (scope === "user") return detail("plants a user-tier resource loaded by every future session");
170
+ return undefined;
171
+ case "update":
172
+ if (scope === "user") return detail("rewrites a user-tier resource loaded by every future session");
173
+ return undefined;
174
+ default:
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ export interface RootGateParamsLike {
180
+ action?: unknown;
181
+ /** Top-level scope override ("user" | "project" | "both"). */
182
+ scope?: unknown;
183
+ /** Top-level force (goal ops). */
184
+ force?: unknown;
185
+ /** Top-level subAction (schedule/goal). */
186
+ subAction?: unknown;
187
+ /** Advisory-only dryRun preview flag. */
188
+ dryRun?: unknown;
189
+ config?: unknown;
190
+ }
191
+
192
+ function configRecord(config: unknown): Record<string, unknown> {
193
+ return config && typeof config === "object" && !Array.isArray(config) ? (config as Record<string, unknown>) : {};
194
+ }
195
+
196
+ function resolveScopeParam(params: RootGateParamsLike): RootGateScope {
197
+ const fromConfig = configRecord(params.config).scope;
198
+ const raw = params.scope ?? fromConfig;
199
+ return raw === "project" ? "project" : "user";
200
+ }
201
+
202
+ /**
203
+ * Facade resolver: derive (subcommand, scope) from raw team-tool params for
204
+ * the gated actions and decide whether a NON-ROOT session must be blocked.
205
+ * `update` is special: its effective write scope is the matched resource's
206
+ * scope, unknown until `resolveMutable` — the facade blocks explicit
207
+ * `scope:"user"` and management.ts gates the resolved case.
208
+ *
209
+ * @returns block reason string, or `undefined` to allow.
210
+ */
211
+ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.ProcessEnv = process.env): string | undefined {
212
+ const action = typeof params.action === "string" ? params.action : undefined;
213
+ if (!action) return undefined;
214
+ const cfg = configRecord(params.config);
215
+ const rawSub =
216
+ (typeof params.subAction === "string" ? params.subAction : undefined) ??
217
+ (typeof cfg.subAction === "string" ? cfg.subAction : undefined);
218
+ const subcommand = typeof rawSub === "string" ? rawSub.toLowerCase() : undefined;
219
+ const scope = resolveScopeParam(params);
220
+ const force = params.force === true || cfg.force === true;
221
+
222
+ // Reads / previews never gated (destructive-gate dryRun convention).
223
+ const isPreview = params.dryRun === true || cfg.dryRun === true;
224
+
225
+ // goal: gate `start` and any force:true op; status/pause/read-only stay open.
226
+ if (action === "goal") {
227
+ if (force) return assertRootOnlyAction("goal", "force", scope, env)?.message;
228
+ if (subcommand === "start" && !isPreview) return assertRootOnlyAction("goal", "start", scope, env)?.message;
229
+ return undefined;
230
+ }
231
+ // schedule: every subAction path mutates (add is the default); previews of
232
+ // create carry no dryRun contract in handle-schedule — gate all.
233
+ if (action === "schedule") {
234
+ return assertRootOnlyAction("schedule", subcommand, scope, env)?.message;
235
+ }
236
+ // config: gate only the WRITE path (non-empty patch or unset paths).
237
+ if (action === "config") {
238
+ const patchKeys = Object.keys(cfg).filter(
239
+ (k) => !["scope", "unset", "subAction", "dryRun", "confirm", "force"].includes(k) && cfg[k] !== undefined,
240
+ );
241
+ const unset = Array.isArray(cfg.unset) ? cfg.unset.length > 0 : cfg.unset !== undefined;
242
+ const isWrite = patchKeys.length > 0 || unset;
243
+ if (!isWrite) return undefined;
244
+ }
245
+ // autonomy: gate only the WRITE path (patch present).
246
+ if (action === "autonomy") {
247
+ const patchKeys = Object.keys(cfg).filter((k) => !["subAction", "dryRun", "confirm"].includes(k) && cfg[k] !== undefined);
248
+ if (patchKeys.length === 0) return undefined;
249
+ }
250
+ // settings: derive the subcommand from `config.args` ("set k v", "unset k",
251
+ // "theme <name>", …). Non-mutating subcommands (list/get/json/scope/themes
252
+ // browse) pass. The derived sub REPLACES the (undefined) subAction token.
253
+ let effectiveSub = subcommand;
254
+ if (action === "settings") {
255
+ const args = typeof cfg.args === "string" ? cfg.args.trim() : "";
256
+ const sub = args === "theme" || args.startsWith("theme ") ? "theme" : (args.split(/\s+/)[0] ?? "");
257
+ if (sub !== "set" && sub !== "unset" && sub !== "theme") return undefined;
258
+ effectiveSub = sub;
259
+ }
260
+ // goal force:true (top-level or config) is its own gated token.
261
+ if (action === "goal" && force) effectiveSub = "force";
262
+ // create/update/config/settings/autonomy below share scope gating.
263
+ if (isPreview && action === "create") return undefined;
264
+ const reason = assertRootOnlyAction(action, effectiveSub, scope, env);
265
+ return reason?.message;
266
+ }
@@ -287,7 +287,17 @@ export async function startGoalWrappedRun(
287
287
  },
288
288
  });
289
289
 
290
- const spawned = await spawnBackgroundTeamRun(goalLoopManifest);
290
+ // wave-2b (goal-wrap zombie): a spawn failure left goalState saved as
291
+ // "running" with NO pid — `goal status` reports a live loop forever.
292
+ // Mirror goal.ts:236-241 (the non-wrapped goal-loop's spawn-failure
293
+ // branch): revert the status (blocked — the GoalLoopStatus enum has no
294
+ // "failed"; blocked is the existing precedent for this exact condition)
295
+ // and emit the failure event.
296
+ const spawned = await spawnBackgroundTeamRun(goalLoopManifest).catch((spawnError: unknown) => {
297
+ const message = spawnError instanceof Error ? spawnError.message : String(spawnError);
298
+ store.setStatus(goalId, "blocked", paths.eventsPath);
299
+ throw spawnError;
300
+ });
291
301
  const pid = spawned.pid ?? 0;
292
302
  // FIX: persist async.pid on the OUTER goal-loop manifest (not just goal state).
293
303
  // Without this, async-notifier.markDeadAsyncRunIfNeeded returns early on
@@ -14,6 +14,7 @@
14
14
  * §0c C11 (control path: cooperative flag + handleCancel(currentRunId) via abortOwned).
15
15
  */
16
16
 
17
+ import { LIMIT_CEILINGS } from "../../config/config-validation.ts";
17
18
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
18
19
  import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
19
20
  import { captureRunModelContext } from "../../runtime/model/session-model.ts";
@@ -76,6 +77,17 @@ function formatGoalStatus(goal: GoalLoopState): string {
76
77
  .join("\n");
77
78
  }
78
79
 
80
+ /**
81
+ * R3-SEC-1 rider (Wave-3): clamp worker-supplied maxTurns to the SAME runtime
82
+ * ceiling the config paths use (LIMIT_CEILINGS.runtimeMaxTurns — no dedicated
83
+ * goal ceiling exists; budgetTotal is already guarded in handleStart).
84
+ * @internal — exported for the clamp pinning test.
85
+ */
86
+ export function clampGoalMaxTurns(raw: unknown): number {
87
+ const parsed = typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? raw : 20; // Claude/Codex parity default
88
+ return Math.min(Math.floor(parsed), LIMIT_CEILINGS.runtimeMaxTurns);
89
+ }
90
+
79
91
  /** `goal start` — create state + spawn background process (P0: writes state; spawn wiring TBD per host). */
80
92
  async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof result>> {
81
93
  const { params, ctx, store } = input;
@@ -86,7 +98,7 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
86
98
  const goalId = store.createGoalId();
87
99
  const ownerSessionId = ctx.sessionId ?? "unknown";
88
100
  const now = new Date().toISOString();
89
- const maxTurns = typeof params.config?.maxTurns === "number" && params.config.maxTurns > 0 ? params.config.maxTurns : 20; // Claude/Codex parity default
101
+ const maxTurns = clampGoalMaxTurns(params.config?.maxTurns);
90
102
  // P1d (RFC v0.5 §P1d): budget is REQUIRED. Either an explicit budgetTotal (>=1000, enforced
91
103
  // by schema) OR an explicit budgetUnlimited:true opt-out (audit-logged). No silent unbounded
92
104
  // default — without a cap the loop could spend unboundedly across many turns × workers.
@@ -327,12 +327,14 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
327
327
  if (requeuedIds.length > 0) {
328
328
  try {
329
329
  const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
330
- saveCrewAgents(
331
- fresh.manifest,
332
- updatedTasks
333
- .filter((task) => requeuedIds.includes(task.id))
334
- .map((task) => recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process")),
335
- );
330
+ // R4-A1 (fix): merge requeued records into the existing index — the
331
+ // subset-only save wiped sibling agent records (team-runner.ts:525 pattern).
332
+ const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
333
+ for (const task of updatedTasks.filter((task) => requeuedIds.includes(task.id))) {
334
+ const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
335
+ mergedById.set(record.id, record);
336
+ }
337
+ saveCrewAgents(fresh.manifest, [...mergedById.values()]);
336
338
  } catch (error) {
337
339
  logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
338
340
  }
@@ -137,7 +137,7 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
137
137
  runId: failed.runId,
138
138
  message,
139
139
  data: { pid, detail: liveness.detail },
140
- });
140
+ }).catch((error) => logInternalError("team-tool.run.asyncFailedEvent", error, `runId=${failed.runId}`)); // R5-M-B3 (fix)
141
141
  });
142
142
  } catch (error) {
143
143
  // One-shot unref'd timer — a lock-contention error must not escape and
@@ -589,7 +589,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
589
589
  envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
590
590
  },
591
591
  },
592
- });
592
+ }).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
593
593
  unregisterActiveRun(blocked.runId);
594
594
  return result(
595
595
  [
@@ -622,7 +622,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
622
622
  type: "async.spawned",
623
623
  runId: effectiveManifest.runId,
624
624
  data: { pid: spawned.pid, logPath: spawned.logPath },
625
- });
625
+ }).catch((error) => logInternalError("team-tool.run.asyncSpawnedEvent", error, `runId=${effectiveManifest.runId}`)); // R5-M-B3 (fix)
626
626
  ctx.onRunStarted?.(effectiveManifest.runId);
627
627
  scheduleBackgroundEarlyExitGuard(resolvedCtx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
628
628
  // CORE-8: unified deadline for waitForRun timeout (background process is detached).
@@ -684,7 +684,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
684
684
  envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
685
685
  },
686
686
  },
687
- });
687
+ }).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
688
688
  unregisterActiveRun(blocked.runId);
689
689
  return result(
690
690
  [
@@ -57,6 +57,7 @@ import { waitForRun } from "../runtime/run-tracker.ts";
57
57
  import { normalizeSkillOverride } from "../runtime/skill-instructions.ts";
58
58
  import { formatActionSuggestion } from "./action-suggestions.ts";
59
59
  import { type CacheControlDeps, invalidateSnapshot } from "./team-tool/cache-control.ts";
60
+ import { rootGateBlockReason } from "./team-tool/destructive-gate.ts";
60
61
  // API-5 facade dispatch: domain routers replace the former 54-case switch.
61
62
  import {
62
63
  domainForAction,
@@ -1225,6 +1226,15 @@ export async function handleTeamTool(params: TeamToolParamsValue, ctx: TeamConte
1225
1226
  // numbers before domain routers read them. See normalizeLooseNumericFields.
1226
1227
  params = normalizeLooseNumericFields(params);
1227
1228
  const action = params.action ?? "list";
1229
+ // Wave-3 root-gate (R2-M-B1/R3-SEC-1/R5-M-B1/B2): user-tier persistent
1230
+ // actions (schedule mutations, goal start/force, user-scope config/settings/
1231
+ // create/update, autonomy, settings theme) are reserved for the user's
1232
+ // ROOT session. Non-root callers (workers) can still write project scope.
1233
+ // Single chokepoint: every domain router dispatch passes through here.
1234
+ const rootGateReason = rootGateBlockReason(params);
1235
+ if (rootGateReason) {
1236
+ return result(rootGateReason, { action: String(action), status: "error" }, true);
1237
+ }
1228
1238
  const domain = domainForAction(action);
1229
1239
  switch (domain) {
1230
1240
  case "run":