pi-crew 0.11.1 → 0.11.4

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 (154) hide show
  1. package/CHANGELOG.md +82 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +742 -91462
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +5 -4
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +206 -41
  12. package/src/agents/agent-config.ts +1 -1
  13. package/src/agents/discover-agents.ts +1 -1
  14. package/src/config/config-validation.ts +15 -0
  15. package/src/config/config.ts +47 -13
  16. package/src/config/env-vars.ts +35 -0
  17. package/src/config/types.ts +19 -0
  18. package/src/errors.ts +2 -2
  19. package/src/extension/async-notifier.ts +55 -8
  20. package/src/extension/crew-input-router.ts +9 -2
  21. package/src/extension/help.ts +21 -10
  22. package/src/extension/knowledge-injection.ts +2 -1
  23. package/src/extension/management.ts +8 -3
  24. package/src/extension/notification-sink.ts +17 -0
  25. package/src/extension/plan-orchestrate.ts +19 -3
  26. package/src/extension/registration/command-utils.ts +28 -2
  27. package/src/extension/registration/commands/dashboard.ts +11 -1
  28. package/src/extension/registration/commands/manage.ts +31 -15
  29. package/src/extension/registration/commands/run.ts +24 -2
  30. package/src/extension/registration/commands/shared.ts +34 -10
  31. package/src/extension/registration/commands/status.ts +25 -2
  32. package/src/extension/registration/context-builder.ts +8 -2
  33. package/src/extension/registration/foreground-run-controller.ts +2 -2
  34. package/src/extension/registration/health-notify-policy.ts +100 -0
  35. package/src/extension/registration/lazy-configurers.ts +38 -0
  36. package/src/extension/registration/lifecycle-handlers.ts +107 -40
  37. package/src/extension/registration/lifecycle.ts +77 -10
  38. package/src/extension/registration/observability.ts +125 -53
  39. package/src/extension/registration/registration-types.ts +7 -5
  40. package/src/extension/registration/runtime-cleanup.ts +9 -3
  41. package/src/extension/registration/subagent-helpers.ts +38 -0
  42. package/src/extension/registration/team-tool.ts +9 -2
  43. package/src/extension/registration/wire-cross-extension.ts +30 -0
  44. package/src/extension/run-compare.ts +220 -0
  45. package/src/extension/run-export.ts +37 -5
  46. package/src/extension/run-maintenance.ts +155 -5
  47. package/src/extension/team-manager-command.ts +3 -2
  48. package/src/extension/team-tool/dispatch/index.ts +3 -2
  49. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  50. package/src/extension/team-tool/goal-wrap.ts +7 -0
  51. package/src/extension/team-tool/goal.ts +16 -1
  52. package/src/extension/team-tool/handle-settings.ts +19 -2
  53. package/src/extension/team-tool/health-monitor.ts +21 -7
  54. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  55. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  56. package/src/extension/team-tool/plan.ts +10 -0
  57. package/src/extension/team-tool/routing-hint.ts +63 -0
  58. package/src/extension/team-tool/run-intent.ts +1 -1
  59. package/src/extension/team-tool/run.ts +1 -1
  60. package/src/extension/team-tool/status.ts +4 -0
  61. package/src/extension/team-tool.ts +52 -6
  62. package/src/extension/webhook-notify.ts +382 -0
  63. package/src/observability/metric-sink.ts +12 -2
  64. package/src/prompt/prompt-runtime.ts +82 -31
  65. package/src/prompt/worker-events-channel.ts +12 -0
  66. package/src/runtime/README.md +1 -1
  67. package/src/runtime/async-runner.ts +123 -1
  68. package/src/runtime/background-runner.ts +313 -234
  69. package/src/runtime/broker/crew-broker.ts +76 -71
  70. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  73. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  74. package/src/runtime/broker/wait-status-cache.ts +1 -1
  75. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  76. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  77. package/src/runtime/crew-agent-records.ts +337 -45
  78. package/src/runtime/deadletter.ts +43 -1
  79. package/src/runtime/delegate-spawn.ts +21 -3
  80. package/src/runtime/deterministic-ast.ts +14 -2
  81. package/src/runtime/dispatch-batch.ts +72 -5
  82. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  83. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  84. package/src/runtime/goal-workflow/goal-loop-runner.ts +105 -4
  85. package/src/runtime/group-join.ts +11 -3
  86. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  87. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  88. package/src/runtime/model/model-fallback.ts +21 -1
  89. package/src/runtime/model/pi-args.ts +8 -10
  90. package/src/runtime/model/runtime-warmup.ts +2 -0
  91. package/src/runtime/output/output-validator.ts +10 -5
  92. package/src/runtime/plan-approval.ts +14 -1
  93. package/src/runtime/recovery/crash-recovery.ts +96 -49
  94. package/src/runtime/run-worker.ts +12 -1
  95. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  96. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  97. package/src/runtime/scheduling/scheduler.ts +49 -13
  98. package/src/runtime/scheduling/semaphore.ts +148 -20
  99. package/src/runtime/scratchpad/README.md +1 -1
  100. package/src/runtime/scratchpad/protocol.ts +1 -1
  101. package/src/runtime/settings-store.ts +1 -1
  102. package/src/runtime/skill-instructions.ts +22 -0
  103. package/src/runtime/stale-reconciler.ts +85 -13
  104. package/src/runtime/subagent-manager.ts +43 -13
  105. package/src/runtime/task-runner/child-executor.ts +17 -6
  106. package/src/runtime/task-runner/post-execution.ts +44 -14
  107. package/src/runtime/task-runner/pre-execution.ts +26 -2
  108. package/src/runtime/task-runner/prompt-builder.ts +150 -45
  109. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  110. package/src/runtime/task-runner.ts +28 -1
  111. package/src/runtime/team-runner.ts +42 -2
  112. package/src/runtime/verification/verification-worktree.ts +3 -0
  113. package/src/runtime/workspace-lock.ts +4 -1
  114. package/src/schema/config-schema.ts +14 -0
  115. package/src/schema/team-tool-schema.ts +17 -0
  116. package/src/state/atomic-write.ts +53 -0
  117. package/src/state/contracts.ts +109 -0
  118. package/src/state/coordination/locks.ts +191 -33
  119. package/src/state/coordination/mailbox.ts +147 -17
  120. package/src/state/crew-init.ts +87 -12
  121. package/src/state/event-log/cursor.ts +37 -1
  122. package/src/state/event-log/event-log-rotation.ts +72 -7
  123. package/src/state/stores/active-run-registry.ts +13 -1
  124. package/src/state/stores/state-store.ts +120 -23
  125. package/src/state/types.ts +4 -0
  126. package/src/ui/dashboard-panes/agents-pane.ts +22 -10
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/index.ts +1 -1
  129. package/src/ui/keybinding-map.ts +22 -4
  130. package/src/ui/live-conversation-overlay.ts +6 -3
  131. package/src/ui/render-diff.ts +19 -6
  132. package/src/ui/run-dashboard.ts +109 -6
  133. package/src/ui/run-snapshot-cache.ts +27 -4
  134. package/src/ui/spinner.ts +26 -2
  135. package/src/ui/tool-progress-formatter.ts +2 -1
  136. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  137. package/src/ui/tool-renderers/index.ts +3 -3
  138. package/src/ui/widget/task-list.ts +8 -5
  139. package/src/ui/widget/widget-model.ts +3 -1
  140. package/src/utils/incremental-reader.ts +11 -3
  141. package/src/utils/paths.ts +94 -12
  142. package/src/utils/project-markers.ts +40 -0
  143. package/src/worktree/worktree-manager.ts +206 -26
  144. package/tsconfig.json +2 -0
  145. package/workflows/distill.workflow.md +3 -3
  146. package/workflows/fast-fix.workflow.md +1 -1
  147. package/workflows/plan-execute.workflow.md +1 -1
  148. package/workflows/review.workflow.md +1 -1
  149. package/workflows/strict-fast-fix.workflow.md +1 -1
  150. package/docs/migration-v0.4-v0.5.md +0 -208
  151. package/docs/runtime-flow.md +0 -148
  152. package/src/ui/card-colors.ts +0 -168
  153. package/src/utils/completion-dedupe.ts +0 -63
  154. package/src/utils/resolve-shell.ts +0 -36
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
5
  import { getBuiltinTemplates, instantiateTemplate, listTemplates } from "../../../skills/skill-templates.ts";
5
6
  import { atomicWriteFile } from "../../../state/atomic-write.ts";
@@ -9,6 +10,29 @@ import { commandText, notifyCommandResult, parseScalar, pushUnset, setNestedConf
9
10
  import type { RegisterTeamCommandsDeps } from "./shared.ts";
10
11
  import { handleTeamTool, openTeamSettingsOverlay, teamCommandContext } from "./shared.ts";
11
12
 
13
+ /**
14
+ * Resolve the user-level skills directory (<pi-crew package root>/skills).
15
+ *
16
+ * ESM-safe replacement for the former `require.resolve("../../../../package.json",
17
+ * { paths: [__dirname] })`: neither `require` nor `__dirname` exists under
18
+ * strip-types source loading or inside the esbuild ESM bundle (dist/index.mjs).
19
+ * Anchoring on `import.meta.url` and walking up to the nearest package.json
20
+ * yields the package root under BOTH layouts — src/.../commands/manage.ts and
21
+ * dist/index.mjs sit at different depths, so a fixed relative hop count cannot
22
+ * serve both.
23
+ */
24
+ export function resolveUserSkillsDir(): string {
25
+ let dir = fileURLToPath(new URL(".", import.meta.url));
26
+ for (;;) {
27
+ if (fs.existsSync(path.join(dir, "package.json"))) return path.join(dir, "skills");
28
+ const parent = path.dirname(dir);
29
+ if (parent === dir) {
30
+ throw new Error(`pi-crew: cannot locate package root (no package.json above ${dir})`);
31
+ }
32
+ dir = parent;
33
+ }
34
+ }
35
+
12
36
  export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
13
37
  pi.registerCommand("team-prune", {
14
38
  description: "Prune old finished pi-crew runs, keeping the newest N",
@@ -210,16 +234,20 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
210
234
  return idx === -1 ? [s, ""] : [s.slice(0, idx), s.slice(idx + 1)];
211
235
  });
212
236
  const templateId = tokens.find((t) => !t.startsWith("--") && !t.includes("="));
237
+ const availableTemplateIds = listTemplates().map((t) => t.id);
213
238
  if (!templateId) {
214
239
  await notifyCommandResult(
215
240
  ctx,
216
- "Usage: /skill-create <template-id> [--var key=value...] [--project]\nRun /skill-list to see available templates.",
241
+ `Usage: /skill-create <template-id> [--var key=value...] [--project]\nAvailable templates: ${availableTemplateIds.join(", ")}`,
217
242
  );
218
243
  return;
219
244
  }
220
245
  const template = getBuiltinTemplates().find((t) => t.id === templateId);
221
246
  if (!template) {
222
- await notifyCommandResult(ctx, `Unknown template '${templateId}'. Run /skill-list to see available templates.`);
247
+ await notifyCommandResult(
248
+ ctx,
249
+ `Unknown template '${templateId}'.\nUsage: /skill-create <template-id> [--var key=value...] [--project]\nAvailable templates: ${availableTemplateIds.join(", ")}`,
250
+ );
223
251
  return;
224
252
  }
225
253
  const variables: Record<string, string> = {};
@@ -249,19 +277,7 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
249
277
  await notifyCommandResult(ctx, error instanceof Error ? error.message : String(error));
250
278
  return;
251
279
  }
252
- const skillsDir = path.resolve(
253
- cwd,
254
- useProject
255
- ? "skills"
256
- : path.join(
257
- path.dirname(
258
- require.resolve("../../../../package.json", {
259
- paths: [__dirname],
260
- }),
261
- ),
262
- "skills",
263
- ),
264
- );
280
+ const skillsDir = useProject ? path.resolve(cwd, "skills") : resolveUserSkillsDir();
265
281
  const skillDir = path.join(skillsDir, template.id);
266
282
  const skillPath = path.join(skillDir, "SKILL.md");
267
283
  try {
@@ -109,6 +109,10 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
109
109
  const taskToken = tokens[0] === "--all" ? tokens.shift() : tokens.shift();
110
110
  const taskId = taskToken === "--all" ? undefined : taskToken;
111
111
  const message = tokens.join(" ") || undefined;
112
+ if (!runId || !taskToken || !message) {
113
+ await notifyCommandResult(ctx, "Usage: /team-respond <runId> <taskId|--all> <message>…");
114
+ return;
115
+ }
112
116
  const result = await handleTeamTool({ action: "respond", runId, taskId, message }, teamCommandContext(ctx));
113
117
  await notifyCommandResult(ctx, commandText(result));
114
118
  },
@@ -163,7 +167,19 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
163
167
 
164
168
  pi.registerCommand("team-goal", {
165
169
  description:
166
- "Autonomous goal loop control: [start|status|pause|resume|stop|step|clear] [goalId] [--objective=...] [--evaluatorModel=...] [--maxTurns=N]",
170
+ "Autonomous goal loop control (defaults to status): [start|status|pause|resume|stop|cancel|reset|step|clear] [goalId] [--objective=...] [--evaluatorModel=...] [--maxTurns=N]",
171
+ // Suggest the stop-aliases only while completing the FIRST argument:
172
+ // pi hands `getArgumentCompletions` the whole argument text (there is no
173
+ // argument-index parameter), so any whitespace in it means the cursor is
174
+ // already past arg 1 — nothing useful to suggest for goalId/flags.
175
+ getArgumentCompletions: (argumentPrefix: string) => {
176
+ if (argumentPrefix.includes(" ")) return [];
177
+ const prefix = argumentPrefix.trim();
178
+ return [
179
+ { value: "cancel", label: "cancel", description: "stop the goal loop (alias of stop)" },
180
+ { value: "reset", label: "reset", description: "stop the goal loop (alias of stop)" },
181
+ ].filter((item) => item.value.startsWith(prefix));
182
+ },
167
183
  handler: async (args: string, ctx: ExtensionCommandContext) => {
168
184
  const tokens = args.trim().split(/\s+/).filter(Boolean);
169
185
  const knownSubs = new Set(["start", "status", "pause", "resume", "stop", "step", "clear", "cancel", "reset"]);
@@ -203,7 +219,13 @@ export function registerRunCommands(pi: ExtensionAPI, deps: RegisterTeamCommands
203
219
  metricRegistry: deps.getMetricRegistry?.(),
204
220
  },
205
221
  );
206
- await notifyCommandResult(ctx, commandText(result));
222
+ const text = commandText(result);
223
+ const trimmed = text.trim();
224
+ const hint =
225
+ !trimmed || trimmed === "[]"
226
+ ? "\nNo metrics yet — observability may be disabled for this team. Set `observability: true` in the team frontmatter to enable."
227
+ : "";
228
+ await notifyCommandResult(ctx, text + hint);
207
229
  },
208
230
  });
209
231
 
@@ -46,6 +46,7 @@ export async function handleTeamTool(
46
46
  if (!_cachedHandleTeamTool) {
47
47
  if (!_handleTeamToolPromise) {
48
48
  _handleTeamToolPromise = import("../../team-tool.ts").then((mod) => {
49
+ // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
49
50
  _cachedHandleTeamTool = mod.handleTeamTool;
50
51
  return mod.handleTeamTool;
51
52
  });
@@ -109,13 +110,13 @@ export async function ui(): Promise<NonNullable<typeof _uiCache>> {
109
110
  if (!_uiCachePromise) {
110
111
  _uiCachePromise = (async () => {
111
112
  const [rd, tv, co, md, mc, ap, ma] = await Promise.all([
112
- import("../../../ui/run-dashboard.ts"),
113
- import("../../../ui/transcript-viewer.ts"),
114
- import("../../../ui/overlays/confirm-overlay.ts"),
115
- import("../../../ui/overlays/mailbox-detail-overlay.ts"),
116
- import("../../../ui/overlays/mailbox-compose-overlay.ts"),
117
- import("../../../ui/overlays/agent-picker-overlay.ts"),
118
- import("../../../ui/mascot.ts"),
113
+ import("../../../ui/run-dashboard.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
114
+ import("../../../ui/transcript-viewer.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
115
+ import("../../../ui/overlays/confirm-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
116
+ import("../../../ui/overlays/mailbox-detail-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
117
+ import("../../../ui/overlays/mailbox-compose-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
118
+ import("../../../ui/overlays/agent-picker-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
119
+ import("../../../ui/mascot.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
119
120
  ]);
120
121
  const cache = {
121
122
  RunDashboard: rd.RunDashboard,
@@ -249,9 +250,9 @@ export function teamCommandContext(ctx: ExtensionCommandContext): ExtensionComma
249
250
  export async function openTeamSettingsOverlay(ctx: ExtensionContext): Promise<void> {
250
251
  if (!ctx.hasUI) return;
251
252
  const [{ updateConfig, parseConfig }, { asCrewTheme }, { createSettingsOverlay }] = await Promise.all([
252
- import("../../../config/config.ts"),
253
- import("../../../ui/theme-adapter.ts"),
254
- import("../../../ui/settings-overlay.ts"),
253
+ import("../../../config/config.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
254
+ import("../../../ui/theme-adapter.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
255
+ import("../../../ui/settings-overlay.ts"), // LAZY: defer heavy command/UI chain until first slash-command (blind-spot #2)
255
256
  ]);
256
257
  const loaded = loadConfig(ctx.cwd);
257
258
  const config = loaded.config as Record<string, unknown>;
@@ -576,6 +577,29 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
576
577
  deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
577
578
  continue;
578
579
  }
580
+ // US-020: dashboard `x` cancel — dispatches the EXISTING cancel channel
581
+ // (control-domain handleCancel). The 2-step dashboard gate is the user's
582
+ // explicit intent; `intent` satisfies requireIntentForDestructiveActions
583
+ // when that policy is on. Dashboard reopens on continue (selection kept).
584
+ if (selection.action === "cancel" && selection.runId) {
585
+ // F4 pattern: surface backend rejections at error level, truncate long text.
586
+ const cancelResult = await handleTeamTool(
587
+ {
588
+ action: "cancel",
589
+ runId: selection.runId,
590
+ config: { intent: "dashboard cancel keystroke (2-step confirm)" },
591
+ },
592
+ teamCommandContext(cmdCtx),
593
+ );
594
+ const cancelText = commandText(cancelResult);
595
+ depsNotify(
596
+ cmdCtx,
597
+ cancelText.length > 800 ? `${cancelText.slice(0, 797)}...` : cancelText,
598
+ cancelResult.isError ? "error" : "info",
599
+ );
600
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
601
+ continue;
602
+ }
579
603
  if (selection.action === "plan-approve" || selection.action === "plan-deny") {
580
604
  await handlePlanDashboardAction(cmdCtx, selection);
581
605
  deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
@@ -1,10 +1,28 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadRunManifestById } from "../../../state/stores/state-store.ts";
2
3
  import { suggestRunIds } from "../../command-completions.ts";
3
4
  import { piTeamsHelp } from "../../help.ts";
4
- import { commandText, notifyCommandResult } from "../command-utils.ts";
5
+ import { commandText, NOTIFY_TEXT_CAP, notifyCommandResult } from "../command-utils.ts";
5
6
  import type { RegisterTeamCommandsDeps } from "./shared.ts";
6
7
  import { handleTeamTool, teamCommandContext } from "./shared.ts";
7
8
 
9
+ /**
10
+ * W5 (slash-commands fix spec): `/team-events` pointer. When the events
11
+ * listing is about to be clipped by the 800-char notification cap, resolve
12
+ * the run's on-disk events log so the message can end with a reachable path.
13
+ * Derived from the SAME source the `events` team action reads
14
+ * (inspect.ts): locateRunCwd + loadRunManifestById → manifest.eventsPath.
15
+ * The dynamic team-tool import is free here — the awaited handleTeamTool
16
+ * call above it already loaded that module into the ESM cache.
17
+ */
18
+ async function eventsLogFooter(runId: string | undefined, text: string, cwd: string): Promise<string | undefined> {
19
+ if (!runId || text.length <= NOTIFY_TEXT_CAP) return undefined;
20
+ const { locateRunCwd } = await import("../../team-tool.ts"); // LAZY: defer team-tool chain to call site (handleTeamTool already cached it).
21
+ const runCwd = locateRunCwd(runId, cwd);
22
+ const manifest = runCwd ? loadRunManifestById(runCwd, runId)?.manifest : undefined;
23
+ return manifest ? ` Full log: ${manifest.eventsPath}` : undefined;
24
+ }
25
+
8
26
  export function registerStatusCommands(pi: ExtensionAPI, deps: RegisterTeamCommandsDeps): void {
9
27
  pi.registerCommand("teams", {
10
28
  description: "List pi-crew teams, workflows, and agents",
@@ -34,7 +52,12 @@ export function registerStatusCommands(pi: ExtensionAPI, deps: RegisterTeamComma
34
52
  getRunSnapshotCache: deps.getRunSnapshotCache,
35
53
  },
36
54
  );
37
- await notifyCommandResult(ctx, commandText(result));
55
+ const text = commandText(result);
56
+ await notifyCommandResult(ctx, text, {
57
+ // W5: only `team-events` output is a truncatable on-disk log —
58
+ // point at the full file when the listing gets clipped.
59
+ truncatedFooter: action === "events" ? await eventsLogFooter(runId, text, ctx.cwd) : undefined,
60
+ });
38
61
  },
39
62
  });
40
63
  }
@@ -58,6 +58,7 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
58
58
  autoRepairTimer: undefined,
59
59
  tempReconcileTimer: undefined,
60
60
  otlpExporter: undefined,
61
+ initPromise: undefined,
61
62
  } as ObservabilityState,
62
63
  lifecycleState: {
63
64
  notifierStarted: false,
@@ -121,7 +122,12 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
121
122
  };
122
123
 
123
124
  ctx.getManifestCache = (cwd: string) => {
124
- if (ctx.manifestCache && ctx.cacheCwd === cwd) return ctx.manifestCache;
125
+ // F13 (RR-018): a disposed cache pair must be recreated EVEN WHEN the cwd
126
+ // is unchanged. Session cleanup disposes the caches but used to leave
127
+ // `cacheCwd` pointing at them, so same-cwd access returned the disposed
128
+ // instance (no disposed flag existed). isDisposed() makes the pair's
129
+ // liveness part of the swap check.
130
+ if (ctx.manifestCache && ctx.cacheCwd === cwd && !ctx.runSnapshotCache.isDisposed()) return ctx.manifestCache;
125
131
  if (ctx.manifestCache) ctx.manifestCache.dispose();
126
132
  if (ctx.runSnapshotCache) ctx.runSnapshotCache.dispose?.();
127
133
  ctx.cacheCwd = cwd;
@@ -130,7 +136,7 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
130
136
  return ctx.manifestCache;
131
137
  };
132
138
  ctx.getRunSnapshotCache = (cwd: string) => {
133
- if (ctx.cacheCwd !== cwd) ctx.getManifestCache(cwd);
139
+ if (ctx.cacheCwd !== cwd || ctx.runSnapshotCache.isDisposed()) ctx.getManifestCache(cwd);
134
140
  return ctx.runSnapshotCache;
135
141
  };
136
142
 
@@ -103,7 +103,7 @@ function startForegroundRunImpl(
103
103
  extensionCtx.ui.setWorkingMessage(runId ? `pi-crew foreground run ${runId}...` : "pi-crew foreground run...");
104
104
  }
105
105
  if (runId) {
106
- void import("../../runtime/foreground-watchdog.ts")
106
+ void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
107
107
  .then(({ startForegroundWatchdog }) => {
108
108
  startForegroundWatchdog({ pi, cwd: extensionCtx.cwd, runId });
109
109
  })
@@ -139,7 +139,7 @@ function startForegroundRunImpl(
139
139
  .finally(() => {
140
140
  ctx.foregroundTeamRunControllers.delete(key);
141
141
  if (runId) {
142
- void import("../../runtime/foreground-watchdog.ts")
142
+ void import("../../runtime/foreground-watchdog.ts") // LAZY: defer watchdog module until first foreground run
143
143
  .then(({ stopWatchdog }) => {
144
144
  stopWatchdog(runId);
145
145
  })
@@ -0,0 +1,100 @@
1
+ /**
2
+ * health-notify-policy.ts — FINDING 6 (2026-09-23 battery): ambient
3
+ * notification re-delivery storms.
4
+ *
5
+ * Observed live: a zombie-run leak made the health monitor re-fire the same
6
+ * `recovery_missing_heartbeat` notification every cooldown window (5 min) for
7
+ * hours. Each fire queued a follow-up message on the host session while the
8
+ * parent was busy; the host drains follow-ups ONE PER TURN BOUNDARY
9
+ * (followUpMode "one-at-a-time"), so the queued duplicates kept dripping into
10
+ * the parent conversation for ~5 hours AFTER the runs were deleted and the
11
+ * monitor had written its `clear` entries.
12
+ *
13
+ * Policy fixes (both sides of the storm):
14
+ * 1. BOUNDED RE-FIRE — a given notification key may fire at most
15
+ * MAX_HEALTH_NOTIFY_FIRES times unless the underlying fingerprint changes
16
+ * (a different dead/missing/task-count shape is a NEW situation and gets a
17
+ * fresh budget). Previously the 5-min cooldown re-armed forever.
18
+ * 2. RESET-ON-CLEAR — the clear path resets the fire budget, so a genuine
19
+ * recurrence after a resolved incident notifies again (up to the cap).
20
+ *
21
+ * The host-queue purge of already-queued copies is handled separately
22
+ * (purgeQueuedAmbientNotifications in subagent-helpers.ts) — this module only
23
+ * stops pi-crew from FEEDING the queue.
24
+ */
25
+
26
+ /** Maximum notifications emitted per key before a fingerprint change. */
27
+ export const MAX_HEALTH_NOTIFY_FIRES = 3;
28
+
29
+ export interface AutoRecoveryEntry {
30
+ insertedAt: number;
31
+ lastAccessAt: number;
32
+ /** Times this key has fired under the current fingerprint. */
33
+ fires?: number;
34
+ /** Shape of the situation last notified (see healthNotifyFingerprint). */
35
+ fingerprint?: string;
36
+ }
37
+
38
+ export interface HealthNotifyState {
39
+ /** Map keyed `${kind}_${runId}` — typically ctx.autoRecoveryLast. */
40
+ entries: Map<string, AutoRecoveryEntry>;
41
+ /** LRU cap — typically ctx.AUTO_RECOVERY_LAST_MAX_ENTRIES. */
42
+ maxEntries: number;
43
+ }
44
+
45
+ /** Compact fingerprint of the health situation: same counts = same situation. */
46
+ export function healthNotifyFingerprint(summary: { dead: number; missing: number }, totalTasks: number): string {
47
+ return `${summary.dead}/${summary.missing}/${totalTasks}`;
48
+ }
49
+
50
+ /**
51
+ * Decide whether a health notification may fire now, and record the decision.
52
+ *
53
+ * Fires when: (a) never fired before, (b) cooldown elapsed AND the same
54
+ * fingerprint still has budget, or (c) the fingerprint CHANGED (new situation
55
+ * — fresh budget). Blocks within the cooldown window and after the budget is
56
+ * exhausted. Evicts oldest-access entries at the cap (LRU, mirrors the
57
+ * previous inline behavior).
58
+ */
59
+ export function recordHealthNotifyDecision(
60
+ state: HealthNotifyState,
61
+ key: string,
62
+ fingerprint: string,
63
+ now: number,
64
+ cooldownMs = 5 * 60_000,
65
+ maxFires = MAX_HEALTH_NOTIFY_FIRES,
66
+ ): boolean {
67
+ const previous = state.entries.get(key);
68
+ if (previous !== undefined && now - previous.lastAccessAt < cooldownMs) return false;
69
+ if (previous !== undefined && previous.fires !== undefined && previous.fires >= maxFires && previous.fingerprint === fingerprint) {
70
+ // Budget exhausted for an unchanged situation. Still refresh access so
71
+ // LRU ordering reflects interest in this key.
72
+ previous.lastAccessAt = now;
73
+ return false;
74
+ }
75
+ while (state.entries.size >= state.maxEntries) {
76
+ let oldestKey: string | undefined;
77
+ let oldestAccess = Infinity;
78
+ for (const [k, v] of state.entries) {
79
+ if (v.lastAccessAt < oldestAccess) {
80
+ oldestAccess = v.lastAccessAt;
81
+ oldestKey = k;
82
+ }
83
+ }
84
+ if (oldestKey === undefined) break;
85
+ state.entries.delete(oldestKey);
86
+ }
87
+ const fires = previous?.fingerprint === fingerprint ? (previous.fires ?? 0) + 1 : 1;
88
+ state.entries.set(key, {
89
+ insertedAt: previous?.insertedAt ?? now,
90
+ lastAccessAt: now,
91
+ fires,
92
+ fingerprint,
93
+ });
94
+ return true;
95
+ }
96
+
97
+ /** Reset a key's fire budget (clear path: resolved incident gets fresh budget). */
98
+ export function resetHealthNotifyEntry(state: HealthNotifyState, key: string): void {
99
+ state.entries.delete(key);
100
+ }
@@ -24,6 +24,7 @@ import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts"
24
24
  import { reconcileOrphanedTempWorkspaces } from "../../runtime/stale-reconciler.ts";
25
25
  import { requestPowerbarUpdate } from "../../ui/powerbar-publisher.ts";
26
26
  import { logInternalError } from "../../utils/internal-error.ts";
27
+ import { extractSessionId } from "../../utils/session-utils.ts";
27
28
  import type { RegistrationContext } from "./registration-types.ts";
28
29
  import { sendAgentWakeUp, sendFollowUp } from "./subagent-helpers.ts";
29
30
 
@@ -48,6 +49,35 @@ export function installLazyConfigurers(pi: ExtensionAPI, ctx: RegistrationContex
48
49
  ctx.configureDeliveryCoordinator = (): void => {
49
50
  void configureDeliveryCoordinatorImpl(pi, ctx);
50
51
  };
52
+ installTurnReconcileHook(pi, ctx);
53
+ }
54
+
55
+ /**
56
+ * F12 (RR-018): register the before_agent_start reconcile hook EXACTLY ONCE
57
+ * for the extension lifetime. The callback resolves the CURRENT session
58
+ * (`ctx.currentCtx`) at fire time instead of capturing the configuring
59
+ * session's ExtensionContext, so a stale session's hook can never re-activate,
60
+ * double-reconcile a turn, or flip the shared manifest-cache cwd back to an
61
+ * old session's directory. (There is no pi.off unregister surface, so the old
62
+ * per-session registration accumulated one dead hook per session switch.)
63
+ */
64
+ function installTurnReconcileHook(pi: ExtensionAPI, ctx: RegistrationContext): void {
65
+ try {
66
+ pi.on?.("before_agent_start", () => {
67
+ const current = ctx.currentCtx;
68
+ if (!current || ctx.cleanedUp) return;
69
+ try {
70
+ // RR-021 WI-1.5: async reconcile — fire-and-forget from the sync hook.
71
+ void reconcileAllStaleRuns(current.cwd, ctx.getManifestCache(current.cwd), undefined, extractSessionId(current)).catch(
72
+ (error) => logInternalError("register.autoRepair.turnHook", error),
73
+ );
74
+ } catch (error) {
75
+ logInternalError("register.autoRepair.turnHook", error);
76
+ }
77
+ });
78
+ } catch {
79
+ /* older Pi without before_agent_start — rely on the interval alone */
80
+ }
51
81
  }
52
82
 
53
83
  /**
@@ -58,6 +88,9 @@ export function installLazyConfigurers(pi: ExtensionAPI, ctx: RegistrationContex
58
88
  * `ctx.currentCtx` and caches.
59
89
  */
60
90
  async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationContext, extCtx: ExtensionContext): Promise<void> {
91
+ // F11: capture the owning generation BEFORE the first await — the deps
92
+ // closure below re-checks it after lifecycle.ts's lazy imports resolve.
93
+ const ownerGeneration = ctx.sessionGeneration;
61
94
  try {
62
95
  // LAZY: registration/lifecycle is heavy (notification-router + sink)
63
96
  const lifecycleModule = await import("./lifecycle.ts");
@@ -68,6 +101,7 @@ async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationCon
68
101
  getManifestCache: ctx.getManifestCache,
69
102
  getRunSnapshotCache: ctx.getRunSnapshotCache,
70
103
  requestPowerbarUpdate,
104
+ isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
71
105
  });
72
106
  } catch (error) {
73
107
  logInternalError("register.configureNotifications", error);
@@ -90,6 +124,8 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
90
124
  getManifestCache: ctx.getManifestCache,
91
125
  notifyOperator: ctx.notifyOperator,
92
126
  isCleanedUp: () => ctx.cleanedUp,
127
+ // F11: reuse the existing sessionGeneration counter — never a parallel one.
128
+ getSessionGeneration: () => ctx.sessionGeneration,
93
129
  reconcileStaleRuns: (cwd, cache, currentSessionId) => reconcileAllStaleRuns(cwd, cache, undefined, currentSessionId),
94
130
  reconcileOrphanedTempWorkspaces: (now, opts) => reconcileOrphanedTempWorkspaces(now, opts),
95
131
  cleanupOrphanTempDirs,
@@ -108,6 +144,7 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
108
144
  * No dynamic import needed — lifecycle is already loaded by configureNotifications.
109
145
  */
110
146
  async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: RegistrationContext): Promise<void> {
147
+ const ownerGeneration = ctx.sessionGeneration;
111
148
  try {
112
149
  // LAZY: lifecycle.ts may not be loaded yet if configureNotifications hasn't fired.
113
150
  const lifecycleModule = await import("./lifecycle.ts");
@@ -117,6 +154,7 @@ async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: Registrat
117
154
  notifyOperator: ctx.notifyOperator,
118
155
  sendFollowUp,
119
156
  sendAgentWakeUp,
157
+ isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
120
158
  });
121
159
  } catch (error) {
122
160
  logInternalError("register.configureDeliveryCoordinator", error);