pi-messenger 0.13.2 → 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
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
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
+
5
14
  ## [0.13.2] - 2026-03-19
6
15
 
7
16
  ### 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) |
package/crew/index.ts CHANGED
@@ -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
 
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
@@ -80,7 +80,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
80
80
  // State & Configuration
81
81
  // ===========================================================================
82
82
 
83
- const config: MessengerConfig = loadConfig(process.cwd());
83
+ let config: MessengerConfig = loadConfig(process.cwd());
84
84
 
85
85
  const state: MessengerState = {
86
86
  agentName: process.env.PI_AGENT_NAME || "",
@@ -95,6 +95,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
95
95
  broadcastHistory: [],
96
96
  seenSenders: new Map(),
97
97
  model: "",
98
+ cwd: process.cwd(),
98
99
  gitBranch: undefined,
99
100
  spec: undefined,
100
101
  scopeToFolder: config.scopeToFolder,
@@ -337,7 +338,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
337
338
  // ===========================================================================
338
339
 
339
340
  function sendRegistrationContext(ctx: ExtensionContext): void {
340
- const folder = extractFolder(process.cwd());
341
+ const folder = extractFolder(ctx.cwd ?? state.cwd);
341
342
  const locationPart = state.gitBranch
342
343
  ? `${folder} on ${state.gitBranch}`
343
344
  : folder;
@@ -360,6 +361,7 @@ export default function piMessengerExtension(pi: ExtensionAPI) {
360
361
  Usage (action-based API - preferred):
361
362
  // Coordination
362
363
  pi_messenger({ action: "join" }) → Join mesh
364
+ pi_messenger({ action: "leave" }) → Leave mesh for this session
363
365
  pi_messenger({ action: "status" }) → Get status
364
366
  pi_messenger({ action: "list" }) → List agents with presence
365
367
  pi_messenger({ action: "feed", limit: 20 }) → Activity feed
@@ -468,6 +470,13 @@ Usage (action-based API - preferred):
468
470
  sendRegistrationContext(ctx);
469
471
  }
470
472
 
473
+ if (action === "leave" && !state.registered) {
474
+ overlayHandle?.hide();
475
+ overlayHandle = null;
476
+ overlayTui = null;
477
+ overlayOpening = false;
478
+ }
479
+
471
480
  return result;
472
481
  }
473
482
  });
@@ -769,6 +778,11 @@ Usage (action-based API - preferred):
769
778
 
770
779
  pi.on("session_start", async (_event, ctx) => {
771
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;
772
786
  resetAutonomousContinueGuard();
773
787
  startStatusHeartbeat();
774
788
  for (const entry of ctx.sessionManager.getEntries()) {
@@ -776,7 +790,7 @@ Usage (action-based API - preferred):
776
790
  restoreAutonomousState(entry.data as Parameters<typeof restoreAutonomousState>[0]);
777
791
  }
778
792
  }
779
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
793
+ const { staleCleared } = restorePlanningState(state.cwd);
780
794
  if (staleCleared && ctx.hasUI) {
781
795
  ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
782
796
  }
@@ -785,7 +799,7 @@ Usage (action-based API - preferred):
785
799
  try { fs.rmSync(join(homedir(), ".pi/agent/messenger/feed.jsonl"), { force: true }); } catch {}
786
800
 
787
801
  const shouldAutoRegister = config.autoRegister ||
788
- matchesAutoRegisterPath(process.cwd(), config.autoRegisterPaths);
802
+ matchesAutoRegisterPath(state.cwd, config.autoRegisterPaths);
789
803
 
790
804
  if (!shouldAutoRegister) {
791
805
  maybeAutoOpenCrewOverlay(ctx);
@@ -793,7 +807,7 @@ Usage (action-based API - preferred):
793
807
  }
794
808
 
795
809
  if (store.register(state, dirs, ctx, nameTheme)) {
796
- const cwd = ctx.cwd ?? process.cwd();
810
+ const cwd = state.cwd;
797
811
  store.startWatcher(state, dirs, deliverMessage);
798
812
  updateStatus(ctx);
799
813
  pruneFeed(cwd, config.feedRetention);
@@ -889,28 +903,6 @@ Usage (action-based API - preferred):
889
903
  });
890
904
  }
891
905
 
892
- pi.on("session_switch", async (_event, ctx) => {
893
- latestCtx = ctx;
894
- resetAutonomousContinueGuard();
895
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
896
- if (staleCleared && ctx.hasUI) {
897
- ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
898
- }
899
- recoverWatcherIfNeeded();
900
- updateStatus(ctx);
901
- maybeAutoOpenCrewOverlay(ctx);
902
- });
903
- pi.on("session_fork", async (_event, ctx) => {
904
- latestCtx = ctx;
905
- resetAutonomousContinueGuard();
906
- const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
907
- if (staleCleared && ctx.hasUI) {
908
- ctx.ui.notify("Stale planning state cleared (planner process exited)", "warning");
909
- }
910
- recoverWatcherIfNeeded();
911
- updateStatus(ctx);
912
- maybeAutoOpenCrewOverlay(ctx);
913
- });
914
906
  pi.on("session_tree", async (_event, ctx) => {
915
907
  latestCtx = ctx;
916
908
  const { staleCleared } = restorePlanningState(ctx.cwd ?? process.cwd());
@@ -1101,7 +1093,12 @@ Usage (action-based API - preferred):
1101
1093
  if (recentTestTimer) { clearTimeout(recentTestTimer); recentTestTimer = null; }
1102
1094
  if (recentEditTimer) { clearTimeout(recentEditTimer); recentEditTimer = null; }
1103
1095
  store.stopWatcher(state);
1104
- 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
+ }
1105
1102
  });
1106
1103
 
1107
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.2",
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;