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 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="722" height="351" alt="pi-messenger chat overlay" src="https://github.com/user-attachments/assets/4d0f1db7-90dd-4ffb-9463-560426edebd9" />
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.push("--model", model);
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],
@@ -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 buildFirstPassPrompt(prdPath: string, prdContent: string, existingProgress: string, isPromptBased: boolean): string {
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 title, description, and dependsOn.`;
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 title, description, and dependsOn.`;
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 {
@@ -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.push("--model", model);
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
@@ -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
-
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Crew - Agent Discovery
2
+ * Crew - Agent & Skill Discovery
3
3
  *
4
- * Discovers agent definitions from extension and project directories.
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
- fs.appendFileSync(p, JSON.stringify(event) + "\n");
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
- events.push(JSON.parse(line));
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 time = new Date(event.ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
123
- const isCrew = CREW_EVENT_TYPES.has(event.type);
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}${event.agent}`;
153
+ let line = `${time} ${prefix}${sanitized.agent}`;
126
154
 
127
- const rawPreview = event.preview?.trim();
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 (event.type) {
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 ${event.target ?? ""}`; break;
137
- case "release": line += ` released ${event.target ?? ""}`; break;
165
+ case "reserve": line += ` reserved ${target}`; break;
166
+ case "release": line += ` released ${target}`; break;
138
167
  case "message":
139
- if (event.target) {
140
- line += ` → ${event.target}`;
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 ${event.target ?? ""}`; break;
154
- case "task.start": line += withPreview(` started ${event.target ?? ""}`); break;
155
- case "task.done": line += withPreview(` completed ${event.target ?? ""}`); break;
156
- case "task.block": line += withPreview(` blocked ${event.target ?? ""}`); break;
157
- case "task.unblock": line += withPreview(` unblocked ${event.target ?? ""}`); break;
158
- case "task.reset": line += withPreview(` reset ${event.target ?? ""}`); break;
159
- case "task.delete": line += withPreview(` deleted ${event.target ?? ""}`); break;
160
- case "task.split": line += withPreview(` split ${event.target ?? ""}`); break;
161
- case "task.revise": line += withPreview(` revised ${event.target ?? ""}`); break;
162
- case "task.revise-tree": line += withPreview(` revised ${event.target ?? ""} + dependents`); break;
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 += ` ${event.type}`; break;
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 isNew = lastSeenTs === null || event.ts > lastSeenTs;
228
- const isMessage = event.type === "message";
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, event, width));
236
+ lines.push(...renderMessageLines(theme, sanitized, width));
236
237
  } else {
237
- const formatted = sharedFormatFeedLine(event);
238
- const dimmed = DIM_EVENTS.has(event.type) || !isNew;
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 model = agent.model ? ` (model: ${agent.model})` : "";
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 row = (content: string) => border("│") + pad(" " + content, innerW) + border("│");
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-messenger",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Inter-agent messaging and file reservation system for pi coding agent",
5
5
  "type": "module",
6
6
  "author": "Nico Bailon",