pi-messenger 0.10.0 → 0.11.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.
@@ -0,0 +1,57 @@
1
+ import type { AgentProgress } from "./utils/progress.js";
2
+
3
+ export interface LiveWorkerInfo {
4
+ cwd: string;
5
+ taskId: string;
6
+ agent: string;
7
+ progress: AgentProgress;
8
+ startedAt: number;
9
+ }
10
+
11
+ const liveWorkers = new Map<string, LiveWorkerInfo>();
12
+ const listeners = new Set<() => void>();
13
+
14
+ function getWorkerKey(cwd: string, taskId: string): string {
15
+ return `${cwd}::${taskId}`;
16
+ }
17
+
18
+ export function updateLiveWorker(cwd: string, taskId: string, info: Omit<LiveWorkerInfo, "cwd">): void {
19
+ liveWorkers.set(getWorkerKey(cwd, taskId), {
20
+ ...info,
21
+ cwd,
22
+ });
23
+ notifyListeners();
24
+ }
25
+
26
+ export function removeLiveWorker(cwd: string, taskId: string): void {
27
+ liveWorkers.delete(getWorkerKey(cwd, taskId));
28
+ notifyListeners();
29
+ }
30
+
31
+ export function getLiveWorkers(cwd?: string): ReadonlyMap<string, LiveWorkerInfo> {
32
+ if (!cwd) return new Map(liveWorkers);
33
+
34
+ const filtered = new Map<string, LiveWorkerInfo>();
35
+ for (const info of liveWorkers.values()) {
36
+ if (info.cwd !== cwd) continue;
37
+ filtered.set(info.taskId, info);
38
+ }
39
+ return filtered;
40
+ }
41
+
42
+ export function hasLiveWorkers(cwd?: string): boolean {
43
+ if (!cwd) return liveWorkers.size > 0;
44
+ for (const info of liveWorkers.values()) {
45
+ if (info.cwd === cwd) return true;
46
+ }
47
+ return false;
48
+ }
49
+
50
+ export function onLiveWorkersChanged(fn: () => void): () => void {
51
+ listeners.add(fn);
52
+ return () => listeners.delete(fn);
53
+ }
54
+
55
+ function notifyListeners(): void {
56
+ for (const fn of listeners) fn();
57
+ }
package/crew/state.ts CHANGED
@@ -17,7 +17,6 @@ export interface AutonomousState {
17
17
  active: boolean;
18
18
  cwd: string | null;
19
19
  waveNumber: number;
20
- attemptsPerTask: Record<string, number>;
21
20
  waveHistory: WaveResult[];
22
21
  startedAt: string | null;
23
22
  stoppedAt: string | null;
@@ -33,52 +32,37 @@ export const autonomousState: AutonomousState = {
33
32
  active: false,
34
33
  cwd: null,
35
34
  waveNumber: 0,
36
- attemptsPerTask: {},
37
35
  waveHistory: [],
38
36
  startedAt: null,
39
37
  stoppedAt: null,
40
38
  stopReason: null,
41
39
  };
42
40
 
43
- /**
44
- * Start autonomous mode.
45
- */
46
41
  export function startAutonomous(cwd: string): void {
47
42
  autonomousState.active = true;
48
43
  autonomousState.cwd = cwd;
49
44
  autonomousState.waveNumber = 1;
50
- autonomousState.attemptsPerTask = {};
51
45
  autonomousState.waveHistory = [];
52
46
  autonomousState.startedAt = new Date().toISOString();
53
47
  autonomousState.stoppedAt = null;
54
48
  autonomousState.stopReason = null;
55
49
  }
56
50
 
57
- /**
58
- * Stop autonomous mode.
59
- */
60
51
  export function stopAutonomous(reason: "completed" | "blocked" | "manual"): void {
61
52
  autonomousState.active = false;
62
53
  autonomousState.stoppedAt = new Date().toISOString();
63
54
  autonomousState.stopReason = reason;
64
55
  }
65
56
 
66
- /**
67
- * Add a wave result to history.
68
- */
69
57
  export function addWaveResult(result: WaveResult): void {
70
58
  autonomousState.waveHistory.push(result);
71
59
  autonomousState.waveNumber++;
72
60
  }
73
61
 
74
- /**
75
- * Restore autonomous state from session data.
76
- */
77
62
  export function restoreAutonomousState(data: Partial<AutonomousState>): void {
78
63
  if (data.active !== undefined) autonomousState.active = data.active;
79
64
  if (data.cwd !== undefined) autonomousState.cwd = data.cwd;
80
65
  if (data.waveNumber !== undefined) autonomousState.waveNumber = data.waveNumber;
81
- if (data.attemptsPerTask !== undefined) autonomousState.attemptsPerTask = data.attemptsPerTask;
82
66
  if (data.waveHistory !== undefined) autonomousState.waveHistory = data.waveHistory;
83
67
  if (data.startedAt !== undefined) autonomousState.startedAt = data.startedAt;
84
68
  if (data.stoppedAt !== undefined) autonomousState.stoppedAt = data.stoppedAt;
package/crew/types.ts CHANGED
@@ -36,6 +36,7 @@ export interface Task {
36
36
  id: string; // task-N format
37
37
  title: string;
38
38
  status: TaskStatus;
39
+ model?: string;
39
40
  depends_on: string[]; // Task IDs this depends on
40
41
  created_at: string; // ISO timestamp
41
42
  updated_at: string; // ISO timestamp
@@ -91,6 +92,7 @@ export interface CrewParams {
91
92
  // Work options
92
93
  autonomous?: boolean;
93
94
  concurrency?: number;
95
+ model?: string;
94
96
 
95
97
  // Task reset
96
98
  cascade?: boolean;
@@ -131,6 +133,8 @@ export interface ReviewResult {
131
133
  export interface AgentTask {
132
134
  agent: string;
133
135
  task: string;
136
+ taskId?: string;
137
+ modelOverride?: string;
134
138
  maxOutput?: MaxOutputConfig;
135
139
  }
136
140
 
@@ -141,6 +145,8 @@ export interface AgentResult {
141
145
  truncated: boolean;
142
146
  progress: AgentProgress;
143
147
  config?: CrewAgentConfig;
148
+ taskId?: string;
149
+ wasGracefullyShutdown?: boolean;
144
150
  error?: string;
145
151
  artifactPaths?: {
146
152
  input: string;
@@ -13,6 +13,12 @@ const USER_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "pi-messenger.j
13
13
  const PROJECT_CONFIG_FILE = "config.json";
14
14
 
15
15
  export interface CrewConfig {
16
+ models?: {
17
+ planner?: string;
18
+ worker?: string;
19
+ reviewer?: string;
20
+ analyst?: string;
21
+ };
16
22
  concurrency: {
17
23
  workers: number;
18
24
  };
@@ -30,7 +36,13 @@ export interface CrewConfig {
30
36
  planSync: { enabled: boolean };
31
37
  review: { enabled: boolean; maxIterations: number };
32
38
  planning: { maxPasses: number };
33
- work: { maxAttemptsPerTask: number; maxWaves: number; stopOnBlock: boolean };
39
+ work: {
40
+ maxAttemptsPerTask: number;
41
+ maxWaves: number;
42
+ stopOnBlock: boolean;
43
+ env?: Record<string, string>;
44
+ shutdownGracePeriodMs?: number;
45
+ };
34
46
  }
35
47
 
36
48
  const DEFAULT_CONFIG: CrewConfig = {
@@ -48,7 +60,7 @@ const DEFAULT_CONFIG: CrewConfig = {
48
60
  planSync: { enabled: false },
49
61
  review: { enabled: true, maxIterations: 3 },
50
62
  planning: { maxPasses: 3 },
51
- work: { maxAttemptsPerTask: 5, maxWaves: 50, stopOnBlock: false },
63
+ work: { maxAttemptsPerTask: 5, maxWaves: 50, stopOnBlock: false, shutdownGracePeriodMs: 30000 },
52
64
  };
53
65
 
54
66
  function loadJson(filePath: string): Record<string, unknown> {
@@ -60,19 +72,25 @@ function loadJson(filePath: string): Record<string, unknown> {
60
72
  }
61
73
 
62
74
  function deepMerge<T extends object>(target: T, ...sources: Partial<T>[]): T {
63
- const result = { ...target };
75
+ const result: Record<string, unknown> = target && typeof target === "object"
76
+ ? { ...(target as Record<string, unknown>) }
77
+ : {};
64
78
  for (const source of sources) {
65
- for (const key of Object.keys(source) as (keyof T)[]) {
79
+ const src = source as Record<string, unknown>;
80
+ for (const key of Object.keys(src)) {
66
81
  const targetVal = result[key];
67
- const sourceVal = source[key];
82
+ const sourceVal = src[key];
68
83
  if (sourceVal && typeof sourceVal === "object" && !Array.isArray(sourceVal)) {
69
- result[key] = deepMerge(targetVal as object, sourceVal as object) as T[keyof T];
84
+ const base = targetVal && typeof targetVal === "object" && !Array.isArray(targetVal)
85
+ ? targetVal as object
86
+ : {};
87
+ result[key] = deepMerge(base, sourceVal as object);
70
88
  } else if (sourceVal !== undefined) {
71
- result[key] = sourceVal as T[keyof T];
89
+ result[key] = sourceVal;
72
90
  }
73
91
  }
74
92
  }
75
- return result;
93
+ return result as T;
76
94
  }
77
95
 
78
96
  /**
@@ -90,9 +108,6 @@ export function loadCrewConfig(crewDir: string): CrewConfig {
90
108
  return deepMerge(DEFAULT_CONFIG, userCrewConfig, projectConfig);
91
109
  }
92
110
 
93
- /**
94
- * Get truncation config for a specific role.
95
- */
96
111
  export function getTruncationForRole(config: CrewConfig, role: string): MaxOutputConfig {
97
112
  switch (role) {
98
113
  case "planner": return config.truncation.planners;
@@ -10,13 +10,14 @@ export interface AgentProgress {
10
10
  currentTool?: string;
11
11
  currentToolArgs?: string;
12
12
  recentTools: Array<{ tool: string; args: string; endMs: number }>;
13
+ toolCallCount: number;
13
14
  tokens: number;
14
15
  durationMs: number;
15
16
  error?: string;
16
17
  }
17
18
 
18
19
  // Event types from pi's --mode json output
19
- interface PiEvent {
20
+ export interface PiEvent {
20
21
  type: string;
21
22
  toolName?: string;
22
23
  args?: Record<string, unknown>;
@@ -34,6 +35,7 @@ export function createProgress(agent: string): AgentProgress {
34
35
  agent,
35
36
  status: "pending",
36
37
  recentTools: [],
38
+ toolCallCount: 0,
37
39
  tokens: 0,
38
40
  durationMs: 0,
39
41
  };
@@ -59,6 +61,7 @@ export function updateProgress(progress: AgentProgress, event: PiEvent, startTim
59
61
  break;
60
62
 
61
63
  case "tool_execution_end":
64
+ progress.toolCallCount++;
62
65
  if (progress.currentTool) {
63
66
  progress.recentTools.unshift({
64
67
  tool: progress.currentTool,
package/crew-overlay.ts CHANGED
@@ -10,6 +10,7 @@ import type { Theme } from "@mariozechner/pi-coding-agent";
10
10
  import * as crewStore from "./crew/store.js";
11
11
  import { autonomousState } from "./crew/state.js";
12
12
  import type { Task } from "./crew/types.js";
13
+ import { getLiveWorkers, type LiveWorkerInfo } from "./crew/live-progress.js";
13
14
 
14
15
  // Status icons
15
16
  const STATUS_ICONS: Record<string, string> = {
@@ -49,9 +50,9 @@ export function renderCrewContent(
49
50
  }
50
51
 
51
52
  const tasks = crewStore.getTasks(cwd);
53
+ const workers = getLiveWorkers(cwd);
52
54
 
53
55
  // Header: PRD with progress
54
- const pct = plan.task_count > 0 ? Math.round((plan.completed_count / plan.task_count) * 100) : 0;
55
56
  const progressText = `[${plan.completed_count}/${plan.task_count}]`;
56
57
  const prdLine = `📋 ${plan.prd}`;
57
58
  const prdWidth = visibleWidth(prdLine);
@@ -61,13 +62,42 @@ export function renderCrewContent(
61
62
  lines.push(prdLine + " ".repeat(padding) + theme.fg("accent", progressText));
62
63
  lines.push("");
63
64
 
65
+ if (workers.size > 0) {
66
+ lines.push(theme.fg("dim", "─".repeat(Math.min(width, 40)) + " Active Workers"));
67
+
68
+ for (const [taskId, info] of workers) {
69
+ const activity = info.progress.currentTool
70
+ ? `${info.progress.currentTool}${info.progress.currentToolArgs ? ` ${info.progress.currentToolArgs}` : ""}`
71
+ : "thinking...";
72
+ const calls = `${info.progress.toolCallCount} calls`;
73
+ const tokens = info.progress.tokens > 1000
74
+ ? `${(info.progress.tokens / 1000).toFixed(0)}k tokens`
75
+ : `${info.progress.tokens} tokens`;
76
+ const elapsed = `${Math.floor((Date.now() - info.startedAt) / 1000)}s`;
77
+
78
+ const line = ` ⚡ ${taskId}: ${activity}`;
79
+ const stats = `${calls} ${tokens} ${elapsed}`;
80
+ lines.push(truncateToWidth(line + " " + theme.fg("dim", stats), width));
81
+ }
82
+
83
+ lines.push(theme.fg("dim", "─".repeat(Math.min(width, 40))));
84
+ lines.push("");
85
+ }
86
+
64
87
  // Task list
88
+ const taskListStartLine = lines.length;
65
89
  if (tasks.length === 0) {
66
90
  lines.push(theme.fg("dim", " (no tasks yet)"));
67
91
  } else {
68
92
  for (let i = 0; i < tasks.length; i++) {
69
93
  const task = tasks[i];
70
- const taskLine = renderTaskLine(theme, task, i === viewState.selectedTaskIndex, width);
94
+ const taskLine = renderTaskLine(
95
+ theme,
96
+ task,
97
+ i === viewState.selectedTaskIndex,
98
+ width,
99
+ workers.get(task.id),
100
+ );
71
101
  lines.push(taskLine);
72
102
  }
73
103
  }
@@ -83,16 +113,22 @@ export function renderCrewContent(
83
113
 
84
114
  // Handle scrolling if content exceeds height
85
115
  if (lines.length > height) {
86
- const startIdx = Math.min(viewState.scrollOffset, lines.length - height);
87
- return lines.slice(startIdx, startIdx + height);
116
+ // Auto-scroll to keep the selected task visible
117
+ if (tasks.length > 0) {
118
+ const selectedLine = taskListStartLine + Math.min(viewState.selectedTaskIndex, tasks.length - 1);
119
+ if (selectedLine < viewState.scrollOffset) {
120
+ viewState.scrollOffset = selectedLine;
121
+ } else if (selectedLine >= viewState.scrollOffset + height) {
122
+ viewState.scrollOffset = selectedLine - height + 1;
123
+ }
124
+ }
125
+ viewState.scrollOffset = Math.max(0, Math.min(viewState.scrollOffset, lines.length - height));
126
+ return lines.slice(viewState.scrollOffset, viewState.scrollOffset + height);
88
127
  }
89
128
 
90
129
  return lines.slice(0, height);
91
130
  }
92
131
 
93
- /**
94
- * Render the status bar for autonomous mode.
95
- */
96
132
  export function renderCrewStatusBar(theme: Theme, cwd: string, width: number): string {
97
133
  const plan = crewStore.getPlan(cwd);
98
134
 
@@ -168,7 +204,8 @@ function renderTaskLine(
168
204
  theme: Theme,
169
205
  task: Task,
170
206
  isSelected: boolean,
171
- width: number
207
+ width: number,
208
+ liveWorker?: LiveWorkerInfo
172
209
  ): string {
173
210
  const icon = STATUS_ICONS[task.status] ?? "?";
174
211
  const selectIndicator = isSelected ? theme.fg("accent", "▸ ") : " ";
@@ -191,7 +228,12 @@ function renderTaskLine(
191
228
 
192
229
  // Build task suffix (assigned agent or dependencies)
193
230
  let suffix = "";
194
- if (task.status === "in_progress" && task.assigned_to) {
231
+ if (task.status === "in_progress" && liveWorker) {
232
+ const activity = liveWorker.progress.currentTool
233
+ ? `${liveWorker.progress.currentTool}${liveWorker.progress.currentToolArgs ? ` ${liveWorker.progress.currentToolArgs}` : ""}`
234
+ : "thinking...";
235
+ suffix = ` (${activity})`;
236
+ } else if (task.status === "in_progress" && task.assigned_to) {
195
237
  suffix = ` (${task.assigned_to})`;
196
238
  } else if (task.status === "todo" && task.depends_on.length > 0) {
197
239
  suffix = ` → deps: ${task.depends_on.join(", ")}`;
@@ -219,9 +261,6 @@ function renderLegend(theme: Theme, width: number): string {
219
261
  return truncateToWidth(theme.fg("dim", legend), width);
220
262
  }
221
263
 
222
- /**
223
- * Navigate to next/prev task.
224
- */
225
264
  export function navigateTask(viewState: CrewViewState, direction: 1 | -1, taskCount: number): void {
226
265
  if (taskCount === 0) return;
227
266
  viewState.selectedTaskIndex = Math.max(
@@ -229,5 +268,3 @@ export function navigateTask(viewState: CrewViewState, direction: 1 | -1, taskCo
229
268
  Math.min(taskCount - 1, viewState.selectedTaskIndex + direction)
230
269
  );
231
270
  }
232
-
233
-
package/index.ts CHANGED
@@ -36,6 +36,7 @@ import type { CrewParams } from "./crew/types.js";
36
36
  import { autonomousState, restoreAutonomousState, stopAutonomous } from "./crew/state.js";
37
37
  import { loadCrewConfig } from "./crew/utils/config.js";
38
38
  import * as crewStore from "./crew/store.js";
39
+ import { getLiveWorkers, onLiveWorkersChanged } from "./crew/live-progress.js";
39
40
 
40
41
  let overlayTui: TUI | null = null;
41
42
 
@@ -225,13 +226,22 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
225
226
  const cwd = ctx.cwd ?? process.cwd();
226
227
  const plan = crewStore.getPlan(cwd);
227
228
  if (plan) {
229
+ const workerCount = getLiveWorkers(cwd).size;
228
230
  crewStr = theme.fg("accent", ` ⚡${plan.completed_count}/${plan.task_count}`);
231
+ if (workerCount > 0) {
232
+ crewStr += theme.fg("dim", ` 🔨${workerCount}`);
233
+ }
229
234
  }
230
235
  }
231
236
 
232
237
  ctx.ui.setStatus("messenger", `msg: ${nameStr}${countStr}${unreadStr}${crewStr}`);
233
238
  }
234
239
 
240
+ let latestCtx: ExtensionContext | null = null;
241
+ onLiveWorkersChanged(() => {
242
+ if (latestCtx) updateStatus(latestCtx);
243
+ });
244
+
235
245
  // ===========================================================================
236
246
  // Tool Registration
237
247
  // ===========================================================================
@@ -303,6 +313,7 @@ Mode: action (if provided) > legacy key-based routing`,
303
313
  type: Type.Optional(StringEnum(["plan", "impl"], { description: "Review type (inferred from target if omitted)" })),
304
314
  autonomous: Type.Optional(Type.Boolean({ description: "Run work continuously until done/blocked" })),
305
315
  concurrency: Type.Optional(Type.Number({ description: "Override worker concurrency" })),
316
+ model: Type.Optional(Type.String({ description: "Override worker model for this work wave" })),
306
317
  cascade: Type.Optional(Type.Boolean({ description: "For task.reset - also reset dependent tasks" })),
307
318
  limit: Type.Optional(Type.Number({ description: "Number of events to return (for feed action, default 20)" })),
308
319
  paths: Type.Optional(Type.Array(Type.String(), { description: "Paths for reserve/release actions" })),
@@ -349,7 +360,8 @@ Mode: action (if provided) > legacy key-based routing`,
349
360
  autoRegisterPath?: "add" | "remove" | "list";
350
361
  list?: boolean;
351
362
  limit?: number;
352
- }, _signal, _onUpdate, ctx) {
363
+ }, signal, _onUpdate, ctx) {
364
+ latestCtx = ctx;
353
365
  const {
354
366
  action,
355
367
  join,
@@ -384,7 +396,8 @@ Mode: action (if provided) > legacy key-based routing`,
384
396
  deliverMessage,
385
397
  updateStatus,
386
398
  (type, data) => pi.appendEntry(type, data),
387
- { stuckThreshold: config.stuckThreshold, crewEventsInFeed: config.crewEventsInFeed, nameTheme, feedRetention: config.feedRetention }
399
+ { stuckThreshold: config.stuckThreshold, crewEventsInFeed: config.crewEventsInFeed, nameTheme, feedRetention: config.feedRetention },
400
+ signal
388
401
  );
389
402
  }
390
403
 
@@ -699,6 +712,7 @@ Mode: action (if provided) > legacy key-based routing`,
699
712
  // ===========================================================================
700
713
 
701
714
  pi.on("session_start", async (_event, ctx) => {
715
+ latestCtx = ctx;
702
716
  for (const entry of ctx.sessionManager.getEntries()) {
703
717
  if (entry.type === "custom" && entry.customType === "crew-state") {
704
718
  restoreAutonomousState(entry.data as Parameters<typeof restoreAutonomousState>[0]);
@@ -741,16 +755,22 @@ Mode: action (if provided) > legacy key-based routing`,
741
755
  }
742
756
 
743
757
  pi.on("session_switch", async (_event, ctx) => {
758
+ latestCtx = ctx;
744
759
  recoverWatcherIfNeeded();
745
760
  updateStatus(ctx);
746
761
  });
747
762
  pi.on("session_fork", async (_event, ctx) => {
763
+ latestCtx = ctx;
748
764
  recoverWatcherIfNeeded();
749
765
  updateStatus(ctx);
750
766
  });
751
- pi.on("session_tree", async (_event, ctx) => updateStatus(ctx));
767
+ pi.on("session_tree", async (_event, ctx) => {
768
+ latestCtx = ctx;
769
+ updateStatus(ctx);
770
+ });
752
771
 
753
772
  pi.on("turn_end", async (event, ctx) => {
773
+ latestCtx = ctx;
754
774
  store.processAllPendingMessages(state, dirs, deliverMessage);
755
775
  recoverWatcherIfNeeded();
756
776
  updateStatus(ctx);
package/overlay.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  navigateTask,
35
35
  type CrewViewState,
36
36
  } from "./crew-overlay.js";
37
+ import { hasLiveWorkers, onLiveWorkersChanged } from "./crew/live-progress.js";
37
38
  import { loadConfig } from "./config.js";
38
39
 
39
40
  const AGENTS_TAB = "[agents]";
@@ -50,6 +51,8 @@ export class MessengerOverlay implements Component, Focusable {
50
51
  private crewViewState: CrewViewState = createCrewViewState();
51
52
  private cwd: string;
52
53
  private stuckThresholdMs: number;
54
+ private progressTimer: ReturnType<typeof setInterval> | null = null;
55
+ private progressUnsubscribe: (() => void) | null = null;
53
56
 
54
57
  constructor(
55
58
  private tui: TUI,
@@ -68,6 +71,18 @@ export class MessengerOverlay implements Component, Focusable {
68
71
  if (this.selectedAgent && this.selectedAgent !== AGENTS_TAB && this.selectedAgent !== CREW_TAB) {
69
72
  state.unreadCounts.set(this.selectedAgent, 0);
70
73
  }
74
+
75
+ this.progressUnsubscribe = onLiveWorkersChanged(() => {
76
+ if (this.selectedAgent === CREW_TAB) {
77
+ if (hasLiveWorkers(this.cwd)) this.startProgressRefresh();
78
+ else this.stopProgressRefresh();
79
+ }
80
+ this.tui.requestRender();
81
+ });
82
+
83
+ if (this.selectedAgent === CREW_TAB && hasLiveWorkers(this.cwd)) {
84
+ this.startProgressRefresh();
85
+ }
71
86
  }
72
87
 
73
88
  private getAgentsSorted(): AgentRegistration[] {
@@ -101,6 +116,30 @@ export class MessengerOverlay implements Component, Focusable {
101
116
  this.state.unreadCounts.set(agentName, 0);
102
117
  }
103
118
  this.scrollPosition = 0;
119
+
120
+ if (agentName === CREW_TAB && hasLiveWorkers(this.cwd)) {
121
+ this.startProgressRefresh();
122
+ } else {
123
+ this.stopProgressRefresh();
124
+ }
125
+ }
126
+
127
+ private startProgressRefresh(): void {
128
+ if (this.progressTimer) return;
129
+ this.progressTimer = setInterval(() => {
130
+ if (hasLiveWorkers(this.cwd)) {
131
+ this.tui.requestRender();
132
+ } else {
133
+ this.stopProgressRefresh();
134
+ }
135
+ }, 1000);
136
+ }
137
+
138
+ private stopProgressRefresh(): void {
139
+ if (this.progressTimer) {
140
+ clearInterval(this.progressTimer);
141
+ this.progressTimer = null;
142
+ }
104
143
  }
105
144
 
106
145
  private scroll(delta: number): void {
@@ -341,7 +380,9 @@ export class MessengerOverlay implements Component, Focusable {
341
380
  lines.push(row(this.renderTabBar(innerW - 2, agents)));
342
381
  lines.push(border("├" + "─".repeat(innerW) + "┤"));
343
382
 
344
- const messageAreaHeight = 10; // Fixed height for message area
383
+ const chromeLines = 7; // top border, empty row, tab bar, separator, separator, input bar, bottom border
384
+ const termRows = process.stdout.rows ?? 24;
385
+ const messageAreaHeight = Math.min(25, Math.max(8, termRows - chromeLines - 2));
345
386
  const messageLines = this.renderMessages(innerW - 2, messageAreaHeight, agents);
346
387
  for (const line of messageLines) {
347
388
  lines.push(row(line));
@@ -709,5 +750,9 @@ export class MessengerOverlay implements Component, Focusable {
709
750
  // No cached state to invalidate
710
751
  }
711
752
 
712
- dispose(): void {}
753
+ dispose(): void {
754
+ this.stopProgressRefresh();
755
+ this.progressUnsubscribe?.();
756
+ this.progressUnsubscribe = null;
757
+ }
713
758
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-messenger",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Inter-agent messaging and file reservation system for pi coding agent",
5
5
  "type": "module",
6
6
  "author": "Nico Bailon",
@@ -34,9 +34,15 @@
34
34
  "crew/**",
35
35
  "skills/**",
36
36
  "README.md",
37
- "CHANGELOG.md",
38
- "ARCHITECTURE.md"
37
+ "CHANGELOG.md"
39
38
  ],
39
+ "scripts": {
40
+ "test": "vitest run",
41
+ "test:watch": "vitest"
42
+ },
43
+ "devDependencies": {
44
+ "vitest": "^2.1.8"
45
+ },
40
46
  "pi": {
41
47
  "extensions": ["./index.ts"]
42
48
  }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["tests/**/*.test.ts"],
7
+ clearMocks: true,
8
+ restoreMocks: true,
9
+ },
10
+ });