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.
- package/CHANGELOG.md +22 -0
- package/agents/explorer.md +3 -3
- package/agents/librarian.md +3 -3
- package/dist/index.mjs +523 -520
- package/docs/dynamic-workflows.md +14 -2
- package/package.json +4 -4
- package/src/agents/discover-agents.ts +8 -2
- package/src/config/config-validation.ts +3 -1
- package/src/config/env-allowlist-registry.ts +5 -0
- package/src/config/env-vars.ts +9 -6
- package/src/config/role-tools.ts +7 -6
- package/src/extension/management.ts +7 -0
- package/src/extension/register.ts +15 -0
- package/src/extension/run-index.ts +24 -1
- package/src/extension/team-tool/destructive-gate.ts +208 -0
- package/src/extension/team-tool/goal-wrap.ts +11 -1
- package/src/extension/team-tool/goal.ts +13 -1
- package/src/extension/team-tool/respond.ts +8 -6
- package/src/extension/team-tool/run.ts +4 -4
- package/src/extension/team-tool.ts +10 -0
- package/src/observability/exporters/otlp-exporter.ts +11 -1
- package/src/prompt/prompt-runtime.ts +102 -18
- package/src/prompt/surface-worker.ts +13 -1
- package/src/runtime/broker/crew-broker-client.ts +7 -0
- package/src/runtime/broker/crew-broker.ts +113 -152
- package/src/runtime/broker/protocol/connection-state.ts +159 -1
- package/src/runtime/broker/protocol/events-replay.ts +50 -10
- package/src/runtime/broker/protocol/request-parsers.ts +68 -0
- package/src/runtime/child-pi/child-pi-constants.ts +9 -0
- package/src/runtime/child-pi/child-pi-rpc.ts +72 -7
- package/src/runtime/child-pi/child-pi-streams.ts +19 -3
- package/src/runtime/child-pi/postmortem-registry.ts +8 -2
- package/src/runtime/classifier/ambient-noise-gate.ts +5 -1
- package/src/runtime/compaction/tool-output-pruner.ts +20 -2
- package/src/runtime/crew-agent-records.ts +92 -17
- package/src/runtime/custom-tools/submit-result-tool.ts +17 -0
- package/src/runtime/deadletter.ts +5 -1
- package/src/runtime/dispatch-batch.ts +60 -16
- package/src/runtime/effectiveness.ts +24 -1
- package/src/runtime/finalize-run.ts +12 -1
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +12 -3
- package/src/runtime/goal-workflow/goal-evaluator.ts +30 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +60 -3
- package/src/runtime/live-session/live-agent-control.ts +34 -14
- package/src/runtime/live-session/live-agent-manager.ts +20 -5
- package/src/runtime/live-session/live-session-runtime.ts +66 -81
- package/src/runtime/live-session/usage-bridge.ts +66 -2
- package/src/runtime/mcp-proxy.ts +10 -0
- package/src/runtime/model/model-fallback.ts +68 -0
- package/src/runtime/model/runtime-policy.ts +20 -6
- package/src/runtime/model/runtime-resolver.ts +5 -3
- package/src/runtime/orphan-worker-registry.ts +64 -24
- package/src/runtime/pi-spawn.ts +4 -1
- package/src/runtime/process/zombie-scanner.ts +6 -2
- package/src/runtime/recovery/crash-recovery.ts +35 -2
- package/src/runtime/scheduling/batch-barrier.ts +42 -5
- package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
- package/src/runtime/scheduling/scheduler.ts +74 -29
- package/src/runtime/stale-reconciler.ts +45 -6
- package/src/runtime/surface/herdr-provider.ts +54 -1
- package/src/runtime/surface/launch-script.ts +7 -1
- package/src/runtime/surface/resolve-surface.ts +4 -1
- package/src/runtime/surface/tmux-provider.ts +32 -2
- package/src/runtime/task-runner/child-executor.ts +3 -2
- package/src/runtime/workspace-lock.ts +52 -4
- package/src/state/contracts.ts +2 -0
- package/src/state/coordination/process-lock.ts +50 -0
- package/src/state/event-log/cursor.ts +24 -3
- package/src/state/event-log/event-log-rotation.ts +26 -2
- package/src/state/event-log/worker-atomic-writer.ts +5 -0
- package/src/state/stores/active-run-registry.ts +56 -5
- package/src/state/stores/ownership-map.ts +26 -7
- package/src/state/stores/plan-store.ts +30 -1
- package/src/state/stores/sqlite-run-state.ts +91 -1
- package/src/state/types.ts +5 -0
- package/src/ui/dashboard-panes/agents-pane.ts +4 -3
- package/src/ui/dashboard-panes/plan-pane.ts +5 -3
- package/src/ui/dashboard-panes/transcript-pane.ts +2 -1
- package/src/ui/overlays/mailbox-detail-overlay.ts +5 -3
- package/src/ui/run-dashboard.ts +6 -4
- package/src/ui/run-snapshot-cache.ts +3 -2
- package/src/ui/settings-overlay.ts +68 -10
- package/src/ui/theme-discovery.ts +26 -7
- package/src/ui/transcript-viewer.ts +5 -3
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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). */
|
package/src/config/env-vars.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
package/src/config/role-tools.ts
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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", "
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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":
|