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.
- package/CHANGELOG.md +39 -9
- package/README.md +161 -1036
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -91462
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +29 -0
- package/skills/real-test-pi-crew/SKILL.md +193 -36
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -0
- package/src/config/config.ts +47 -13
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -0
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/help.ts +21 -10
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +31 -15
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -0
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +19 -2
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +14 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/dashboard-panes/agents-pane.ts +11 -2
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/keybinding-map.ts +22 -4
- package/src/ui/live-conversation-overlay.ts +6 -3
- package/src/ui/run-dashboard.ts +98 -5
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/spinner.ts +26 -2
- package/src/ui/tool-progress-formatter.ts +2 -1
- package/src/ui/tool-renderers/brief-mode.ts +2 -1
- package/src/ui/tool-renderers/index.ts +3 -3
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- 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
|
|
657
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
60
|
-
|
|
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(): {
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
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
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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,
|