pi-crew 0.11.1 → 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 (121) hide show
  1. package/CHANGELOG.md +39 -9
  2. package/README.md +161 -1036
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -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 +1 -3
  10. package/schema.json +29 -0
  11. package/skills/real-test-pi-crew/SKILL.md +193 -36
  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 +23 -0
  20. package/src/extension/help.ts +21 -10
  21. package/src/extension/knowledge-injection.ts +2 -1
  22. package/src/extension/management.ts +8 -3
  23. package/src/extension/notification-sink.ts +17 -0
  24. package/src/extension/registration/command-utils.ts +28 -2
  25. package/src/extension/registration/commands/dashboard.ts +11 -1
  26. package/src/extension/registration/commands/manage.ts +31 -15
  27. package/src/extension/registration/commands/run.ts +24 -2
  28. package/src/extension/registration/commands/shared.ts +23 -0
  29. package/src/extension/registration/commands/status.ts +25 -2
  30. package/src/extension/registration/context-builder.ts +8 -2
  31. package/src/extension/registration/health-notify-policy.ts +100 -0
  32. package/src/extension/registration/lazy-configurers.ts +35 -0
  33. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  34. package/src/extension/registration/lifecycle.ts +75 -10
  35. package/src/extension/registration/observability.ts +98 -35
  36. package/src/extension/registration/registration-types.ts +7 -5
  37. package/src/extension/registration/runtime-cleanup.ts +9 -3
  38. package/src/extension/registration/subagent-helpers.ts +38 -0
  39. package/src/extension/registration/wire-cross-extension.ts +28 -0
  40. package/src/extension/run-compare.ts +220 -0
  41. package/src/extension/run-export.ts +37 -5
  42. package/src/extension/run-maintenance.ts +155 -5
  43. package/src/extension/team-tool/dispatch/index.ts +3 -2
  44. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  45. package/src/extension/team-tool/goal.ts +4 -1
  46. package/src/extension/team-tool/handle-settings.ts +19 -2
  47. package/src/extension/team-tool/health-monitor.ts +21 -7
  48. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  49. package/src/extension/team-tool/plan.ts +10 -0
  50. package/src/extension/team-tool/routing-hint.ts +63 -0
  51. package/src/extension/team-tool/status.ts +4 -0
  52. package/src/extension/team-tool.ts +52 -6
  53. package/src/extension/webhook-notify.ts +382 -0
  54. package/src/observability/metric-sink.ts +12 -2
  55. package/src/prompt/prompt-runtime.ts +82 -31
  56. package/src/prompt/worker-events-channel.ts +12 -0
  57. package/src/runtime/README.md +1 -1
  58. package/src/runtime/async-runner.ts +87 -1
  59. package/src/runtime/background-runner.ts +313 -234
  60. package/src/runtime/broker/crew-broker.ts +17 -11
  61. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  62. package/src/runtime/broker/wait-status-cache.ts +1 -1
  63. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  64. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  65. package/src/runtime/crew-agent-records.ts +337 -45
  66. package/src/runtime/deadletter.ts +43 -1
  67. package/src/runtime/delegate-spawn.ts +5 -1
  68. package/src/runtime/dispatch-batch.ts +72 -5
  69. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  70. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  71. package/src/runtime/model/model-fallback.ts +21 -1
  72. package/src/runtime/model/pi-args.ts +8 -10
  73. package/src/runtime/recovery/crash-recovery.ts +25 -1
  74. package/src/runtime/run-worker.ts +12 -1
  75. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  76. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  77. package/src/runtime/scheduling/scheduler.ts +49 -13
  78. package/src/runtime/scheduling/semaphore.ts +148 -20
  79. package/src/runtime/scratchpad/README.md +1 -1
  80. package/src/runtime/scratchpad/protocol.ts +1 -1
  81. package/src/runtime/settings-store.ts +1 -1
  82. package/src/runtime/skill-instructions.ts +22 -0
  83. package/src/runtime/stale-reconciler.ts +85 -13
  84. package/src/runtime/task-runner/pre-execution.ts +26 -2
  85. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  86. package/src/runtime/task-runner.ts +21 -1
  87. package/src/runtime/team-runner.ts +38 -1
  88. package/src/runtime/workspace-lock.ts +4 -1
  89. package/src/schema/config-schema.ts +14 -0
  90. package/src/schema/team-tool-schema.ts +17 -0
  91. package/src/state/atomic-write.ts +53 -0
  92. package/src/state/contracts.ts +109 -0
  93. package/src/state/coordination/locks.ts +191 -33
  94. package/src/state/coordination/mailbox.ts +140 -15
  95. package/src/state/crew-init.ts +87 -12
  96. package/src/state/event-log/cursor.ts +37 -1
  97. package/src/state/event-log/event-log-rotation.ts +72 -7
  98. package/src/state/stores/active-run-registry.ts +13 -1
  99. package/src/state/stores/state-store.ts +112 -22
  100. package/src/state/types.ts +4 -0
  101. package/src/ui/dashboard-panes/agents-pane.ts +11 -2
  102. package/src/ui/heartbeat-aggregator.ts +34 -0
  103. package/src/ui/keybinding-map.ts +22 -4
  104. package/src/ui/live-conversation-overlay.ts +6 -3
  105. package/src/ui/run-dashboard.ts +98 -5
  106. package/src/ui/run-snapshot-cache.ts +18 -1
  107. package/src/ui/spinner.ts +26 -2
  108. package/src/ui/tool-progress-formatter.ts +2 -1
  109. package/src/ui/tool-renderers/brief-mode.ts +2 -1
  110. package/src/ui/tool-renderers/index.ts +3 -3
  111. package/src/utils/incremental-reader.ts +11 -3
  112. package/src/utils/paths.ts +94 -12
  113. package/src/utils/project-markers.ts +40 -0
  114. package/src/worktree/worktree-manager.ts +206 -26
  115. package/workflows/distill.workflow.md +3 -3
  116. package/workflows/fast-fix.workflow.md +1 -1
  117. package/workflows/plan-execute.workflow.md +1 -1
  118. package/workflows/review.workflow.md +1 -1
  119. package/workflows/strict-fast-fix.workflow.md +1 -1
  120. package/docs/migration-v0.4-v0.5.md +0 -208
  121. package/docs/runtime-flow.md +0 -148
@@ -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.
@@ -82,7 +82,11 @@ const SEEN_STEER_ID_CAP = 1024;
82
82
  * Factory-shaped so multiple prompt-runtime instances (tests, parallel
83
83
  * workers) get independent sets.
84
84
  */
85
- export function createSeenSteerIdSet(): { markOrSkip: (id?: string) => boolean; size: () => number } {
85
+ export function createSeenSteerIdSet(): {
86
+ markOrSkip: (id?: string) => boolean;
87
+ unmark: (id?: string) => void;
88
+ size: () => number;
89
+ } {
86
90
  const seen: string[] = [];
87
91
  const set = new Set<string>();
88
92
  return {
@@ -98,6 +102,15 @@ export function createSeenSteerIdSet(): { markOrSkip: (id?: string) => boolean;
98
102
  }
99
103
  return true;
100
104
  },
105
+ unmark(id?: string): void {
106
+ // F-L2: a steer whose DELIVERY failed must release its dedup mark so
107
+ // the retry (after the poll's offset rewind) is not skipped as a
108
+ // duplicate — that would silently drop the steer instead.
109
+ if (id === undefined) return;
110
+ if (!set.delete(id)) return;
111
+ const idx = seen.indexOf(id);
112
+ if (idx !== -1) seen.splice(idx, 1);
113
+ },
101
114
  size: () => set.size,
102
115
  };
103
116
  }
@@ -861,42 +874,80 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
861
874
  try {
862
875
  const buf = Buffer.alloc(stat.size - lastOffset);
863
876
  fs.readSync(fd, buf, 0, buf.length, lastOffset);
864
- lastOffset = stat.size;
865
- const lines = buf.toString("utf8").split("\n").filter(Boolean);
866
- for (const line of lines) {
877
+ const offsetBefore = lastOffset;
878
+ let cursor = 0;
879
+ // F-L2 (live battery 2026-09-21, run team_20260921035840): a steer
880
+ // written while the extension is still LOADING makes pi.sendMessage
881
+ // throw ("Extension runtime not initialized" — pi's loader gates
882
+ // action methods until bindExtensions). The old code advanced
883
+ // lastOffset to stat.size BEFORE the line loop and its per-line
884
+ // catch swallowed that throw as "malformed line" — the entry was
885
+ // then silently lost forever (the offset was already past it).
886
+ // Walk the buffer by byte offset and advance only past lines with
887
+ // a terminal verdict (delivered / non-steer / rejected / malformed);
888
+ // a DELIVERY failure rewinds to the failing line's start so the
889
+ // next tick re-reads it (with its dedup id unmarked).
890
+ while (cursor < buf.length) {
891
+ const nl = buf.indexOf(10, cursor);
892
+ const end = nl === -1 ? buf.length : nl;
893
+ const line = buf.subarray(cursor, end).toString("utf8");
894
+ if (line.length === 0) {
895
+ cursor = end + 1;
896
+ continue;
897
+ }
867
898
  try {
868
899
  const entry = JSON.parse(line) as SteerEntry;
869
- if (entry.type !== "steer") continue;
870
- // FIX-S1: cross-channel dedup. The broker writes the
871
- // same steer to both the mailbox (live fanout via the
872
- // onSteer callback below) and this JSONL file. A
873
- // connected worker receives it via the broker first
874
- // and via this poll second; the seen-id set ensures
875
- // only the first arrival reaches pi.sendMessage.
876
- const entryId =
877
- typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : undefined;
878
- if (!seenSteers.markOrSkip(entryId)) continue;
879
- // FIX-02: sanitize each steer entry before forwarding
880
- // to pi.sendMessage. Reject oversized payloads,
881
- // excessive newlines, and control characters.
882
- const sanitized = sanitizeSteerMessage(entry);
883
- if (!sanitized.valid || sanitized.message === undefined) {
884
- logInternalError(
885
- "prompt-runtime.steer-rejected",
886
- new Error(sanitized.reason ?? "steer-sanitization-failed"),
887
- `line-preview=${line.slice(0, 64)}`,
888
- "warn",
889
- );
890
- continue;
900
+ if (entry.type === "steer") {
901
+ // FIX-S1: cross-channel dedup. The broker writes the
902
+ // same steer to both the mailbox (live fanout via the
903
+ // onSteer callback below) and this JSONL file. A
904
+ // connected worker receives it via the broker first
905
+ // and via this poll second; the seen-id set ensures
906
+ // only the first arrival reaches pi.sendMessage.
907
+ const entryId =
908
+ typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : undefined;
909
+ if (seenSteers.markOrSkip(entryId)) {
910
+ // FIX-02: sanitize each steer entry before forwarding
911
+ // to pi.sendMessage. Reject oversized payloads,
912
+ // excessive newlines, and control characters.
913
+ const sanitized = sanitizeSteerMessage(entry);
914
+ if (sanitized.valid && sanitized.message !== undefined) {
915
+ try {
916
+ pi.sendMessage(
917
+ { customType: "crew-steer", content: sanitized.message, display: false },
918
+ { deliverAs: "steer" },
919
+ );
920
+ } catch (sendErr) {
921
+ // Delivery failed (e.g. pre-bind notInitialized).
922
+ // Release the dedup mark so the retry after the
923
+ // offset rewind is not skipped as a duplicate, then
924
+ // rewind: cursor stays at this line's start so the
925
+ // next tick re-reads the entry.
926
+ seenSteers.unmark(entryId);
927
+ logInternalError(
928
+ "prompt-runtime.steer-delivery-failed",
929
+ sendErr instanceof Error ? sendErr : new Error(String(sendErr)),
930
+ `line-preview=${line.slice(0, 64)}`,
931
+ "warn",
932
+ );
933
+ break;
934
+ }
935
+ } else {
936
+ logInternalError(
937
+ "prompt-runtime.steer-rejected",
938
+ new Error(sanitized.reason ?? "steer-sanitization-failed"),
939
+ `line-preview=${line.slice(0, 64)}`,
940
+ "warn",
941
+ );
942
+ }
943
+ }
891
944
  }
892
- pi.sendMessage(
893
- { customType: "crew-steer", content: sanitized.message, display: false },
894
- { deliverAs: "steer" },
895
- );
896
945
  } catch {
897
- // Malformed line — skip
946
+ // Malformed line — skip (terminal verdict — advance past)
898
947
  }
948
+ cursor = end + 1;
899
949
  }
950
+ lastOffset = offsetBefore + Math.min(cursor, buf.length);
900
951
  } finally {
901
952
  try {
902
953
  fs.closeSync(fd);
@@ -107,6 +107,18 @@ export function createWorkerEventsChannel(options: WorkerEventsChannelOptions =
107
107
  const write = (item: { type: string; data: Record<string, unknown>; droppedSinceLast: number }): boolean => {
108
108
  try {
109
109
  appendEvent(eventsPath as string, {
110
+ // TeamEvent contract: `time` (ISO string) is REQUIRED — the import
111
+ // bundle validator (run-bundle-schema.ts validateEvent) rejects the
112
+ // whole bundle when any event lacks it, and event readers sort by it.
113
+ // The channel's default appender is a raw O_APPEND writer (NOT
114
+ // event-log.ts appendEvent, which stamps time itself), so the stamp
115
+ // must happen HERE — once, at the single choke point both emit()
116
+ // and emitTerminal() pass through. Found live 2026-09-21: every
117
+ // worker.started/completed of a tmux-surface run was written
118
+ // time-less, and exporting that run produced an un-importable bundle
119
+ // ("events[i].time must be a string"). Uses the injected clock so
120
+ // tests stay deterministic.
121
+ time: new Date(now()).toISOString(),
110
122
  type: item.type,
111
123
  runId,
112
124
  taskId,