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.
- package/CHANGELOG.md +95 -0
- package/README.md +9 -2
- package/package.json +3 -2
- package/src/config/defaults.ts +8 -4
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/capability-inventory.ts +20 -1
- package/src/runtime/child-pi.ts +23 -3
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +77 -10
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/skills/discover-skills.ts +61 -8
- package/src/skills/validate.ts +267 -0
- package/src/state/types.ts +5 -0
- package/src/ui/keybinding-map.ts +128 -41
- package/src/ui/run-event-bus.ts +83 -0
|
@@ -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
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
package/src/ui/keybinding-map.ts
CHANGED
|
@@ -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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
}
|
package/src/ui/run-event-bus.ts
CHANGED
|
@@ -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
|
}
|