pi-messenger 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.14.0] - 2026-04-03
6
+
7
+ ### Added
8
+ - **`leave` action** — `pi_messenger({ action: "leave" })` now lets the current session leave the mesh without restarting pi. It releases reservations, auto-unclaims the session's active swarm claim, closes the messenger overlay, and allows later rejoin from the same session.
9
+
10
+ ### Fixed
11
+ - **Leave guardrails for Crew state** — `leave` now refuses while project planning, autonomous work, or session-owned in-progress Crew tasks are still active, preventing stranded coordination state.
12
+ - **Immutable-session cwd handling** — Registration, folder scoping, auto-register path matching, and messenger context now follow the live session cwd after pi runtime/session replacement instead of relying on `process.cwd()`.
13
+
14
+ ## [0.13.2] - 2026-03-19
15
+
16
+ ### Added
17
+ - **`work.stop` action** — `pi_messenger({ action: "work.stop" })` now stops autonomous Crew work for the current project and persists the stop state.
18
+ - **Autonomous guard coverage tests** — Added targeted tests for `work.stop` routing and `agent_end` autonomous continuation guard behavior.
19
+
20
+ ### Fixed
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+ - **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.
27
+ - **Runtime artifact commit noise** — Added `.pi/` to `.gitignore` to avoid accidentally committing runtime files like feed artifacts.
28
+
3
29
  ## [0.13.1] - 2026-03-14
4
30
 
5
31
  ### Added
package/README.md CHANGED
@@ -47,6 +47,7 @@ pi_messenger({ action: "join" })
47
47
  pi_messenger({ action: "reserve", paths: ["src/auth/"], reason: "Refactoring" })
48
48
  pi_messenger({ action: "send", to: "GoldFalcon", message: "auth is done" })
49
49
  pi_messenger({ action: "release" })
50
+ pi_messenger({ action: "leave" })
50
51
  ```
51
52
 
52
53
  For multi-agent task orchestration from a PRD:
@@ -67,7 +68,7 @@ pi_messenger({ action: "review", target: "task-1" }) // Reviewer checks imple
67
68
 
68
69
  **Messaging** - Send messages between agents. Recipients wake up immediately and see the message as a steering prompt.
69
70
 
70
- **File Reservations** - Claim files or directories. Other agents get blocked with a clear message telling them who to coordinate with. Auto-releases on exit.
71
+ **File Reservations** - Claim files or directories. Other agents get blocked with a clear message telling them who to coordinate with. Auto-releases on `leave` or exit.
71
72
 
72
73
  **Stuck Detection** - Agents idle too long with an open task or reservation are flagged as stuck. Peers get a notification.
73
74
 
@@ -251,6 +252,7 @@ Agent definitions live in `crew/agents/` within the extension. To customize one
251
252
  | Action | Description |
252
253
  |--------|-------------|
253
254
  | `join` | Join the agent mesh |
255
+ | `leave` | Leave the mesh for the current session |
254
256
  | `list` | List agents with presence info |
255
257
  | `status` | Show your status or crew progress |
256
258
  | `whois` | Detailed info about an agent (`name` required) |
@@ -268,6 +270,7 @@ Agent definitions live in `crew/agents/` within the extension. To customize one
268
270
  |--------|-------------|
269
271
  | `plan` | Create plan from PRD or inline prompt (`prd`, `prompt` optional — auto-discovers PRD if omitted, auto-starts workers unless `autoWork: false`) |
270
272
  | `work` | Run ready tasks (`autonomous`, `concurrency` optional) |
273
+ | `work.stop` | Stop autonomous work for the current project |
271
274
  | `review` | Review implementation (`target` task ID required) |
272
275
  | `task.list` | List all tasks |
273
276
  | `task.show` | Show task details (`id` required) |
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;
@@ -77,6 +77,9 @@ export async function executeCrewAction(
77
77
  case 'status':
78
78
  return handlers.executeStatus(state, dirs, ctx.cwd ?? process.cwd());
79
79
 
80
+ case 'leave':
81
+ return handlers.executeLeave(state, dirs, ctx);
82
+
80
83
  case 'list':
81
84
  return handlers.executeList(state, dirs, ctx.cwd ?? process.cwd(), { stuckThreshold: config?.stuckThreshold });
82
85
 
@@ -180,6 +183,16 @@ export async function executeCrewAction(
180
183
  }
181
184
 
182
185
  case 'work': {
186
+ if (op === 'stop') {
187
+ const cwd = ctx.cwd ?? process.cwd();
188
+ if (!isAutonomousForCwd(cwd)) {
189
+ return result("No autonomous work running for this project.", { mode: "work.stop" });
190
+ }
191
+ stopAutonomous("manual");
192
+ appendEntry("crew-state", autonomousState);
193
+ return result("Autonomous work stopped.", { mode: "work.stop", autonomous: false });
194
+ }
195
+
183
196
  try {
184
197
  const workHandler = await import("./handlers/work.js");
185
198
  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/handlers.ts CHANGED
@@ -26,6 +26,7 @@ import * as store from "./store.js";
26
26
  import * as crewStore from "./crew/store.js";
27
27
  import { getAutoRegisterPaths, saveAutoRegisterPaths, matchesAutoRegisterPath } from "./config.js";
28
28
  import { readFeedEvents, logFeedEvent, pruneFeed, formatFeedLine, isCrewEvent, type FeedEvent } from "./feed.js";
29
+ import { isAutonomousForCwd, isPlanningForCwd } from "./crew/state.js";
29
30
  import { loadCrewConfig } from "./crew/utils/config.js";
30
31
 
31
32
  let messagesSentThisSession = 0;
@@ -127,6 +128,118 @@ export function executeJoin(
127
128
  });
128
129
  }
129
130
 
131
+ export async function executeLeave(
132
+ state: MessengerState,
133
+ dirs: Dirs,
134
+ ctx: ExtensionContext,
135
+ ) {
136
+ if (!state.registered) {
137
+ return notRegisteredError();
138
+ }
139
+
140
+ const cwd = ctx.cwd ?? process.cwd();
141
+
142
+ if (isPlanningForCwd(cwd)) {
143
+ return result(
144
+ "Cannot leave while Crew planning is active for this project. Cancel it first with pi_messenger({ action: \"plan.cancel\" }).",
145
+ { mode: "leave", error: "planning_active" }
146
+ );
147
+ }
148
+
149
+ if (isAutonomousForCwd(cwd)) {
150
+ return result(
151
+ "Cannot leave while autonomous Crew work is active for this project. Stop it first with pi_messenger({ action: \"work.stop\" }).",
152
+ { mode: "leave", error: "autonomous_active" }
153
+ );
154
+ }
155
+
156
+ const inProgressTasks = crewStore
157
+ .getTasks(cwd)
158
+ .filter(task => task.status === "in_progress" && task.assigned_to === state.agentName)
159
+ .map(task => task.id);
160
+
161
+ if (inProgressTasks.length > 0) {
162
+ return result(
163
+ `Cannot leave while Crew task${inProgressTasks.length === 1 ? "" : "s"} assigned to you ${inProgressTasks.length === 1 ? "is" : "are"} still in progress: ${inProgressTasks.join(", ")}. Finish, block, or reset ${inProgressTasks.length === 1 ? "it" : "them"} first.`,
164
+ { mode: "leave", error: "crew_tasks_in_progress", taskIds: inProgressTasks }
165
+ );
166
+ }
167
+
168
+ const activeClaim = store.getAgentCurrentClaim(dirs, state.agentName);
169
+ let releasedClaim: { spec: string; taskId: string; reason?: string } | undefined;
170
+ if (activeClaim) {
171
+ const claimDisplay = displaySpecPath(activeClaim.spec, cwd);
172
+ try {
173
+ const unclaimResult = await store.unclaimTask(dirs, activeClaim.spec, activeClaim.taskId, state.agentName);
174
+ if (!store.isUnclaimSuccess(unclaimResult)) {
175
+ return result(
176
+ `Cannot leave because the active swarm claim ${activeClaim.taskId} in ${claimDisplay} could not be released. Resolve it first and retry.`,
177
+ {
178
+ mode: "leave",
179
+ error: unclaimResult.error,
180
+ activeClaim: { ...activeClaim, spec: claimDisplay },
181
+ ...(store.isUnclaimNotYours(unclaimResult) ? { claimedBy: unclaimResult.claimedBy } : {}),
182
+ }
183
+ );
184
+ }
185
+ releasedClaim = activeClaim;
186
+ } catch (error) {
187
+ const message = error instanceof Error ? error.message : String(error);
188
+ return result(
189
+ `Cannot leave because the active swarm claim ${activeClaim.taskId} in ${claimDisplay} could not be released: ${message}`,
190
+ {
191
+ mode: "leave",
192
+ error: "unclaim_failed",
193
+ message,
194
+ activeClaim: { ...activeClaim, spec: claimDisplay },
195
+ }
196
+ );
197
+ }
198
+ }
199
+
200
+ const releasedReservations = state.reservations.map(r => r.pattern);
201
+ state.reservations = [];
202
+ store.updateRegistration(state, dirs, ctx);
203
+ for (const pattern of releasedReservations) {
204
+ logFeedEvent(cwd, state.agentName, "release", pattern);
205
+ }
206
+
207
+ try {
208
+ store.unregister(state, dirs);
209
+ } catch (error) {
210
+ const message = error instanceof Error ? error.message : String(error);
211
+ return result(
212
+ `Could not leave pi-messenger: ${message}`,
213
+ { mode: "leave", error: "unregister_failed", message }
214
+ );
215
+ }
216
+
217
+ logFeedEvent(cwd, state.agentName, "leave");
218
+ store.stopWatcher(state);
219
+
220
+ if (ctx.hasUI) {
221
+ ctx.ui.setStatus("messenger", undefined);
222
+ }
223
+
224
+ const claimText = releasedClaim
225
+ ? `\nReleased claim: ${releasedClaim.taskId} in ${displaySpecPath(releasedClaim.spec, cwd)}`
226
+ : "";
227
+
228
+ return result(
229
+ `Left pi-messenger.${releasedReservations.length > 0 ? `\nReleased reservations: ${releasedReservations.join(", ")}` : ""}${claimText}`,
230
+ {
231
+ mode: "leave",
232
+ releasedReservations,
233
+ releasedClaim: releasedClaim
234
+ ? {
235
+ ...releasedClaim,
236
+ spec: displaySpecPath(releasedClaim.spec, cwd),
237
+ }
238
+ : undefined,
239
+ }
240
+ );
241
+ }
242
+
130
243
  export function executeStatus(state: MessengerState, dirs: Dirs, cwd: string = process.cwd()) {
131
244
  if (!state.registered) {
132
245
  return notRegisteredError();
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";
@@ -79,7 +80,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
79
80
  // State & Configuration
80
81
  // ===========================================================================
81
82
 
82
- const config: MessengerConfig = loadConfig(process.cwd());
83
+ let config: MessengerConfig = loadConfig(process.cwd());
83
84
 
84
85
  const state: MessengerState = {
85
86
  agentName: process.env.PI_AGENT_NAME || "",
@@ -94,6 +95,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
94
95
  broadcastHistory: [],
95
96
  seenSenders: new Map(),
96
97
  model: "",
98
+ cwd: process.cwd(),
97
99
  gitBranch: undefined,
98
100
  spec: undefined,
99
101
  scopeToFolder: config.scopeToFolder,
@@ -295,6 +297,24 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
295
297
  const STATUS_HEARTBEAT_MS = 15_000;
296
298
  let latestCtx: ExtensionContext | null = null;
297
299
  let statusHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
300
+ const AUTONOMOUS_CONTINUE_REPEAT_LIMIT = 3;
301
+ let autonomousContinueSignature: string | null = null;
302
+ let autonomousContinueRepeats = 0;
303
+
304
+ function resetAutonomousContinueGuard(): void {
305
+ autonomousContinueSignature = null;
306
+ autonomousContinueRepeats = 0;
307
+ }
308
+
309
+ function trackAutonomousContinue(signature: string): number {
310
+ if (autonomousContinueSignature === signature) {
311
+ autonomousContinueRepeats += 1;
312
+ } else {
313
+ autonomousContinueSignature = signature;
314
+ autonomousContinueRepeats = 1;
315
+ }
316
+ return autonomousContinueRepeats;
317
+ }
298
318
 
299
319
  function startStatusHeartbeat(): void {
300
320
  if (statusHeartbeatTimer) return;
@@ -318,7 +338,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
318
338
  // ===========================================================================
319
339
 
320
340
  function sendRegistrationContext(ctx: ExtensionContext): void {
321
- const folder = extractFolder(process.cwd());
341
+ const folder = extractFolder(ctx.cwd ?? state.cwd);
322
342
  const locationPart = state.gitBranch
323
343
  ? `${folder} on ${state.gitBranch}`
324
344
  : folder;
@@ -341,6 +361,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
341
361
  Usage (action-based API - preferred):
342
362
  // Coordination
343
363
  pi_messenger({ action: "join" }) → Join mesh
364
+ pi_messenger({ action: "leave" }) → Leave mesh for this session
344
365
  pi_messenger({ action: "status" }) → Get status
345
366
  pi_messenger({ action: "list" }) → List agents with presence
346
367
  pi_messenger({ action: "feed", limit: 20 }) → Activity feed
@@ -358,6 +379,7 @@ Usage (action-based API - preferred):
358
379
  // Crew: Work through tasks
359
380
  pi_messenger({ action: "work" }) → Run ready tasks
360
381
  pi_messenger({ action: "work", autonomous: true }) → Run until done/blocked
382
+ pi_messenger({ action: "work.stop" }) → Stop autonomous work for this project
361
383
 
362
384
  // Crew: Tasks
363
385
  pi_messenger({ action: "task.show", id: "task-1" }) → Show task
@@ -448,6 +470,13 @@ Usage (action-based API - preferred):
448
470
  sendRegistrationContext(ctx);
449
471
  }
450
472
 
473
+ if (action === "leave" && !state.registered) {
474
+ overlayHandle?.hide();
475
+ overlayHandle = null;
476
+ overlayTui = null;
477
+ overlayOpening = false;
478
+ }
479
+
451
480
  return result;
452
481
  }
453
482
  });
@@ -749,13 +778,19 @@ Usage (action-based API - preferred):
749
778
 
750
779
  pi.on("session_start", async (_event, ctx) => {
751
780
  latestCtx = ctx;
781
+ state.cwd = ctx.cwd ?? process.cwd();
782
+ config = loadConfig(state.cwd);
783
+ state.scopeToFolder = config.scopeToFolder;
784
+ nameTheme.theme = config.nameTheme;
785
+ nameTheme.customWords = config.nameWords;
786
+ resetAutonomousContinueGuard();
752
787
  startStatusHeartbeat();
753
788
  for (const entry of ctx.sessionManager.getEntries()) {
754
789
  if (entry.type === "custom" && entry.customType === "crew-state") {
755
790
  restoreAutonomousState(entry.data as Parameters<typeof restoreAutonomousState>[0]);
756
791
  }
757
792
  }
758
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
793
+ const { staleCleared } = restorePlanningState(state.cwd);
759
794
  if (staleCleared && ctx.hasUI) {
760
795
  ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
761
796
  }
@@ -764,7 +799,7 @@ Usage (action-based API - preferred):
764
799
  try { fs.rmSync(join(homedir(), ".pi/agent/messenger/feed.jsonl"), { force: true }); } catch {}
765
800
 
766
801
  const shouldAutoRegister = config.autoRegister ||
767
- matchesAutoRegisterPath(process.cwd(), config.autoRegisterPaths);
802
+ matchesAutoRegisterPath(state.cwd, config.autoRegisterPaths);
768
803
 
769
804
  if (!shouldAutoRegister) {
770
805
  maybeAutoOpenCrewOverlay(ctx);
@@ -772,7 +807,7 @@ Usage (action-based API - preferred):
772
807
  }
773
808
 
774
809
  if (store.register(state, dirs, ctx, nameTheme)) {
775
- const cwd = ctx.cwd ?? process.cwd();
810
+ const cwd = state.cwd;
776
811
  store.startWatcher(state, dirs, deliverMessage);
777
812
  updateStatus(ctx);
778
813
  pruneFeed(cwd, config.feedRetention);
@@ -868,26 +903,6 @@ Usage (action-based API - preferred):
868
903
  });
869
904
  }
870
905
 
871
- pi.on("session_switch", async (_event, ctx) => {
872
- latestCtx = ctx;
873
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
874
- if (staleCleared && ctx.hasUI) {
875
- ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
876
- }
877
- recoverWatcherIfNeeded();
878
- updateStatus(ctx);
879
- maybeAutoOpenCrewOverlay(ctx);
880
- });
881
- pi.on("session_fork", async (_event, ctx) => {
882
- latestCtx = ctx;
883
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
884
- if (staleCleared && ctx.hasUI) {
885
- ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
886
- }
887
- recoverWatcherIfNeeded();
888
- updateStatus(ctx);
889
- maybeAutoOpenCrewOverlay(ctx);
890
- });
891
906
  pi.on("session_tree", async (_event, ctx) => {
892
907
  latestCtx = ctx;
893
908
  const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
@@ -936,6 +951,22 @@ Usage (action-based API - preferred):
936
951
  // ===========================================================================
937
952
 
938
953
  pi.on("agent_end", async (_event, ctx) => {
954
+ if (process.env.PI_CREW_WORKER === "1" || process.env.PI_LOBBY_ID) {
955
+ return;
956
+ }
957
+
958
+ if (!state.registered) {
959
+ if (autonomousState.active) {
960
+ stopAutonomous("manual");
961
+ pi.appendEntry("crew-state", autonomousState);
962
+ resetAutonomousContinueGuard();
963
+ if (ctx.hasUI) {
964
+ ctx.ui.notify("Autonomous stopped: this session is not registered in pi-messenger.", "warning");
965
+ }
966
+ }
967
+ return;
968
+ }
969
+
939
970
  // --- Auto-work after plan completion ---
940
971
  const autoWork = consumePendingAutoWork();
941
972
  if (autoWork && !overlayTui) {
@@ -955,15 +986,26 @@ Usage (action-based API - preferred):
955
986
  }
956
987
 
957
988
  // --- Existing autonomous continuation ---
958
- if (!autonomousState.active) return;
989
+ if (!autonomousState.active) {
990
+ resetAutonomousContinueGuard();
991
+ return;
992
+ }
993
+
994
+ const currentCwd = ctx.cwd ?? process.cwd();
995
+ if (!isAutonomousForCwd(currentCwd)) {
996
+ resetAutonomousContinueGuard();
997
+ return;
998
+ }
959
999
 
960
- const cwd = autonomousState.cwd ?? ctx.cwd ?? process.cwd();
1000
+ const cwd = autonomousState.cwd ?? currentCwd;
961
1001
  const crewDir = join(cwd, ".pi", "messenger", "crew");
962
1002
  const crewConfig = loadCrewConfig(crewDir);
963
1003
 
964
1004
  // Check max waves limit
965
1005
  if (autonomousState.waveNumber >= crewConfig.work.maxWaves) {
966
1006
  stopAutonomous("manual");
1007
+ pi.appendEntry("crew-state", autonomousState);
1008
+ resetAutonomousContinueGuard();
967
1009
  if (ctx.hasUI) {
968
1010
  ctx.ui.notify(`Autonomous stopped: max waves (${crewConfig.work.maxWaves}) reached`, "warning");
969
1011
  }
@@ -972,14 +1014,16 @@ Usage (action-based API - preferred):
972
1014
 
973
1015
  // Check for ready tasks
974
1016
  const readyTasks = crewStore.getReadyTasks(cwd, { advisory: crewConfig.dependencies === "advisory" });
975
-
1017
+
976
1018
  if (readyTasks.length === 0) {
977
1019
  // No ready tasks - check if all done or blocked
978
1020
  const allTasks = crewStore.getTasks(cwd);
979
1021
  const allDone = allTasks.every(t => t.status === "done");
980
-
1022
+
981
1023
  stopAutonomous(allDone ? "completed" : "blocked");
982
-
1024
+ pi.appendEntry("crew-state", autonomousState);
1025
+ resetAutonomousContinueGuard();
1026
+
983
1027
  const plan = crewStore.getPlan(cwd);
984
1028
  if (ctx.hasUI) {
985
1029
  if (allDone) {
@@ -992,6 +1036,26 @@ Usage (action-based API - preferred):
992
1036
  return;
993
1037
  }
994
1038
 
1039
+ const continueSignature = `${cwd}:${autonomousState.waveNumber}:${readyTasks.map(task => task.id).sort().join(",")}`;
1040
+ const continueRepeatCount = trackAutonomousContinue(continueSignature);
1041
+ if (continueRepeatCount >= AUTONOMOUS_CONTINUE_REPEAT_LIMIT) {
1042
+ stopAutonomous("manual");
1043
+ pi.appendEntry("crew-state", autonomousState);
1044
+ resetAutonomousContinueGuard();
1045
+
1046
+ const plan = crewStore.getPlan(cwd);
1047
+ 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 }).`;
1048
+ if (ctx.hasUI) {
1049
+ ctx.ui.notify(message, "warning");
1050
+ }
1051
+ pi.sendMessage({
1052
+ customType: "crew_continue_stopped",
1053
+ content: message,
1054
+ display: true,
1055
+ });
1056
+ return;
1057
+ }
1058
+
995
1059
  // Continue to next wave
996
1060
  // Note: waveNumber was already incremented by addWaveResult() in work.ts
997
1061
  const plan = crewStore.getPlan(cwd);
@@ -1029,7 +1093,12 @@ Usage (action-based API - preferred):
1029
1093
  if (recentTestTimer) { clearTimeout(recentTestTimer); recentTestTimer = null; }
1030
1094
  if (recentEditTimer) { clearTimeout(recentEditTimer); recentEditTimer = null; }
1031
1095
  store.stopWatcher(state);
1032
- store.unregister(state, dirs);
1096
+ try {
1097
+ store.unregister(state, dirs);
1098
+ } catch {
1099
+ // Safe to ignore during shutdown: the process is exiting, so any leftover
1100
+ // registration will be cleaned up as stale on the next registry read.
1101
+ }
1033
1102
  });
1034
1103
 
1035
1104
  // ===========================================================================
package/lib.ts CHANGED
@@ -73,6 +73,7 @@ export interface MessengerState {
73
73
  broadcastHistory: AgentMailMessage[];
74
74
  seenSenders: Map<string, string>;
75
75
  model: string;
76
+ cwd: string;
76
77
  gitBranch?: string;
77
78
  spec?: string;
78
79
  scopeToFolder: boolean;
@@ -388,8 +389,8 @@ export function buildSelfRegistration(state: MessengerState): AgentRegistration
388
389
  name: state.agentName,
389
390
  pid: process.pid,
390
391
  sessionId: "",
391
- cwd: process.cwd(),
392
392
  model: state.model,
393
+ cwd: state.cwd,
393
394
  startedAt: state.sessionStartedAt,
394
395
  gitBranch: state.gitBranch,
395
396
  spec: state.spec,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-messenger",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Inter-agent messaging and file reservation system for pi coding agent",
5
5
  "type": "module",
6
6
  "author": "Nico Bailon",
@@ -9,9 +9,10 @@ Use pi-messenger for multi-agent coordination and Crew task orchestration.
9
9
 
10
10
  ## Quick Reference
11
11
 
12
- ### Join the Mesh (Required First)
12
+ ### Join or Leave the Mesh
13
13
  ```typescript
14
14
  pi_messenger({ action: "join" })
15
+ pi_messenger({ action: "leave" })
15
16
  ```
16
17
 
17
18
  ### Check Status
package/store.ts CHANGED
@@ -167,7 +167,7 @@ export function getRegistrationPath(state: MessengerState, dirs: Dirs): string {
167
167
  export function getActiveAgents(state: MessengerState, dirs: Dirs): AgentRegistration[] {
168
168
  const now = Date.now();
169
169
  const excludeName = state.agentName;
170
- const myCwd = normalizeCwd(process.cwd());
170
+ const myCwd = normalizeCwd(state.cwd);
171
171
  const scopeToFolder = state.scopeToFolder;
172
172
 
173
173
  // Cache key includes scopeToFolder and cwd for proper cache invalidation
@@ -339,7 +339,7 @@ export function register(state: MessengerState, dirs: Dirs, ctx: ExtensionContex
339
339
 
340
340
  ensureDirSync(getMyInbox(state, dirs));
341
341
 
342
- const cwd = normalizeCwd(process.cwd());
342
+ const cwd = normalizeCwd(ctx.cwd ?? process.cwd());
343
343
  const gitBranch = getGitBranch(cwd);
344
344
  const now = new Date().toISOString();
345
345
  const registration: AgentRegistration = {
@@ -378,6 +378,7 @@ export function register(state: MessengerState, dirs: Dirs, ctx: ExtensionContex
378
378
  if (verified) {
379
379
  state.registered = true;
380
380
  state.model = ctx.model?.id ?? "unknown";
381
+ state.cwd = cwd;
381
382
  state.gitBranch = gitBranch;
382
383
  state.activity.lastActivityAt = now;
383
384
  invalidateAgentsCache();
@@ -426,6 +427,7 @@ export function updateRegistration(state: MessengerState, dirs: Dirs, ctx: Exten
426
427
  const currentModel = ctx.model?.id ?? reg.model;
427
428
  reg.model = currentModel;
428
429
  state.model = currentModel;
430
+ reg.cwd = state.cwd;
429
431
  reg.reservations = state.reservations.length > 0 ? state.reservations : undefined;
430
432
  if (state.spec) {
431
433
  reg.spec = state.spec;
@@ -452,6 +454,7 @@ export function flushActivityToRegistry(state: MessengerState, dirs: Dirs, ctx:
452
454
  const currentModel = ctx.model?.id ?? reg.model;
453
455
  reg.model = currentModel;
454
456
  state.model = currentModel;
457
+ reg.cwd = state.cwd;
455
458
  reg.session = { ...state.session };
456
459
  reg.activity = { ...state.activity };
457
460
  reg.statusMessage = state.statusMessage;
@@ -464,11 +467,15 @@ export function flushActivityToRegistry(state: MessengerState, dirs: Dirs, ctx:
464
467
  export function unregister(state: MessengerState, dirs: Dirs): void {
465
468
  if (!state.registered) return;
466
469
 
470
+ const regPath = getRegistrationPath(state, dirs);
467
471
  try {
468
- fs.unlinkSync(getRegistrationPath(state, dirs));
469
- } catch {
470
- // Ignore errors
472
+ fs.unlinkSync(regPath);
473
+ } catch (error) {
474
+ if (fs.existsSync(regPath)) {
475
+ throw error;
476
+ }
471
477
  }
478
+
472
479
  state.registered = false;
473
480
  invalidateAgentsCache();
474
481
  }
@@ -515,7 +522,7 @@ export function renameAgent(
515
522
 
516
523
  processAllPendingMessages(state, dirs, deliverFn);
517
524
 
518
- const cwd = normalizeCwd(process.cwd());
525
+ const cwd = normalizeCwd(ctx.cwd ?? process.cwd());
519
526
  const gitBranch = getGitBranch(cwd);
520
527
  const now = new Date().toISOString();
521
528
  const registration: AgentRegistration = {
@@ -598,6 +605,7 @@ export function renameAgent(
598
605
  }
599
606
 
600
607
  state.model = ctx.model?.id ?? "unknown";
608
+ state.cwd = cwd;
601
609
  state.gitBranch = gitBranch;
602
610
  state.sessionStartedAt = now;
603
611
  state.activity.lastActivityAt = now;