pi-crew 0.11.24 → 0.11.26

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 (112) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -2
  3. package/agents/designer.md +23 -0
  4. package/agents/orchestrator.md +17 -0
  5. package/agents/verifier.md +1 -1
  6. package/dist/index.mjs +568 -559
  7. package/{workflows/chain.workflow.md → docs/chain-workflow.md} +8 -0
  8. package/docs/dynamic-workflows.md +23 -0
  9. package/docs/publishing.md +34 -5
  10. package/docs/troubleshooting.md +50 -2
  11. package/docs/usage.md +7 -2
  12. package/install.mjs +2 -1
  13. package/package.json +4 -2
  14. package/skills/read-only-explorer/SKILL.md +15 -5
  15. package/skills/real-test-pi-crew/SKILL.md +7 -7
  16. package/src/config/config-validation.ts +58 -0
  17. package/src/config/config.ts +90 -5
  18. package/src/config/env-vars.ts +14 -7
  19. package/src/config/role-tools.ts +7 -5
  20. package/src/config/types.ts +4 -1
  21. package/src/extension/crew-cleanup.ts +6 -0
  22. package/src/extension/project-init.ts +19 -1
  23. package/src/extension/register.ts +68 -30
  24. package/src/extension/registration/artifact-cleanup.ts +52 -11
  25. package/src/extension/registration/commands/manage.ts +9 -2
  26. package/src/extension/registration/hook-registration.ts +3 -0
  27. package/src/extension/registration/lazy-configurers.ts +2 -0
  28. package/src/extension/registration/lifecycle-handlers.ts +79 -18
  29. package/src/extension/registration/render-loop.ts +27 -2
  30. package/src/extension/registration/tool-loop-guard.ts +5 -3
  31. package/src/extension/registration/verify-gate-tool.ts +7 -3
  32. package/src/extension/team-tool/api/read.ts +12 -3
  33. package/src/extension/team-tool/cancel.ts +11 -0
  34. package/src/extension/team-tool/config-patch.ts +56 -11
  35. package/src/extension/team-tool/destructive-gate.ts +72 -12
  36. package/src/extension/team-tool/dispatch/manage.ts +9 -1
  37. package/src/extension/team-tool/handle-schedule.ts +38 -4
  38. package/src/extension/team-tool/handle-settings.ts +76 -6
  39. package/src/extension/team-tool/health-monitor.ts +14 -2
  40. package/src/extension/team-tool/lifecycle-actions.ts +28 -10
  41. package/src/extension/team-tool/respond.ts +12 -5
  42. package/src/extension/team-tool/run-deadline.ts +29 -2
  43. package/src/extension/team-tool/run.ts +11 -0
  44. package/src/extension/team-tool/status.ts +64 -8
  45. package/src/extension/team-tool/workflow-manage.ts +5 -1
  46. package/src/extension/team-tool.ts +3 -0
  47. package/src/i18n.ts +18 -14
  48. package/src/prompt/inbox-poll.ts +14 -2
  49. package/src/prompt/message-tool.ts +1 -1
  50. package/src/prompt/prompt-runtime.ts +25 -3
  51. package/src/prompt/scratchpad-lifecycle.ts +14 -13
  52. package/src/prompt/surface-worker.ts +35 -9
  53. package/src/runtime/broker/crew-broker.ts +113 -111
  54. package/src/runtime/broker/delegate/delegate-abort.ts +118 -0
  55. package/src/runtime/broker/delegate/delegate-event.ts +3 -1
  56. package/src/runtime/broker/delegate/delegate-finalize.ts +179 -0
  57. package/src/runtime/broker/protocol/wait-resolve.ts +71 -5
  58. package/src/runtime/budget-enforcement.ts +26 -6
  59. package/src/runtime/chain-parser.ts +5 -3
  60. package/src/runtime/chain-runner.ts +41 -22
  61. package/src/runtime/child-pi/mock-fixtures.ts +12 -5
  62. package/src/runtime/compaction/tool-output-pruner.ts +8 -3
  63. package/src/runtime/delegate-spawn.ts +61 -8
  64. package/src/runtime/dispatch-batch.ts +10 -4
  65. package/src/runtime/effectiveness.ts +31 -3
  66. package/src/runtime/goal-workflow/plan-templates.ts +2 -2
  67. package/src/runtime/live-session/live-agent-control.ts +89 -6
  68. package/src/runtime/live-session/live-agent-manager.ts +94 -10
  69. package/src/runtime/live-session/live-session-runtime.ts +9 -2
  70. package/src/runtime/model/model-fallback.ts +6 -9
  71. package/src/runtime/model/pi-args.ts +3 -3
  72. package/src/runtime/model/runtime-policy.ts +4 -2
  73. package/src/runtime/model/runtime-resolver.ts +6 -2
  74. package/src/runtime/process/orphan-reaper.ts +143 -0
  75. package/src/runtime/process-status.ts +38 -16
  76. package/src/runtime/resilient-edit.ts +170 -93
  77. package/src/runtime/role-permission.ts +3 -1
  78. package/src/runtime/run-tracker.ts +9 -1
  79. package/src/runtime/sanitize/worker-text.ts +98 -0
  80. package/src/runtime/scheduling/run-coalesced-task-group.ts +82 -28
  81. package/src/runtime/spawn-policy.ts +190 -8
  82. package/src/runtime/stale-reconciler.ts +36 -1
  83. package/src/runtime/subagent-manager.ts +29 -0
  84. package/src/runtime/surface/herdr-provider.ts +31 -1
  85. package/src/runtime/task-output-context.ts +58 -11
  86. package/src/runtime/task-runner/post-execution.ts +150 -0
  87. package/src/runtime/task-runner/prompt-builder.ts +41 -8
  88. package/src/runtime/task-runner.ts +16 -3
  89. package/src/runtime/verification/classifier-pre-gate.ts +27 -6
  90. package/src/runtime/verification/verify-gate.ts +153 -30
  91. package/src/state/atomic-write.ts +73 -4
  92. package/src/state/contracts.ts +7 -0
  93. package/src/state/coordination/mailbox.ts +40 -11
  94. package/src/state/event-log/event-log-rotation.ts +28 -6
  95. package/src/state/event-log/poison-marker.ts +16 -1
  96. package/src/state/stores/artifact-store.ts +181 -6
  97. package/src/state/stores/manifest-io.ts +126 -8
  98. package/src/state/stores/state-store.ts +30 -31
  99. package/src/state/types.ts +13 -0
  100. package/src/state/usage.ts +41 -0
  101. package/src/ui/run-dashboard.ts +3 -3
  102. package/src/ui/settings-overlay.ts +19 -1
  103. package/src/utils/internal-error.ts +5 -1
  104. package/src/utils/timer-limits.ts +30 -0
  105. package/src/utils/token-counter.ts +47 -0
  106. package/src/workflows/discover-workflows.ts +9 -1
  107. package/src/worktree/worktree-manager.ts +128 -18
  108. package/workflows/default.workflow.md +1 -1
  109. package/workflows/fast-fix.workflow.md +1 -1
  110. package/workflows/plan-execute.workflow.md +1 -1
  111. package/workflows/review.workflow.md +1 -1
  112. package/workflows/strict-fast-fix.workflow.md +1 -1
@@ -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();
@@ -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";
@@ -97,56 +98,93 @@ export async function registerPiTeams(pi: ExtensionAPI): Promise<void> {
97
98
  const ctx = buildRegistrationContext(pi);
98
99
  ctx.importCrashRecovery = importCrashRecovery;
99
100
  ctx.purgeStaleActiveRunIndexSyncIfLoaded = purgeStaleActiveRunIndexSyncIfLoaded;
100
- installRuntimeCleanup(pi, ctx);
101
- installForegroundRunController(pi, ctx);
102
- installSubagentManager(pi, ctx);
103
- installLazyConfigurers(pi, ctx);
104
- 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));
105
126
 
106
127
  time("register.policy");
107
- registerAutonomousPolicy(pi);
108
- registerKnowledgeInjection(pi);
128
+ withRegistrar("autonomousPolicy", () => registerAutonomousPolicy(pi));
129
+ withRegistrar("knowledgeInjection", () => registerKnowledgeInjection(pi));
109
130
 
110
- registerPiTools(pi, ctx);
111
- registerPiCommands(pi, ctx);
112
- installPiHooks(pi, ctx);
113
- 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));
114
135
  // Phase 0 inter-pi broker: install the lifecycle controller immediately
115
136
  // after the session handlers. The controller's gate (broker.enabled AND
116
137
  // root-session only) decides whether anything is actually done; for
117
138
  // subagents or when the flag is off, it returns a no-op controller.
118
- ctx.brokerController = installCrewBrokerLifecycleController(pi, ctx);
139
+ ctx.brokerController = withRegistrar("crewBrokerLifecycle", () => installCrewBrokerLifecycleController(pi, ctx));
119
140
 
120
141
  // M3-1 (UI-AUDIT P0-2/P1-1): wire the tab-title + Ghostty progress
121
142
  // controller to runEventBus. Before this the controller was never
122
143
  // constructed (dead since v0.8.3). Registers the dispose hook that the
123
144
  // SIGTERM/SIGHUP handler calls (crew-cleanup.ts).
124
- installTerminalStatus(pi, ctx);
125
- registerCompactionGuard(pi, {
126
- foregroundControllers: ctx.foregroundControllers,
127
- foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
128
- });
145
+ withRegistrar("terminalStatus", () => installTerminalStatus(pi, ctx));
146
+ withRegistrar("compactionGuard", () =>
147
+ registerCompactionGuard(pi, {
148
+ foregroundControllers: ctx.foregroundControllers,
149
+ foregroundTeamRunControllers: ctx.foregroundTeamRunControllers,
150
+ }),
151
+ );
129
152
 
130
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.
131
159
  // LAZY: resilient-edit only loads when the opt-in env var is set.
132
160
  import("../runtime/resilient-edit.ts")
133
- .then(({ wrapEditWithResilientReplace }) => wrapEditWithResilientReplace(pi))
134
- .catch(() => {
135
- /* 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)}`);
136
172
  });
137
173
  }
138
174
 
139
- registerCrewMessageRenderers(pi);
140
- registerCrewInputRouter(pi);
141
- registerCrewShortcuts(pi);
142
- registerContextStatusInjection(pi, {
143
- enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
144
- });
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
+ );
145
183
 
146
- try {
147
- registerCrewVibes(pi);
148
- } catch (err) {
149
- 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(", ")}`);
150
188
  }
151
189
 
152
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
  }
@@ -27,6 +27,8 @@ import {
27
27
  } from "../../extension/run-maintenance.ts";
28
28
  import { type BrokerSpawnCredentials, setActiveBrokerIssuer, setActiveBrokerRevoker } from "../../runtime/broker/broker-issuer.ts";
29
29
  import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
30
+ import { setActiveDelegateAborter } from "../../runtime/broker/delegate/delegate-abort.ts";
31
+ import { DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, withRunLockBusyRetryAsync } from "../../runtime/broker/protocol/lock-busy.ts";
30
32
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
31
33
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
32
34
  import { configuredModelInfosFromPiConfig } from "../../runtime/model/model-fallback.ts";
@@ -40,7 +42,8 @@ import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
40
42
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
41
43
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
42
44
  import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
43
- import { loadRunManifestById } from "../../state/stores/state-store.ts";
45
+ import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
46
+ import type { TeamRunManifest } from "../../state/types.ts";
44
47
  import { installInlinePanel } from "../../ui/inline-panel/index.ts";
45
48
  import { clearSessionSwitchInFlight, markSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts";
46
49
  import { registerPiCrewPowerbarSegments, resetPowerbarDedupState, updatePiCrewPowerbar } from "../../ui/powerbar-publisher.ts";
@@ -532,6 +535,56 @@ async function runDeferredSessionCleanup(
532
535
  }
533
536
  }
534
537
 
538
+ /** Manifest keys the scheduler stamps for traceability. They are NOT declared
539
+ * on TeamRunManifest (carried as extra keys, exactly like the pre-fix inline
540
+ * write did) — the provenance stamp is advisory metadata, never read back by
541
+ * any consumer. */
542
+ type SchedulerProvenanceFields = { schedulerJobId: string; schedulerName: string };
543
+
544
+ /**
545
+ * M3-7 (R22-F2 / F22-2, fix-wave 6): stamp scheduler provenance on a scheduled
546
+ * run's manifest WITHOUT racing the runner.
547
+ *
548
+ * Pre-fix the executor did `atomicWriteJson(stateRoot + "/manifest.json",
549
+ * {...loaded.manifest, schedulerJobId, schedulerName})` — a RAW write that
550
+ * (a) bypassed run.lock, so it interleaved with team-runner's saveRunManifest /
551
+ * saveRunManifestAsync, and (b) bypassed the write-layer terminal-preserve
552
+ * guard, so a cancel/reconciler flip landing in the window was RESURRECTED to
553
+ * "running" while usage/artifacts were reverted to the pre-write snapshot. It
554
+ * fired on EVERY scheduled fire.
555
+ *
556
+ * Fix: re-read INSIDE run.lock (bounded busy-retry) and persist through
557
+ * `saveRunManifest`, which applies `preserveDiskTerminalStatus` (never erases a
558
+ * terminal disk status) and invalidates/repopulates the manifest cache. Read +
559
+ * write under the same lock closes the lost-update window by construction.
560
+ *
561
+ * Best-effort by contract (provenance must never break a scheduled fire): a
562
+ * pruned run, exhausted lock budget or write fault logs at warn and returns
563
+ * false.
564
+ *
565
+ * @internal exported for the M3-7 unit pin
566
+ * (test/unit/runtime/scheduling/scheduler-provenance-lock.test.ts).
567
+ */
568
+ export async function stampSchedulerRunProvenance(cwd: string, runId: string, job: { id: string; name: string }): Promise<boolean> {
569
+ try {
570
+ const loaded = loadRunManifestById(cwd, runId);
571
+ if (!loaded) return false; // run pruned/unknown — nothing to stamp
572
+ const provenance: SchedulerProvenanceFields = { schedulerJobId: job.id, schedulerName: job.name };
573
+ await withRunLockBusyRetryAsync(loaded.manifest, DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS, async () => {
574
+ // Fresh reload INSIDE the lock (respond.ts:42-43 discipline): outside
575
+ // would reintroduce the exact lost-update window being closed.
576
+ const fresh = loadRunManifestById(loaded.manifest.cwd, runId);
577
+ if (!fresh) return; // pruned between the outer load and the lock
578
+ const next: TeamRunManifest & SchedulerProvenanceFields = { ...fresh.manifest, ...provenance };
579
+ saveRunManifest(next);
580
+ });
581
+ return true;
582
+ } catch (error) {
583
+ logInternalError("scheduler.stamp-provenance", error, `runId=${runId} jobId=${job.id}`, "warn");
584
+ return false;
585
+ }
586
+ }
587
+
535
588
  /**
536
589
  * Build a CrewScheduler wired to the current session. The scheduler's
537
590
  * executor closure invokes handleTeamTool lazily — the heavy team-tool
@@ -596,22 +649,11 @@ export function setupCrewScheduler(
596
649
  const runId = runResult?.details?.runId;
597
650
  if (runId && typeof runId === "string") {
598
651
  crewScheduler?.recordSpawnedRun(job.id, runId);
599
- // Update run manifest with scheduler provenance for traceability
600
- try {
601
- const cwd = extensionCtx.cwd ?? process.cwd();
602
- const loaded = loadRunManifestById(cwd, runId);
603
- if (loaded) {
604
- // LAZY: defer dynamic import of atomic-write.ts to its call site.
605
- const { atomicWriteJson } = await import("../../state/atomic-write.ts");
606
- atomicWriteJson(loaded.manifest.stateRoot + "/manifest.json", {
607
- ...loaded.manifest,
608
- schedulerJobId: job.id,
609
- schedulerName: job.name,
610
- });
611
- }
612
- } catch {
613
- /* best-effort provenance tracking */
614
- }
652
+ // Update run manifest with scheduler provenance for traceability.
653
+ // M3-7 (R22-F2): lock-held + terminal-preserving (was a raw
654
+ // atomicWriteJson outside run.lock that could resurrect a
655
+ // terminal status). Best-effort by contract.
656
+ await stampSchedulerRunProvenance(extensionCtx.cwd ?? process.cwd(), runId, job);
615
657
  }
616
658
  // R6-2(b) + I6 (round-6 audit): update state FIRST, then persist the
617
659
  // UPDATED job. The old order persisted the fire-time snapshot (still
@@ -690,7 +732,7 @@ export interface CrewBrokerLifecycleController {
690
732
  }
691
733
 
692
734
  function isRootSession(env: NodeJS.ProcessEnv = process.env): boolean {
693
- if (env.PI_CREW_KIND === "subagent") return false;
735
+ if (getCrewEnv("PI_CREW_KIND", env) === "subagent") return false;
694
736
  try {
695
737
  return currentCrewDepth(env) === 0;
696
738
  } catch {
@@ -847,6 +889,24 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
847
889
  };
848
890
  setActiveBrokerRevoker(revokeForTask);
849
891
 
892
+ // M3-5 / R43-N3: publish the delegate canceller alongside the revoker. The
893
+ // run-terminal paths (budget abort / cancellation → budget-enforcement.ts
894
+ // terminaliseRunWithDrain) call it so in-flight DELEGATE grandchildren are
895
+ // aborted instead of outliving the run to their own 24h deadline. Same
896
+ // self-gating as the revoker: no bound broker ⇒ nothing to cancel.
897
+ const abortDelegatesForRun = (runId: string, reason: string): number => {
898
+ if (!runId || typeof runId !== "string") return 0;
899
+ if (!isRootSession(process.env)) return 0;
900
+ if (!broker || brokerSessionId !== cachedSessionId) return 0;
901
+ try {
902
+ return broker.abortDelegatesForRun(runId, reason);
903
+ } catch (error) {
904
+ logInternalError("broker.abort-delegates", error instanceof Error ? error : new Error(String(error)), `runId=${runId}`, "warn");
905
+ return 0;
906
+ }
907
+ };
908
+ setActiveDelegateAborter(abortDelegatesForRun);
909
+
850
910
  // Publish this issuer as the process-local active issuer so runChildPi can
851
911
  // default `brokerIssuer` without the registration context being threaded
852
912
  // through every runner call site. The issuer self-gates (root + flag), so
@@ -858,6 +918,7 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
858
918
  stop: async () => {
859
919
  setActiveBrokerIssuer(undefined);
860
920
  setActiveBrokerRevoker(undefined);
921
+ setActiveDelegateAborter(undefined);
861
922
  if (broker) {
862
923
  try {
863
924
  await broker.stop();
@@ -19,7 +19,7 @@ import { forgetDetachedRun, hasDetachedRuns, peekFinishedDetachedRunResults } fr
19
19
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
20
20
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
21
21
  import { loadTasksWithRecovery } from "../../state/stores/manifest-io.ts";
22
- import type { TeamRunManifest } from "../../state/types.ts";
22
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
23
23
  import { overlayFreshTaskStatuses, summarizeHeartbeats } from "../../ui/heartbeat-aggregator.ts";
24
24
  import { requestRender, setExtensionWidget, toPiWidgetPlacement } from "../../ui/pi-ui-compat.ts";
25
25
  import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
@@ -27,6 +27,7 @@ import { RenderScheduler } from "../../ui/render-scheduler.ts";
27
27
  import { runEventBus } from "../../ui/run-event-bus.ts";
28
28
  import type { createRunSnapshotCache } from "../../ui/run-snapshot-cache.ts";
29
29
  import { updateCrewWidget } from "../../ui/widget/index.ts";
30
+ import { readJsonFileCoalesced } from "../../utils/file-coalescer.ts";
30
31
  import { logInternalError } from "../../utils/internal-error.ts";
31
32
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
32
33
  import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
@@ -128,6 +129,30 @@ export function applyTerminalRunEventToManifests(manifests: TeamRunManifest[], e
128
129
  return isTerminalRunEventType(event.type) ? evictRunFromManifests(manifests, event.runId) : manifests;
129
130
  }
130
131
 
132
+ /**
133
+ * R15-P1: GATE 3 used to call `loadTasksWithRecovery` — an uncached
134
+ * `readFileSync` + `JSON.parse` of the FULL task packet array (~8.4 KB/task) —
135
+ * once per running run on every 160 ms tick. Measured 14.78 ms/call at 200
136
+ * tasks, i.e. ~28% of the tick blocked on the event loop while runs were live.
137
+ *
138
+ * The read now goes through the in-tree coalesced reader (stat-gated on
139
+ * mtime+size, LRU-capped, short TTL): a tick that sees an unchanged file pays a
140
+ * syscall instead of a parse. The TTL is far below the stale (60 s) / dead
141
+ * (5 min) thresholds GATE 3 protects, and the quarantine/reconstruct path stays
142
+ * intact and self-invalidating — a recovered file is rewritten, so its mtime
143
+ * changes and the next tick misses the cache.
144
+ */
145
+ const TASKS_READ_TTL_MS = 200;
146
+
147
+ /**
148
+ * Read a run's tasks.json through the coalesced reader (GATE 3 truth source).
149
+ * Exported so the cache behaviour (and its freshness/quarantine invariants) can
150
+ * be pinned directly — see test/unit/extension/registration/render-loop-tasks-read-cache.test.ts.
151
+ */
152
+ export function readTasksCoalesced(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
153
+ return readJsonFileCoalesced(tasksPath, TASKS_READ_TTL_MS, () => loadTasksWithRecovery(tasksPath, eventsPath, runId));
154
+ }
155
+
131
156
  /**
132
157
  * Build the render scheduler + preload loop + bounded run watchers.
133
158
  *
@@ -385,7 +410,7 @@ export function setupRenderLoop(
385
410
  // "dead worker" (live: team_20260923100114, 01_explore parked on
386
411
  // ask). Overlay FRESH statuses before summarizing; divergence also
387
412
  // schedules an in-place rebuild so the cache syncs off the tick.
388
- const freshTasks = loadTasksWithRecovery(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
413
+ const freshTasks = readTasksCoalesced(freshManifest.tasksPath, freshManifest.eventsPath, run.runId);
389
414
  const overlaid = overlayFreshTaskStatuses(snapshot, freshTasks);
390
415
  if (overlaid !== snapshot) refreshSnapshotInPlace(run.runId);
391
416
  const summary = summarizeHeartbeats(overlaid, { now });
@@ -215,7 +215,9 @@ export function createLoopGuardState() {
215
215
  /**
216
216
  * Install the loop guard on a Pi instance. Both hooks are best-effort:
217
217
  * older Pi versions without these events are tolerated (same pattern as
218
- * installResourcesDiscoverHook).
218
+ * installResourcesDiscoverHook). R49-F4: `pi.on()` never THROWS on an unknown
219
+ * event name (pi 0.77/1.0.4 verified) — so the try/catch below is defensive
220
+ * only, and the real older-host degradation is a NEVER-FIRING hook.
219
221
  */
220
222
  export function installToolLoopGuard(pi: ExtensionAPI): void {
221
223
  const state = createLoopGuardState();
@@ -228,7 +230,7 @@ export function installToolLoopGuard(pi: ExtensionAPI): void {
228
230
  return undefined;
229
231
  });
230
232
  } catch {
231
- /* older Pi without tool_call events */
233
+ /* R49-F4: defensive only — pi.on() does not throw on unknown events; the hook simply never fires */
232
234
  }
233
235
  try {
234
236
  pi.on("tool_result", (event: { toolName: string; input?: unknown; content?: unknown }) => {
@@ -238,6 +240,6 @@ export function installToolLoopGuard(pi: ExtensionAPI): void {
238
240
  return { content: [...existing, ...appends] };
239
241
  });
240
242
  } catch {
241
- /* older Pi without tool_result events */
243
+ /* R49-F4: defensive only — pi.on() does not throw on unknown events; the hook simply never fires */
242
244
  }
243
245
  }
@@ -33,7 +33,6 @@ import { type Static, Type } from "@sinclair/typebox";
33
33
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
34
34
  import {
35
35
  DEFAULT_VERIFY_GATE_TIMEOUT_MS,
36
- defaultVerifyGateChecks,
37
36
  renderVerifyGateContext,
38
37
  runVerifyGate,
39
38
  type VerifyGateCheck,
@@ -59,7 +58,7 @@ const VerifyGateToolParams = Type.Object({
59
58
  }),
60
59
  {
61
60
  description:
62
- "Explicit checks. Defaults to the repo preset: package.json scripts 'typecheck' + 'test:critical' when present. Add grep evidence lines here (e.g. `grep -c \"<marker>\" dist/index.mjs`).",
61
+ 'Explicit checks — REQUIRED for a meaningful verdict (M1-7: the gate never derives commands from a worker-mutable package.json). Without checks the verdict is INCONCLUSIVE (escalate). Add grep evidence lines here (e.g. `grep -c "<marker>" dist/index.mjs`).',
63
62
  },
64
63
  ),
65
64
  ),
@@ -152,7 +151,12 @@ export function registerVerifyGateTool(pi: ExtensionAPI, deps: VerifyGateToolDep
152
151
  }),
153
152
  async execute(_id, params: Static<typeof VerifyGateToolParams>, signal, _onUpdate, ctx) {
154
153
  const cwd = params.cwd && params.cwd.trim().length > 0 ? params.cwd : ctx.cwd;
155
- const checks: VerifyGateCheck[] = params.checks ?? defaultVerifyGateChecks(cwd);
154
+ // M1-7 (R47-1): NO package.json fallback — the task/session cwd is
155
+ // worker-mutable, so deriving checks from it at call time let a worker
156
+ // grade its own work (tampered `scripts.typecheck: "exit 0"` ⇒ PASS).
157
+ // Callers must pass explicit checks; without them the gate is
158
+ // INCONCLUSIVE (escalate — never a PASS-shaped verdict).
159
+ const checks: VerifyGateCheck[] = params.checks ?? [];
156
160
  // Nested-limit guards — validate BEFORE any executeTool call.
157
161
  if (checks.length > VERIFY_GATE_TOOL_MAX_CHECKS) {
158
162
  return {