pi-crew 0.9.7 → 0.9.9

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.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * SKILL.md frontmatter validation (L3 of deer-flow→pi-crew plan).
3
+ *
4
+ * Parses a SKILL.md's YAML frontmatter and validates it against the
5
+ * `ALLOWED_SKILL_PROPS` whitelist using HYBRID policy:
6
+ * - HARD errors (missing/malformed `name`/`description`, type mismatches,
7
+ * read/parse failures): EXCLUDE the skill from `discoverSkills()`.
8
+ * - SOFT warnings (unknown props, missing `name` derived from directory):
9
+ * KEEP the skill; surface via `getLastDiscoveryDiagnostics()`.
10
+ *
11
+ * YAML parsing uses the `yaml` package (^2.9.0). It is now a direct dep;
12
+ * before L3 it was only transitively available through
13
+ * `@earendil-works/pi-coding-agent`. Adding it as a direct dep is justified by:
14
+ * - Replaces a fragile line-prefix parser that broke on multi-line folded
15
+ * scalars (`description: >`), quoted strings, and nested YAML.
16
+ * - Standard lib (eemeli/yaml), MIT, actively maintained.
17
+ * - Already in the lockfile at the same version → zero install cost.
18
+ * - Frontmatter is small and well-formed; YAML parsing cost is negligible.
19
+ *
20
+ * The validator runs once per skill at discovery. Discovery is already cached
21
+ * (`CACHE_TTL_MS = 30_000` in discover-skills.ts) so the validation cost is
22
+ * bounded regardless of how many skills exist.
23
+ */
24
+
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import yaml from "yaml";
28
+
29
+ /**
30
+ * Properties allowed in SKILL.md frontmatter.
31
+ *
32
+ * Why this list:
33
+ * - `name`, `description` are the contract used by pi-crew's prompt
34
+ * rendering (see src/runtime/skill-instructions.ts) and capability
35
+ * inventory. Both are HARD-required.
36
+ * - `license`, `allowed-tools`, `compatibility`, `version`, `author` are
37
+ * common metadata; we surface but don't enforce content beyond type.
38
+ * - `metadata` is a free-form key/value bag (mirrors deer-flow `validation.py`).
39
+ *
40
+ * Unknown props (e.g. bundled skills' `origin`, `triggers`) are SOFT-warned,
41
+ * not rejected — see HYBRID policy in the module docstring.
42
+ */
43
+ export const ALLOWED_SKILL_PROPS = new Set<string>([
44
+ "name",
45
+ "description",
46
+ "license",
47
+ "allowed-tools",
48
+ "metadata",
49
+ "compatibility",
50
+ "version",
51
+ "author",
52
+ ]);
53
+
54
+ /** Hyphen-case name regex (Anthropic Agent Skills spec compatible). */
55
+ const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
56
+ const NAME_MAX_LEN = 64;
57
+ const DESCRIPTION_MAX_LEN = 1024;
58
+ const VERSION_REGEX = /^\d+\.\d+(\.\d+)?(-[A-Za-z0-9.-]+)?$/;
59
+
60
+ /**
61
+ * Structured error so callers (capability inventory, logs) can present
62
+ * actionable diagnostics instead of silently dropping malformed skills.
63
+ */
64
+ export interface SkillValidationError {
65
+ /** Absolute path to the skill directory. */
66
+ path: string;
67
+ /** Field name (e.g. "name", "description", "<unknown-prop>") or "frontmatter" for parse errors. */
68
+ field: string;
69
+ /** Human-readable reason. Safe to surface in capability listings. */
70
+ reason: string;
71
+ /** Severity — "error" excludes the skill; "warn" keeps it. */
72
+ severity: "error" | "warn";
73
+ }
74
+
75
+ /**
76
+ * Validated manifest exposes the parsed frontmatter fields in a typed shape.
77
+ * Only present for valid skills; undefined for invalid ones.
78
+ */
79
+ export interface ValidatedSkillManifest {
80
+ name: string;
81
+ description: string;
82
+ license?: string;
83
+ allowedTools?: string[];
84
+ metadata?: Record<string, unknown>;
85
+ compatibility?: string;
86
+ version?: string;
87
+ author?: string;
88
+ }
89
+
90
+ export interface ValidationResult {
91
+ ok: boolean;
92
+ errors: SkillValidationError[];
93
+ manifest?: ValidatedSkillManifest;
94
+ }
95
+
96
+ /**
97
+ * Parse YAML frontmatter from SKILL.md content.
98
+ *
99
+ * Returns an empty object when there is no frontmatter block. Parsing errors
100
+ * surface as `{ ok: false }` rather than throwing — discovery must remain
101
+ * exception-safe.
102
+ */
103
+ export function parseSkillFrontmatter(
104
+ content: string,
105
+ ): { ok: true; data: Record<string, unknown> } | { ok: false; error: string } {
106
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
107
+ if (!match) return { ok: true, data: {} };
108
+ try {
109
+ const parsed = yaml.parse(match[1]);
110
+ if (parsed === null || parsed === undefined) return { ok: true, data: {} };
111
+ if (typeof parsed !== "object" || Array.isArray(parsed)) {
112
+ return { ok: false, error: "Frontmatter must be a YAML mapping, not a scalar or list." };
113
+ }
114
+ return { ok: true, data: parsed as Record<string, unknown> };
115
+ } catch (e) {
116
+ return { ok: false, error: `YAML parse error: ${(e as Error).message}` };
117
+ }
118
+ }
119
+
120
+ function hard(path: string, field: string, reason: string): SkillValidationError {
121
+ return { path, field, reason, severity: "error" };
122
+ }
123
+
124
+ function warn(path: string, field: string, reason: string): SkillValidationError {
125
+ return { path, field, reason, severity: "warn" };
126
+ }
127
+
128
+ /**
129
+ * Validate a single skill's frontmatter.
130
+ *
131
+ * @param skillDir Absolute path to the skill directory (must contain SKILL.md).
132
+ * @returns ValidationResult — `ok` is true when there are no HARD errors.
133
+ * `errors[]` always lists ALL violations (HARD + SOFT).
134
+ *
135
+ * Back-compat: when `name` is missing from frontmatter, the validator
136
+ * DERIVES it from the directory name and emits a SOFT warning. Bundled
137
+ * pi-crew skills always set `name` explicitly, so the warning is informational.
138
+ */
139
+ export function validateSkillFrontmatter(skillDir: string): ValidationResult {
140
+ const errors: SkillValidationError[] = [];
141
+ const skillMdPath = path.join(skillDir, "SKILL.md");
142
+ const derivedName = path.basename(skillDir);
143
+
144
+ let content: string;
145
+ try {
146
+ content = fs.readFileSync(skillMdPath, "utf-8");
147
+ } catch (e) {
148
+ errors.push(hard(skillDir, "SKILL.md", `Cannot read SKILL.md: ${(e as Error).message}`));
149
+ return { ok: false, errors };
150
+ }
151
+
152
+ const parsed = parseSkillFrontmatter(content);
153
+ if (!parsed.ok) {
154
+ errors.push(hard(skillDir, "frontmatter", parsed.error));
155
+ return { ok: false, errors };
156
+ }
157
+
158
+ const data = parsed.data;
159
+
160
+ // No frontmatter at all → back-compat: derive everything from the
161
+ // directory. Pre-L3 skills shipped without frontmatter and we don't want
162
+ // to regress them. Surface a SOFT warning so authors know to add YAML.
163
+ if (Object.keys(data).length === 0) {
164
+ errors.push(warn(skillDir, "frontmatter", "No frontmatter block; deriving name from directory and leaving description empty."));
165
+ return {
166
+ ok: true,
167
+ errors,
168
+ manifest: { name: derivedName, description: "" },
169
+ };
170
+ }
171
+
172
+ // ── name: HARD if type/length/regex bad; SOFT if missing (derive from dir)
173
+ const nameRaw = data.name;
174
+ let resolvedName = derivedName;
175
+ if (nameRaw === undefined || nameRaw === null) {
176
+ errors.push(warn(skillDir, "name", `Frontmatter 'name' missing; using directory name "${derivedName}" as fallback. Add explicit 'name' to silence this.`));
177
+ } else if (typeof nameRaw !== "string") {
178
+ errors.push(hard(skillDir, "name", `'name' must be a string, got ${typeof nameRaw}.`));
179
+ } else if (nameRaw.length === 0) {
180
+ errors.push(hard(skillDir, "name", "'name' is empty."));
181
+ } else if (nameRaw.length > NAME_MAX_LEN) {
182
+ errors.push(hard(skillDir, "name", `'name' exceeds ${NAME_MAX_LEN} chars (got ${nameRaw.length}).`));
183
+ } else if (!NAME_REGEX.test(nameRaw)) {
184
+ errors.push(hard(skillDir, "name", `'name' must be hyphen-case lowercase (a-z, 0-9, single hyphens); got "${nameRaw}".`));
185
+ } else {
186
+ resolvedName = nameRaw;
187
+ }
188
+
189
+ // ── description: HARD required
190
+ const descRaw = data.description;
191
+ if (descRaw === undefined || descRaw === null) {
192
+ errors.push(hard(skillDir, "description", "Required field 'description' is missing."));
193
+ } else if (typeof descRaw !== "string") {
194
+ errors.push(hard(skillDir, "description", `'description' must be a string, got ${typeof descRaw}.`));
195
+ } else if (descRaw.length > DESCRIPTION_MAX_LEN) {
196
+ errors.push(hard(skillDir, "description", `'description' exceeds ${DESCRIPTION_MAX_LEN} chars (got ${descRaw.length}).`));
197
+ } else if (descRaw.includes("<") || descRaw.includes(">")) {
198
+ errors.push(hard(skillDir, "description", `'description' must not contain '<' or '>' (prompt-safety).`));
199
+ }
200
+
201
+ // ── OPTIONAL: license
202
+ if (data.license !== undefined && typeof data.license !== "string") {
203
+ errors.push(hard(skillDir, "license", `'license' must be a string.`));
204
+ }
205
+
206
+ // ── OPTIONAL: allowed-tools
207
+ if (data["allowed-tools"] !== undefined) {
208
+ const at = data["allowed-tools"];
209
+ if (!Array.isArray(at) || !at.every((x) => typeof x === "string")) {
210
+ errors.push(hard(skillDir, "allowed-tools", `'allowed-tools' must be an array of strings.`));
211
+ }
212
+ }
213
+
214
+ // ── OPTIONAL: metadata
215
+ if (data.metadata !== undefined) {
216
+ const m = data.metadata;
217
+ if (typeof m !== "object" || m === null || Array.isArray(m)) {
218
+ errors.push(hard(skillDir, "metadata", `'metadata' must be an object.`));
219
+ }
220
+ }
221
+
222
+ // ── OPTIONAL: compatibility
223
+ if (data.compatibility !== undefined && typeof data.compatibility !== "string") {
224
+ errors.push(hard(skillDir, "compatibility", `'compatibility' must be a string.`));
225
+ }
226
+
227
+ // ── OPTIONAL: version
228
+ if (data.version !== undefined) {
229
+ if (typeof data.version !== "string" || !VERSION_REGEX.test(data.version)) {
230
+ errors.push(hard(skillDir, "version", `'version' must be a semver string (e.g. "1.2.3" or "1.2.3-beta.1"); got "${String(data.version)}".`));
231
+ }
232
+ }
233
+
234
+ // ── OPTIONAL: author
235
+ if (data.author !== undefined && typeof data.author !== "string") {
236
+ errors.push(hard(skillDir, "author", `'author' must be a string.`));
237
+ }
238
+
239
+ // ── UNKNOWN PROPS: SOFT warn (HYBRID policy)
240
+ for (const key of Object.keys(data)) {
241
+ if (!ALLOWED_SKILL_PROPS.has(key)) {
242
+ errors.push(warn(skillDir, `<unknown-prop:${key}>`, `Unknown property '${key}' is not in the whitelist; keeping for forward-compat.`));
243
+ }
244
+ }
245
+
246
+ // ── Result: ok iff no HARD errors
247
+ const hasHardError = errors.some((e) => e.severity === "error");
248
+ if (!hasHardError) {
249
+ const manifest: ValidatedSkillManifest = {
250
+ name: resolvedName,
251
+ description: typeof data.description === "string" ? data.description : "",
252
+ };
253
+ if (typeof data.license === "string") manifest.license = data.license;
254
+ if (Array.isArray(data["allowed-tools"])) {
255
+ manifest.allowedTools = (data["allowed-tools"] as unknown[]).filter((x) => typeof x === "string") as string[];
256
+ }
257
+ if (typeof data.metadata === "object" && data.metadata !== null && !Array.isArray(data.metadata)) {
258
+ manifest.metadata = data.metadata as Record<string, unknown>;
259
+ }
260
+ if (typeof data.compatibility === "string") manifest.compatibility = data.compatibility;
261
+ if (typeof data.version === "string") manifest.version = data.version;
262
+ if (typeof data.author === "string") manifest.author = data.author;
263
+ return { ok: true, errors, manifest };
264
+ }
265
+
266
+ return { ok: false, errors };
267
+ }
@@ -3,6 +3,7 @@ import type { TaskClaimState } from "./task-claims.ts";
3
3
  import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
4
4
  import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
5
5
  import type { RolloutEntry, CoherenceMark } from "./decision-ledger.ts";
6
+ import type { CrashClass } from "../runtime/crash-classification.ts";
6
7
  export type { RolloutEntry, CoherenceMark };
7
8
  export type { CrewAgentProgress };
8
9
 
@@ -116,6 +117,10 @@ export interface WorkerExitStatus {
116
117
  signal?: string;
117
118
  cleanupErrors: string[];
118
119
  finalDrainMs: number;
120
+ /** Categorical classification of the exit (P0 crash taxonomy). Optional
121
+ * because it is populated by child-pi.ts at settle time; older/synthetic
122
+ * exit statuses may omit it. */
123
+ crashClass?: CrashClass;
119
124
  /** Phase-0 diagnostic (HB-003a): final-drain race state for the exit-null
120
125
  * disableTools bug. Optional + read-only — absent when no drain timer was
121
126
  * ever armed. Phase 1 will use `finalDrainArmed` to decide whether a
@@ -1,3 +1,28 @@
1
+ /**
2
+ * Dashboard keybinding map (L2 refactor: data-driven dispatch).
3
+ *
4
+ * Before L2 this module exposed `DASHBOARD_KEYS` (a data table) but dispatched
5
+ * via a 30-line `if (includes(...)) return "..."` chain — adding a key meant
6
+ * editing BOTH the table AND the dispatch, a DRY violation. L2 collapses the
7
+ * dispatch into a single `for (const b of BINDINGS)` loop driven by the
8
+ * `BINDINGS` table below. `DASHBOARD_KEYS` is retained as the raw key data so
9
+ * existing imports and the dead-but-intentional `KEY_RESERVED` set keep working.
10
+ *
11
+ * Recalibration vs. the original L2 plan: the plan also called for an
12
+ * `inTextInput` guard to prevent letter-key leaks into TUI text inputs.
13
+ * Verified during implementation that this is NOT needed — overlays are
14
+ * mutually exclusive and each has its own `handleInput`. `mailbox-compose-overlay.ts:111`
15
+ * captures every single-char key via `appendText(data)` and never delegates to
16
+ * `dashboardActionForKey`, so there is no leak path. Adding the guard would
17
+ * complicate the API (`run-dashboard.ts:485` has no text-input state to pass)
18
+ * for zero benefit. The input-guard half of L2 is therefore intentionally
19
+ * skipped; only the DRY/data-driven dispatch refactor landed.
20
+ *
21
+ * Origin pattern: deer-flow `frontend/src/components/workspace/command-palette.tsx:39-50`
22
+ * drives shortcuts from a single data array consumed by one loop in
23
+ * `use-global-shortcuts.ts:38-61`.
24
+ */
25
+
1
26
  export const DASHBOARD_KEYS = {
2
27
  close: ["q", "\u001b"],
3
28
  select: ["\r", "\n", "s"],
@@ -21,20 +46,23 @@ export const DASHBOARD_KEYS = {
21
46
  notification: { dismissAll: ["H"] },
22
47
  } as const;
23
48
 
24
- /** @internal */
25
- const KEY_RESERVED = new Set<string>([
26
- ...DASHBOARD_KEYS.close,
27
- ...DASHBOARD_KEYS.select,
28
- ...Object.values(DASHBOARD_KEYS.root).flat(),
29
- ...Object.values(DASHBOARD_KEYS.pane).flat(),
30
- ...Object.values(DASHBOARD_KEYS.navigation).flat(),
31
- ...Object.values(DASHBOARD_KEYS.mailbox).flat(),
32
- ...Object.values(DASHBOARD_KEYS.health).flat(),
33
- ...Object.values(DASHBOARD_KEYS.notification).flat(),
34
- ]);
49
+ /**
50
+ * Pane identifiers that can scope a binding. `undefined` means the binding
51
+ * fires in every pane.
52
+ */
53
+ export type ActivePane = "agents" | "progress" | "mailbox" | "output" | "health" | "metrics";
35
54
 
36
- function includes(values: readonly string[], data: string): boolean {
37
- return values.includes(data);
55
+ /**
56
+ * A single keybinding: the keys that trigger it, the action it produces, and
57
+ * an optional pane restriction. The dispatch loop returns the FIRST matching
58
+ * binding, so table ORDER IS SIGNIFICANT and must mirror the old if-chain
59
+ * precedence (pane-specific overrides before their generic competitors).
60
+ */
61
+ export interface KeyBinding {
62
+ readonly keys: readonly string[];
63
+ readonly action: DashboardKeyAction;
64
+ /** When set, the binding only fires when `activePane === pane`. */
65
+ readonly pane?: ActivePane;
38
66
  }
39
67
 
40
68
  export type DashboardKeyAction =
@@ -65,34 +93,93 @@ export type DashboardKeyAction =
65
93
  | "health-diagnostic-export"
66
94
  | "notifications-dismiss";
67
95
 
68
- export function dashboardActionForKey(data: string, activePane?: "agents" | "progress" | "mailbox" | "output" | "health" | "metrics"): DashboardKeyAction | undefined {
69
- if (includes(DASHBOARD_KEYS.close, data)) return "close";
70
- if (activePane === "mailbox" && includes(DASHBOARD_KEYS.mailbox.openDetail, data)) return "mailbox-detail";
71
- if (activePane === "health") {
72
- if (includes(DASHBOARD_KEYS.health.recovery, data)) return "health-recovery";
73
- if (includes(DASHBOARD_KEYS.health.killStale, data)) return "health-kill-stale";
74
- if (includes(DASHBOARD_KEYS.health.diagnosticExport, data)) return "health-diagnostic-export";
96
+ /**
97
+ * The dispatch table. ORDER MATTERS — first match wins.
98
+ *
99
+ * Precedence notes (must match the pre-L2 if-chain exactly):
100
+ * 1. `close` always wins (q / Esc).
101
+ * 2. `mailbox-detail` (\r, \n) is pane-scoped to mailbox and MUST precede
102
+ * `select` (which also binds \r, \n) so Enter opens the detail instead of
103
+ * triggering select while in the mailbox pane.
104
+ * 3. `health-*` are pane-scoped to health.
105
+ * 4. `notifications-dismiss` (H) is global.
106
+ * 5. `select`, then the root actions, pane switches, and navigation.
107
+ *
108
+ * NOTE: mailbox action keys A/N/C/P/X (ack/nudge/compose/preview/ackAll) are
109
+ * intentionally NOT in this table. They live in `DASHBOARD_KEYS.mailbox` for
110
+ * reservation but are handled by the mailbox overlay's own `handleInput`,
111
+ * not by the dashboard dispatch. Adding them here would change behavior.
112
+ */
113
+ const BINDINGS: readonly KeyBinding[] = [
114
+ { keys: DASHBOARD_KEYS.close, action: "close" },
115
+ { keys: DASHBOARD_KEYS.mailbox.openDetail, action: "mailbox-detail", pane: "mailbox" },
116
+ { keys: DASHBOARD_KEYS.health.recovery, action: "health-recovery", pane: "health" },
117
+ { keys: DASHBOARD_KEYS.health.killStale, action: "health-kill-stale", pane: "health" },
118
+ { keys: DASHBOARD_KEYS.health.diagnosticExport, action: "health-diagnostic-export", pane: "health" },
119
+ { keys: DASHBOARD_KEYS.notification.dismissAll, action: "notifications-dismiss" },
120
+ { keys: DASHBOARD_KEYS.select, action: "select" },
121
+ { keys: DASHBOARD_KEYS.root.summary, action: "summary" },
122
+ { keys: DASHBOARD_KEYS.root.artifacts, action: "artifacts" },
123
+ { keys: DASHBOARD_KEYS.root.api, action: "api" },
124
+ { keys: DASHBOARD_KEYS.root.agents, action: "agents" },
125
+ { keys: DASHBOARD_KEYS.root.mailbox, action: "mailbox" },
126
+ { keys: DASHBOARD_KEYS.root.events, action: "events" },
127
+ { keys: DASHBOARD_KEYS.root.output, action: "output" },
128
+ { keys: DASHBOARD_KEYS.root.transcript, action: "transcript" },
129
+ { keys: DASHBOARD_KEYS.root.liveConversation, action: "live-conversation" },
130
+ { keys: DASHBOARD_KEYS.root.reload, action: "reload" },
131
+ { keys: DASHBOARD_KEYS.root.progressToggle, action: "progressToggle" },
132
+ { keys: DASHBOARD_KEYS.pane.agents, action: "pane-agents" },
133
+ { keys: DASHBOARD_KEYS.pane.progress, action: "pane-progress" },
134
+ { keys: DASHBOARD_KEYS.pane.mailbox, action: "pane-mailbox" },
135
+ { keys: DASHBOARD_KEYS.pane.output, action: "pane-output" },
136
+ { keys: DASHBOARD_KEYS.pane.health, action: "pane-health" },
137
+ { keys: DASHBOARD_KEYS.pane.metrics, action: "pane-metrics" },
138
+ { keys: DASHBOARD_KEYS.navigation.up, action: "up" },
139
+ { keys: DASHBOARD_KEYS.navigation.down, action: "down" },
140
+ ];
141
+
142
+ /**
143
+ * Reserved keys — every key the dashboard claims, including mailbox/health
144
+ * action keys that are NOT dispatched here but are handled by their own
145
+ * overlays. Derived from `DASHBOARD_KEYS` (the full key set) rather than from
146
+ * `BINDINGS` (the dispatched subset) so overlay-handled keys stay reserved.
147
+ *
148
+ * @internal Currently unused outside this module but retained to document
149
+ * intent and support future callers that need to know which keys the
150
+ * dashboard ecosystem owns.
151
+ */
152
+ const KEY_RESERVED = new Set<string>([
153
+ ...DASHBOARD_KEYS.close,
154
+ ...DASHBOARD_KEYS.select,
155
+ ...Object.values(DASHBOARD_KEYS.root).flat(),
156
+ ...Object.values(DASHBOARD_KEYS.pane).flat(),
157
+ ...Object.values(DASHBOARD_KEYS.navigation).flat(),
158
+ ...Object.values(DASHBOARD_KEYS.mailbox).flat(),
159
+ ...Object.values(DASHBOARD_KEYS.health).flat(),
160
+ ...Object.values(DASHBOARD_KEYS.notification).flat(),
161
+ ]);
162
+
163
+ export { KEY_RESERVED };
164
+
165
+ /**
166
+ * Resolve a raw input `data` string to a dashboard action.
167
+ *
168
+ * Data-driven dispatch: iterates `BINDINGS` in order and returns the action of
169
+ * the first binding whose `keys` contain `data` and whose optional `pane`
170
+ * restriction matches `activePane`. Behavior is identical to the pre-L2
171
+ * if-chain (verified by `test/unit/keybinding-map.parity.test.ts`).
172
+ *
173
+ * @param data Raw key input (single char or escape sequence).
174
+ * @param activePane Currently focused pane; pane-scoped bindings only fire
175
+ * when this matches. `undefined` disables all pane-scoped
176
+ * bindings (matching the old behavior where omitting the
177
+ * arg skipped the `activePane === ...` branches).
178
+ */
179
+ export function dashboardActionForKey(data: string, activePane?: ActivePane): DashboardKeyAction | undefined {
180
+ for (const binding of BINDINGS) {
181
+ if (binding.pane !== undefined && binding.pane !== activePane) continue;
182
+ if (binding.keys.includes(data)) return binding.action;
75
183
  }
76
- if (includes(DASHBOARD_KEYS.notification.dismissAll, data)) return "notifications-dismiss";
77
- if (includes(DASHBOARD_KEYS.select, data)) return "select";
78
- if (includes(DASHBOARD_KEYS.root.summary, data)) return "summary";
79
- if (includes(DASHBOARD_KEYS.root.artifacts, data)) return "artifacts";
80
- if (includes(DASHBOARD_KEYS.root.api, data)) return "api";
81
- if (includes(DASHBOARD_KEYS.root.agents, data)) return "agents";
82
- if (includes(DASHBOARD_KEYS.root.mailbox, data)) return "mailbox";
83
- if (includes(DASHBOARD_KEYS.root.events, data)) return "events";
84
- if (includes(DASHBOARD_KEYS.root.output, data)) return "output";
85
- if (includes(DASHBOARD_KEYS.root.transcript, data)) return "transcript";
86
- if (includes(DASHBOARD_KEYS.root.liveConversation, data)) return "live-conversation";
87
- if (includes(DASHBOARD_KEYS.root.reload, data)) return "reload";
88
- if (includes(DASHBOARD_KEYS.root.progressToggle, data)) return "progressToggle";
89
- if (includes(DASHBOARD_KEYS.pane.agents, data)) return "pane-agents";
90
- if (includes(DASHBOARD_KEYS.pane.progress, data)) return "pane-progress";
91
- if (includes(DASHBOARD_KEYS.pane.mailbox, data)) return "pane-mailbox";
92
- if (includes(DASHBOARD_KEYS.pane.output, data)) return "pane-output";
93
- if (includes(DASHBOARD_KEYS.pane.health, data)) return "pane-health";
94
- if (includes(DASHBOARD_KEYS.pane.metrics, data)) return "pane-metrics";
95
- if (includes(DASHBOARD_KEYS.navigation.up, data)) return "up";
96
- if (includes(DASHBOARD_KEYS.navigation.down, data)) return "down";
97
184
  return undefined;
98
185
  }
@@ -1,4 +1,5 @@
1
1
  import type { TeamEvent } from "../state/event-log.ts";
2
+ import { readEventsCursor } from "../state/event-log.ts";
2
3
 
3
4
  export type RunEventType =
4
5
  | "task_started"
@@ -59,6 +60,18 @@ export interface RunEventPayload {
59
60
  timestamp?: string;
60
61
  data?: unknown;
61
62
  channel?: EventChannel;
63
+ /**
64
+ * L1: monotonic sequence from the durable event log
65
+ * (`TeamEvent.metadata.seq`). Present on events that originated from a
66
+ * logged TeamEvent (via emitFromTeamEvent). Absent on transient live-only
67
+ * events (e.g. worker_status from the stream bridge) that are never
68
+ * persisted and therefore cannot be replayed or deduped.
69
+ *
70
+ * Used by onWithReplay() to dedup: a live event with seq <= the last seq
71
+ * replayed to a subscriber is suppressed (it was already delivered from
72
+ * the durable log).
73
+ */
74
+ seq?: number;
62
75
  }
63
76
 
64
77
  export type RunEventCallback = (event: RunEventPayload) => void;
@@ -115,6 +128,73 @@ class RunEventBus {
115
128
  };
116
129
  }
117
130
 
131
+ /**
132
+ * L1: subscribe with a catch-up replay from the durable event log.
133
+ *
134
+ * Closes the transient-subscriber-absence gap: when an overlay/widget is
135
+ * disposed and recreated (toggle, reconnect), live events emitted in that
136
+ * window are lost as notification triggers. This method replays the
137
+ * missed TeamEvents from the durable JSONL log BEFORE attaching the live
138
+ * listener, then dedups so events delivered both ways fire exactly once.
139
+ *
140
+ * Unlike deer-flow's 256-event RAM ring buffer (lost on crash), this uses
141
+ * pi-crew's existing durable `readEventsCursor` — O(new bytes) via
142
+ * byte-offset incremental reads, monotonic seq, tail-capped. Strictly
143
+ * better: survives crashes, bounded memory.
144
+ *
145
+ * @param runId Run to subscribe to (live listener scope).
146
+ * @param eventsPath Path to the run's events JSONL (manifest.eventsPath).
147
+ * @param lastSeenSeq Last seq the caller processed; events with seq > this
148
+ * are replayed. Pass 0 to replay everything.
149
+ * @param callback Receives both replayed and live events. Replayed
150
+ * events are delivered directly (NOT via emit, so no
151
+ * fan-out to other subscribers).
152
+ * @returns unsubscribe handle (detaches the live listener).
153
+ */
154
+ onWithReplay(
155
+ runId: string,
156
+ eventsPath: string,
157
+ lastSeenSeq: number,
158
+ callback: RunEventCallback,
159
+ ): () => void {
160
+ // Phase 1: replay missed events from the durable log directly to this
161
+ // callback. Bounded by limit; readEventsCursor already tail-caps.
162
+ let maxReplayedSeq = lastSeenSeq;
163
+ try {
164
+ const cursor = readEventsCursor(eventsPath, { sinceSeq: lastSeenSeq, limit: 1000 });
165
+ for (const teamEvent of cursor.events) {
166
+ const type = teamEventToRunEventType(teamEvent);
167
+ if (!type) continue; // not all TeamEvents map to a RunEventType
168
+ const payload: RunEventPayload = {
169
+ type,
170
+ runId: teamEvent.runId,
171
+ taskId: teamEvent.taskId,
172
+ timestamp: teamEvent.time,
173
+ data: teamEvent.data,
174
+ channel: classifyEventChannel(type),
175
+ seq: teamEvent.metadata?.seq,
176
+ };
177
+ try { callback(payload); } catch { /* subscriber errors are non-fatal */ }
178
+ if (typeof teamEvent.metadata?.seq === "number") {
179
+ maxReplayedSeq = Math.max(maxReplayedSeq, teamEvent.metadata.seq);
180
+ }
181
+ }
182
+ } catch {
183
+ // Log read failures are non-fatal — fall through to live-only
184
+ // subscription. The durable log may not exist yet for a brand-new run.
185
+ }
186
+
187
+ // Phase 2: attach the live listener with dedup. A live event whose seq
188
+ // was already replayed (seq <= maxReplayedSeq) is suppressed. Events
189
+ // without a seq (transient live-only, e.g. worker_status) always
190
+ // deliver — they are never persisted and thus never replayed.
191
+ const liveCallback: RunEventCallback = (event) => {
192
+ if (typeof event.seq === "number" && event.seq <= maxReplayedSeq) return;
193
+ callback(event);
194
+ };
195
+ return this.on(runId, liveCallback);
196
+ }
197
+
118
198
  emit(event: RunEventPayload): void {
119
199
  // Auto-classify channel if not already set.
120
200
  // M2: Use local variable for routing, but also set on event
@@ -206,5 +286,8 @@ export function emitFromTeamEvent(event: TeamEvent): void {
206
286
  taskId: event.taskId,
207
287
  timestamp: event.time,
208
288
  data: event.data,
289
+ // L1: stamp the durable-log seq so onWithReplay() can dedup live
290
+ // delivery against replayed events.
291
+ seq: event.metadata?.seq,
209
292
  });
210
293
  }