pi-crew 0.9.7 → 0.9.8
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 +62 -0
- package/README.md +9 -2
- package/package.json +3 -2
- package/src/config/defaults.ts +8 -4
- package/src/runtime/capability-inventory.ts +20 -1
- package/src/runtime/child-pi.ts +8 -1
- package/src/runtime/task-output-context.ts +25 -9
- package/src/skills/discover-skills.ts +61 -8
- package/src/skills/validate.ts +267 -0
- package/src/ui/keybinding-map.ts +128 -41
- package/src/ui/run-event-bus.ts +83 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v0.9.8] — deer-flow learning integration: L1/L2/L3/L4 (2026-06-24)
|
|
4
|
+
|
|
5
|
+
Four improvements distilled from researching [bytedance/deer-flow](https://github.com/bytedance/deer-flow) and the wider Pi-ecosystem (pi-boomerang, pi-subagents, pi-dynamic-workflows). Each was calibrated against real pi-crew code (the research over-reported gaps — several patterns pi-crew already does *better* than deer-flow) and sized from measured data, not guesses.
|
|
6
|
+
|
|
7
|
+
### L3 — Strict SKILL.md frontmatter validation (commit 5348c47)
|
|
8
|
+
|
|
9
|
+
Malformed skills now **fail-fast at discovery** instead of silently producing broken behavior at runtime. New `src/skills/validate.ts` validates frontmatter against the `ALLOWED_SKILL_PROPS` whitelist using a **HYBRID policy**:
|
|
10
|
+
|
|
11
|
+
- **HARD errors** (missing/malformed `name` or `description`, type mismatches) → skill excluded from `discoverSkills()`.
|
|
12
|
+
- **SOFT warnings** (unknown props like `origin`/`triggers`, missing `name` derived from directory) → skill kept, surfaced via `getLastDiscoveryDiagnostics()` / `buildSkillValidationDiagnostics()`.
|
|
13
|
+
|
|
14
|
+
Replaces the fragile line-prefix parser (broke on multi-line folded scalars `description: >`, quoted strings, nested YAML) with the `yaml` package (^2.9.0, already transitive, added as direct dep — zero install cost). Back-compat preserved: missing `name` derives from the directory; no-frontmatter skills still load with empty description.
|
|
15
|
+
|
|
16
|
+
**Bonus value**: pre-flight on the real environment surfaced 2 user skills that were silently broken (`agent-browser`: `allowed-tools` wrong type; `spike-wrap-up`: `<>` in description).
|
|
17
|
+
|
|
18
|
+
### L2 — Data-driven keybinding dispatch (commit 35fc3c6)
|
|
19
|
+
|
|
20
|
+
Replaced the 30-line imperative `if (includes(...))` chain in `dashboardActionForKey` with a single `for (const b of BINDINGS)` loop driven by a declarative `BINDINGS[]` table. Adding a key now means editing ONE place (the table) instead of two (table + dispatch) — removes the DRY violation that caused table-vs-dispatch drift. `KEY_RESERVED` is now exported and derived.
|
|
21
|
+
|
|
22
|
+
Behavior is **provably identical** to the old chain: a golden-snapshot parity test asserts every `(data, activePane)` pair returns the same action (~190 pairs). Pane-scoped bindings (`mailbox-detail`, `health-*`) precede their generic competitors so first-match-wins reproduces old precedence.
|
|
23
|
+
|
|
24
|
+
The `inTextInput` guard from the original plan was **intentionally skipped** — overlays are mutually exclusive and each handles its own input (`mailbox-compose-overlay.ts` captures every single-char key), so there is no leak path. Documented in the commit.
|
|
25
|
+
|
|
26
|
+
### L1 — RunEventBus.onWithReplay catch-up primitive (commit a2a478b)
|
|
27
|
+
|
|
28
|
+
Closes the transient-subscriber-absence gap: when an overlay/widget is disposed and recreated (toggle, reconnect), live events emitted in that window are lost as notification triggers. `onWithReplay(runId, eventsPath, lastSeenSeq, callback)` replays missed events from the durable JSONL log before attaching the live listener, then dedups via `metadata.seq` so each event fires exactly once.
|
|
29
|
+
|
|
30
|
+
Unlike deer-flow's 256-event RAM ring buffer (lost on crash), this reuses pi-crew's existing `readEventsCursor` — O(new bytes) via byte-offset incremental reads, monotonic seq, tail-capped. Strictly better: survives crashes, bounded memory. `RunEventPayload` gains optional `seq`; `emitFromTeamEvent` stamps it.
|
|
31
|
+
|
|
32
|
+
The **primitive is landed + fully tested** (7 cases: replay order, dedup race, transient live-only, cursor bound, sinceSeq filter, missing-log fallback, unsubscribe). Dashboard wiring (switching `onAny()` → `onWithReplay()` per-run) is deferred — the dashboard subscribes across multiple runs and needs a subscription-model refactor; state isn't lost during absence anyway (`run-snapshot-cache` rebuilds from disk, TTL 1500ms).
|
|
33
|
+
|
|
34
|
+
### L4 — Data-driven output thresholds + head/tail compaction (commit 463d08d)
|
|
35
|
+
|
|
36
|
+
Worker output was being truncated at 3 points with thresholds sized by guess, not data. Measured 27 real result artifacts: **max 9226 bytes, median 8272, 100% under 16KB**. The old thresholds cut **62% of real outputs** (head-only, no recovery path). This change sizes thresholds from that data and switches compaction from head-only to head+tail so closing markdown structure (code fences, headings) survives.
|
|
37
|
+
|
|
38
|
+
| Threshold | Before | After |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `maxAssistantTextChars` | 8192 | **16384** |
|
|
41
|
+
| `maxToolResultChars` | 1024 | **8192** |
|
|
42
|
+
| `maxCompactContentChars` | 4096 | **8192** |
|
|
43
|
+
| `maxToolInputChars` | 2048 | **4096** |
|
|
44
|
+
| `readIfSmall` (3 inconsistent values) | 24K/40K/80K | **single 32KB** |
|
|
45
|
+
| Compaction shape | head-only | **head(75%)+tail(25%)** |
|
|
46
|
+
|
|
47
|
+
**Why not caveman-shrink** (the alternative considered): tested it on the same 27 artifacts — only 3.9% compression (vs 42% on prose fixtures) because pi-crew output is code-citation-heavy with little prose to strip, AND it has a real data-loss bug (`funccall` protected-pattern eats sentinel placeholders for the `identifier (inline-code)` pattern, corrupting 24/27 files with null bytes, 127 inline codes lost). caveman's *concept* (detect/validate) is worth borrowing but its engine doesn't fit pi-crew's content type. Threshold-only wins on the data.
|
|
48
|
+
|
|
49
|
+
### Tests & verification
|
|
50
|
+
|
|
51
|
+
- 10 new L4 tests, 25 L3 validator tests, 7 L1 replay tests, 7 L2 parity tests.
|
|
52
|
+
- `npm run typecheck` + `check:lazy-imports` green.
|
|
53
|
+
- End-to-end team-run smoke tests confirm all 4 features load and run without crash.
|
|
54
|
+
- Real-world smoke scripts at `test/manual/l{1,2,3}-*-smoke.mjs`.
|
|
55
|
+
- Research artifacts at `source/deer-flow/.research/` + `.crew/research/worker-output-handling.md`.
|
|
56
|
+
|
|
57
|
+
### Backward compatibility
|
|
58
|
+
|
|
59
|
+
All four changes are additive or behavior-preserving:
|
|
60
|
+
- L3: valid skills unaffected; only malformed ones now excluded (was: silent breakage).
|
|
61
|
+
- L2: golden-snapshot parity test proves identical dispatch.
|
|
62
|
+
- L1: new method added; existing `on`/`onAny`/`emit` unchanged.
|
|
63
|
+
- L4: outputs that fit (100% of measured real outputs) are unchanged; only oversized ones now keep head+tail instead of head-only.
|
|
64
|
+
|
|
3
65
|
## [v0.9.7] — round-18 + process-safety fix (2026-06-23)
|
|
4
66
|
|
|
5
67
|
P2-3 feature: durable checkpoint + resume for dynamic-workflow runs. When a `.dwf.ts`
|
package/README.md
CHANGED
|
@@ -39,9 +39,9 @@ npm: pi-crew
|
|
|
39
39
|
repo: https://github.com/baphuongna/pi-crew
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
**v0.9.4 / v0.9.5**: See [CHANGELOG.md](CHANGELOG.md).
|
|
42
|
+
**v0.9.4 / v0.9.5 / v0.9.8**: See [CHANGELOG.md](CHANGELOG.md).
|
|
43
43
|
|
|
44
|
-
### Highlights (v0.6.4 → v0.9.
|
|
44
|
+
### Highlights (v0.6.4 → v0.9.8)
|
|
45
45
|
|
|
46
46
|
A long arc of **trust, cliff-resilience, and robustness** work. Principle: *build
|
|
47
47
|
trust and cliff-resilience, stay lean, delete before adding.*
|
|
@@ -198,6 +198,9 @@ background-dispatch discriminator.
|
|
|
198
198
|
- **Health scoring** — penalty-based run health with time-series snapshots
|
|
199
199
|
- **Autonomous goal loops** (P0/P1) — `team action='goal'` runs an autonomous multi-turn loop: a worker does a turn, a separate LLM judge evaluates the transcript+evidence against the goal, and on "not-achieved" the reason is fed into the next turn's prompt. Stops on achieved / maxTurns / budget / blocked. Claude-Code-style `/goal`. See `docs/goals.md`.
|
|
200
200
|
- **Dynamic workflows** (P2/P3) — author orchestration as a `.dwf.ts` script (JS loops/branch/cross-review) instead of a static step list. The script runs in the background, calls subagents via `ctx.agent()`/`ctx.fanOut()`, holds intermediate results in JS variables, and only `ctx.setResult()` reaches the main context. `ctx.phase()` marks logical phases; **round-14** adds `ctx.log()` (durable `dwf.log` events), `ctx.budget` (per-workflow token budget that auto-rejects `ctx.agent()` when exhausted), and `ctx.args<T>()` (typed workflow arguments). TypeScript IntelliSense is available via `import type { WorkflowCtx } from "pi-crew/workflow"`. `workflow-create`/`-delete`/`-save` require `confirm:true` at the tool-call layer (the only gate — a malicious agent that passes `confirm:true` programmatically bypasses it; this is postinstall-equivalent trust, not a human-in-the-loop dialog). See `docs/dynamic-workflows.md`.
|
|
201
|
+
- **Strict SKILL.md validation** (L3, v0.9.8) — skills with malformed frontmatter (missing/malformed `name`/`description`, type mismatches) now **fail-fast at discovery** with visible diagnostics, instead of silently producing broken behavior at runtime. HYBRID policy: HARD on required fields, SOFT (warn) on unknown props for forward-compat. Surfaced via `buildSkillValidationDiagnostics()`.
|
|
202
|
+
- **Durable event replay** (L1, v0.9.8) — `RunEventBus.onWithReplay()` catches up a re-subscribing dashboard/overlay with events it missed during transient absence (toggle, reconnect), replaying from the durable JSONL log with seq-based dedup. No information loss even if the live subscriber was briefly gone.
|
|
203
|
+
- **Lossless-by-default output handling** (L4, v0.9.8) — worker output thresholds sized from measured data (100% of real outputs fit without compaction); when compaction is unavoidable it keeps head+tail (preserves closing code fences/headings) instead of head-only truncation. No more `[pi-crew compacted N chars]` markers eating the end of a worker's result.
|
|
201
204
|
|
|
202
205
|
---
|
|
203
206
|
|
|
@@ -468,6 +471,10 @@ pi-crew survives Pi's context compaction. When the context is compacted (auto or
|
|
|
468
471
|
Context compacted. 1 pi-crew run(s) still in-flight — use team status to continue.
|
|
469
472
|
```
|
|
470
473
|
|
|
474
|
+
**Durable event replay** (v0.9.8, L1): even if a dashboard/overlay is briefly gone during compaction or a reconnect, `RunEventBus.onWithReplay()` catches it up with the events it missed, replaying from the durable JSONL log with seq-based dedup — no information loss. (The dashboard wires this up per-run; the primitive is available for any subscriber.)
|
|
475
|
+
|
|
476
|
+
**Lossless-by-default worker output** (v0.9.8, L4): output-handling thresholds are sized from measured real data (100% of real worker outputs fit without any compaction). When compaction *is* unavoidable, it keeps head+tail instead of head-only truncation, so closing code fences and headings survive — no more `[pi-crew compacted N chars]` markers eating the end of a result.
|
|
477
|
+
|
|
471
478
|
### Plan-level human-in-the-loop (HITL)
|
|
472
479
|
|
|
473
480
|
Set `runtime.requirePlanApproval = true` to gate **any workflow** at the plan→execute boundary. After the read-only (planning) phases complete, the run pauses for explicit approval before mutating tasks run:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-crew",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.8",
|
|
4
4
|
"description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
|
|
5
5
|
"author": "baphuongna",
|
|
6
6
|
"license": "MIT",
|
|
@@ -90,7 +90,8 @@
|
|
|
90
90
|
"ajv": "^8.20.0",
|
|
91
91
|
"cli-highlight": "^2.1.11",
|
|
92
92
|
"diff": "^5.2.0",
|
|
93
|
-
"jiti": "^2.7.0"
|
|
93
|
+
"jiti": "^2.7.0",
|
|
94
|
+
"yaml": "^2.9.0"
|
|
94
95
|
},
|
|
95
96
|
"devDependencies": {
|
|
96
97
|
"@biomejs/biome": "^2.4.15",
|
package/src/config/defaults.ts
CHANGED
|
@@ -16,10 +16,14 @@ export const DEFAULT_CHILD_PI: Readonly<{
|
|
|
16
16
|
// Keep this as a coarse stuck-worker guard rather than a short per-message latency budget.
|
|
17
17
|
responseTimeoutMs: 5 * 60_000,
|
|
18
18
|
maxCaptureBytes: 256 * 1024,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// L4 output-handling: thresholds sized from real worker-output data
|
|
20
|
+
// (27 result artifacts measured: max 9226 bytes, median 8272, 100% < 16KB).
|
|
21
|
+
// Previous values (8192/1024/4096) truncated 62% of real results.
|
|
22
|
+
// See .crew/research/worker-output-handling.md + source/deer-flow/.research/.
|
|
23
|
+
maxAssistantTextChars: 16_384,
|
|
24
|
+
maxToolResultChars: 8_192,
|
|
25
|
+
maxToolInputChars: 4_096,
|
|
26
|
+
maxCompactContentChars: 8_192,
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
export const DEFAULT_LIVE_SESSION = {
|
|
@@ -2,7 +2,8 @@ import type { AgentConfig, ResourceSource } from "../agents/agent-config.ts";
|
|
|
2
2
|
import { discoverAgents } from "../agents/discover-agents.ts";
|
|
3
3
|
import { discoverTeams } from "../teams/discover-teams.ts";
|
|
4
4
|
import { discoverWorkflows } from "../workflows/discover-workflows.ts";
|
|
5
|
-
import { discoverSkills } from "../skills/discover-skills.ts";
|
|
5
|
+
import { discoverSkills, getLastDiscoveryDiagnostics } from "../skills/discover-skills.ts";
|
|
6
|
+
import type { SkillValidationError } from "../skills/validate.ts";
|
|
6
7
|
import type { PiTeamsConfig } from "../config/config.ts";
|
|
7
8
|
|
|
8
9
|
export type CapabilityKind = "team" | "workflow" | "agent" | "skill" | "tool" | "runtime";
|
|
@@ -114,3 +115,21 @@ export function buildCapabilityInventory(cwd: string, config?: PiTeamsConfig): C
|
|
|
114
115
|
|
|
115
116
|
return items.sort((a, b) => a.id.localeCompare(b.id));
|
|
116
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* L3: surface skill-validation diagnostics from the most recent
|
|
121
|
+
* `discoverSkills()` call. Skills that fail HARD validation are silently
|
|
122
|
+
* excluded from `buildCapabilityInventory()`; this function exposes the
|
|
123
|
+
* underlying errors so users see WHY a skill is missing instead of just
|
|
124
|
+
* noticing the absence.
|
|
125
|
+
*
|
|
126
|
+
* Soft warnings (unknown props, derived-name fallback) are also returned so
|
|
127
|
+
* skill authors can clean up their frontmatter over time.
|
|
128
|
+
*
|
|
129
|
+
* IMPORTANT: `discoverSkills()` is internally cached for 30s, so this
|
|
130
|
+
* function returns diagnostics from whichever call last populated the cache.
|
|
131
|
+
* Call `buildCapabilityInventory(cwd)` first to ensure a fresh pass.
|
|
132
|
+
*/
|
|
133
|
+
export function buildSkillValidationDiagnostics(): SkillValidationError[] {
|
|
134
|
+
return getLastDiscoveryDiagnostics();
|
|
135
|
+
}
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -380,7 +380,14 @@ function appendTranscript(input: ChildPiRunInput, line: string): void {
|
|
|
380
380
|
|
|
381
381
|
function compactString(value: string, maxChars = MAX_COMPACT_CONTENT_CHARS): string {
|
|
382
382
|
if (value.length <= maxChars) return value;
|
|
383
|
-
|
|
383
|
+
// L4: head + tail instead of head-only. Keeps closing markdown structure
|
|
384
|
+
// (code fences, headings, list tails) instead of dropping them — the old
|
|
385
|
+
// head-only slice left unclosed ``` fences that downstream parsers and
|
|
386
|
+
// output-validator.ts flagged as "output may be truncated". Head gets 75%
|
|
387
|
+
// (opening structure + bulk of content); tail gets 25% (closing structure).
|
|
388
|
+
const head = Math.floor(maxChars * 0.75);
|
|
389
|
+
const tail = maxChars - head;
|
|
390
|
+
return `${value.slice(0, head)}\n...[pi-crew compacted ${value.length - maxChars} chars, head+tail preserved]...\n${value.slice(-tail)}`;
|
|
384
391
|
}
|
|
385
392
|
|
|
386
393
|
function compactValue(value: unknown): unknown {
|
|
@@ -30,20 +30,36 @@ function containedExists(filePath: string, baseDir?: string): boolean {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* L4 output-handling: single consistent threshold for all artifact reads.
|
|
35
|
+
* Sized from real data (27 result artifacts: max 9226 bytes; 100% < 16KB).
|
|
36
|
+
* 32KB gives 2x headroom over the largest observed real output while still
|
|
37
|
+
* bounding memory. Larger than the old inconsistent per-call-site values
|
|
38
|
+
* (24K/40K/80K) which truncated the same artifact differently depending on
|
|
39
|
+
* which code path read it.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_RESULT_INLINE_BYTES = 32_000;
|
|
42
|
+
|
|
43
|
+
function readIfSmall(filePath: string, baseDir?: string): string | undefined {
|
|
44
|
+
const maxBytes = MAX_RESULT_INLINE_BYTES;
|
|
34
45
|
try {
|
|
35
46
|
const safePath = baseDir ? resolveRealContainedPath(baseDir, filePath) : filePath;
|
|
36
47
|
const stat = fs.statSync(safePath);
|
|
37
48
|
if (stat.size > maxBytes) {
|
|
38
|
-
//
|
|
39
|
-
|
|
49
|
+
// L4: head + tail instead of head-only. Keeps closing markdown
|
|
50
|
+
// structure (code fences, headings) instead of leaving them truncated.
|
|
51
|
+
const head = Math.floor(maxBytes * 0.75);
|
|
52
|
+
const tail = maxBytes - head;
|
|
53
|
+
const headBuf = Buffer.alloc(head);
|
|
54
|
+
const tailBuf = Buffer.alloc(tail);
|
|
40
55
|
const fd = fs.openSync(safePath, "r");
|
|
41
56
|
try {
|
|
42
|
-
fs.readSync(fd,
|
|
57
|
+
fs.readSync(fd, headBuf, 0, head, 0);
|
|
58
|
+
fs.readSync(fd, tailBuf, 0, tail, stat.size - tail);
|
|
43
59
|
} finally {
|
|
44
60
|
fs.closeSync(fd);
|
|
45
61
|
}
|
|
46
|
-
return `${
|
|
62
|
+
return `${headBuf.toString("utf-8")}\n\n...[pi-crew truncated ${stat.size - maxBytes} bytes, head+tail preserved]...\n${tailBuf.toString("utf-8")}`;
|
|
47
63
|
}
|
|
48
64
|
return fs.readFileSync(safePath, "utf-8");
|
|
49
65
|
} catch {
|
|
@@ -99,7 +115,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
99
115
|
const byStep = new Map(tasks.map((item) => [item.stepId, item]).filter((entry): entry is [string, TeamTaskState] => Boolean(entry[0])));
|
|
100
116
|
const byId = new Map(tasks.map((item) => [item.id, item]));
|
|
101
117
|
const dependencies = task.dependsOn.map((dep) => byStep.get(dep) ?? byId.get(dep)).filter((item): item is TeamTaskState => Boolean(item)).map((item) => {
|
|
102
|
-
const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path,
|
|
118
|
+
const resultText = item.resultArtifact ? readIfSmall(item.resultArtifact.path, manifest.artifactsRoot) : undefined;
|
|
103
119
|
return {
|
|
104
120
|
taskId: item.id,
|
|
105
121
|
role: item.role,
|
|
@@ -113,7 +129,7 @@ export function collectDependencyOutputContext(manifest: TeamRunManifest, tasks:
|
|
|
113
129
|
});
|
|
114
130
|
const sharedReads = (step.reads === false ? [] : step.reads ?? []).map((name) => {
|
|
115
131
|
const filePath = sharedPath(manifest, name);
|
|
116
|
-
return { name, path: filePath, content: readIfSmall(filePath,
|
|
132
|
+
return { name, path: filePath, content: readIfSmall(filePath, path.resolve(manifest.artifactsRoot, "shared")) ?? "" };
|
|
117
133
|
}).filter((item) => item.content.trim().length > 0);
|
|
118
134
|
return { dependencies, sharedReads };
|
|
119
135
|
}
|
|
@@ -139,7 +155,7 @@ export function renderDependencyOutputContext(context: DependencyOutputContext):
|
|
|
139
155
|
export function writeTaskSharedOutput(manifest: TeamRunManifest, step: WorkflowStep, task: TeamTaskState): ArtifactDescriptor | undefined {
|
|
140
156
|
if (step.output === false) return undefined;
|
|
141
157
|
const name = safeSharedName(step.output || `${task.id}.md`);
|
|
142
|
-
const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path,
|
|
158
|
+
const source = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest.artifactsRoot) : undefined;
|
|
143
159
|
if (!source) return undefined;
|
|
144
160
|
return writeArtifact(manifest.artifactsRoot, {
|
|
145
161
|
kind: "metadata",
|
|
@@ -160,7 +176,7 @@ export function writeTaskInputsArtifact(manifest: TeamRunManifest, task: TeamTas
|
|
|
160
176
|
|
|
161
177
|
export function aggregateTaskOutputs(tasks: TeamTaskState[], manifest?: TeamRunManifest): string {
|
|
162
178
|
return tasks.map((task, index) => {
|
|
163
|
-
const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path,
|
|
179
|
+
const body = task.resultArtifact ? readIfSmall(task.resultArtifact.path, manifest?.artifactsRoot) : undefined;
|
|
164
180
|
const hasBody = Boolean(body?.trim());
|
|
165
181
|
const expectedMissing = task.resultArtifact && !containedExists(task.resultArtifact.path, manifest?.artifactsRoot);
|
|
166
182
|
const status = task.status === "skipped"
|
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { getAgentDir } from "../runtime/peer-dep.ts";
|
|
8
8
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
9
9
|
import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
10
|
+
import { parseSkillFrontmatter, validateSkillFrontmatter, type SkillValidationError } from "./validate.ts";
|
|
10
11
|
|
|
11
12
|
const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
|
|
12
13
|
|
|
@@ -54,16 +55,44 @@ function listSkillDirs(cwd: string): Array<{ root: string; source: SkillDescript
|
|
|
54
55
|
];
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// ── Diagnostics (L3) ──────────────────────────────────────────────────────────
|
|
59
|
+
// Module-level buffer populated each `discoverSkills()` call. Cleared at the
|
|
60
|
+
// start of every call so callers see only the most recent run's diagnostics.
|
|
61
|
+
// Surfaced via `getLastDiscoveryDiagnostics()` so capability-inventory and
|
|
62
|
+
// other consumers can convert silent exclusions into visible feedback.
|
|
63
|
+
let lastDiagnostics: SkillValidationError[] = [];
|
|
64
|
+
|
|
65
|
+
export function getLastDiscoveryDiagnostics(): SkillValidationError[] {
|
|
66
|
+
return lastDiagnostics;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse frontmatter defensively. Falls back to the legacy line-prefix match
|
|
71
|
+
* if YAML parsing fails — preserves back-compat for malformed but readable
|
|
72
|
+
* SKILL.md files that pre-date the validator (we record a diagnostic in that
|
|
73
|
+
* case but still return the description we could salvage).
|
|
74
|
+
*/
|
|
75
|
+
function readDescription(content: string): { description: string; parseError: string | null } {
|
|
76
|
+
const parsed = parseSkillFrontmatter(content);
|
|
77
|
+
if (parsed.ok) {
|
|
78
|
+
const d = parsed.data.description;
|
|
79
|
+
return { description: typeof d === "string" ? d : "", parseError: null };
|
|
80
|
+
}
|
|
81
|
+
// YAML parse failed — fall back to legacy line-prefix match so we don't
|
|
82
|
+
// regress existing skills whose frontmatter the old parser could read.
|
|
83
|
+
const legacyMatch = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
84
|
+
if (legacyMatch) {
|
|
85
|
+
const line = legacyMatch[1].split(/\r?\n/).find((entry) => entry.startsWith("description:"));
|
|
86
|
+
const fallback = line?.slice("description:".length).trim() ?? "";
|
|
87
|
+
return { description: fallback, parseError: parsed.error };
|
|
88
|
+
}
|
|
89
|
+
return { description: "", parseError: parsed.error };
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
export function discoverSkills(cwd: string): SkillDescriptor[] {
|
|
65
93
|
if (cache && cache.cwd === cwd && Date.now() - cache.cachedAt < CACHE_TTL_MS) return cache.skills;
|
|
66
94
|
const results: SkillDescriptor[] = [];
|
|
95
|
+
const diagnostics: SkillValidationError[] = [];
|
|
67
96
|
for (const dir of listSkillDirs(cwd)) {
|
|
68
97
|
if (!fs.existsSync(dir.root)) continue;
|
|
69
98
|
try {
|
|
@@ -94,7 +123,16 @@ export function discoverSkills(cwd: string): SkillDescriptor[] {
|
|
|
94
123
|
// (e.g. macOS /var → /private/var). Fall through with un-resolved path.
|
|
95
124
|
}
|
|
96
125
|
const content = fs.readFileSync(readPath, "utf-8");
|
|
97
|
-
description =
|
|
126
|
+
const { description: desc, parseError } = readDescription(content);
|
|
127
|
+
description = desc;
|
|
128
|
+
if (parseError) {
|
|
129
|
+
diagnostics.push({
|
|
130
|
+
path: path.dirname(skillMdPath),
|
|
131
|
+
field: "frontmatter",
|
|
132
|
+
reason: parseError,
|
|
133
|
+
severity: "error",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
98
136
|
} catch (error) {
|
|
99
137
|
logInternalError("discoverSkills.readSkill", error, `skill=${entry.name}`);
|
|
100
138
|
}
|
|
@@ -104,6 +142,21 @@ export function discoverSkills(cwd: string): SkillDescriptor[] {
|
|
|
104
142
|
logInternalError("discoverSkills.readdir", error, `root=${dir.root}`);
|
|
105
143
|
}
|
|
106
144
|
}
|
|
107
|
-
|
|
108
|
-
|
|
145
|
+
// L3: strict validation pass after we've collected every (skill, source)
|
|
146
|
+
// candidate. Excludes malformed skills (HYBRID policy: missing/malformed
|
|
147
|
+
// `name`/`description` hard-fail; unknown props warn). Diagnostics are
|
|
148
|
+
// always recorded, including for skills that PASSED validation but had
|
|
149
|
+
// unknown-prop warnings.
|
|
150
|
+
const filtered: SkillDescriptor[] = [];
|
|
151
|
+
for (const skill of results) {
|
|
152
|
+
const validation = validateSkillFrontmatter(path.dirname(skill.path));
|
|
153
|
+
if (validation.ok) {
|
|
154
|
+
filtered.push(skill);
|
|
155
|
+
} else {
|
|
156
|
+
diagnostics.push(...validation.errors);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
lastDiagnostics = diagnostics;
|
|
160
|
+
cache = { skills: filtered, cachedAt: Date.now(), cwd };
|
|
161
|
+
return filtered;
|
|
109
162
|
}
|
|
@@ -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/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
|
}
|