pi-messenger 0.12.1 → 0.13.1
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 +15 -0
- package/README.md +34 -4
- package/crew/agents/crew-planner.md +5 -3
- package/crew/agents/crew-worker.md +12 -0
- package/crew/agents.ts +1 -1
- package/crew/handlers/plan.ts +41 -11
- package/crew/handlers/review.ts +1 -1
- package/crew/handlers/work.ts +48 -4
- package/crew/prompt.ts +44 -0
- package/crew/spawn.ts +6 -3
- package/crew/types.ts +2 -0
- package/crew/utils/artifacts.ts +3 -1
- package/crew/utils/discover.ts +96 -2
- package/feed.ts +54 -22
- package/overlay-coordinator.ts +150 -0
- package/overlay-render.ts +9 -7
- package/overlay.ts +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.13.1] - 2026-03-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **Auto-review after task completion** — Workers' completed tasks now get an automatic reviewer pass before being counted as done. Controlled by existing `config.review.enabled` (default: true) and `config.review.maxIterations` (default: 3). SHIP keeps the task done, NEEDS_WORK resets it to todo for retry with review feedback injected into the next worker's prompt, MAJOR_RETHINK blocks the task. Reviews run sequentially between worker completion and wave result reporting, and respect the abort signal. Adds `review_count` to the Task interface and `task.review` to the activity feed.
|
|
7
|
+
|
|
8
|
+
## [0.13.0] - 2026-03-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Dynamic skill loading for crew workers** — Workers can now acquire domain-specific knowledge on demand during task execution. A three-tier discovery system scans user skills (`~/.pi/agent/skills/`), extension skills (`crew/skills/`), and project skills (`.pi/messenger/crew/skills/`) to build a skill catalog. The planner sees the catalog and can tag tasks with relevant skill names. Workers see tagged skills as "Recommended" in their prompt alongside the full catalog, and load what they need via `read()` — zero upfront token cost, no config changes. Project-level skills override extension, which override user, matching the agent override pattern. When no skills are configured, prompts are unchanged.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- **Artifact dir creation** — `writeArtifact`, `writeMetadata`, and `appendJsonl` now create parent directories on demand. Fixes ENOENT on first `plan` run.
|
|
15
|
+
- **Multiline feed sanitization** — Feed events with embedded newlines no longer corrupt the TUI overlay layout.
|
|
16
|
+
- **Config model override** — `crew.models` config now actually overrides agent defaults. Priority: task override > config > agent frontmatter.
|
|
17
|
+
|
|
3
18
|
## [0.12.1] - 2026-02-22
|
|
4
19
|
|
|
5
20
|
### Fixed
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
pi install npm:pi-messenger
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Crew agents ship with the extension (`crew/agents/*.md`) and are discovered automatically. The `pi-messenger-crew` skill is auto-loaded from the extension.
|
|
19
|
+
Crew agents ship with the extension (`crew/agents/*.md`) and are discovered automatically. The `pi-messenger-crew` skill is auto-loaded from the extension. Workers can load domain-specific [crew skills](#crew-skills) on demand during task execution.
|
|
20
20
|
|
|
21
21
|
To show available crew agents:
|
|
22
22
|
|
|
@@ -92,11 +92,13 @@ Chat input supports `@Name msg` for DMs and `@all msg` for broadcasts. Text with
|
|
|
92
92
|
|
|
93
93
|
Crew turns a PRD into a dependency graph of tasks, then executes them in parallel waves.
|
|
94
94
|
|
|
95
|
+
Crew logs are per project, under that project's working directory: `.pi/messenger/crew/`. For example, if you run Crew from `/path/to/my-app`, the planner log lives at `/path/to/my-app/.pi/messenger/crew/planning-progress.md`.
|
|
96
|
+
|
|
95
97
|
### Workflow
|
|
96
98
|
|
|
97
99
|
1. **Plan** — Planner explores the codebase and PRD, drafts tasks with dependencies. A reviewer checks the plan; the planner refines until SHIP or `maxPasses` is reached. History is stored in `planning-progress.md`.
|
|
98
|
-
2. **Work** — Workers implement ready tasks (all dependencies met) in parallel waves. A single `work` call runs one wave. `autonomous: true` runs waves back-to-back until everything is done or blocked.
|
|
99
|
-
3. **Review** —
|
|
100
|
+
2. **Work** — Workers implement ready tasks (all dependencies met) in parallel waves. A single `work` call runs one wave. `autonomous: true` runs waves back-to-back until everything is done or blocked. Each completed task gets an automatic reviewer pass — SHIP keeps it done, NEEDS_WORK resets it for retry with feedback, MAJOR_RETHINK blocks it. Controlled by `review.enabled` and `review.maxIterations`.
|
|
101
|
+
3. **Review** — Manual review of a specific task or the plan: `pi_messenger({ action: "review", target: "task-1" })`. Returns SHIP, NEEDS_WORK, or MAJOR_RETHINK with detailed feedback.
|
|
100
102
|
|
|
101
103
|
No special PRD format required — the planner auto-discovers `PRD.md`, `SPEC.md`, `DESIGN.md`, etc. in your project root and `docs/`. Or skip the file entirely:
|
|
102
104
|
|
|
@@ -123,6 +125,34 @@ Wave 3: task-5 (→ task-2, task-4) ── both deps done
|
|
|
123
125
|
|
|
124
126
|
The planner structures tasks to maximize parallelism. Foundation work has no dependencies and starts immediately. Features that don't touch each other get separate chains. Autonomous mode stops when all tasks are done or blocked.
|
|
125
127
|
|
|
128
|
+
### Crew Skills
|
|
129
|
+
|
|
130
|
+
Workers follow the same join/read/implement/commit/release protocol regardless of the task — what changes between tasks is domain knowledge. Crew skills let workers acquire that knowledge on demand.
|
|
131
|
+
|
|
132
|
+
Skills are discovered from three locations (later sources override earlier by name):
|
|
133
|
+
|
|
134
|
+
1. **User skills** — `~/.pi/agent/skills/` (pi's standard `dir/SKILL.md` format)
|
|
135
|
+
2. **Extension skills** — `crew/skills/` within the extension (flat `.md` files)
|
|
136
|
+
3. **Project skills** — `.pi/messenger/crew/skills/` in your project root (flat `.md` files)
|
|
137
|
+
|
|
138
|
+
The planner sees a compact index of all discovered skills and can tag tasks with relevant ones. Workers see tagged skills as "Recommended for this task" with the full catalog under "Also available", and load what they need via `read()`. Zero tokens spent until a worker actually needs the knowledge.
|
|
139
|
+
|
|
140
|
+
To add a project-level skill, drop a `.md` file in `.pi/messenger/crew/skills/`:
|
|
141
|
+
|
|
142
|
+
```markdown
|
|
143
|
+
---
|
|
144
|
+
name: our-api-patterns
|
|
145
|
+
description: REST API conventions for this project — auth, pagination, error shapes.
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
# API Patterns
|
|
149
|
+
|
|
150
|
+
Always use Bearer token auth. Paginate with cursor-based `?after=` params.
|
|
151
|
+
Error responses use `{ error: { code, message, details? } }` shape.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Any skills you already have in `~/.pi/agent/skills/` are automatically available to crew workers — no setup needed.
|
|
155
|
+
|
|
126
156
|
### Crew Configuration
|
|
127
157
|
|
|
128
158
|
Crew spawns multiple LLM sessions in parallel — it can burn tokens fast. Start with a cheap worker model and scale up once you've seen the workflow. Add this to `~/.pi/agent/pi-messenger.json`:
|
|
@@ -304,7 +334,7 @@ Incoming messages wake the receiving agent via `pi.sendMessage()` with `triggerT
|
|
|
304
334
|
|
|
305
335
|
Crew workers are spawned as `pi --mode json` subprocesses with the agent's system prompt, model, and tool restrictions from their `.md` definitions. Progress is tracked via JSONL streaming — the overlay subscribes to a live progress store that shows each worker's current tool, call count, and token usage in real time. Aborting a work run triggers graceful shutdown: each worker receives an inbox message asking it to stop, followed by a grace period before SIGTERM. The planner and reviewer work the same way — just pi instances with different agent configs.
|
|
306
336
|
|
|
307
|
-
All coordination is file-based, no daemon required. Shared state (registry, inboxes, swarm claims/completions) lives in `~/.pi/agent/messenger/`. Activity feed and crew data are project-scoped under `.pi/messenger/` inside your project. Dead agents are detected via PID checks and cleaned up automatically.
|
|
337
|
+
All coordination is file-based, no daemon required. Shared state (registry, inboxes, swarm claims/completions) lives in `~/.pi/agent/messenger/`. Activity feed and crew data are project-scoped under `.pi/messenger/` inside your project, so Crew logs live at `<project>/.pi/messenger/crew/` and the shared activity feed lives at `<project>/.pi/messenger/feed.jsonl`. Dead agents are detected via PID checks and cleaned up automatically.
|
|
308
338
|
|
|
309
339
|
## Credits
|
|
310
340
|
|
|
@@ -143,7 +143,7 @@ Dependencies: none
|
|
|
143
143
|
|
|
144
144
|
### JSON format (for reliable parsing):
|
|
145
145
|
|
|
146
|
-
Include this fenced block after the markdown tasks. Titles must match exactly.
|
|
146
|
+
Include this fenced block after the markdown tasks. Titles must match exactly. If your prompt lists Available Skills, include a `skills` array with relevant skill names.
|
|
147
147
|
|
|
148
148
|
````
|
|
149
149
|
```tasks-json
|
|
@@ -151,12 +151,14 @@ Include this fenced block after the markdown tasks. Titles must match exactly.
|
|
|
151
151
|
{
|
|
152
152
|
"title": "Title matching ### Task 1 above",
|
|
153
153
|
"description": "Full description including acceptance criteria",
|
|
154
|
-
"dependsOn": []
|
|
154
|
+
"dependsOn": [],
|
|
155
|
+
"skills": ["react-best-practices"]
|
|
155
156
|
},
|
|
156
157
|
{
|
|
157
158
|
"title": "Title matching ### Task 2 above",
|
|
158
159
|
"description": "Full description",
|
|
159
|
-
"dependsOn": ["Title matching ### Task 1 above"]
|
|
160
|
+
"dependsOn": ["Title matching ### Task 1 above"],
|
|
161
|
+
"skills": ["testing", "api-design"]
|
|
160
162
|
}
|
|
161
163
|
]
|
|
162
164
|
```
|
|
@@ -35,6 +35,18 @@ Read the task spec file for detailed requirements:
|
|
|
35
35
|
read({ path: ".pi/messenger/crew/tasks/<TASK_ID>.md" })
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Phase 2.5: Load Relevant Skills
|
|
39
|
+
|
|
40
|
+
If your task prompt includes an **Available Skills** section, read any that match what you're building before starting implementation.
|
|
41
|
+
|
|
42
|
+
If skills are marked **Recommended for this task**, read those first.
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
read({ path: "<skill-path-from-the-list>" })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Skip this phase if no Available Skills section is present.
|
|
49
|
+
|
|
38
50
|
## Phase 3: Start Task & Reserve Files
|
|
39
51
|
|
|
40
52
|
```typescript
|
package/crew/agents.ts
CHANGED
|
@@ -203,7 +203,7 @@ async function runAgent(
|
|
|
203
203
|
return new Promise((resolve) => {
|
|
204
204
|
// Build args for pi command
|
|
205
205
|
const args = ["--mode", "json", "--no-session", "-p"];
|
|
206
|
-
const model = task.modelOverride ?? agentConfig?.model;
|
|
206
|
+
const model = task.modelOverride ?? config.models?.[role] ?? agentConfig?.model;
|
|
207
207
|
if (model) pushModelArgs(args, model);
|
|
208
208
|
|
|
209
209
|
const thinking = resolveThinking(
|
package/crew/handlers/plan.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
|
11
11
|
import type { CrewParams } from "../types.js";
|
|
12
12
|
import { result } from "../utils/result.js";
|
|
13
13
|
import { spawnAgents } from "../agents.js";
|
|
14
|
-
import { discoverCrewAgents } from "../utils/discover.js";
|
|
14
|
+
import { discoverCrewAgents, discoverCrewSkills, type CrewSkillInfo } from "../utils/discover.js";
|
|
15
15
|
import { loadCrewConfig } from "../utils/config.js";
|
|
16
16
|
import { parseVerdict, type ParsedReview } from "../utils/verdict.js";
|
|
17
17
|
import { logFeedEvent } from "../../feed.js";
|
|
@@ -298,6 +298,7 @@ export async function execute(
|
|
|
298
298
|
const config = loadCrewConfig(store.getCrewDir(cwd));
|
|
299
299
|
const maxPasses = Math.max(1, config.planning.maxPasses);
|
|
300
300
|
const hasReviewer = availableAgents.some(a => a.name === "crew-reviewer");
|
|
301
|
+
const skills = discoverCrewSkills(cwd);
|
|
301
302
|
|
|
302
303
|
const existingProgress = readProgressForPrompt(cwd);
|
|
303
304
|
|
|
@@ -327,8 +328,8 @@ export async function execute(
|
|
|
327
328
|
notify(ctx, `Planning pass ${pass}/${maxPasses} in progress`, "info");
|
|
328
329
|
|
|
329
330
|
const plannerPrompt = pass === 1
|
|
330
|
-
? buildFirstPassPrompt(prdPath, prdContent, existingProgress, isPromptBased)
|
|
331
|
-
: buildRefinementPrompt(prdPath, prdContent, readProgressForPrompt(cwd), isPromptBased);
|
|
331
|
+
? buildFirstPassPrompt(prdPath, prdContent, existingProgress, isPromptBased, skills)
|
|
332
|
+
: buildRefinementPrompt(prdPath, prdContent, readProgressForPrompt(cwd), isPromptBased, skills);
|
|
332
333
|
|
|
333
334
|
const [plannerResult] = await spawnAgents([{
|
|
334
335
|
agent: PLANNER_AGENT,
|
|
@@ -435,13 +436,13 @@ export async function execute(
|
|
|
435
436
|
});
|
|
436
437
|
}
|
|
437
438
|
|
|
438
|
-
const createdTasks: { id: string; title: string; dependsOn: string[] }[] = [];
|
|
439
|
+
const createdTasks: { id: string; title: string; dependsOn: string[]; skills?: string[] }[] = [];
|
|
439
440
|
const titleToId = new Map<string, string>();
|
|
440
441
|
|
|
441
442
|
for (let i = 0; i < tasks.length; i++) {
|
|
442
443
|
const task = tasks[i];
|
|
443
444
|
const created = store.createTask(cwd, task.title, task.description);
|
|
444
|
-
createdTasks.push({ id: created.id, title: task.title, dependsOn: task.dependsOn });
|
|
445
|
+
createdTasks.push({ id: created.id, title: task.title, dependsOn: task.dependsOn, skills: task.skills });
|
|
445
446
|
titleToId.set(task.title.toLowerCase(), created.id);
|
|
446
447
|
titleToId.set(`task ${i + 1}`, created.id);
|
|
447
448
|
titleToId.set(`task-${i + 1}`, created.id);
|
|
@@ -460,6 +461,10 @@ export async function execute(
|
|
|
460
461
|
store.updateTask(cwd, task.id, { depends_on: resolvedDeps });
|
|
461
462
|
}
|
|
462
463
|
}
|
|
464
|
+
|
|
465
|
+
if (task.skills && task.skills.length > 0) {
|
|
466
|
+
store.updateTask(cwd, task.id, { skills: task.skills });
|
|
467
|
+
}
|
|
463
468
|
}
|
|
464
469
|
|
|
465
470
|
pruneTransitiveDeps(cwd, createdTasks.map(t => t.id));
|
|
@@ -539,19 +544,40 @@ ${nextSteps}`;
|
|
|
539
544
|
// Prompt Builders
|
|
540
545
|
// =============================================================================
|
|
541
546
|
|
|
542
|
-
function
|
|
547
|
+
function formatSkillsForPlanner(skills: CrewSkillInfo[]): string {
|
|
548
|
+
if (skills.length === 0) return "";
|
|
549
|
+
|
|
550
|
+
const lines = skills.map(s => ` ${s.name} — ${s.description}`);
|
|
551
|
+
return `
|
|
552
|
+
## Available Skills
|
|
553
|
+
|
|
554
|
+
Workers can load these skills on demand during task execution. When creating tasks, you may include a \`skills\` array with relevant skill names to help workers prioritize which to read.
|
|
555
|
+
|
|
556
|
+
${lines.join("\n")}
|
|
557
|
+
|
|
558
|
+
`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function tasksJsonFormatHint(skills: CrewSkillInfo[]): string {
|
|
562
|
+
return skills.length > 0
|
|
563
|
+
? "title, description, dependsOn, and optionally skills (array of skill names from the Available Skills list that are relevant to the task)"
|
|
564
|
+
: "title, description, and dependsOn";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function buildFirstPassPrompt(prdPath: string, prdContent: string, existingProgress: string, isPromptBased: boolean, skills: CrewSkillInfo[]): string {
|
|
543
568
|
const specType = isPromptBased ? "request" : "PRD";
|
|
544
569
|
const specLabel = isPromptBased ? "Request" : `PRD: ${prdPath}`;
|
|
545
570
|
const progressSection = existingProgress
|
|
546
571
|
? `\n## Previous Planning Context\n${existingProgress}\n`
|
|
547
572
|
: "";
|
|
573
|
+
const skillsSection = formatSkillsForPlanner(skills);
|
|
548
574
|
|
|
549
575
|
return `Create a task breakdown for implementing this ${specType}.
|
|
550
576
|
|
|
551
577
|
## ${specLabel}
|
|
552
578
|
|
|
553
579
|
${prdContent}
|
|
554
|
-
${progressSection}
|
|
580
|
+
${progressSection}${skillsSection}
|
|
555
581
|
You must follow this sequence strictly:
|
|
556
582
|
1) Understand the ${specType}
|
|
557
583
|
2) Review relevant code/docs/reference resources
|
|
@@ -566,7 +592,7 @@ Return output in this exact section order and headings:
|
|
|
566
592
|
|
|
567
593
|
In section 4, include both:
|
|
568
594
|
- markdown task breakdown
|
|
569
|
-
- a \`tasks-json\` fenced block with task objects containing
|
|
595
|
+
- a \`tasks-json\` fenced block with task objects containing ${tasksJsonFormatHint(skills)}.`;
|
|
570
596
|
}
|
|
571
597
|
|
|
572
598
|
function buildRefinementPrompt(
|
|
@@ -574,13 +600,15 @@ function buildRefinementPrompt(
|
|
|
574
600
|
prdContent: string,
|
|
575
601
|
progressFileContent: string,
|
|
576
602
|
isPromptBased: boolean,
|
|
603
|
+
skills: CrewSkillInfo[],
|
|
577
604
|
): string {
|
|
578
605
|
const specLabel = isPromptBased ? "Request" : `PRD: ${prdPath}`;
|
|
606
|
+
const skillsSection = formatSkillsForPlanner(skills);
|
|
579
607
|
return `Refine your task breakdown based on review feedback.
|
|
580
608
|
|
|
581
609
|
## ${specLabel}
|
|
582
610
|
${prdContent}
|
|
583
|
-
|
|
611
|
+
${skillsSection}
|
|
584
612
|
## Planning Progress
|
|
585
613
|
${progressFileContent}
|
|
586
614
|
|
|
@@ -596,7 +624,7 @@ Return output in this exact section order and headings:
|
|
|
596
624
|
|
|
597
625
|
In section 4, include both:
|
|
598
626
|
- markdown task breakdown
|
|
599
|
-
- a \`tasks-json\` fenced block with task objects containing
|
|
627
|
+
- a \`tasks-json\` fenced block with task objects containing ${tasksJsonFormatHint(skills)}.`;
|
|
600
628
|
}
|
|
601
629
|
|
|
602
630
|
function buildPlanReviewPrompt(
|
|
@@ -652,6 +680,7 @@ interface ParsedTask {
|
|
|
652
680
|
title: string;
|
|
653
681
|
description: string;
|
|
654
682
|
dependsOn: string[];
|
|
683
|
+
skills?: string[];
|
|
655
684
|
}
|
|
656
685
|
|
|
657
686
|
function extractPlanSections(output: string): PlanSections | null {
|
|
@@ -701,7 +730,8 @@ function parseJsonTaskBlock(output: string): ParsedTask[] | null {
|
|
|
701
730
|
.map((t: Record<string, unknown>) => ({
|
|
702
731
|
title: (t.title as string).trim(),
|
|
703
732
|
description: typeof t.description === "string" ? t.description : "",
|
|
704
|
-
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.filter((d: unknown) => typeof d === "string") : []
|
|
733
|
+
dependsOn: Array.isArray(t.dependsOn) ? t.dependsOn.filter((d: unknown) => typeof d === "string") : [],
|
|
734
|
+
skills: Array.isArray(t.skills) ? t.skills.filter((s: unknown) => typeof s === "string") : undefined,
|
|
705
735
|
}));
|
|
706
736
|
return tasks.length > 0 ? tasks : null;
|
|
707
737
|
} catch {
|
package/crew/handlers/review.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function execute(
|
|
|
55
55
|
// Implementation Review
|
|
56
56
|
// =============================================================================
|
|
57
57
|
|
|
58
|
-
async function reviewImplementation(cwd: string, taskId: string, modelOverride?: string) {
|
|
58
|
+
export async function reviewImplementation(cwd: string, taskId: string, modelOverride?: string) {
|
|
59
59
|
const task = store.getTask(cwd, taskId);
|
|
60
60
|
if (!task) {
|
|
61
61
|
return result(`Error: Task ${taskId} not found.`, {
|
package/crew/handlers/work.ts
CHANGED
|
@@ -11,8 +11,9 @@ import type { CrewParams, AppendEntryFn } from "../types.js";
|
|
|
11
11
|
import { result } from "../utils/result.js";
|
|
12
12
|
import { resolveModel, spawnAgents } from "../agents.js";
|
|
13
13
|
import { loadCrewConfig } from "../utils/config.js";
|
|
14
|
-
import { discoverCrewAgents } from "../utils/discover.js";
|
|
14
|
+
import { discoverCrewAgents, discoverCrewSkills } from "../utils/discover.js";
|
|
15
15
|
import { buildWorkerPrompt } from "../prompt.js";
|
|
16
|
+
import { reviewImplementation } from "./review.js";
|
|
16
17
|
import * as store from "../store.js";
|
|
17
18
|
import { getCrewDir } from "../store.js";
|
|
18
19
|
import { autonomousState, isAutonomousForCwd, startAutonomous, stopAutonomous, addWaveResult, clampConcurrency } from "../state.js";
|
|
@@ -109,6 +110,8 @@ export async function execute(
|
|
|
109
110
|
appendEntry("crew-state", autonomousState);
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
const skills = discoverCrewSkills(cwd);
|
|
114
|
+
|
|
112
115
|
// Assign tasks to lobby workers first (they're already running and warmed up)
|
|
113
116
|
const prdLabel = store.getPlanLabel(plan);
|
|
114
117
|
const lobbyAssigned = new Set<string>();
|
|
@@ -118,7 +121,7 @@ export async function execute(
|
|
|
118
121
|
if (!task) break;
|
|
119
122
|
|
|
120
123
|
const others = readyTasks.filter(t => t.id !== task.id);
|
|
121
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
124
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
122
125
|
store.updateTask(cwd, task.id, {
|
|
123
126
|
status: "in_progress",
|
|
124
127
|
started_at: new Date().toISOString(),
|
|
@@ -145,7 +148,7 @@ export async function execute(
|
|
|
145
148
|
config.models?.worker,
|
|
146
149
|
);
|
|
147
150
|
const others = readyTasks.filter(t => t.id !== task.id);
|
|
148
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
151
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
149
152
|
store.appendTaskProgress(cwd, task.id, "system", `Assigned to crew-worker (attempt ${task.attempt_count + 1})`);
|
|
150
153
|
|
|
151
154
|
return {
|
|
@@ -218,6 +221,48 @@ export async function execute(
|
|
|
218
221
|
}
|
|
219
222
|
}
|
|
220
223
|
|
|
224
|
+
// Auto-review succeeded tasks
|
|
225
|
+
if (config.review.enabled && succeeded.length > 0) {
|
|
226
|
+
const hasReviewer = availableAgents.some(a => a.name === "crew-reviewer");
|
|
227
|
+
if (hasReviewer) {
|
|
228
|
+
for (const taskId of [...succeeded]) {
|
|
229
|
+
if (signal?.aborted) break;
|
|
230
|
+
const task = store.getTask(cwd, taskId);
|
|
231
|
+
if (!task || !task.base_commit) continue;
|
|
232
|
+
if ((task.review_count ?? 0) >= config.review.maxIterations) continue;
|
|
233
|
+
|
|
234
|
+
const rr = await reviewImplementation(cwd, taskId, config.models?.reviewer);
|
|
235
|
+
const verdict = rr.details?.verdict as string | undefined;
|
|
236
|
+
if (!verdict) {
|
|
237
|
+
store.appendTaskProgress(cwd, taskId, "system",
|
|
238
|
+
`Auto-review skipped: ${rr.details?.error ?? "unknown"}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const reviewCount = (task.review_count ?? 0) + 1;
|
|
243
|
+
store.updateTask(cwd, taskId, { review_count: reviewCount });
|
|
244
|
+
|
|
245
|
+
if (verdict === "SHIP") {
|
|
246
|
+
logFeedEvent(cwd, "crew", "task.review", taskId, "SHIP");
|
|
247
|
+
} else if (verdict === "NEEDS_WORK") {
|
|
248
|
+
store.resetTask(cwd, taskId);
|
|
249
|
+
logFeedEvent(cwd, "crew", "task.review", taskId, "NEEDS_WORK — reset for retry");
|
|
250
|
+
succeeded.splice(succeeded.indexOf(taskId), 1);
|
|
251
|
+
failed.push(taskId);
|
|
252
|
+
} else {
|
|
253
|
+
const lastReview = store.getTask(cwd, taskId)?.last_review;
|
|
254
|
+
const summary = lastReview?.summary
|
|
255
|
+
? lastReview.summary.split("\n")[0].slice(0, 120)
|
|
256
|
+
: "Major issues found";
|
|
257
|
+
store.blockTask(cwd, taskId, `Reviewer: ${summary}`);
|
|
258
|
+
logFeedEvent(cwd, "crew", "task.review", taskId, "MAJOR_RETHINK — blocked");
|
|
259
|
+
succeeded.splice(succeeded.indexOf(taskId), 1);
|
|
260
|
+
blocked.push(taskId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
221
266
|
syncCompletedCount(cwd);
|
|
222
267
|
|
|
223
268
|
// Save current wave number BEFORE addWaveResult increments it
|
|
@@ -325,4 +370,3 @@ function syncCompletedCount(cwd: string): void {
|
|
|
325
370
|
store.updatePlan(cwd, { completed_count: doneCount });
|
|
326
371
|
}
|
|
327
372
|
}
|
|
328
|
-
|
package/crew/prompt.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Task } from "./types.js";
|
|
9
9
|
import type { CrewConfig } from "./utils/config.js";
|
|
10
|
+
import type { CrewSkillInfo } from "./utils/discover.js";
|
|
10
11
|
import * as store from "./store.js";
|
|
11
12
|
import { buildDependencySection, buildCoordinationContext, buildCoordinationInstructions } from "./handlers/coordination.js";
|
|
12
13
|
|
|
@@ -16,6 +17,7 @@ export function buildWorkerPrompt(
|
|
|
16
17
|
cwd: string,
|
|
17
18
|
config: CrewConfig,
|
|
18
19
|
concurrentTasks: Task[],
|
|
20
|
+
skills?: CrewSkillInfo[],
|
|
19
21
|
): string {
|
|
20
22
|
const taskSpec = store.getTaskSpec(cwd, task.id);
|
|
21
23
|
const planSpec = store.getPlanSpec(cwd);
|
|
@@ -108,5 +110,47 @@ ${truncatedSpec}
|
|
|
108
110
|
prompt += coordInstructions;
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
const skillsSection = buildSkillsSection(skills, task.skills);
|
|
114
|
+
if (skillsSection) {
|
|
115
|
+
prompt += skillsSection;
|
|
116
|
+
}
|
|
117
|
+
|
|
111
118
|
return prompt;
|
|
112
119
|
}
|
|
120
|
+
|
|
121
|
+
function buildSkillsSection(
|
|
122
|
+
skills: CrewSkillInfo[] | undefined,
|
|
123
|
+
taskSkills: string[] | undefined,
|
|
124
|
+
): string | null {
|
|
125
|
+
if (!skills || skills.length === 0) return null;
|
|
126
|
+
|
|
127
|
+
const recommended = new Set(taskSkills ?? []);
|
|
128
|
+
const recSkills = skills.filter(s => recommended.has(s.name));
|
|
129
|
+
const otherSkills = skills.filter(s => !recommended.has(s.name));
|
|
130
|
+
|
|
131
|
+
let section = `## Available Skills
|
|
132
|
+
|
|
133
|
+
Read any skill that matches what you're implementing.
|
|
134
|
+
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
if (recSkills.length > 0) {
|
|
138
|
+
section += "**Recommended for this task:**\n";
|
|
139
|
+
for (const s of recSkills) {
|
|
140
|
+
section += ` ${s.name} — ${s.description}\n ${s.path}\n`;
|
|
141
|
+
}
|
|
142
|
+
section += "\n";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (otherSkills.length > 0) {
|
|
146
|
+
if (recSkills.length > 0) section += "**Also available:**\n";
|
|
147
|
+
for (const s of otherSkills) {
|
|
148
|
+
section += ` ${s.name} — ${s.description}\n ${s.path}\n`;
|
|
149
|
+
}
|
|
150
|
+
section += "\n";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
section += `To load a skill: read({ path: "<skill-path>" })\n`;
|
|
154
|
+
|
|
155
|
+
return section;
|
|
156
|
+
}
|
package/crew/spawn.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import * as store from "./store.js";
|
|
10
10
|
import { loadCrewConfig } from "./utils/config.js";
|
|
11
|
+
import { discoverCrewSkills } from "./utils/discover.js";
|
|
11
12
|
import { buildWorkerPrompt } from "./prompt.js";
|
|
12
13
|
import { logFeedEvent } from "../feed.js";
|
|
13
14
|
import {
|
|
@@ -32,6 +33,7 @@ export function spawnWorkersForReadyTasks(
|
|
|
32
33
|
const config = loadCrewConfig(crewDir);
|
|
33
34
|
const prdLabel = store.getPlanLabel(plan);
|
|
34
35
|
const inboxDir = join(cwd, ".pi", "messenger", "inbox");
|
|
36
|
+
const skills = discoverCrewSkills(cwd);
|
|
35
37
|
|
|
36
38
|
let assigned = 0;
|
|
37
39
|
let firstWorkerName: string | null = null;
|
|
@@ -44,7 +46,7 @@ export function spawnWorkersForReadyTasks(
|
|
|
44
46
|
|
|
45
47
|
const task = fresh[0];
|
|
46
48
|
const others = fresh.filter(t => t.id !== task.id);
|
|
47
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
49
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
48
50
|
|
|
49
51
|
store.updateTask(cwd, task.id, {
|
|
50
52
|
status: "in_progress",
|
|
@@ -71,7 +73,7 @@ export function spawnWorkersForReadyTasks(
|
|
|
71
73
|
|
|
72
74
|
const task = fresh[0];
|
|
73
75
|
const others = fresh.filter(t => t.id !== task.id);
|
|
74
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
76
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
75
77
|
const worker = spawnWorkerForTask(cwd, task.id, prompt);
|
|
76
78
|
if (!worker) break;
|
|
77
79
|
|
|
@@ -95,9 +97,10 @@ export function spawnSingleWorker(
|
|
|
95
97
|
const crewDir = store.getCrewDir(cwd);
|
|
96
98
|
const config = loadCrewConfig(crewDir);
|
|
97
99
|
const prdLabel = store.getPlanLabel(plan);
|
|
100
|
+
const skills = discoverCrewSkills(cwd);
|
|
98
101
|
const readyTasks = store.getReadyTasks(cwd, { advisory: config.dependencies === "advisory" });
|
|
99
102
|
const others = readyTasks.filter(t => t.id !== task.id);
|
|
100
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
103
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
101
104
|
const worker = spawnWorkerForTask(cwd, taskId, prompt);
|
|
102
105
|
return worker ? { name: worker.name } : null;
|
|
103
106
|
}
|
package/crew/types.ts
CHANGED
|
@@ -40,6 +40,7 @@ export interface Task {
|
|
|
40
40
|
milestone?: boolean;
|
|
41
41
|
model?: string;
|
|
42
42
|
depends_on: string[]; // Task IDs this depends on
|
|
43
|
+
skills?: string[]; // Skill names from planner
|
|
43
44
|
created_at: string; // ISO timestamp
|
|
44
45
|
updated_at: string; // ISO timestamp
|
|
45
46
|
started_at?: string; // When task.start was called
|
|
@@ -50,6 +51,7 @@ export interface Task {
|
|
|
50
51
|
evidence?: TaskEvidence; // Evidence from task.done
|
|
51
52
|
blocked_reason?: string; // Reason from task.block
|
|
52
53
|
attempt_count: number; // How many times attempted (for auto-block)
|
|
54
|
+
review_count?: number; // How many times reviewed
|
|
53
55
|
last_review?: ReviewFeedback; // Feedback from last review (for retry)
|
|
54
56
|
}
|
|
55
57
|
|
package/crew/utils/artifacts.ts
CHANGED
|
@@ -37,14 +37,16 @@ export function ensureArtifactsDir(dir: string): void {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
export function writeArtifact(filePath: string, content: string): void {
|
|
40
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
40
41
|
fs.writeFileSync(filePath, content, "utf-8");
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function writeMetadata(filePath: string, metadata: object): void {
|
|
45
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
44
46
|
fs.writeFileSync(filePath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export function appendJsonl(filePath: string, line: string): void {
|
|
50
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
48
51
|
fs.appendFileSync(filePath, `${line}\n`);
|
|
49
52
|
}
|
|
50
|
-
|
package/crew/utils/discover.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Crew - Agent Discovery
|
|
2
|
+
* Crew - Agent & Skill Discovery
|
|
3
3
|
*
|
|
4
|
-
* Discovers agent definitions
|
|
4
|
+
* Discovers agent definitions and skill files from extension,
|
|
5
|
+
* project, and user directories.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
8
10
|
import * as path from "node:path";
|
|
9
11
|
import { fileURLToPath } from "node:url";
|
|
10
12
|
import type { MaxOutputConfig } from "./truncate.js";
|
|
@@ -12,6 +14,7 @@ import type { MaxOutputConfig } from "./truncate.js";
|
|
|
12
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
13
15
|
const __dirname = path.dirname(__filename);
|
|
14
16
|
const DEFAULT_EXTENSION_AGENTS_DIR = path.resolve(__dirname, "..", "agents");
|
|
17
|
+
const DEFAULT_EXTENSION_SKILLS_DIR = path.resolve(__dirname, "..", "skills");
|
|
15
18
|
|
|
16
19
|
export type CrewRole = "planner" | "worker" | "reviewer" | "analyst";
|
|
17
20
|
|
|
@@ -123,3 +126,94 @@ export function discoverCrewAgents(cwd: string, extensionAgentsDir?: string): Cr
|
|
|
123
126
|
|
|
124
127
|
return Array.from(agentMap.values());
|
|
125
128
|
}
|
|
129
|
+
|
|
130
|
+
// =============================================================================
|
|
131
|
+
// Skill Discovery
|
|
132
|
+
// =============================================================================
|
|
133
|
+
|
|
134
|
+
export interface CrewSkillInfo {
|
|
135
|
+
name: string;
|
|
136
|
+
description: string;
|
|
137
|
+
path: string;
|
|
138
|
+
source: "user" | "extension" | "project";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function loadSkillsFromFlatDir(dir: string, source: "extension" | "project"): CrewSkillInfo[] {
|
|
142
|
+
if (!fs.existsSync(dir)) return [];
|
|
143
|
+
const skills: CrewSkillInfo[] = [];
|
|
144
|
+
|
|
145
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
146
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
147
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
148
|
+
|
|
149
|
+
const filePath = path.join(dir, entry.name);
|
|
150
|
+
let content: string;
|
|
151
|
+
try {
|
|
152
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
158
|
+
if (!frontmatter.name || !frontmatter.description) continue;
|
|
159
|
+
|
|
160
|
+
skills.push({
|
|
161
|
+
name: frontmatter.name as string,
|
|
162
|
+
description: (frontmatter.description as string).split("\n")[0].trim(),
|
|
163
|
+
path: filePath,
|
|
164
|
+
source,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return skills;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadSkillsFromUserDir(dir: string): CrewSkillInfo[] {
|
|
172
|
+
if (!fs.existsSync(dir)) return [];
|
|
173
|
+
const skills: CrewSkillInfo[] = [];
|
|
174
|
+
|
|
175
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
176
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
177
|
+
|
|
178
|
+
const skillFile = path.join(dir, entry.name, "SKILL.md");
|
|
179
|
+
let content: string;
|
|
180
|
+
try {
|
|
181
|
+
content = fs.readFileSync(skillFile, "utf-8");
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { frontmatter } = parseFrontmatter(content);
|
|
187
|
+
if (!frontmatter.name || !frontmatter.description) continue;
|
|
188
|
+
|
|
189
|
+
skills.push({
|
|
190
|
+
name: frontmatter.name as string,
|
|
191
|
+
description: (frontmatter.description as string).split("\n")[0].trim(),
|
|
192
|
+
path: skillFile,
|
|
193
|
+
source: "user",
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return skills;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function discoverCrewSkills(
|
|
201
|
+
cwd: string,
|
|
202
|
+
extensionSkillsDir?: string,
|
|
203
|
+
userSkillsDir?: string,
|
|
204
|
+
): CrewSkillInfo[] {
|
|
205
|
+
const extDir = extensionSkillsDir ?? DEFAULT_EXTENSION_SKILLS_DIR;
|
|
206
|
+
const projectSkillsDir = path.join(cwd, ".pi", "messenger", "crew", "skills");
|
|
207
|
+
const userDir = userSkillsDir ?? path.join(os.homedir(), ".pi", "agent", "skills");
|
|
208
|
+
|
|
209
|
+
const userSkills = loadSkillsFromUserDir(userDir);
|
|
210
|
+
const extensionSkills = loadSkillsFromFlatDir(extDir, "extension");
|
|
211
|
+
const projectSkills = loadSkillsFromFlatDir(projectSkillsDir, "project");
|
|
212
|
+
|
|
213
|
+
const skillMap = new Map<string, CrewSkillInfo>();
|
|
214
|
+
for (const skill of userSkills) skillMap.set(skill.name, skill);
|
|
215
|
+
for (const skill of extensionSkills) skillMap.set(skill.name, skill);
|
|
216
|
+
for (const skill of projectSkills) skillMap.set(skill.name, skill);
|
|
217
|
+
|
|
218
|
+
return Array.from(skillMap.values());
|
|
219
|
+
}
|
package/feed.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type FeedEventType =
|
|
|
18
18
|
| "edit"
|
|
19
19
|
| "task.start"
|
|
20
20
|
| "task.done"
|
|
21
|
+
| "task.review"
|
|
21
22
|
| "task.block"
|
|
22
23
|
| "task.unblock"
|
|
23
24
|
| "task.reset"
|
|
@@ -47,6 +48,31 @@ function feedPath(cwd: string): string {
|
|
|
47
48
|
return path.join(cwd, ".pi", "messenger", "feed.jsonl");
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function sanitizeInlineText(value?: string): string | undefined {
|
|
52
|
+
if (!value) return undefined;
|
|
53
|
+
const normalized = value
|
|
54
|
+
.replaceAll("\r", " ")
|
|
55
|
+
.replaceAll("\n", " ")
|
|
56
|
+
.replaceAll("\t", " ")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sanitizeAgentName(value: string): string {
|
|
63
|
+
return sanitizeInlineText(value) ?? "unknown";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function sanitizeFeedEvent(event: FeedEvent): FeedEvent {
|
|
67
|
+
return {
|
|
68
|
+
ts: event.ts,
|
|
69
|
+
type: event.type,
|
|
70
|
+
agent: sanitizeAgentName(event.agent),
|
|
71
|
+
target: sanitizeInlineText(event.target),
|
|
72
|
+
preview: sanitizeInlineText(event.preview),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
export function appendFeedEvent(cwd: string, event: FeedEvent): void {
|
|
51
77
|
const p = feedPath(cwd);
|
|
52
78
|
try {
|
|
@@ -54,7 +80,8 @@ export function appendFeedEvent(cwd: string, event: FeedEvent): void {
|
|
|
54
80
|
if (!fs.existsSync(feedDir)) {
|
|
55
81
|
fs.mkdirSync(feedDir, { recursive: true });
|
|
56
82
|
}
|
|
57
|
-
|
|
83
|
+
const sanitized = sanitizeFeedEvent(event);
|
|
84
|
+
fs.appendFileSync(p, JSON.stringify(sanitized) + "\n");
|
|
58
85
|
} catch {
|
|
59
86
|
// Best effort
|
|
60
87
|
}
|
|
@@ -71,7 +98,8 @@ export function readFeedEvents(cwd: string, limit: number = 20): FeedEvent[] {
|
|
|
71
98
|
const events: FeedEvent[] = [];
|
|
72
99
|
for (const line of lines) {
|
|
73
100
|
try {
|
|
74
|
-
|
|
101
|
+
const parsed = JSON.parse(line) as FeedEvent;
|
|
102
|
+
events.push(sanitizeFeedEvent(parsed));
|
|
75
103
|
} catch {
|
|
76
104
|
// Skip malformed lines
|
|
77
105
|
}
|
|
@@ -101,6 +129,7 @@ export function pruneFeed(cwd: string, maxEvents: number): void {
|
|
|
101
129
|
const CREW_EVENT_TYPES = new Set<FeedEventType>([
|
|
102
130
|
"task.start",
|
|
103
131
|
"task.done",
|
|
132
|
+
"task.review",
|
|
104
133
|
"task.block",
|
|
105
134
|
"task.unblock",
|
|
106
135
|
"task.reset",
|
|
@@ -119,25 +148,27 @@ const CREW_EVENT_TYPES = new Set<FeedEventType>([
|
|
|
119
148
|
]);
|
|
120
149
|
|
|
121
150
|
export function formatFeedLine(event: FeedEvent): string {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
151
|
+
const sanitized = sanitizeFeedEvent(event);
|
|
152
|
+
const time = new Date(sanitized.ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
153
|
+
const isCrew = CREW_EVENT_TYPES.has(sanitized.type);
|
|
124
154
|
const prefix = isCrew ? "[Crew] " : "";
|
|
125
|
-
let line = `${time} ${prefix}${
|
|
155
|
+
let line = `${time} ${prefix}${sanitized.agent}`;
|
|
126
156
|
|
|
127
|
-
const rawPreview =
|
|
157
|
+
const rawPreview = sanitized.preview;
|
|
128
158
|
const preview = rawPreview
|
|
129
159
|
? rawPreview.length > 90 ? rawPreview.slice(0, 87) + "..." : rawPreview
|
|
130
160
|
: "";
|
|
131
161
|
const withPreview = (base: string) => preview ? `${base} — ${preview}` : base;
|
|
162
|
+
const target = sanitized.target ?? "";
|
|
132
163
|
|
|
133
|
-
switch (
|
|
164
|
+
switch (sanitized.type) {
|
|
134
165
|
case "join": line += " joined"; break;
|
|
135
166
|
case "leave": line = withPreview(line + " left"); break;
|
|
136
|
-
case "reserve": line += ` reserved ${
|
|
137
|
-
case "release": line += ` released ${
|
|
167
|
+
case "reserve": line += ` reserved ${target}`; break;
|
|
168
|
+
case "release": line += ` released ${target}`; break;
|
|
138
169
|
case "message":
|
|
139
|
-
if (
|
|
140
|
-
line += ` → ${
|
|
170
|
+
if (target) {
|
|
171
|
+
line += ` → ${target}`;
|
|
141
172
|
if (preview) line += `: ${preview}`;
|
|
142
173
|
} else {
|
|
143
174
|
line += " ✦";
|
|
@@ -150,16 +181,17 @@ export function formatFeedLine(event: FeedEvent): string {
|
|
|
150
181
|
case "test":
|
|
151
182
|
line += preview ? ` ran tests (${preview})` : " ran tests";
|
|
152
183
|
break;
|
|
153
|
-
case "edit": line += ` editing ${
|
|
154
|
-
case "task.start": line += withPreview(` started ${
|
|
155
|
-
case "task.done": line += withPreview(` completed ${
|
|
156
|
-
case "task.
|
|
157
|
-
case "task.
|
|
158
|
-
case "task.
|
|
159
|
-
case "task.
|
|
160
|
-
case "task.
|
|
161
|
-
case "task.
|
|
162
|
-
case "task.revise
|
|
184
|
+
case "edit": line += ` editing ${target}`; break;
|
|
185
|
+
case "task.start": line += withPreview(` started ${target}`); break;
|
|
186
|
+
case "task.done": line += withPreview(` completed ${target}`); break;
|
|
187
|
+
case "task.review": line += withPreview(` reviewed ${target}`); break;
|
|
188
|
+
case "task.block": line += withPreview(` blocked ${target}`); break;
|
|
189
|
+
case "task.unblock": line += withPreview(` unblocked ${target}`); break;
|
|
190
|
+
case "task.reset": line += withPreview(` reset ${target}`); break;
|
|
191
|
+
case "task.delete": line += withPreview(` deleted ${target}`); break;
|
|
192
|
+
case "task.split": line += withPreview(` split ${target}`); break;
|
|
193
|
+
case "task.revise": line += withPreview(` revised ${target}`); break;
|
|
194
|
+
case "task.revise-tree": line += withPreview(` revised ${target} + dependents`); break;
|
|
163
195
|
case "plan.start": line += withPreview(" planning started"); break;
|
|
164
196
|
case "plan.pass.start": line += withPreview(" planning pass started"); break;
|
|
165
197
|
case "plan.pass.done": line += withPreview(" planning pass finished"); break;
|
|
@@ -169,7 +201,7 @@ export function formatFeedLine(event: FeedEvent): string {
|
|
|
169
201
|
case "plan.cancel": line += " planning cancelled"; break;
|
|
170
202
|
case "plan.failed": line += withPreview(" planning failed"); break;
|
|
171
203
|
case "stuck": line += " appears stuck"; break;
|
|
172
|
-
default: line += ` ${
|
|
204
|
+
default: line += ` ${sanitized.type}`; break;
|
|
173
205
|
}
|
|
174
206
|
return line;
|
|
175
207
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { OverlayHandle, TUI } from "@mariozechner/pi-tui";
|
|
2
|
+
|
|
3
|
+
const QUIET_PERIOD_MS = 80;
|
|
4
|
+
const RENDER_THROTTLE_MS = 32;
|
|
5
|
+
const STDOUT_GUARD_MS = 32;
|
|
6
|
+
|
|
7
|
+
type StdoutWrite = typeof process.stdout.write;
|
|
8
|
+
|
|
9
|
+
function hasRenderableOutput(chunk: unknown): boolean {
|
|
10
|
+
if (typeof chunk === "string") return chunk.length > 0;
|
|
11
|
+
if (chunk instanceof Uint8Array) return chunk.length > 0;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Coordinates overlay rendering with main agent stdout to prevent visual collision.
|
|
17
|
+
*
|
|
18
|
+
* Strategy: Keep overlay visible, but schedule a "repair" render after foreign
|
|
19
|
+
* output settles. Brief visual corruption is acceptable if it self-heals quickly.
|
|
20
|
+
*/
|
|
21
|
+
export class OverlayRenderCoordinator {
|
|
22
|
+
private tui: TUI | null = null;
|
|
23
|
+
private handle: OverlayHandle | null = null;
|
|
24
|
+
private originalRequestRender: TUI["requestRender"] | null = null;
|
|
25
|
+
private originalStdoutWrite: StdoutWrite | null = null;
|
|
26
|
+
private repairTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
private lastRenderAt = 0;
|
|
28
|
+
private stdoutGuardUntil = 0;
|
|
29
|
+
private foreignOutputDetected = false;
|
|
30
|
+
|
|
31
|
+
installStdoutInterceptor(): void {
|
|
32
|
+
if (this.originalStdoutWrite) return;
|
|
33
|
+
|
|
34
|
+
const original = process.stdout.write.bind(process.stdout) as StdoutWrite;
|
|
35
|
+
this.originalStdoutWrite = original;
|
|
36
|
+
|
|
37
|
+
const coordinator = this;
|
|
38
|
+
(process.stdout.write as StdoutWrite) = function writeIntercept(
|
|
39
|
+
chunk: Parameters<StdoutWrite>[0],
|
|
40
|
+
...args: Parameters<StdoutWrite> extends [unknown, ...infer Rest] ? Rest : never
|
|
41
|
+
) {
|
|
42
|
+
const result = original(chunk, ...args);
|
|
43
|
+
coordinator.handleStdoutWrite(chunk);
|
|
44
|
+
return result;
|
|
45
|
+
} as StdoutWrite;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
uninstallStdoutInterceptor(): void {
|
|
49
|
+
if (!this.originalStdoutWrite) return;
|
|
50
|
+
(process.stdout.write as StdoutWrite) = this.originalStdoutWrite;
|
|
51
|
+
this.originalStdoutWrite = null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
attach(tui: TUI): void {
|
|
55
|
+
if (this.tui === tui && this.originalRequestRender) return;
|
|
56
|
+
|
|
57
|
+
this.detach();
|
|
58
|
+
this.tui = tui;
|
|
59
|
+
this.originalRequestRender = tui.requestRender.bind(tui);
|
|
60
|
+
tui.requestRender = ((force?: boolean) => {
|
|
61
|
+
this.requestRender(force);
|
|
62
|
+
}) as TUI["requestRender"];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setHandle(handle: OverlayHandle | null): void {
|
|
66
|
+
this.handle = handle;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
detach(): void {
|
|
70
|
+
if (this.repairTimer) {
|
|
71
|
+
clearTimeout(this.repairTimer);
|
|
72
|
+
this.repairTimer = null;
|
|
73
|
+
}
|
|
74
|
+
if (this.tui && this.originalRequestRender) {
|
|
75
|
+
this.tui.requestRender = this.originalRequestRender;
|
|
76
|
+
}
|
|
77
|
+
this.tui = null;
|
|
78
|
+
this.handle = null;
|
|
79
|
+
this.originalRequestRender = null;
|
|
80
|
+
this.lastRenderAt = 0;
|
|
81
|
+
this.stdoutGuardUntil = 0;
|
|
82
|
+
this.foreignOutputDetected = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dispose(): void {
|
|
86
|
+
this.detach();
|
|
87
|
+
this.uninstallStdoutInterceptor();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Called by hooks when main agent activity is expected */
|
|
91
|
+
noteForegroundActivity(): void {
|
|
92
|
+
if (!this.tui || !this.handle) return;
|
|
93
|
+
if (this.handle.isHidden()) return;
|
|
94
|
+
|
|
95
|
+
this.foreignOutputDetected = true;
|
|
96
|
+
this.scheduleRepair();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private handleStdoutWrite(chunk: unknown): void {
|
|
100
|
+
if (!this.tui || !this.handle) return;
|
|
101
|
+
if (!hasRenderableOutput(chunk)) return;
|
|
102
|
+
if (Date.now() <= this.stdoutGuardUntil) return;
|
|
103
|
+
if (this.handle.isHidden()) return;
|
|
104
|
+
|
|
105
|
+
this.foreignOutputDetected = true;
|
|
106
|
+
this.scheduleRepair();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private scheduleRepair(): void {
|
|
110
|
+
if (this.repairTimer) clearTimeout(this.repairTimer);
|
|
111
|
+
this.repairTimer = setTimeout(() => {
|
|
112
|
+
this.repairTimer = null;
|
|
113
|
+
this.repair();
|
|
114
|
+
}, QUIET_PERIOD_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private repair(): void {
|
|
118
|
+
if (!this.tui || !this.handle) return;
|
|
119
|
+
if (this.handle.isHidden()) return;
|
|
120
|
+
if (!this.foreignOutputDetected) return;
|
|
121
|
+
|
|
122
|
+
this.foreignOutputDetected = false;
|
|
123
|
+
this.flushRender(true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
requestRender(force = false): void {
|
|
127
|
+
if (!this.originalRequestRender) return;
|
|
128
|
+
if (this.handle?.isHidden()) return;
|
|
129
|
+
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (!force) {
|
|
132
|
+
const elapsed = now - this.lastRenderAt;
|
|
133
|
+
if (elapsed < RENDER_THROTTLE_MS) {
|
|
134
|
+
// Skip this render, repair timer will catch up
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.flushRender(force);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private flushRender(force: boolean): void {
|
|
143
|
+
if (!this.originalRequestRender) return;
|
|
144
|
+
if (this.handle?.isHidden()) return;
|
|
145
|
+
|
|
146
|
+
this.lastRenderAt = Date.now();
|
|
147
|
+
this.stdoutGuardUntil = this.lastRenderAt + STDOUT_GUARD_MS;
|
|
148
|
+
this.originalRequestRender(force);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/overlay-render.ts
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
import type { Task } from "./crew/types.js";
|
|
28
28
|
import { getLiveWorkers, type LiveWorkerInfo } from "./crew/live-progress.js";
|
|
29
29
|
import type { ToolEntry } from "./crew/utils/progress.js";
|
|
30
|
-
import { formatFeedLine as sharedFormatFeedLine, type FeedEvent } from "./feed.js";
|
|
30
|
+
import { formatFeedLine as sharedFormatFeedLine, sanitizeFeedEvent, type FeedEvent } from "./feed.js";
|
|
31
31
|
import { discoverCrewAgents } from "./crew/utils/discover.js";
|
|
32
32
|
import { loadConfig } from "./config.js";
|
|
33
33
|
import { loadCrewConfig } from "./crew/utils/config.js";
|
|
@@ -224,18 +224,19 @@ export function renderFeedSection(theme: Theme, events: FeedEvent[], width: numb
|
|
|
224
224
|
let lastWasMessage = false;
|
|
225
225
|
|
|
226
226
|
for (const event of events) {
|
|
227
|
-
const
|
|
228
|
-
const
|
|
227
|
+
const sanitized = sanitizeFeedEvent(event);
|
|
228
|
+
const isNew = lastSeenTs === null || sanitized.ts > lastSeenTs;
|
|
229
|
+
const isMessage = sanitized.type === "message";
|
|
229
230
|
|
|
230
231
|
if (lines.length > 0 && isMessage !== lastWasMessage) {
|
|
231
232
|
lines.push(theme.fg("dim", " ·"));
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
if (isMessage) {
|
|
235
|
-
lines.push(...renderMessageLines(theme,
|
|
236
|
+
lines.push(...renderMessageLines(theme, sanitized, width));
|
|
236
237
|
} else {
|
|
237
|
-
const formatted = sharedFormatFeedLine(
|
|
238
|
-
const dimmed = DIM_EVENTS.has(
|
|
238
|
+
const formatted = sharedFormatFeedLine(sanitized);
|
|
239
|
+
const dimmed = DIM_EVENTS.has(sanitized.type) || !isNew;
|
|
239
240
|
lines.push(truncateToWidth(dimmed ? theme.fg("dim", formatted) : formatted, width));
|
|
240
241
|
}
|
|
241
242
|
lastWasMessage = isMessage;
|
|
@@ -341,7 +342,8 @@ export function renderEmptyState(theme: Theme, cwd: string, width: number, heigh
|
|
|
341
342
|
lines.push(theme.fg("dim", " (none discovered)"));
|
|
342
343
|
} else {
|
|
343
344
|
for (const agent of agents) {
|
|
344
|
-
const
|
|
345
|
+
const effectiveModel = crewConfig.models?.[agent.crewRole ?? "worker"] ?? agent.model;
|
|
346
|
+
const model = effectiveModel ? ` (model: ${effectiveModel})` : "";
|
|
345
347
|
lines.push(` ${agent.name}${model}`);
|
|
346
348
|
}
|
|
347
349
|
}
|
package/overlay.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
|
|
6
|
-
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
|
6
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
7
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
8
8
|
import {
|
|
9
9
|
extractFolder,
|
|
@@ -553,7 +553,14 @@ export class MessengerOverlay implements Component, Focusable {
|
|
|
553
553
|
const sectionW = innerW - 2;
|
|
554
554
|
const border = (s: string) => this.theme.fg("dim", s);
|
|
555
555
|
const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
556
|
-
const
|
|
556
|
+
const sanitizeRowContent = (content: string) => content
|
|
557
|
+
.replaceAll("\r", " ")
|
|
558
|
+
.replaceAll("\n", " ")
|
|
559
|
+
.replaceAll("\t", " ");
|
|
560
|
+
const row = (content: string) => {
|
|
561
|
+
const safe = truncateToWidth(sanitizeRowContent(content), sectionW);
|
|
562
|
+
return border("│") + pad(" " + safe, innerW) + border("│");
|
|
563
|
+
};
|
|
557
564
|
const emptyRow = () => border("│") + " ".repeat(innerW) + border("│");
|
|
558
565
|
const sectionSeparator = this.theme.fg("dim", "─".repeat(sectionW));
|
|
559
566
|
|