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
@@ -0,0 +1,63 @@
1
+ /**
2
+ * DP-04 (2026-09-22): small-goal routing hint.
3
+ *
4
+ * Measured: a trivial goal through the default team burns 7.6–9k tokens just on
5
+ * the 3 context-setup steps (assess → execute → verify). The engine ALREADY
6
+ * supports `singleAgent: true` (handlePlan → composeSingleAgentPrompt); nothing
7
+ * suggested it for small goals.
8
+ *
9
+ * This module decides the hint. It is deliberately PURE and explainable — no
10
+ * ML, no hidden state — so the routing decision can be asserted in tests and
11
+ * audited from the plan output. Default mode is "suggest" (visible nudge, the
12
+ * caller/agent still decides), NOT a silent switch: this project has been
13
+ * burned by silent routing changes before (the auto-boomerang aliasing trap),
14
+ * so the decision must be visible.
15
+ */
16
+
17
+ export type SmallGoalRoutingMode = "off" | "suggest" | "auto";
18
+
19
+ /** Goals at or below this many whitespace tokens are considered "small". */
20
+ export const SMALL_GOAL_TOKEN_THRESHOLD = 40;
21
+
22
+ export interface RoutingHintInput {
23
+ goal: string;
24
+ /** True when the caller supplied an explicit team and/or workflow override. */
25
+ explicitOverride: boolean;
26
+ /** Configured mode; defaults to "suggest". */
27
+ mode?: SmallGoalRoutingMode;
28
+ }
29
+
30
+ export interface RoutingHint {
31
+ /** Whether a cheaper single-agent composition is advised. */
32
+ suggested: boolean;
33
+ /** Machine-readable reason (empty when not suggested). */
34
+ reason?: string;
35
+ /** Human-readable, shown in the plan output. */
36
+ message?: string;
37
+ /** Rough token saving estimate for the plan text (informational). */
38
+ estimatedSavings?: string;
39
+ }
40
+
41
+ /** Cheap, deterministic token count (whitespace split) — no tokenizer dep. */
42
+ export function goalTokenCount(goal: string): number {
43
+ const trimmed = goal.trim();
44
+ if (trimmed.length === 0) return 0;
45
+ return trimmed.split(/\s+/).length;
46
+ }
47
+
48
+ export function resolveRoutingHint(input: RoutingHintInput): RoutingHint {
49
+ const mode = input.mode ?? "suggest";
50
+ if (mode === "off") return { suggested: false };
51
+ // Never second-guess an explicit team/workflow choice.
52
+ if (input.explicitOverride) return { suggested: false };
53
+ const tokens = goalTokenCount(input.goal);
54
+ if (tokens === 0 || tokens > SMALL_GOAL_TOKEN_THRESHOLD) return { suggested: false };
55
+ return {
56
+ suggested: true,
57
+ reason: `goal is small (${tokens} tokens ≤ ${SMALL_GOAL_TOKEN_THRESHOLD})`,
58
+ message:
59
+ `Hint: this goal is small (${tokens} tokens). The default team runs 3 context-setup phases ` +
60
+ `(~8k tokens overhead) before real work — consider \`singleAgent: true\` (or a 2-step chain) to skip them.`,
61
+ estimatedSavings: "~5-6k tokens",
62
+ };
63
+ }
@@ -2,6 +2,7 @@ import { loadConfig } from "../../config/config.ts";
2
2
  import { applyAttentionState, formatActivityAge, resolveCrewControlConfig } from "../../runtime/agent-control.ts";
3
3
  import { extractCommandTrace } from "../../runtime/command-trace.ts";
4
4
  import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
5
+ import { deadletterStatusLine } from "../../runtime/deadletter.ts";
5
6
  import { evaluateRunEffectiveness } from "../../runtime/effectiveness.ts";
6
7
  import { computePhaseProgress } from "../../runtime/phase-progress.ts";
7
8
  import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
@@ -150,6 +151,7 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
150
151
  }
151
152
  const counts = new Map<string, number>();
152
153
  for (const task of tasks) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
154
+ const deadletterLine = deadletterStatusLine(manifest);
153
155
  const phaseProgress = computePhaseProgress(tasks);
154
156
  // PERF (2026-08-24): intentionally NOT passing `limit` here — readEventsCursor's
155
157
  // limit is a HEAD cap (oldest-first slice for streaming pagination), not a tail
@@ -267,6 +269,8 @@ export function handleStatus(params: TeamToolParamsValue, ctx: TeamContext): PiT
267
269
  )
268
270
  : ["- (none)"]),
269
271
  `Task counts: ${[...counts.entries()].map(([status, count]) => `${status}=${count}`).join(", ") || "none"}`,
272
+ // US-003 AC-3: surface exhausted-retry failures — only when entries exist.
273
+ ...(deadletterLine ? [deadletterLine] : []),
270
274
  "Effectiveness:",
271
275
  `- observable=${effectiveness.observable}/${Math.max(1, effectiveness.completed)} completed tasks`,
272
276
  `- workerExecution=${effectiveness.workerExecution} guard=${effectiveness.guardMode} severity=${effectiveness.severity}`,
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { AgentConfig } from "../agents/agent-config.ts";
4
4
  import { allAgents, discoverAgents, listDynamicAgents, registerDynamicAgent, unregisterDynamicAgent } from "../agents/discover-agents.ts";
5
5
  import { loadConfig } from "../config/config.ts";
6
+ import { DEFAULT_PATHS } from "../config/defaults.ts";
6
7
  import { getCrewEnv } from "../config/env-vars.ts";
7
8
  // Heavy runtime — lazy-loaded to avoid 1.4s import cost at extension registration.
8
9
  // executeTeamRun is only called when a team run actually executes.
@@ -17,6 +18,7 @@ import { loadRunManifestById, saveRunManifestAsync, saveRunTasks, updateRunStatu
17
18
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
18
19
  import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
19
20
  import { logInternalError } from "../utils/internal-error.ts";
21
+ import { findRepoRoot, projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
20
22
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
21
23
  import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
22
24
  import { listRecentRuns } from "./run-index.ts";
@@ -191,9 +193,9 @@ function artifactKey(artifact: ArtifactDescriptor): string {
191
193
  * passes) but handlers expect real numbers (TeamToolParamsValue types them as
192
194
  * number). This converts the string form back to a number — and treats "" as
193
195
  * unset (deleted) — before any domain router reads the param. */
194
- const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline"] as const;
196
+ const LOOSE_NUMERIC_PARAM_KEYS = ["interval", "budgetWarning", "budgetAbort", "tokenBudget", "replyDeadline", "budgetTotal"] as const;
195
197
 
196
- function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
198
+ export function normalizeLooseNumericFields(params: TeamToolParamsValue): TeamToolParamsValue {
197
199
  let mutated = false;
198
200
  const out: Record<string, unknown> = { ...params };
199
201
  for (const key of LOOSE_NUMERIC_PARAM_KEYS) {
@@ -400,7 +402,10 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
400
402
  data: { runtimeResolution, action: "resume" },
401
403
  });
402
404
  if (runtime.safety === "blocked") {
403
- const runningManifest = updateRunStatus(runtimeManifest, "running", "Checking worker runtime availability before resume.");
405
+ const runningManifest = updateRunStatus(runtimeManifest, "running", "Checking worker runtime availability before resume.", {
406
+ // Resume is the ONE legitimate terminal-exit flow (finding 8 write guard).
407
+ allowTerminalExit: true,
408
+ });
404
409
  const blocked = updateRunStatus(
405
410
  runningManifest,
406
411
  "blocked",
@@ -511,6 +516,8 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
511
516
  reliability: decision.executedConfig.reliability,
512
517
  metricRegistry: ctx.metricRegistry,
513
518
  workspaceId: ctx.sessionId ?? ctx.cwd,
519
+ // Finding 8: resume is the legitimate terminal-exit flow.
520
+ isResume: true,
514
521
  });
515
522
  return result(
516
523
  [
@@ -652,9 +659,48 @@ export function locateRunCwd(runId: string, baseCwd: string): string | undefined
652
659
  * - Skips hidden entries (starting with `.`) unless they look like run directories
653
660
  * (e.g. .crew, .pi, .tmp-crew-runs)
654
661
  */
662
+ /**
663
+ * F-L1 companion (2026-09-23 correction): a hit counts when the resolved
664
+ * stateRoot sits under EITHER root that `list` unions (run-index
665
+ * `scopedRunRoots` = userCrewRoot + projectCrewRoot), because this function
666
+ * answers "which cwd can read this run's state" — not "was it created here".
667
+ *
668
+ * History: the first F-L1 fix (5d3a3d0f) restricted hits to the candidate
669
+ * cwd's PRIMARY root. That kept `locateRunCwd` from claiming a run created in
670
+ * a sibling, but it also made every by-ID handler (status/events/summary/
671
+ * artifacts/worktrees/api/plans/respond/cancel) reject runs that `list` shows
672
+ * from the user root: `list` unions both roots, `loadRunManifestById` resolves
673
+ * both roots, yet `locateRunCwd` returned undefined → "Run not found" for
674
+ * runs the dashboard had just listed. Measured live: 10/10 user-root runs
675
+ * listed by `action='list'` failed `action='status'`.
676
+ *
677
+ * Sibling isolation is preserved WITHOUT the primary-only restriction: a run
678
+ * living under a sibling's PROJECT root (…/sibling/.crew/state/runs/<id>) is
679
+ * not under the candidate cwd's project root nor the user root, so it still
680
+ * does not match (pinned by locate-run-cwd.test.ts "sibling directory").
681
+ */
682
+ function runUnderListedRoot(cwd: string, runId: string): boolean {
683
+ const loaded = loadRunManifestById(cwd, runId);
684
+ if (!loaded) return false;
685
+ const roots = findRepoRoot(cwd) ? [projectCrewRoot(cwd), userCrewRoot()] : [userCrewRoot()];
686
+ return roots.some((root) => loaded.manifest.stateRoot === path.join(root, DEFAULT_PATHS.state.runsSubdir, runId));
687
+ }
688
+
689
+ /**
690
+ * Scan-path variant: a CHILD directory may claim the run only when the run's
691
+ * state actually lives under that child's OWN crew root (the nested-child
692
+ * case this scan exists for). Without this, a shared user-root run would be
693
+ * "claimed" by whichever child happened to be scanned first.
694
+ */
695
+ function runUnderOwnRoot(cwd: string, runId: string): boolean {
696
+ const loaded = loadRunManifestById(cwd, runId);
697
+ if (!loaded) return false;
698
+ return loaded.manifest.stateRoot === path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.runsSubdir, runId);
699
+ }
700
+
655
701
  export function locateRunCwdUncached(runId: string, baseCwd: string): string | undefined {
656
- // Fast path: run is in the current CWD
657
- if (loadRunManifestById(baseCwd, runId)) {
702
+ // Fast path: run resolves under one of the roots `list` shows (see helper note).
703
+ if (runUnderListedRoot(baseCwd, runId)) {
658
704
  return baseCwd;
659
705
  }
660
706
 
@@ -670,7 +716,7 @@ export function locateRunCwdUncached(runId: string, baseCwd: string): string | u
670
716
  if (!entry.name.startsWith(".crew") && !entry.name.startsWith(".pi") && !entry.name.startsWith(".tmp-crew")) continue;
671
717
  }
672
718
  const candidate = path.join(baseCwd, entry.name);
673
- if (loadRunManifestById(candidate, runId)) {
719
+ if (runUnderOwnRoot(candidate, runId)) {
674
720
  return candidate;
675
721
  }
676
722
  }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * US-030 (docs/specs/US-030.md) — outbound webhook notifications on run
3
+ * terminal transitions (completed/failed/cancelled).
4
+ *
5
+ * Pattern: bounded sibling of notification-router.ts / schedule-toast-bridge.ts
6
+ * — an opt-in, fire-and-forget sink. The terminal TRANSITION itself is
7
+ * observed by the async-run notifier (src/extension/async-notifier.ts, the
8
+ * existing poller that also toasts run completion for background runs);
9
+ * this module only formats + delivers. No new polling loop was introduced.
10
+ *
11
+ * Safety model (spec "SECURITY-SENSITIVE"):
12
+ * - Disabled by default: no `notifications.webhook.url` (or `enabled:false`)
13
+ * → the factory returns a shared no-op singleton — ZERO network calls and
14
+ * zero allocation on the hot path (notifyTerminalRun is an empty fn).
15
+ * - SSRF guard: URL must be http(s) and must not target localhost /
16
+ * 127.0.0.0/8 / 0.0.0.0 / [::1] / :: / fe80::/10 / 169.254.0.0/16 unless
17
+ * `allowLocalhost: true` is set explicitly (checked once at creation —
18
+ * fail closed). Hostname-LITERAL checks only (no DNS resolution): a DNS
19
+ * name that resolves to loopback is not detected — documented limitation.
20
+ * - PII-safe payload: event/runId/status/team/goal FIRST LINE/durationMs/
21
+ * cost/tokens/at only. NO transcripts, events, task bodies, or summaries.
22
+ * - The config block is `sensitive: true` in the schema, so project-level
23
+ * config drops it (user config only) — an untrusted repo cannot point
24
+ * pi-crew at an attacker URL.
25
+ * - Delivery never throws into the caller (async-notifier poller / run
26
+ * lifecycle path): every entry point is wrapped; failures land in
27
+ * logInternalError (always-on "warn") + a `crew.webhook.failed` pi event
28
+ * surfaced via deps.onFailure (wired in registration/lifecycle.ts).
29
+ *
30
+ * Delivery: ONE POST (content-type application/json) with a 5 s per-attempt
31
+ * timeout and EXACTLY ONE retry on 5xx/network error, then give up. Uses the
32
+ * Node 22 global fetch — no new dependency.
33
+ */
34
+ import { createHmac } from "node:crypto";
35
+ import type { CrewWebhookConfig } from "../config/types.ts";
36
+ import { loadRunManifestById } from "../state/stores/state-store.ts";
37
+ import type { TeamTaskState } from "../state/types.ts";
38
+ import { logInternalError } from "../utils/internal-error.ts";
39
+ import { isInQuietHours } from "./notification-router.ts";
40
+
41
+ /** Documented payload (spec §Design). Field order matches the spec example. */
42
+ export interface WebhookPayload {
43
+ event: "run.terminal";
44
+ runId: string;
45
+ status: string;
46
+ team: string;
47
+ goal: string;
48
+ durationMs: number;
49
+ cost: number;
50
+ tokens: number;
51
+ at: string;
52
+ }
53
+
54
+ /** Manifest projection the notifier needs — TeamRunManifest satisfies this structurally. */
55
+ export interface WebhookTerminalRun {
56
+ runId: string;
57
+ status: string;
58
+ team: string;
59
+ goal?: string;
60
+ createdAt?: string;
61
+ /** Working dir of the project that owns the run (task loader input). */
62
+ cwd: string;
63
+ }
64
+
65
+ export interface WebhookDeliveryFailure {
66
+ runId: string;
67
+ /** Target URL — carries no secret material (the secret only ever lives in the signature header). */
68
+ url: string;
69
+ attempts: number;
70
+ error: string;
71
+ }
72
+
73
+ export interface WebhookNotifier {
74
+ /** Fire-and-forget terminal notification. NEVER throws. */
75
+ notifyTerminalRun(run: WebhookTerminalRun): void;
76
+ /** Determinism seam: resolves once no delivery is in flight. */
77
+ settled(): Promise<void>;
78
+ /** Stop accepting new deliveries (in-flight ones still settle). */
79
+ dispose(): void;
80
+ }
81
+
82
+ export interface WebhookNotifierDeps {
83
+ /** Fetch implementation (injectable for tests); default = Node 22 global fetch. */
84
+ fetch?: (url: string, init: RequestInit) => Promise<Response>;
85
+ /** Clock for the quiet-hours gate and the payload `at` field; default `() => new Date()`. */
86
+ now?: () => Date;
87
+ /** Task loader for cost/token sums; default reads the run's tasks from disk (best-effort). */
88
+ loadTasks?: (run: WebhookTerminalRun) => TeamTaskState[] | undefined;
89
+ /** Extra failure surface (the wiring emits `crew.webhook.failed` here). logInternalError always fires. */
90
+ onFailure?: (failure: WebhookDeliveryFailure) => void;
91
+ /** Per-attempt timeout. Default 5000 ms (spec "e.g. 5 s"). */
92
+ timeoutMs?: number;
93
+ }
94
+
95
+ export interface WebhookNotifyOptions {
96
+ /** `notifications.webhook` config block (url/enabled/secret/allowLocalhost). */
97
+ webhook?: CrewWebhookConfig;
98
+ /** `notifications.quietHours` — the SAME gate as the TUI router (reuses isInQuietHours — do not duplicate the logic). */
99
+ quietHours?: string;
100
+ }
101
+
102
+ const DEFAULT_TIMEOUT_MS = 5_000;
103
+ /** One attempt + exactly one retry (spec: "ONE retry on 5xx/network error"). */
104
+ const MAX_ATTEMPTS = 2;
105
+
106
+ // ── SSRF guard ─────────────────────────────────────────────────────────────
107
+
108
+ function ipv4Octets(host: string): number[] | undefined {
109
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return undefined;
110
+ const parts = host.split(".").map(Number);
111
+ if (!parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) return undefined;
112
+ return parts;
113
+ }
114
+
115
+ /**
116
+ * True for localhost NAMES and loopback/unspecified/link-local address
117
+ * literals. RFC1918 (10/8, 172.16/12, 192.168/16) is deliberately NOT
118
+ * blocked — internal webhooks are a primary use case (spec blocks only
119
+ * localhost / 127.0.0.1 / [::1] / 169.254.0.0/16).
120
+ */
121
+ function isLocalAddress(hostname: string): boolean {
122
+ const host = hostname.toLowerCase();
123
+ // URL.hostname keeps brackets on IPv6 literals — strip for matching. A
124
+ // trailing dot ("localhost.", FQDN form) also resolves to loopback via
125
+ // getaddrinfo — strip it too (security review finding).
126
+ let bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
127
+ if (bare.endsWith(".")) bare = bare.slice(0, -1);
128
+ if (bare === "localhost" || bare.endsWith(".localhost")) return true;
129
+ // IPv4 literal, incl. IPv4-mapped IPv6 (::ffff:a.b.c.d).
130
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
131
+ const v4 = ipv4Octets(mapped ? mapped[1] : bare);
132
+ if (v4) {
133
+ const [a, b] = v4;
134
+ // 127/8 loopback, 0/8 unspecified, 169.254/16 link-local.
135
+ return a === 127 || a === 0 || (a === 169 && b === 254);
136
+ }
137
+ if (bare === "::1" || bare === "::") return true;
138
+ // IPv6 link-local fe80::/10: first non-empty hextet in 0xfe80..0xfebf.
139
+ const firstHextet = bare.split(":").find((segment) => segment.length > 0);
140
+ if (firstHextet && /^[0-9a-f]{1,4}$/.test(firstHextet)) {
141
+ const value = Number.parseInt(firstHextet, 16);
142
+ return value >= 0xfe80 && value <= 0xfebf;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /** SSRF guard (pure, exported for tests): http(s) only + local-address refusal unless opted in. */
148
+ export function isWebhookUrlAllowed(rawUrl: string, allowLocalhost = false): boolean {
149
+ let parsed: URL;
150
+ try {
151
+ parsed = new URL(rawUrl);
152
+ } catch {
153
+ return false;
154
+ }
155
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
156
+ // Credentials embedded in the URL would leak into logs — refuse them
157
+ // (use the `secret` HMAC field, never userinfo).
158
+ if (parsed.username || parsed.password) return false;
159
+ if (allowLocalhost) return true;
160
+ return !isLocalAddress(parsed.hostname);
161
+ }
162
+
163
+ /** Log-safe URL form: origin only — never path, query, or userinfo. */
164
+ function safeUrlForLog(url: string): string {
165
+ try {
166
+ return new URL(url).origin;
167
+ } catch {
168
+ return "(unparseable-url)";
169
+ }
170
+ }
171
+
172
+ // ── payload ────────────────────────────────────────────────────────────────
173
+
174
+ function sumUsage(tasks: TeamTaskState[] | undefined): { cost: number; tokens: number } {
175
+ let cost = 0;
176
+ let tokens = 0;
177
+ for (const task of tasks ?? []) {
178
+ const usage = task.usage;
179
+ if (!usage) continue;
180
+ cost += usage.cost ?? 0;
181
+ tokens += (usage.input ?? 0) + (usage.output ?? 0);
182
+ }
183
+ // Round the FP cost sum at micro-USD precision — keeps the JSON stable and test-friendly.
184
+ return { cost: Math.round(cost * 1e6) / 1e6, tokens };
185
+ }
186
+
187
+ /** Pure payload builder (exported for tests). PII-safe by construction: only
188
+ * the documented fields; goal is reduced to its FIRST line. */
189
+ export function buildWebhookPayload(run: WebhookTerminalRun, tasks: TeamTaskState[] | undefined, now: Date): WebhookPayload {
190
+ const createdAtMs = run.createdAt ? new Date(run.createdAt).getTime() : Number.NaN;
191
+ const { cost, tokens } = sumUsage(tasks);
192
+ return {
193
+ event: "run.terminal",
194
+ runId: run.runId,
195
+ status: run.status,
196
+ team: run.team,
197
+ // First line, capped at 256 chars: goals of nested runs are
198
+ // agent-composable — an uncapped line is a data-stuffing channel to
199
+ // the configured endpoint (security review finding).
200
+ goal: ((run.goal ?? "").split(/\r?\n/)[0] ?? "").slice(0, 256),
201
+ durationMs: Number.isFinite(createdAtMs) ? Math.max(0, now.getTime() - createdAtMs) : 0,
202
+ cost,
203
+ tokens,
204
+ at: now.toISOString(),
205
+ };
206
+ }
207
+
208
+ // ── notifier ───────────────────────────────────────────────────────────────
209
+
210
+ const DISABLED_NOTIFIER: WebhookNotifier = Object.freeze({
211
+ notifyTerminalRun: () => undefined,
212
+ settled: () => Promise.resolve(),
213
+ dispose: () => undefined,
214
+ });
215
+
216
+ function defaultLoadTasks(run: WebhookTerminalRun): TeamTaskState[] | undefined {
217
+ try {
218
+ return loadRunManifestById(run.cwd, run.runId)?.tasks;
219
+ } catch {
220
+ return undefined;
221
+ }
222
+ }
223
+
224
+ /** Active-config check (pure, exported for tests): url present + not disabled. */
225
+ export function isWebhookActive(config: { webhook?: CrewWebhookConfig }): boolean {
226
+ return Boolean(config.webhook?.url) && config.webhook?.enabled !== false;
227
+ }
228
+
229
+ export function createWebhookNotifier(options: WebhookNotifyOptions, deps: WebhookNotifierDeps = {}): WebhookNotifier {
230
+ const webhook = options.webhook;
231
+ const url = webhook?.url ?? "";
232
+ if (!isWebhookActive(options)) return DISABLED_NOTIFIER;
233
+ if (!isWebhookUrlAllowed(url, webhook?.allowLocalhost === true)) {
234
+ // Fail closed at creation: the notifier stays a no-op — a disallowed
235
+ // target must never receive a request, not even once.
236
+ logInternalError(
237
+ "webhook-notify.config",
238
+ new Error(
239
+ `refusing webhook URL '${safeUrlForLog(url)}' (SSRF guard); set notifications.webhook.allowLocalhost=true to target loopback/link-local explicitly`,
240
+ ),
241
+ undefined,
242
+ "warn",
243
+ );
244
+ return DISABLED_NOTIFIER;
245
+ }
246
+ const secret = webhook?.secret;
247
+ const quietHours = options.quietHours;
248
+ const now = deps.now ?? (() => new Date());
249
+ const fetchImpl = deps.fetch ?? ((input: string, init: RequestInit) => fetch(input, init));
250
+ const loadTasks = deps.loadTasks ?? defaultLoadTasks;
251
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
252
+ const onFailure = deps.onFailure;
253
+ let disposed = false;
254
+ const inflight = new Set<Promise<void>>();
255
+
256
+ async function deliverOnce(body: string): Promise<Response> {
257
+ const headers: Record<string, string> = { "content-type": "application/json" };
258
+ if (secret) {
259
+ // Signature over the EXACT serialized body the receiver sees, so the
260
+ // receiver can verify with hmac_sha256(secret, raw_body).
261
+ headers["x-pi-crew-signature"] = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
262
+ }
263
+ const controller = new AbortController();
264
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
265
+ try {
266
+ // SSRF (security review HIGH): redirect:"follow" (the default) lets
267
+ // undici chase up to 20 hops WITHOUT re-running the guard — a
268
+ // 302 at the allowed host reaches 169.254.169.254 / loopback /
269
+ // internal RFC1918, and a 307 REPLAYS the signed body there. Manual
270
+ // mode surfaces any 3xx as a response we treat as failure below.
271
+ return await fetchImpl(url, {
272
+ method: "POST",
273
+ headers,
274
+ body,
275
+ signal: controller.signal,
276
+ redirect: "manual",
277
+ });
278
+ } finally {
279
+ clearTimeout(timer);
280
+ }
281
+ }
282
+
283
+ async function deliver(body: string, runId: string): Promise<void> {
284
+ let lastError: unknown = new Error("webhook delivery failed");
285
+ let attempts = 0;
286
+ while (attempts < MAX_ATTEMPTS) {
287
+ attempts++;
288
+ try {
289
+ const response = await deliverOnce(body);
290
+ // Always release the body — undici pins the socket until the body
291
+ // is drained/cancelled (default bodyTimeout ~300s), which a
292
+ // slow-trickle endpoint would exploit across fire-and-forget calls.
293
+ // biome-ignore lint/suspicious/noEmptyBlockStatements: intentional fire-and-forget — the cancel() rejection has nothing left to act on.
294
+ void response.body?.cancel?.().catch(() => {});
295
+ if (response.ok) return;
296
+ // 3xx under redirect:"manual" = a redirect we REFUSE to follow
297
+ // (the guard validated the configured URL, not the hop target).
298
+ // Permanent failure — no retry.
299
+ if (response.status >= 300 && response.status < 400) {
300
+ lastError = new Error(`refused redirect HTTP ${response.status} (SSRF guard — redirects are not followed)`);
301
+ break;
302
+ }
303
+ lastError = new Error(`HTTP ${response.status}`);
304
+ // Retry ONLY on 5xx (and only once). 4xx is permanent — fail fast.
305
+ if (response.status >= 500 && attempts < MAX_ATTEMPTS) continue;
306
+ break;
307
+ } catch (error) {
308
+ // Network error / abort-timeout → exactly one retry.
309
+ lastError = error;
310
+ }
311
+ }
312
+ const failure: WebhookDeliveryFailure = {
313
+ runId,
314
+ // Origin only: the raw URL (path/query/userinfo) must never reach
315
+ // logs or the pi event stream (security review finding).
316
+ url: safeUrlForLog(url),
317
+ attempts,
318
+ error: lastError instanceof Error ? lastError.message : String(lastError),
319
+ };
320
+ logInternalError(
321
+ "webhook-notify.delivery",
322
+ lastError instanceof Error ? lastError : new Error(String(lastError)),
323
+ `runId=${runId} url=${failure.url} attempts=${failure.attempts}`,
324
+ "warn",
325
+ );
326
+ try {
327
+ onFailure?.(failure);
328
+ } catch (callbackError) {
329
+ logInternalError("webhook-notify.onFailure", callbackError, runId);
330
+ }
331
+ }
332
+
333
+ return {
334
+ notifyTerminalRun(run) {
335
+ // NEVER throws into the caller (async-notifier poller → run lifecycle).
336
+ try {
337
+ if (disposed) return;
338
+ // Quiet-hours gate reuses the router's isInQuietHours — the SAME
339
+ // suppression window as the TUI notification path. A pattern-valid
340
+ // but clock-invalid value ("99:99-00:00" passes the schema regex)
341
+ // throws in parseHHMMRange; suppression is a nicety, not a gate —
342
+ // deliver anyway and log once (security review finding: the throw
343
+ // previously silenced every delivery with only a console line).
344
+ if (quietHours) {
345
+ try {
346
+ if (isInQuietHours(quietHours, now())) return;
347
+ } catch (quietError) {
348
+ logInternalError(
349
+ "webhook-notify.quiet-hours",
350
+ quietError instanceof Error ? quietError : new Error(String(quietError)),
351
+ `invalid quietHours='${quietHours}' — delivering anyway`,
352
+ "warn",
353
+ );
354
+ }
355
+ }
356
+ const tasks = loadTasks(run);
357
+ const payload = buildWebhookPayload(run, tasks, now());
358
+ const body = JSON.stringify(payload);
359
+ const tracked = deliver(body, run.runId).then(
360
+ () => undefined,
361
+ (error) => {
362
+ // deliver() is designed to never reject; belt-only.
363
+ logInternalError("webhook-notify.unexpected", error, run.runId, "warn");
364
+ },
365
+ );
366
+ inflight.add(tracked);
367
+ void tracked.then(() => {
368
+ inflight.delete(tracked);
369
+ });
370
+ } catch (error) {
371
+ logInternalError("webhook-notify.terminal", error, run.runId);
372
+ }
373
+ },
374
+ async settled() {
375
+ // Loop: new deliveries may start while awaiting (only relevant in tests).
376
+ while (inflight.size > 0) await Promise.all([...inflight]);
377
+ },
378
+ dispose() {
379
+ disposed = true;
380
+ },
381
+ };
382
+ }
@@ -56,13 +56,23 @@ export function createMetricFileSink(opts: MetricFileSinkOptions): MetricSink {
56
56
  };
57
57
  const writeSnapshot = (snapshots: MetricSnapshot[]): Promise<void> => {
58
58
  try {
59
- const now = new Date();
60
- const date = now.toISOString().slice(0, 10);
59
+ // RR-020 Fix 2 (cold-verify correction): gate on the crew root ALREADY
60
+ // EXISTING, mirroring notification-sink. The first version of this fix
61
+ // skipped only EMPTY snapshots — dead code in production, because
62
+ // wireEventToMetrics (observability.ts registration) registers ~15
63
+ // metrics at init, so registry.snapshot() is never []; the 60s interval
64
+ // therefore kept materialising <crewRoot>/state/metrics/ for projects
65
+ // that never ran a team. Existence gate ⇒ no-op until a real run
66
+ // creates the crew root; once it exists, writes (empty ticks included)
67
+ // are byte-identical to HEAD.
68
+ if (!fs.existsSync(opts.crewRoot)) return Promise.resolve();
61
69
  const redacted = redactSecrets(snapshots);
62
70
  if (!Array.isArray(redacted)) {
63
71
  logInternalError("metric-sink.type", new Error("redactSecrets did not return an array"), `got=${typeof redacted}`);
64
72
  return Promise.resolve();
65
73
  }
74
+ const now = new Date();
75
+ const date = now.toISOString().slice(0, 10);
66
76
  const target = ensureFd(date);
67
77
  const line = `${JSON.stringify({ exportedAt: now.toISOString(), snapshots: redacted as MetricSnapshot[] })}\n`;
68
78
  // Async write to avoid blocking the main thread on the 60s tick.