pi-messenger 0.13.0 → 0.13.2

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.13.2] - 2026-03-19
6
+
7
+ ### Added
8
+ - **`work.stop` action** — `pi_messenger({ action: "work.stop" })` now stops autonomous Crew work for the current project and persists the stop state.
9
+ - **Autonomous guard coverage tests** — Added targeted tests for `work.stop` routing and `agent_end` autonomous continuation guard behavior.
10
+
11
+ ### Fixed
12
+ - **Autonomous continuation retry loop guard** — Autonomous Crew mode now stops itself after repeated identical `crew_continue` retries without wave progress (for example when steer turns keep aborting). The extension persists the stopped state and emits a warning instead of looping indefinitely.
13
+ - **Persisted autonomous stop state from index-level stop paths** — `max waves` and `no ready tasks` stop paths now append `crew-state` so restored sessions do not revive stale active autonomous state.
14
+ - **Autonomous continuation no longer runs in unregistered sessions** — If Crew autonomous state is restored but the current session is not joined to pi-messenger, the extension now stops autonomous mode and persists that stop instead of emitting repeated continuation steer messages that cannot execute.
15
+ - **Autonomous continuation now runs only in orchestrator sessions** — Worker and lobby processes (`PI_CREW_WORKER`/`PI_LOBBY_ID`) now skip `agent_end` continuation emission to prevent repeated duplicate `crew_continue` steer loops from non-orchestrator agents.
16
+ - **Autonomous continuation is now cwd-scoped** — `agent_end` continuation now requires the session cwd to match the active autonomous cwd (`isAutonomousForCwd`), preventing stale autonomous state from unrelated projects from triggering unexpected `crew_continue` messages.
17
+ - **Autonomous state restore now validates owner process** — Restored active autonomous state now requires a live owner PID matching the current process; missing/dead/foreign ownership is treated as stale and auto-stopped to avoid unexpected continuation after session restore.
18
+ - **Runtime artifact commit noise** — Added `.pi/` to `.gitignore` to avoid accidentally committing runtime files like feed artifacts.
19
+
20
+ ## [0.13.1] - 2026-03-14
21
+
22
+ ### Added
23
+ - **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.
24
+
3
25
  ## [0.13.0] - 2026-03-02
4
26
 
5
27
  ### Added
package/README.md CHANGED
@@ -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
 
@@ -266,6 +268,7 @@ Agent definitions live in `crew/agents/` within the extension. To customize one
266
268
  |--------|-------------|
267
269
  | `plan` | Create plan from PRD or inline prompt (`prd`, `prompt` optional — auto-discovers PRD if omitted, auto-starts workers unless `autoWork: false`) |
268
270
  | `work` | Run ready tasks (`autonomous`, `concurrency` optional) |
271
+ | `work.stop` | Stop autonomous work for the current project |
269
272
  | `review` | Review implementation (`target` task ID required) |
270
273
  | `task.list` | List all tasks |
271
274
  | `task.show` | Show task details (`id` required) |
@@ -332,7 +335,7 @@ Incoming messages wake the receiving agent via `pi.sendMessage()` with `triggerT
332
335
 
333
336
  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.
334
337
 
335
- 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.
338
+ 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.
336
339
 
337
340
  ## Credits
338
341
 
@@ -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.`, {
@@ -13,6 +13,7 @@ import { resolveModel, spawnAgents } from "../agents.js";
13
13
  import { loadCrewConfig } from "../utils/config.js";
14
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";
@@ -220,6 +221,48 @@ export async function execute(
220
221
  }
221
222
  }
222
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
+
223
266
  syncCompletedCount(cwd);
224
267
 
225
268
  // Save current wave number BEFORE addWaveResult increments it
@@ -327,4 +370,3 @@ function syncCompletedCount(cwd: string): void {
327
370
  store.updatePlan(cwd, { completed_count: doneCount });
328
371
  }
329
372
  }
330
-
package/crew/index.ts CHANGED
@@ -10,7 +10,7 @@ import type { MessengerState, Dirs, AgentMailMessage, NameThemeConfig } from "..
10
10
  import * as handlers from "../handlers.js";
11
11
  import type { CrewParams, AppendEntryFn } from "./types.js";
12
12
  import { result } from "./utils/result.js";
13
- import { isPlanningForCwd, cancelPlanningRun } from "./state.js";
13
+ import { isPlanningForCwd, cancelPlanningRun, autonomousState, isAutonomousForCwd, stopAutonomous } from "./state.js";
14
14
  import { logFeedEvent } from "../feed.js";
15
15
 
16
16
  type DeliverFn = (msg: AgentMailMessage) => void;
@@ -180,6 +180,16 @@ export async function executeCrewAction(
180
180
  }
181
181
 
182
182
  case 'work': {
183
+ if (op === 'stop') {
184
+ const cwd = ctx.cwd ?? process.cwd();
185
+ if (!isAutonomousForCwd(cwd)) {
186
+ return result("No autonomous work running for this project.", { mode: "work.stop" });
187
+ }
188
+ stopAutonomous("manual");
189
+ appendEntry("crew-state", autonomousState);
190
+ return result("Autonomous work stopped.", { mode: "work.stop", autonomous: false });
191
+ }
192
+
183
193
  try {
184
194
  const workHandler = await import("./handlers/work.js");
185
195
  return workHandler.execute(params, dirs, ctx, appendEntry, signal);
@@ -25,6 +25,7 @@ export interface AutonomousState {
25
25
  stopReason: "completed" | "blocked" | "manual" | null;
26
26
  concurrency: number;
27
27
  autoOverlayPending: boolean;
28
+ pid: number | null;
28
29
  }
29
30
 
30
31
  export const autonomousState: AutonomousState = {
@@ -37,11 +38,21 @@ export const autonomousState: AutonomousState = {
37
38
  stopReason: null,
38
39
  concurrency: 2,
39
40
  autoOverlayPending: false,
41
+ pid: null,
40
42
  };
41
43
 
42
44
  export const MIN_CONCURRENCY = 1;
43
45
  export const MAX_CONCURRENCY = 10;
44
46
 
47
+ function isProcessAlive(pid: number): boolean {
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
45
56
  export function clampConcurrency(value: number, configMax?: number): number {
46
57
  if (!Number.isFinite(value)) return MIN_CONCURRENCY;
47
58
  const whole = Math.trunc(value);
@@ -79,6 +90,7 @@ export function startAutonomous(cwd: string, concurrency: number): void {
79
90
  autonomousState.stopReason = null;
80
91
  autonomousState.concurrency = clampConcurrency(concurrency);
81
92
  autonomousState.autoOverlayPending = true;
93
+ autonomousState.pid = process.pid;
82
94
  }
83
95
 
84
96
  export function stopAutonomous(reason: "completed" | "blocked" | "manual"): void {
@@ -86,6 +98,7 @@ export function stopAutonomous(reason: "completed" | "blocked" | "manual"): void
86
98
  autonomousState.autoOverlayPending = false;
87
99
  autonomousState.stoppedAt = new Date().toISOString();
88
100
  autonomousState.stopReason = reason;
101
+ autonomousState.pid = null;
89
102
  }
90
103
 
91
104
  export function addWaveResult(result: WaveResult): void {
@@ -106,6 +119,23 @@ export function restoreAutonomousState(data: Partial<AutonomousState>): void {
106
119
  if (data.concurrency !== undefined) {
107
120
  autonomousState.concurrency = clampConcurrency(Number(data.concurrency));
108
121
  }
122
+ if (data.pid !== undefined) {
123
+ autonomousState.pid = typeof data.pid === "number" ? data.pid : null;
124
+ }
125
+
126
+ if (!autonomousState.active) return;
127
+
128
+ const ownerPid = autonomousState.pid;
129
+ const sameProcess = ownerPid === process.pid;
130
+ const ownerAlive = typeof ownerPid === "number" && isProcessAlive(ownerPid);
131
+
132
+ if (!sameProcess || !ownerAlive) {
133
+ autonomousState.active = false;
134
+ autonomousState.autoOverlayPending = false;
135
+ autonomousState.stopReason = autonomousState.stopReason ?? "manual";
136
+ autonomousState.stoppedAt = autonomousState.stoppedAt ?? new Date().toISOString();
137
+ autonomousState.pid = null;
138
+ }
109
139
  }
110
140
 
111
141
  export function isAutonomousForCwd(cwd: string): boolean {
package/crew/types.ts CHANGED
@@ -51,6 +51,7 @@ export interface Task {
51
51
  evidence?: TaskEvidence; // Evidence from task.done
52
52
  blocked_reason?: string; // Reason from task.block
53
53
  attempt_count: number; // How many times attempted (for auto-block)
54
+ review_count?: number; // How many times reviewed
54
55
  last_review?: ReviewFeedback; // Feedback from last review (for retry)
55
56
  }
56
57
 
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"
@@ -128,6 +129,7 @@ export function pruneFeed(cwd: string, maxEvents: number): void {
128
129
  const CREW_EVENT_TYPES = new Set<FeedEventType>([
129
130
  "task.start",
130
131
  "task.done",
132
+ "task.review",
131
133
  "task.block",
132
134
  "task.unblock",
133
135
  "task.reset",
@@ -182,6 +184,7 @@ export function formatFeedLine(event: FeedEvent): string {
182
184
  case "edit": line += ` editing ${target}`; break;
183
185
  case "task.start": line += withPreview(` started ${target}`); break;
184
186
  case "task.done": line += withPreview(` completed ${target}`); break;
187
+ case "task.review": line += withPreview(` reviewed ${target}`); break;
185
188
  case "task.block": line += withPreview(` blocked ${target}`); break;
186
189
  case "task.unblock": line += withPreview(` unblocked ${target}`); break;
187
190
  case "task.reset": line += withPreview(` reset ${target}`); break;
package/index.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  restoreAutonomousState,
59
59
  restorePlanningState,
60
60
  stopAutonomous,
61
+ isAutonomousForCwd,
61
62
  } from "./crew/state.js";
62
63
  import { loadCrewConfig } from "./crew/utils/config.js";
63
64
  import * as crewStore from "./crew/store.js";
@@ -295,6 +296,24 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
295
296
  const STATUS_HEARTBEAT_MS = 15_000;
296
297
  let latestCtx: ExtensionContext | null = null;
297
298
  let statusHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
299
+ const AUTONOMOUS_CONTINUE_REPEAT_LIMIT = 3;
300
+ let autonomousContinueSignature: string | null = null;
301
+ let autonomousContinueRepeats = 0;
302
+
303
+ function resetAutonomousContinueGuard(): void {
304
+ autonomousContinueSignature = null;
305
+ autonomousContinueRepeats = 0;
306
+ }
307
+
308
+ function trackAutonomousContinue(signature: string): number {
309
+ if (autonomousContinueSignature === signature) {
310
+ autonomousContinueRepeats += 1;
311
+ } else {
312
+ autonomousContinueSignature = signature;
313
+ autonomousContinueRepeats = 1;
314
+ }
315
+ return autonomousContinueRepeats;
316
+ }
298
317
 
299
318
  function startStatusHeartbeat(): void {
300
319
  if (statusHeartbeatTimer) return;
@@ -358,6 +377,7 @@ Usage (action-based API - preferred):
358
377
  // Crew: Work through tasks
359
378
  pi_messenger({ action: "work" }) → Run ready tasks
360
379
  pi_messenger({ action: "work", autonomous: true }) → Run until done/blocked
380
+ pi_messenger({ action: "work.stop" }) → Stop autonomous work for this project
361
381
 
362
382
  // Crew: Tasks
363
383
  pi_messenger({ action: "task.show", id: "task-1" }) → Show task
@@ -749,6 +769,7 @@ Usage (action-based API - preferred):
749
769
 
750
770
  pi.on("session_start", async (_event, ctx) => {
751
771
  latestCtx = ctx;
772
+ resetAutonomousContinueGuard();
752
773
  startStatusHeartbeat();
753
774
  for (const entry of ctx.sessionManager.getEntries()) {
754
775
  if (entry.type === "custom" && entry.customType === "crew-state") {
@@ -870,6 +891,7 @@ Usage (action-based API - preferred):
870
891
 
871
892
  pi.on("session_switch", async (_event, ctx) => {
872
893
  latestCtx = ctx;
894
+ resetAutonomousContinueGuard();
873
895
  const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
874
896
  if (staleCleared && ctx.hasUI) {
875
897
  ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
@@ -880,6 +902,7 @@ Usage (action-based API - preferred):
880
902
  });
881
903
  pi.on("session_fork", async (_event, ctx) => {
882
904
  latestCtx = ctx;
905
+ resetAutonomousContinueGuard();
883
906
  const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
884
907
  if (staleCleared && ctx.hasUI) {
885
908
  ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
@@ -936,6 +959,22 @@ Usage (action-based API - preferred):
936
959
  // ===========================================================================
937
960
 
938
961
  pi.on("agent_end", async (_event, ctx) => {
962
+ if (process.env.PI_CREW_WORKER === "1" || process.env.PI_LOBBY_ID) {
963
+ return;
964
+ }
965
+
966
+ if (!state.registered) {
967
+ if (autonomousState.active) {
968
+ stopAutonomous("manual");
969
+ pi.appendEntry("crew-state", autonomousState);
970
+ resetAutonomousContinueGuard();
971
+ if (ctx.hasUI) {
972
+ ctx.ui.notify("Autonomous stopped: this session is not registered in pi-messenger.", "warning");
973
+ }
974
+ }
975
+ return;
976
+ }
977
+
939
978
  // --- Auto-work after plan completion ---
940
979
  const autoWork = consumePendingAutoWork();
941
980
  if (autoWork && !overlayTui) {
@@ -955,15 +994,26 @@ Usage (action-based API - preferred):
955
994
  }
956
995
 
957
996
  // --- Existing autonomous continuation ---
958
- if (!autonomousState.active) return;
997
+ if (!autonomousState.active) {
998
+ resetAutonomousContinueGuard();
999
+ return;
1000
+ }
959
1001
 
960
- const cwd = autonomousState.cwd ?? ctx.cwd ?? process.cwd();
1002
+ const currentCwd = ctx.cwd ?? process.cwd();
1003
+ if (!isAutonomousForCwd(currentCwd)) {
1004
+ resetAutonomousContinueGuard();
1005
+ return;
1006
+ }
1007
+
1008
+ const cwd = autonomousState.cwd ?? currentCwd;
961
1009
  const crewDir = join(cwd, ".pi", "messenger", "crew");
962
1010
  const crewConfig = loadCrewConfig(crewDir);
963
1011
 
964
1012
  // Check max waves limit
965
1013
  if (autonomousState.waveNumber >= crewConfig.work.maxWaves) {
966
1014
  stopAutonomous("manual");
1015
+ pi.appendEntry("crew-state", autonomousState);
1016
+ resetAutonomousContinueGuard();
967
1017
  if (ctx.hasUI) {
968
1018
  ctx.ui.notify(`Autonomous stopped: max waves (${crewConfig.work.maxWaves}) reached`, "warning");
969
1019
  }
@@ -972,14 +1022,16 @@ Usage (action-based API - preferred):
972
1022
 
973
1023
  // Check for ready tasks
974
1024
  const readyTasks = crewStore.getReadyTasks(cwd, { advisory: crewConfig.dependencies === "advisory" });
975
-
1025
+
976
1026
  if (readyTasks.length === 0) {
977
1027
  // No ready tasks - check if all done or blocked
978
1028
  const allTasks = crewStore.getTasks(cwd);
979
1029
  const allDone = allTasks.every(t => t.status === "done");
980
-
1030
+
981
1031
  stopAutonomous(allDone ? "completed" : "blocked");
982
-
1032
+ pi.appendEntry("crew-state", autonomousState);
1033
+ resetAutonomousContinueGuard();
1034
+
983
1035
  const plan = crewStore.getPlan(cwd);
984
1036
  if (ctx.hasUI) {
985
1037
  if (allDone) {
@@ -992,6 +1044,26 @@ Usage (action-based API - preferred):
992
1044
  return;
993
1045
  }
994
1046
 
1047
+ const continueSignature = `${cwd}:${autonomousState.waveNumber}:${readyTasks.map(task => task.id).sort().join(",")}`;
1048
+ const continueRepeatCount = trackAutonomousContinue(continueSignature);
1049
+ if (continueRepeatCount >= AUTONOMOUS_CONTINUE_REPEAT_LIMIT) {
1050
+ stopAutonomous("manual");
1051
+ pi.appendEntry("crew-state", autonomousState);
1052
+ resetAutonomousContinueGuard();
1053
+
1054
+ const plan = crewStore.getPlan(cwd);
1055
+ const message = `Autonomous work on ${plan?.prd ?? "plan"} stopped after ${continueRepeatCount} repeated continuation retries without wave progress. Resolve the abort condition, then run pi_messenger({ action: "work", autonomous: true }).`;
1056
+ if (ctx.hasUI) {
1057
+ ctx.ui.notify(message, "warning");
1058
+ }
1059
+ pi.sendMessage({
1060
+ customType: "crew_continue_stopped",
1061
+ content: message,
1062
+ display: true,
1063
+ });
1064
+ return;
1065
+ }
1066
+
995
1067
  // Continue to next wave
996
1068
  // Note: waveNumber was already incremented by addWaveResult() in work.ts
997
1069
  const plan = crewStore.getPlan(cwd);
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-messenger",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Inter-agent messaging and file reservation system for pi coding agent",
5
5
  "type": "module",
6
6
  "author": "Nico Bailon",