pi-messenger 0.12.0 → 0.13.0
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 +30 -2
- package/crew/agents/crew-planner.md +5 -3
- package/crew/agents/crew-worker.md +12 -0
- package/crew/agents.ts +11 -2
- package/crew/handlers/plan.ts +41 -11
- package/crew/handlers/work.ts +5 -3
- package/crew/lobby.ts +2 -2
- package/crew/prompt.ts +44 -0
- package/crew/spawn.ts +6 -3
- package/crew/types.ts +1 -0
- package/crew/utils/artifacts.ts +3 -1
- package/crew/utils/discover.ts +96 -2
- package/feed.ts +51 -22
- 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.0] - 2026-03-02
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **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.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- **Artifact dir creation** — `writeArtifact`, `writeMetadata`, and `appendJsonl` now create parent directories on demand. Fixes ENOENT on first `plan` run.
|
|
10
|
+
- **Multiline feed sanitization** — Feed events with embedded newlines no longer corrupt the TUI overlay layout.
|
|
11
|
+
- **Config model override** — `crew.models` config now actually overrides agent defaults. Priority: task override > config > agent frontmatter.
|
|
12
|
+
|
|
13
|
+
## [0.12.1] - 2026-02-22
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Wrong model resolved for `provider/model` format** - Worker spawn passed `--model zai/glm-5` as a single flag, which pi's model resolver matched as a literal model ID under `vercel-ai-gateway` instead of interpreting `zai` as the provider. Now splits `provider/model` into separate `--provider` and `--model` flags, matching the intended provider. Affects both task workers and lobby workers.
|
|
17
|
+
|
|
3
18
|
## [0.12.0] - 2026-02-21
|
|
4
19
|
|
|
5
20
|
### Added
|
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
|
|
|
@@ -77,7 +77,7 @@ pi_messenger({ action: "review", target: "task-1" }) // Reviewer checks imple
|
|
|
77
77
|
|
|
78
78
|
`/messenger` opens an interactive overlay with agent presence, activity feed, and chat:
|
|
79
79
|
|
|
80
|
-
<img width="
|
|
80
|
+
<img width="1198" height="1020" alt="pi-messenger crew overlay" src="https://github.com/user-attachments/assets/d66e5d71-5ed9-4702-9f56-9ca3f0e9c584" />
|
|
81
81
|
|
|
82
82
|
Chat input supports `@Name msg` for DMs and `@all msg` for broadcasts. Text without `@` broadcasts from the Agents tab or DMs the selected agent tab.
|
|
83
83
|
|
|
@@ -123,6 +123,34 @@ Wave 3: task-5 (→ task-2, task-4) ── both deps done
|
|
|
123
123
|
|
|
124
124
|
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
125
|
|
|
126
|
+
### Crew Skills
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
Skills are discovered from three locations (later sources override earlier by name):
|
|
131
|
+
|
|
132
|
+
1. **User skills** — `~/.pi/agent/skills/` (pi's standard `dir/SKILL.md` format)
|
|
133
|
+
2. **Extension skills** — `crew/skills/` within the extension (flat `.md` files)
|
|
134
|
+
3. **Project skills** — `.pi/messenger/crew/skills/` in your project root (flat `.md` files)
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
To add a project-level skill, drop a `.md` file in `.pi/messenger/crew/skills/`:
|
|
139
|
+
|
|
140
|
+
```markdown
|
|
141
|
+
---
|
|
142
|
+
name: our-api-patterns
|
|
143
|
+
description: REST API conventions for this project — auth, pagination, error shapes.
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
# API Patterns
|
|
147
|
+
|
|
148
|
+
Always use Bearer token auth. Paginate with cursor-based `?after=` params.
|
|
149
|
+
Error responses use `{ error: { code, message, details? } }` shape.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Any skills you already have in `~/.pi/agent/skills/` are automatically available to crew workers — no setup needed.
|
|
153
|
+
|
|
126
154
|
### Crew Configuration
|
|
127
155
|
|
|
128
156
|
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`:
|
|
@@ -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
|
@@ -58,6 +58,15 @@ export function resolveModel(
|
|
|
58
58
|
return taskModel ?? paramModel ?? configModel ?? agentModel;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function pushModelArgs(args: string[], model: string): void {
|
|
62
|
+
const slashIdx = model.indexOf("/");
|
|
63
|
+
if (slashIdx !== -1) {
|
|
64
|
+
args.push("--provider", model.substring(0, slashIdx), "--model", model.substring(slashIdx + 1));
|
|
65
|
+
} else {
|
|
66
|
+
args.push("--model", model);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
|
|
62
71
|
|
|
63
72
|
export function resolveThinking(
|
|
@@ -194,8 +203,8 @@ async function runAgent(
|
|
|
194
203
|
return new Promise((resolve) => {
|
|
195
204
|
// Build args for pi command
|
|
196
205
|
const args = ["--mode", "json", "--no-session", "-p"];
|
|
197
|
-
const model = task.modelOverride ?? agentConfig?.model;
|
|
198
|
-
if (model) args
|
|
206
|
+
const model = task.modelOverride ?? config.models?.[role] ?? agentConfig?.model;
|
|
207
|
+
if (model) pushModelArgs(args, model);
|
|
199
208
|
|
|
200
209
|
const thinking = resolveThinking(
|
|
201
210
|
config.thinking?.[role],
|
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/work.ts
CHANGED
|
@@ -11,7 +11,7 @@ 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
16
|
import * as store from "../store.js";
|
|
17
17
|
import { getCrewDir } from "../store.js";
|
|
@@ -109,6 +109,8 @@ export async function execute(
|
|
|
109
109
|
appendEntry("crew-state", autonomousState);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
const skills = discoverCrewSkills(cwd);
|
|
113
|
+
|
|
112
114
|
// Assign tasks to lobby workers first (they're already running and warmed up)
|
|
113
115
|
const prdLabel = store.getPlanLabel(plan);
|
|
114
116
|
const lobbyAssigned = new Set<string>();
|
|
@@ -118,7 +120,7 @@ export async function execute(
|
|
|
118
120
|
if (!task) break;
|
|
119
121
|
|
|
120
122
|
const others = readyTasks.filter(t => t.id !== task.id);
|
|
121
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
123
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
122
124
|
store.updateTask(cwd, task.id, {
|
|
123
125
|
status: "in_progress",
|
|
124
126
|
started_at: new Date().toISOString(),
|
|
@@ -145,7 +147,7 @@ export async function execute(
|
|
|
145
147
|
config.models?.worker,
|
|
146
148
|
);
|
|
147
149
|
const others = readyTasks.filter(t => t.id !== task.id);
|
|
148
|
-
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others);
|
|
150
|
+
const prompt = buildWorkerPrompt(task, prdLabel, cwd, config, others, skills);
|
|
149
151
|
store.appendTaskProgress(cwd, task.id, "system", `Assigned to crew-worker (attempt ${task.attempt_count + 1})`);
|
|
150
152
|
|
|
151
153
|
return {
|
package/crew/lobby.ts
CHANGED
|
@@ -13,7 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { generateMemorableName } from "../lib.js";
|
|
16
|
-
import { resolveThinking, modelHasThinkingSuffix } from "./agents.js";
|
|
16
|
+
import { resolveThinking, modelHasThinkingSuffix, pushModelArgs } from "./agents.js";
|
|
17
17
|
import { discoverCrewAgents } from "./utils/discover.js";
|
|
18
18
|
import { loadCrewConfig, type CrewConfig } from "./utils/config.js";
|
|
19
19
|
import {
|
|
@@ -70,7 +70,7 @@ export function spawnLobbyWorker(cwd: string, promptOverride?: string): LobbyWor
|
|
|
70
70
|
|
|
71
71
|
const args = ["--mode", "json", "--no-session", "-p"];
|
|
72
72
|
const model = config.models?.worker ?? workerConfig.model;
|
|
73
|
-
if (model) args
|
|
73
|
+
if (model) pushModelArgs(args, model);
|
|
74
74
|
|
|
75
75
|
const thinking = resolveThinking(
|
|
76
76
|
config.thinking?.worker,
|
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
|
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
|
@@ -47,6 +47,31 @@ function feedPath(cwd: string): string {
|
|
|
47
47
|
return path.join(cwd, ".pi", "messenger", "feed.jsonl");
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function sanitizeInlineText(value?: string): string | undefined {
|
|
51
|
+
if (!value) return undefined;
|
|
52
|
+
const normalized = value
|
|
53
|
+
.replaceAll("\r", " ")
|
|
54
|
+
.replaceAll("\n", " ")
|
|
55
|
+
.replaceAll("\t", " ")
|
|
56
|
+
.replace(/\s+/g, " ")
|
|
57
|
+
.trim();
|
|
58
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sanitizeAgentName(value: string): string {
|
|
62
|
+
return sanitizeInlineText(value) ?? "unknown";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sanitizeFeedEvent(event: FeedEvent): FeedEvent {
|
|
66
|
+
return {
|
|
67
|
+
ts: event.ts,
|
|
68
|
+
type: event.type,
|
|
69
|
+
agent: sanitizeAgentName(event.agent),
|
|
70
|
+
target: sanitizeInlineText(event.target),
|
|
71
|
+
preview: sanitizeInlineText(event.preview),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
export function appendFeedEvent(cwd: string, event: FeedEvent): void {
|
|
51
76
|
const p = feedPath(cwd);
|
|
52
77
|
try {
|
|
@@ -54,7 +79,8 @@ export function appendFeedEvent(cwd: string, event: FeedEvent): void {
|
|
|
54
79
|
if (!fs.existsSync(feedDir)) {
|
|
55
80
|
fs.mkdirSync(feedDir, { recursive: true });
|
|
56
81
|
}
|
|
57
|
-
|
|
82
|
+
const sanitized = sanitizeFeedEvent(event);
|
|
83
|
+
fs.appendFileSync(p, JSON.stringify(sanitized) + "\n");
|
|
58
84
|
} catch {
|
|
59
85
|
// Best effort
|
|
60
86
|
}
|
|
@@ -71,7 +97,8 @@ export function readFeedEvents(cwd: string, limit: number = 20): FeedEvent[] {
|
|
|
71
97
|
const events: FeedEvent[] = [];
|
|
72
98
|
for (const line of lines) {
|
|
73
99
|
try {
|
|
74
|
-
|
|
100
|
+
const parsed = JSON.parse(line) as FeedEvent;
|
|
101
|
+
events.push(sanitizeFeedEvent(parsed));
|
|
75
102
|
} catch {
|
|
76
103
|
// Skip malformed lines
|
|
77
104
|
}
|
|
@@ -119,25 +146,27 @@ const CREW_EVENT_TYPES = new Set<FeedEventType>([
|
|
|
119
146
|
]);
|
|
120
147
|
|
|
121
148
|
export function formatFeedLine(event: FeedEvent): string {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
149
|
+
const sanitized = sanitizeFeedEvent(event);
|
|
150
|
+
const time = new Date(sanitized.ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
151
|
+
const isCrew = CREW_EVENT_TYPES.has(sanitized.type);
|
|
124
152
|
const prefix = isCrew ? "[Crew] " : "";
|
|
125
|
-
let line = `${time} ${prefix}${
|
|
153
|
+
let line = `${time} ${prefix}${sanitized.agent}`;
|
|
126
154
|
|
|
127
|
-
const rawPreview =
|
|
155
|
+
const rawPreview = sanitized.preview;
|
|
128
156
|
const preview = rawPreview
|
|
129
157
|
? rawPreview.length > 90 ? rawPreview.slice(0, 87) + "..." : rawPreview
|
|
130
158
|
: "";
|
|
131
159
|
const withPreview = (base: string) => preview ? `${base} — ${preview}` : base;
|
|
160
|
+
const target = sanitized.target ?? "";
|
|
132
161
|
|
|
133
|
-
switch (
|
|
162
|
+
switch (sanitized.type) {
|
|
134
163
|
case "join": line += " joined"; break;
|
|
135
164
|
case "leave": line = withPreview(line + " left"); break;
|
|
136
|
-
case "reserve": line += ` reserved ${
|
|
137
|
-
case "release": line += ` released ${
|
|
165
|
+
case "reserve": line += ` reserved ${target}`; break;
|
|
166
|
+
case "release": line += ` released ${target}`; break;
|
|
138
167
|
case "message":
|
|
139
|
-
if (
|
|
140
|
-
line += ` → ${
|
|
168
|
+
if (target) {
|
|
169
|
+
line += ` → ${target}`;
|
|
141
170
|
if (preview) line += `: ${preview}`;
|
|
142
171
|
} else {
|
|
143
172
|
line += " ✦";
|
|
@@ -150,16 +179,16 @@ export function formatFeedLine(event: FeedEvent): string {
|
|
|
150
179
|
case "test":
|
|
151
180
|
line += preview ? ` ran tests (${preview})` : " ran tests";
|
|
152
181
|
break;
|
|
153
|
-
case "edit": line += ` editing ${
|
|
154
|
-
case "task.start": line += withPreview(` started ${
|
|
155
|
-
case "task.done": line += withPreview(` completed ${
|
|
156
|
-
case "task.block": line += withPreview(` blocked ${
|
|
157
|
-
case "task.unblock": line += withPreview(` unblocked ${
|
|
158
|
-
case "task.reset": line += withPreview(` reset ${
|
|
159
|
-
case "task.delete": line += withPreview(` deleted ${
|
|
160
|
-
case "task.split": line += withPreview(` split ${
|
|
161
|
-
case "task.revise": line += withPreview(` revised ${
|
|
162
|
-
case "task.revise-tree": line += withPreview(` revised ${
|
|
182
|
+
case "edit": line += ` editing ${target}`; break;
|
|
183
|
+
case "task.start": line += withPreview(` started ${target}`); break;
|
|
184
|
+
case "task.done": line += withPreview(` completed ${target}`); break;
|
|
185
|
+
case "task.block": line += withPreview(` blocked ${target}`); break;
|
|
186
|
+
case "task.unblock": line += withPreview(` unblocked ${target}`); break;
|
|
187
|
+
case "task.reset": line += withPreview(` reset ${target}`); break;
|
|
188
|
+
case "task.delete": line += withPreview(` deleted ${target}`); break;
|
|
189
|
+
case "task.split": line += withPreview(` split ${target}`); break;
|
|
190
|
+
case "task.revise": line += withPreview(` revised ${target}`); break;
|
|
191
|
+
case "task.revise-tree": line += withPreview(` revised ${target} + dependents`); break;
|
|
163
192
|
case "plan.start": line += withPreview(" planning started"); break;
|
|
164
193
|
case "plan.pass.start": line += withPreview(" planning pass started"); break;
|
|
165
194
|
case "plan.pass.done": line += withPreview(" planning pass finished"); break;
|
|
@@ -169,7 +198,7 @@ export function formatFeedLine(event: FeedEvent): string {
|
|
|
169
198
|
case "plan.cancel": line += " planning cancelled"; break;
|
|
170
199
|
case "plan.failed": line += withPreview(" planning failed"); break;
|
|
171
200
|
case "stuck": line += " appears stuck"; break;
|
|
172
|
-
default: line += ` ${
|
|
201
|
+
default: line += ` ${sanitized.type}`; break;
|
|
173
202
|
}
|
|
174
203
|
return line;
|
|
175
204
|
}
|
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
|
|