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 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** — Reviewer checks each implementation: SHIP, NEEDS_WORK, or MAJOR_RETHINK.
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(
@@ -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 {
@@ -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.`, {
@@ -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
 
@@ -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
@@ -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
- fs.appendFileSync(p, JSON.stringify(event) + "\n");
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
- events.push(JSON.parse(line));
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 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);
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}${event.agent}`;
155
+ let line = `${time} ${prefix}${sanitized.agent}`;
126
156
 
127
- const rawPreview = event.preview?.trim();
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 (event.type) {
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 ${event.target ?? ""}`; break;
137
- case "release": line += ` released ${event.target ?? ""}`; break;
167
+ case "reserve": line += ` reserved ${target}`; break;
168
+ case "release": line += ` released ${target}`; break;
138
169
  case "message":
139
- if (event.target) {
140
- line += ` → ${event.target}`;
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 ${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;
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 += ` ${event.type}`; break;
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 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.1",
3
+ "version": "0.13.1",
4
4
  "description": "Inter-agent messaging and file reservation system for pi coding agent",
5
5
  "type": "module",
6
6
  "author": "Nico Bailon",