pi-messenger 0.7.3

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.
Files changed (45) hide show
  1. package/ARCHITECTURE.md +244 -0
  2. package/CHANGELOG.md +418 -0
  3. package/README.md +394 -0
  4. package/banner.png +0 -0
  5. package/config-overlay.ts +172 -0
  6. package/config.ts +178 -0
  7. package/crew/agents/crew-docs-scout.md +55 -0
  8. package/crew/agents/crew-gap-analyst.md +105 -0
  9. package/crew/agents/crew-github-scout.md +111 -0
  10. package/crew/agents/crew-interview-generator.md +79 -0
  11. package/crew/agents/crew-plan-sync.md +64 -0
  12. package/crew/agents/crew-practice-scout.md +62 -0
  13. package/crew/agents/crew-repo-scout.md +65 -0
  14. package/crew/agents/crew-reviewer.md +58 -0
  15. package/crew/agents/crew-web-scout.md +85 -0
  16. package/crew/agents/crew-worker.md +95 -0
  17. package/crew/agents.ts +200 -0
  18. package/crew/handlers/interview.ts +211 -0
  19. package/crew/handlers/plan.ts +358 -0
  20. package/crew/handlers/review.ts +341 -0
  21. package/crew/handlers/status.ts +257 -0
  22. package/crew/handlers/sync.ts +232 -0
  23. package/crew/handlers/task.ts +511 -0
  24. package/crew/handlers/work.ts +289 -0
  25. package/crew/id-allocator.ts +44 -0
  26. package/crew/index.ts +229 -0
  27. package/crew/state.ts +116 -0
  28. package/crew/store.ts +480 -0
  29. package/crew/types.ts +164 -0
  30. package/crew/utils/artifacts.ts +65 -0
  31. package/crew/utils/config.ts +104 -0
  32. package/crew/utils/discover.ts +170 -0
  33. package/crew/utils/install.ts +373 -0
  34. package/crew/utils/progress.ts +107 -0
  35. package/crew/utils/result.ts +16 -0
  36. package/crew/utils/truncate.ts +79 -0
  37. package/crew-overlay.ts +259 -0
  38. package/handlers.ts +799 -0
  39. package/index.ts +591 -0
  40. package/lib.ts +232 -0
  41. package/overlay.ts +687 -0
  42. package/package.json +20 -0
  43. package/skills/pi-messenger-crew/SKILL.md +140 -0
  44. package/store.ts +1068 -0
  45. package/tsconfig.json +19 -0
package/index.ts ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Pi Messenger Extension
3
+ *
4
+ * Enables pi agents to discover and communicate with each other across terminal sessions.
5
+ * Uses file-based coordination - no daemon required.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
11
+ import type { TUI } from "@mariozechner/pi-tui";
12
+ import { truncateToWidth } from "@mariozechner/pi-tui";
13
+ import { Type } from "@sinclair/typebox";
14
+ import { StringEnum } from "@mariozechner/pi-ai";
15
+ import {
16
+ type MessengerState,
17
+ type Dirs,
18
+ type AgentMailMessage,
19
+ MAX_CHAT_HISTORY,
20
+ formatRelativeTime,
21
+ stripAnsiCodes,
22
+ extractFolder,
23
+ displaySpecPath,
24
+ } from "./lib.js";
25
+ import * as store from "./store.js";
26
+ import * as handlers from "./handlers.js";
27
+ import { MessengerOverlay } from "./overlay.js";
28
+ import { MessengerConfigOverlay } from "./config-overlay.js";
29
+ import { loadConfig, matchesAutoRegisterPath, type MessengerConfig } from "./config.js";
30
+ import { executeCrewAction } from "./crew/index.js";
31
+ import type { CrewParams } from "./crew/types.js";
32
+ import { autonomousState, restoreAutonomousState, stopAutonomous } from "./crew/state.js";
33
+ import { loadCrewConfig } from "./crew/utils/config.js";
34
+ import * as crewStore from "./crew/store.js";
35
+
36
+ let overlayTui: TUI | null = null;
37
+
38
+ export default function piMessengerExtension(pi: ExtensionAPI) {
39
+ // ===========================================================================
40
+ // State & Configuration
41
+ // ===========================================================================
42
+
43
+ const config: MessengerConfig = loadConfig(process.cwd());
44
+
45
+ const state: MessengerState = {
46
+ agentName: process.env.PI_AGENT_NAME || "",
47
+ registered: false,
48
+ watcher: null,
49
+ watcherRetries: 0,
50
+ watcherRetryTimer: null,
51
+ watcherDebounceTimer: null,
52
+ reservations: [],
53
+ chatHistory: new Map(),
54
+ unreadCounts: new Map(),
55
+ broadcastHistory: [],
56
+ seenSenders: new Map(),
57
+ gitBranch: undefined,
58
+ spec: undefined,
59
+ scopeToFolder: config.scopeToFolder
60
+ };
61
+
62
+ const baseDir = process.env.PI_MESSENGER_DIR || join(homedir(), ".pi/agent/messenger");
63
+ const dirs: Dirs = {
64
+ base: baseDir,
65
+ registry: join(baseDir, "registry"),
66
+ inbox: join(baseDir, "inbox")
67
+ };
68
+
69
+ // ===========================================================================
70
+ // Message Delivery
71
+ // ===========================================================================
72
+
73
+ function deliverMessage(msg: AgentMailMessage): void {
74
+ // Store in chat history (keyed by sender)
75
+ let history = state.chatHistory.get(msg.from);
76
+ if (!history) {
77
+ history = [];
78
+ state.chatHistory.set(msg.from, history);
79
+ }
80
+ history.push(msg);
81
+ if (history.length > MAX_CHAT_HISTORY) history.shift();
82
+
83
+ // Increment unread count
84
+ const current = state.unreadCounts.get(msg.from) ?? 0;
85
+ state.unreadCounts.set(msg.from, current + 1);
86
+
87
+ // Trigger overlay re-render if open
88
+ overlayTui?.requestRender();
89
+
90
+ // Build message content with optional context
91
+ // Detect if this is a new agent identity (first contact OR same name but different session)
92
+ const sender = store.getActiveAgents(state, dirs).find(a => a.name === msg.from);
93
+ const senderSessionId = sender?.sessionId;
94
+ const prevSessionId = state.seenSenders.get(msg.from);
95
+ const isNewIdentity = !prevSessionId || (senderSessionId && prevSessionId !== senderSessionId);
96
+
97
+ // Update seen senders with current sessionId (only if we could look it up)
98
+ if (senderSessionId) {
99
+ state.seenSenders.set(msg.from, senderSessionId);
100
+ }
101
+
102
+ let content = "";
103
+
104
+ // Add sender details on new identity (first contact or agent restart with same name)
105
+ if (isNewIdentity && config.senderDetailsOnFirstContact && sender) {
106
+ const folder = extractFolder(sender.cwd);
107
+ const locationPart = sender.gitBranch
108
+ ? `${folder} on ${sender.gitBranch}`
109
+ : folder;
110
+ content += `*${msg.from} is in ${locationPart} (${sender.model})*\n\n`;
111
+ }
112
+
113
+ // Add reply hint
114
+ const replyHint = config.replyHint
115
+ ? ` — reply: pi_messenger({ to: "${msg.from}", message: "..." })`
116
+ : "";
117
+
118
+ content += `**Message from ${msg.from}**${replyHint}\n\n${msg.text}`;
119
+
120
+ if (msg.replyTo) {
121
+ content = `*(reply to ${msg.replyTo.substring(0, 8)})*\n\n${content}`;
122
+ }
123
+
124
+ pi.sendMessage(
125
+ { customType: "agent_message", content, display: true, details: msg },
126
+ { triggerTurn: true, deliverAs: "steer" }
127
+ );
128
+ }
129
+
130
+ // ===========================================================================
131
+ // Status
132
+ // ===========================================================================
133
+
134
+ function updateStatus(ctx: ExtensionContext): void {
135
+ if (!ctx.hasUI || !state.registered) return;
136
+
137
+ const agents = store.getActiveAgents(state, dirs);
138
+ const activeNames = new Set(agents.map(a => a.name));
139
+ const count = agents.length;
140
+ const theme = ctx.ui.theme;
141
+
142
+ // Clear unread counts for agents that are no longer active
143
+ for (const name of state.unreadCounts.keys()) {
144
+ if (!activeNames.has(name)) {
145
+ state.unreadCounts.delete(name);
146
+ }
147
+ }
148
+
149
+ // Sum remaining unread counts
150
+ let totalUnread = 0;
151
+ for (const n of state.unreadCounts.values()) totalUnread += n;
152
+
153
+ const nameStr = theme.fg("accent", state.agentName);
154
+ const countStr = theme.fg("dim", ` (${count} peer${count === 1 ? "" : "s"})`);
155
+ const unreadStr = totalUnread > 0 ? theme.fg("accent", ` ●${totalUnread}`) : "";
156
+
157
+ // Add crew status if autonomous mode is active
158
+ let crewStr = "";
159
+ if (autonomousState.active) {
160
+ const cwd = ctx.cwd ?? process.cwd();
161
+ const plan = crewStore.getPlan(cwd);
162
+ if (plan) {
163
+ crewStr = theme.fg("accent", ` ⚡${plan.completed_count}/${plan.task_count}`);
164
+ }
165
+ }
166
+
167
+ ctx.ui.setStatus("messenger", `msg: ${nameStr}${countStr}${unreadStr}${crewStr}`);
168
+ }
169
+
170
+ // ===========================================================================
171
+ // Tool Registration
172
+ // ===========================================================================
173
+
174
+ pi.registerTool({
175
+ name: "pi_messenger",
176
+ label: "Pi Messenger",
177
+ description: `Multi-agent coordination and task orchestration.
178
+
179
+ Usage (action-based API - preferred):
180
+ // Coordination
181
+ pi_messenger({ action: "join" }) → Join mesh
182
+ pi_messenger({ action: "status" }) → Get status
183
+ pi_messenger({ action: "list" }) → List agents
184
+ pi_messenger({ action: "reserve", paths: ["src/"] }) → Reserve files
185
+ pi_messenger({ action: "send", to: "Agent", message: "hi" }) → Send message
186
+
187
+ // Crew: Plan from PRD
188
+ pi_messenger({ action: "plan" }) → Auto-discover PRD
189
+ pi_messenger({ action: "plan", prd: "docs/PRD.md" }) → Explicit PRD path
190
+
191
+ // Crew: Work through tasks
192
+ pi_messenger({ action: "work" }) → Run ready tasks
193
+ pi_messenger({ action: "work", autonomous: true }) → Run until done/blocked
194
+
195
+ // Crew: Tasks
196
+ pi_messenger({ action: "task.show", id: "task-1" }) → Show task
197
+ pi_messenger({ action: "task.list" }) → List all tasks
198
+ pi_messenger({ action: "task.start", id: "task-1" }) → Start task
199
+ pi_messenger({ action: "task.done", id: "task-1", summary: "..." })
200
+ pi_messenger({ action: "task.reset", id: "task-1" }) → Reset task
201
+
202
+ // Crew: Review
203
+ pi_messenger({ action: "review", target: "task-1" }) → Review impl
204
+
205
+ Legacy (backwards compatible):
206
+ pi_messenger({ join: true }) → Join the agent mesh
207
+ pi_messenger({ claim: "TASK-01" }) → Claim a swarm task
208
+ pi_messenger({ to: "Name", message: "hi" }) → Send message
209
+
210
+ Mode: action (if provided) > legacy key-based routing`,
211
+ parameters: Type.Object({
212
+ // ═══════════════════════════════════════════════════════════════════════
213
+ // ACTION PARAMETER (preferred for new usage)
214
+ // ═══════════════════════════════════════════════════════════════════════
215
+ action: Type.Optional(Type.String({
216
+ description: "Action to perform (e.g., 'join', 'plan', 'work', 'task.start')"
217
+ })),
218
+
219
+ // ═══════════════════════════════════════════════════════════════════════
220
+ // CREW PARAMETERS
221
+ // ═══════════════════════════════════════════════════════════════════════
222
+ prd: Type.Optional(Type.String({ description: "PRD file path for plan action" })),
223
+ id: Type.Optional(Type.String({ description: "Task ID (task-N format)" })),
224
+ taskId: Type.Optional(Type.String({ description: "Swarm task ID (e.g., TASK-01) - for action-based claim/unclaim/complete" })),
225
+ title: Type.Optional(Type.String({ description: "Title for task.create" })),
226
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Task IDs this task depends on (for task.create)" })),
227
+ target: Type.Optional(Type.String({ description: "Task ID for review action" })),
228
+ summary: Type.Optional(Type.String({ description: "Summary for task.done" })),
229
+ evidence: Type.Optional(Type.Object({
230
+ commits: Type.Optional(Type.Array(Type.String())),
231
+ tests: Type.Optional(Type.Array(Type.String())),
232
+ prs: Type.Optional(Type.Array(Type.String()))
233
+ }, { description: "Evidence for task.done" })),
234
+ content: Type.Optional(Type.String({ description: "Content for task spec" })),
235
+ type: Type.Optional(StringEnum(["plan", "impl"], { description: "Review type (inferred from target if omitted)" })),
236
+ autonomous: Type.Optional(Type.Boolean({ description: "Run work continuously until done/blocked" })),
237
+ concurrency: Type.Optional(Type.Number({ description: "Override worker concurrency" })),
238
+ cascade: Type.Optional(Type.Boolean({ description: "For task.reset - also reset dependent tasks" })),
239
+ paths: Type.Optional(Type.Array(Type.String(), { description: "Paths for reserve/release actions" })),
240
+ name: Type.Optional(Type.String({ description: "New name for rename action" })),
241
+
242
+ // ═══════════════════════════════════════════════════════════════════════
243
+ // EXISTING COORDINATION PARAMETERS (backwards compatibility)
244
+ // ═══════════════════════════════════════════════════════════════════════
245
+ join: Type.Optional(Type.Boolean({ description: "Join the agent mesh" })),
246
+ spec: Type.Optional(Type.String({ description: "Path to spec/plan file" })),
247
+ claim: Type.Optional(Type.String({ description: "Task ID to claim (legacy - use action: 'claim' with taskId)" })),
248
+ unclaim: Type.Optional(Type.String({ description: "Task ID to release (legacy)" })),
249
+ complete: Type.Optional(Type.String({ description: "Task ID to mark complete (legacy)" })),
250
+ notes: Type.Optional(Type.String({ description: "Completion notes" })),
251
+ swarm: Type.Optional(Type.Boolean({ description: "Get swarm status" })),
252
+ to: Type.Optional(Type.Any({ description: "Target agent name (string) or multiple names (array)" })),
253
+ broadcast: Type.Optional(Type.Boolean({ description: "Send to all active agents" })),
254
+ message: Type.Optional(Type.String({ description: "Message to send" })),
255
+ replyTo: Type.Optional(Type.String({ description: "Message ID if this is a reply" })),
256
+ reserve: Type.Optional(Type.Array(Type.String(), { description: "Paths to reserve (legacy - use action: 'reserve' with paths)" })),
257
+ reason: Type.Optional(Type.String({ description: "Reason for reservation or claim" })),
258
+ release: Type.Optional(Type.Any({ description: "Patterns to release (array) or true to release all (legacy)" })),
259
+ rename: Type.Optional(Type.String({ description: "Rename yourself (legacy - use action: 'rename' with name)" })),
260
+ autoRegisterPath: Type.Optional(StringEnum(["add", "remove", "list"], { description: "Manage auto-register paths: add/remove current folder, or list all" })),
261
+ list: Type.Optional(Type.Boolean({ description: "List other agents" }))
262
+ }),
263
+
264
+ async execute(_toolCallId, params: CrewParams & {
265
+ join?: boolean;
266
+ spec?: string;
267
+ claim?: string;
268
+ unclaim?: string;
269
+ complete?: string;
270
+ notes?: string;
271
+ swarm?: boolean;
272
+ to?: string | string[];
273
+ broadcast?: boolean;
274
+ message?: string;
275
+ replyTo?: string;
276
+ reserve?: string[];
277
+ reason?: string;
278
+ release?: string[] | boolean;
279
+ rename?: string;
280
+ autoRegisterPath?: "add" | "remove" | "list";
281
+ list?: boolean;
282
+ }, _onUpdate, ctx, _signal) {
283
+ const {
284
+ action,
285
+ join,
286
+ spec,
287
+ claim,
288
+ unclaim,
289
+ complete,
290
+ notes,
291
+ swarm,
292
+ to,
293
+ broadcast,
294
+ message,
295
+ replyTo,
296
+ reserve,
297
+ reason,
298
+ release,
299
+ rename,
300
+ autoRegisterPath,
301
+ list
302
+ } = params;
303
+
304
+ // ═══════════════════════════════════════════════════════════════════════
305
+ // ACTION-BASED ROUTING (preferred)
306
+ // ═══════════════════════════════════════════════════════════════════════
307
+ if (action) {
308
+ return executeCrewAction(
309
+ action,
310
+ params,
311
+ state,
312
+ dirs,
313
+ ctx,
314
+ deliverMessage,
315
+ updateStatus,
316
+ (type, data) => pi.appendEntry(type, data)
317
+ );
318
+ }
319
+
320
+ // ═══════════════════════════════════════════════════════════════════════
321
+ // LEGACY KEY-BASED ROUTING (backwards compatibility)
322
+ // ═══════════════════════════════════════════════════════════════════════
323
+
324
+ // Join doesn't require registration
325
+ if (join) {
326
+ const joinResult = handlers.executeJoin(state, dirs, ctx, deliverMessage, updateStatus, spec);
327
+
328
+ // Send registration context after successful join (if configured)
329
+ if (state.registered && config.registrationContext) {
330
+ const folder = extractFolder(process.cwd());
331
+ const locationPart = state.gitBranch
332
+ ? `${folder} on ${state.gitBranch}`
333
+ : folder;
334
+ const specPart = state.spec ? ` working on ${displaySpecPath(state.spec, process.cwd())}` : "";
335
+ pi.sendMessage({
336
+ customType: "messenger_context",
337
+ content: `You are agent "${state.agentName}" in ${locationPart}${specPart}. Use pi_messenger({ swarm: true }) to see task status, pi_messenger({ claim: "TASK-X" }) to claim tasks.`,
338
+ display: false
339
+ }, { triggerTurn: false });
340
+ }
341
+
342
+ return joinResult;
343
+ }
344
+
345
+ // autoRegisterPath doesn't require registration - it's config management
346
+ if (autoRegisterPath) {
347
+ return handlers.executeAutoRegisterPath(autoRegisterPath);
348
+ }
349
+
350
+ // All other operations require registration
351
+ if (!state.registered) return handlers.notRegisteredError();
352
+
353
+ if (swarm) return handlers.executeSwarm(state, dirs, spec);
354
+ if (claim) return await handlers.executeClaim(state, dirs, ctx, claim, spec, reason);
355
+ if (unclaim) return await handlers.executeUnclaim(state, dirs, unclaim, spec);
356
+ if (complete) return await handlers.executeComplete(state, dirs, complete, notes, spec);
357
+ if (spec) return handlers.executeSetSpec(state, dirs, ctx, spec);
358
+ if (to || broadcast) return handlers.executeSend(state, dirs, to, broadcast, message, replyTo);
359
+ if (reserve && reserve.length > 0) return handlers.executeReserve(state, dirs, ctx, reserve, reason);
360
+ if (release === true || (Array.isArray(release) && release.length > 0)) {
361
+ return handlers.executeRelease(state, dirs, ctx, release);
362
+ }
363
+ if (rename) return handlers.executeRename(state, dirs, ctx, rename, deliverMessage, updateStatus);
364
+ if (list) return handlers.executeList(state, dirs);
365
+ return handlers.executeStatus(state, dirs);
366
+ }
367
+ });
368
+
369
+ // ===========================================================================
370
+ // Commands
371
+ // ===========================================================================
372
+
373
+ pi.registerCommand("messenger", {
374
+ description: "Open messenger overlay, or 'config' to manage settings",
375
+ handler: async (args, ctx) => {
376
+ if (!ctx.hasUI) return;
377
+
378
+ // /messenger config - open config overlay
379
+ if (args[0] === "config") {
380
+ await ctx.ui.custom<void>(
381
+ (tui, theme, _keybindings, done) => {
382
+ return new MessengerConfigOverlay(tui, theme, done);
383
+ },
384
+ { overlay: true }
385
+ );
386
+ return;
387
+ }
388
+
389
+ // /messenger - open chat overlay (auto-joins if not registered)
390
+ if (!state.registered) {
391
+ if (!store.register(state, dirs, ctx)) {
392
+ ctx.ui.notify("Failed to join agent mesh", "error");
393
+ return;
394
+ }
395
+ store.startWatcher(state, dirs, deliverMessage);
396
+ updateStatus(ctx);
397
+ }
398
+
399
+ await ctx.ui.custom<void>(
400
+ (tui, theme, _keybindings, done) => {
401
+ overlayTui = tui;
402
+ return new MessengerOverlay(tui, theme, state, dirs, done);
403
+ },
404
+ { overlay: true }
405
+ );
406
+
407
+ // Overlay closed
408
+ overlayTui = null;
409
+ updateStatus(ctx);
410
+ }
411
+ });
412
+
413
+ // ===========================================================================
414
+ // Message Renderer
415
+ // ===========================================================================
416
+
417
+ pi.registerMessageRenderer<AgentMailMessage>("agent_message", (message, _options, theme) => {
418
+ const details = message.details;
419
+ if (!details) return undefined;
420
+
421
+ return {
422
+ render(width: number): string[] {
423
+ const safeFrom = stripAnsiCodes(details.from);
424
+ const safeText = stripAnsiCodes(details.text);
425
+
426
+ const header = theme.fg("accent", `From ${safeFrom}`);
427
+ const time = theme.fg("dim", ` (${formatRelativeTime(details.timestamp)})`);
428
+
429
+ const result: string[] = [];
430
+ result.push(truncateToWidth(header + time, width));
431
+ result.push("");
432
+
433
+ for (const line of safeText.split("\n")) {
434
+ result.push(truncateToWidth(line, width));
435
+ }
436
+
437
+ return result;
438
+ },
439
+ invalidate() {}
440
+ };
441
+ });
442
+
443
+ // ===========================================================================
444
+ // Event Handlers
445
+ // ===========================================================================
446
+
447
+ pi.on("session_start", async (_event, ctx) => {
448
+ // Restore crew autonomous state from session entries
449
+ for (const entry of ctx.sessionManager.getEntries()) {
450
+ if (entry.type === "custom" && entry.customType === "crew-state") {
451
+ restoreAutonomousState(entry.data as Parameters<typeof restoreAutonomousState>[0]);
452
+ }
453
+ }
454
+
455
+ // Check if auto-register is enabled (global or path-based)
456
+ const shouldAutoRegister = config.autoRegister ||
457
+ matchesAutoRegisterPath(process.cwd(), config.autoRegisterPaths);
458
+
459
+ if (!shouldAutoRegister) return;
460
+
461
+ if (store.register(state, dirs, ctx)) {
462
+ store.startWatcher(state, dirs, deliverMessage);
463
+ updateStatus(ctx);
464
+
465
+ // Send registration context (non-displaying, non-triggering)
466
+ if (config.registrationContext) {
467
+ const folder = extractFolder(process.cwd());
468
+ const locationPart = state.gitBranch
469
+ ? `${folder} on ${state.gitBranch}`
470
+ : folder;
471
+ const specPart = state.spec ? ` working on ${displaySpecPath(state.spec, process.cwd())}` : "";
472
+ pi.sendMessage({
473
+ customType: "messenger_context",
474
+ content: `You are agent "${state.agentName}" in ${locationPart}${specPart}. Use pi_messenger({ swarm: true }) to see task status, pi_messenger({ claim: "TASK-X" }) to claim tasks.`,
475
+ display: false
476
+ }, { triggerTurn: false });
477
+ }
478
+ }
479
+ });
480
+
481
+ function recoverWatcherIfNeeded(): void {
482
+ if (state.registered && !state.watcher && !state.watcherRetryTimer) {
483
+ state.watcherRetries = 0;
484
+ store.startWatcher(state, dirs, deliverMessage);
485
+ }
486
+ }
487
+
488
+ pi.on("session_switch", async (_event, ctx) => {
489
+ recoverWatcherIfNeeded();
490
+ updateStatus(ctx);
491
+ });
492
+ pi.on("session_fork", async (_event, ctx) => {
493
+ recoverWatcherIfNeeded();
494
+ updateStatus(ctx);
495
+ });
496
+ pi.on("session_tree", async (_event, ctx) => updateStatus(ctx));
497
+
498
+ pi.on("turn_end", async (_event, ctx) => {
499
+ store.processAllPendingMessages(state, dirs, deliverMessage);
500
+ recoverWatcherIfNeeded();
501
+ updateStatus(ctx);
502
+ });
503
+
504
+ // ===========================================================================
505
+ // Crew Autonomous Mode Continuation
506
+ // ===========================================================================
507
+
508
+ pi.on("agent_end", async (_event, ctx) => {
509
+ // Only continue if autonomous mode is active
510
+ if (!autonomousState.active) return;
511
+
512
+ const cwd = autonomousState.cwd ?? ctx.cwd ?? process.cwd();
513
+ const crewDir = join(cwd, ".pi", "messenger", "crew");
514
+ const crewConfig = loadCrewConfig(crewDir);
515
+
516
+ // Check max waves limit
517
+ if (autonomousState.waveNumber >= crewConfig.work.maxWaves) {
518
+ stopAutonomous("manual");
519
+ if (ctx.hasUI) {
520
+ ctx.ui.notify(`Autonomous stopped: max waves (${crewConfig.work.maxWaves}) reached`, "warning");
521
+ }
522
+ return;
523
+ }
524
+
525
+ // Check for ready tasks
526
+ const readyTasks = crewStore.getReadyTasks(cwd);
527
+
528
+ if (readyTasks.length === 0) {
529
+ // No ready tasks - check if all done or blocked
530
+ const allTasks = crewStore.getTasks(cwd);
531
+ const allDone = allTasks.every(t => t.status === "done");
532
+
533
+ stopAutonomous(allDone ? "completed" : "blocked");
534
+
535
+ const plan = crewStore.getPlan(cwd);
536
+ if (ctx.hasUI) {
537
+ if (allDone) {
538
+ ctx.ui.notify(`✅ All tasks complete for ${plan?.prd ?? "plan"}!`, "info");
539
+ } else {
540
+ const blocked = allTasks.filter(t => t.status === "blocked");
541
+ ctx.ui.notify(`Autonomous stopped: ${blocked.length} task(s) blocked`, "warning");
542
+ }
543
+ }
544
+ return;
545
+ }
546
+
547
+ // Continue to next wave
548
+ // Note: waveNumber was already incremented by addWaveResult() in work.ts
549
+ const plan = crewStore.getPlan(cwd);
550
+ pi.sendMessage({
551
+ customType: "crew_continue",
552
+ content: `Continuing autonomous work on ${plan?.prd ?? "plan"}. Wave ${autonomousState.waveNumber} with ${readyTasks.length} ready task(s).`,
553
+ display: true
554
+ }, { triggerTurn: true, deliverAs: "steer" });
555
+
556
+ // The steer message will trigger the LLM to call work again
557
+ });
558
+
559
+ pi.on("session_shutdown", async () => {
560
+ store.stopWatcher(state);
561
+ store.unregister(state, dirs);
562
+ });
563
+
564
+ // ===========================================================================
565
+ // Reservation Enforcement
566
+ // ===========================================================================
567
+
568
+ pi.on("tool_call", async (event, _ctx) => {
569
+ // Only block write operations - reading reserved files is fine
570
+ if (!["edit", "write"].includes(event.toolName)) return;
571
+
572
+ const path = event.input.path as string;
573
+ if (!path) return;
574
+
575
+ const conflicts = store.getConflictsWithOtherAgents(path, state, dirs);
576
+ if (conflicts.length === 0) return;
577
+
578
+ const c = conflicts[0];
579
+ const folder = extractFolder(c.registration.cwd);
580
+ const locationPart = c.registration.gitBranch
581
+ ? ` (in ${folder} on ${c.registration.gitBranch})`
582
+ : ` (in ${folder})`;
583
+
584
+ const lines = [path, `Reserved by: ${c.agent}${locationPart}`];
585
+ if (c.reason) lines.push(`Reason: "${c.reason}"`);
586
+ lines.push("");
587
+ lines.push(`Coordinate via pi_messenger({ to: "${c.agent}", message: "..." })`);
588
+
589
+ return { block: true, reason: lines.join("\n") };
590
+ });
591
+ }