pi-crew 0.11.0 → 0.11.2

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 (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  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 +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -105,12 +105,22 @@ export function registerDashboardCommands(pi: ExtensionAPI, deps: RegisterTeamCo
105
105
  pi.registerCommand("team-dashboard", {
106
106
  description: "Open a pi-crew run dashboard overlay",
107
107
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
108
+ // W4 (slash-commands fix spec): headless invocation previously fell
109
+ // through to openTeamDashboard's silent `if (!ctx.hasUI) return;`
110
+ // no-op — notify + return here instead so headless users get an
111
+ // actionable pointer instead of silence.
112
+ if (!ctx.hasUI) {
113
+ ctx.ui.notify("team-dashboard needs a UI session — headless runs can use /team-status instead.", "info");
114
+ return;
115
+ }
108
116
  await openTeamDashboard(ctx);
109
117
  },
110
118
  });
111
119
 
112
120
  pi.registerCommand("team-mascot", {
113
- description: "Show an animated mascot splash",
121
+ // W4: cosmetic command is a documented silent no-op without a UI
122
+ // session (handler's `if (!ctx.hasUI) return;` stays).
123
+ description: "Show an animated mascot splash (UI session only)",
114
124
  handler: async (args: string, ctx: ExtensionCommandContext) => {
115
125
  if (!ctx.hasUI) return;
116
126
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -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 {
@@ -278,22 +294,23 @@ export function registerManageCommands(pi: ExtensionAPI, deps: RegisterTeamComma
278
294
  });
279
295
 
280
296
  pi.registerCommand("crew-brief", {
281
- description: "Toggle brief tool output mode: on | off | status",
297
+ description:
298
+ "Toggle brief mode for crew `team`/`agent` tool output (native read/bash/edit/write/find/grep/ls output is unaffected): on | off | status",
282
299
  handler: async (args: string, ctx: ExtensionCommandContext) => {
283
300
  // LAZY: defer dynamic import of ../../ui/tool-renderers/brief-mode.ts to its call site. Multi-line form breaks scripts/check-lazy-imports.mjs (which does `lines[lineNum - 2]`), so keep destructuring + await import on one line and place this LAZY marker directly above.
284
- const { isBrief, setBrief, BRIEF_ENTRY_TYPE, makeBriefEntry } = await import("../../../ui/tool-renderers/brief-mode.ts");
301
+ const { isBrief, setBrief, BRIEF_ENTRY_TYPE } = await import("../../../ui/tool-renderers/brief-mode.ts");
285
302
  const trimmed = args.trim();
286
303
 
287
304
  if (trimmed === "on") {
288
305
  setBrief(true);
289
306
  pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: true });
290
- ctx.ui.notify("Brief mode: on — tool output will show compact summaries", "info");
307
+ ctx.ui.notify("Brief mode: on — crew team/agent tool output will show compact summaries", "info");
291
308
  return;
292
309
  }
293
310
  if (trimmed === "off") {
294
311
  setBrief(false);
295
312
  pi.appendEntry(BRIEF_ENTRY_TYPE, { enabled: false });
296
- ctx.ui.notify("Brief mode: off — full tool output restored", "info");
313
+ ctx.ui.notify("Brief mode: off — full crew team/agent tool output restored", "info");
297
314
  return;
298
315
  }
299
316
  // status (default)
@@ -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
 
@@ -531,7 +531,6 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
531
531
  const selection = await cmdCtx.ui.custom<RunDashboardSelection | undefined>(
532
532
  (tui, theme, _keybindings, done) =>
533
533
  new RunDashboard(runs, done, theme, {
534
- placement: rightPanel ? "right" : "center",
535
534
  showModel: uiConfig?.showModel,
536
535
  showTokens: uiConfig?.showTokens,
537
536
  showTools: uiConfig?.showTools,
@@ -577,6 +576,29 @@ export async function openTeamDashboard(ctx: ExtensionContext): Promise<void> {
577
576
  deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
578
577
  continue;
579
578
  }
579
+ // US-020: dashboard `x` cancel — dispatches the EXISTING cancel channel
580
+ // (control-domain handleCancel). The 2-step dashboard gate is the user's
581
+ // explicit intent; `intent` satisfies requireIntentForDestructiveActions
582
+ // when that policy is on. Dashboard reopens on continue (selection kept).
583
+ if (selection.action === "cancel" && selection.runId) {
584
+ // F4 pattern: surface backend rejections at error level, truncate long text.
585
+ const cancelResult = await handleTeamTool(
586
+ {
587
+ action: "cancel",
588
+ runId: selection.runId,
589
+ config: { intent: "dashboard cancel keystroke (2-step confirm)" },
590
+ },
591
+ teamCommandContext(cmdCtx),
592
+ );
593
+ const cancelText = commandText(cancelResult);
594
+ depsNotify(
595
+ cmdCtx,
596
+ cancelText.length > 800 ? `${cancelText.slice(0, 797)}...` : cancelText,
597
+ cancelResult.isError ? "error" : "info",
598
+ );
599
+ deps.getRunSnapshotCache?.(cmdCtx.cwd).invalidate(selection.runId);
600
+ continue;
601
+ }
580
602
  if (selection.action === "plan-approve" || selection.action === "plan-deny") {
581
603
  await handlePlanDashboardAction(cmdCtx, selection);
582
604
  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
 
@@ -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,32 @@ 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
+ reconcileAllStaleRuns(current.cwd, ctx.getManifestCache(current.cwd), undefined, extractSessionId(current));
71
+ } catch (error) {
72
+ logInternalError("register.autoRepair.turnHook", error);
73
+ }
74
+ });
75
+ } catch {
76
+ /* older Pi without before_agent_start — rely on the interval alone */
77
+ }
51
78
  }
52
79
 
53
80
  /**
@@ -58,6 +85,9 @@ export function installLazyConfigurers(pi: ExtensionAPI, ctx: RegistrationContex
58
85
  * `ctx.currentCtx` and caches.
59
86
  */
60
87
  async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationContext, extCtx: ExtensionContext): Promise<void> {
88
+ // F11: capture the owning generation BEFORE the first await — the deps
89
+ // closure below re-checks it after lifecycle.ts's lazy imports resolve.
90
+ const ownerGeneration = ctx.sessionGeneration;
61
91
  try {
62
92
  // LAZY: registration/lifecycle is heavy (notification-router + sink)
63
93
  const lifecycleModule = await import("./lifecycle.ts");
@@ -68,6 +98,7 @@ async function configureNotificationsImpl(pi: ExtensionAPI, ctx: RegistrationCon
68
98
  getManifestCache: ctx.getManifestCache,
69
99
  getRunSnapshotCache: ctx.getRunSnapshotCache,
70
100
  requestPowerbarUpdate,
101
+ isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
71
102
  });
72
103
  } catch (error) {
73
104
  logInternalError("register.configureNotifications", error);
@@ -90,6 +121,8 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
90
121
  getManifestCache: ctx.getManifestCache,
91
122
  notifyOperator: ctx.notifyOperator,
92
123
  isCleanedUp: () => ctx.cleanedUp,
124
+ // F11: reuse the existing sessionGeneration counter — never a parallel one.
125
+ getSessionGeneration: () => ctx.sessionGeneration,
93
126
  reconcileStaleRuns: (cwd, cache, currentSessionId) => reconcileAllStaleRuns(cwd, cache, undefined, currentSessionId),
94
127
  reconcileOrphanedTempWorkspaces: (now, opts) => reconcileOrphanedTempWorkspaces(now, opts),
95
128
  cleanupOrphanTempDirs,
@@ -108,6 +141,7 @@ async function configureObservabilityImpl(pi: ExtensionAPI, ctx: RegistrationCon
108
141
  * No dynamic import needed — lifecycle is already loaded by configureNotifications.
109
142
  */
110
143
  async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: RegistrationContext): Promise<void> {
144
+ const ownerGeneration = ctx.sessionGeneration;
111
145
  try {
112
146
  // LAZY: lifecycle.ts may not be loaded yet if configureNotifications hasn't fired.
113
147
  const lifecycleModule = await import("./lifecycle.ts");
@@ -117,6 +151,7 @@ async function configureDeliveryCoordinatorImpl(pi: ExtensionAPI, ctx: Registrat
117
151
  notifyOperator: ctx.notifyOperator,
118
152
  sendFollowUp,
119
153
  sendAgentWakeUp,
154
+ isOwnerSession: () => !ctx.cleanedUp && ctx.sessionGeneration === ownerGeneration,
120
155
  });
121
156
  } catch (error) {
122
157
  logInternalError("register.configureDeliveryCoordinator", error);