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.
- package/ARCHITECTURE.md +244 -0
- package/CHANGELOG.md +418 -0
- package/README.md +394 -0
- package/banner.png +0 -0
- package/config-overlay.ts +172 -0
- package/config.ts +178 -0
- package/crew/agents/crew-docs-scout.md +55 -0
- package/crew/agents/crew-gap-analyst.md +105 -0
- package/crew/agents/crew-github-scout.md +111 -0
- package/crew/agents/crew-interview-generator.md +79 -0
- package/crew/agents/crew-plan-sync.md +64 -0
- package/crew/agents/crew-practice-scout.md +62 -0
- package/crew/agents/crew-repo-scout.md +65 -0
- package/crew/agents/crew-reviewer.md +58 -0
- package/crew/agents/crew-web-scout.md +85 -0
- package/crew/agents/crew-worker.md +95 -0
- package/crew/agents.ts +200 -0
- package/crew/handlers/interview.ts +211 -0
- package/crew/handlers/plan.ts +358 -0
- package/crew/handlers/review.ts +341 -0
- package/crew/handlers/status.ts +257 -0
- package/crew/handlers/sync.ts +232 -0
- package/crew/handlers/task.ts +511 -0
- package/crew/handlers/work.ts +289 -0
- package/crew/id-allocator.ts +44 -0
- package/crew/index.ts +229 -0
- package/crew/state.ts +116 -0
- package/crew/store.ts +480 -0
- package/crew/types.ts +164 -0
- package/crew/utils/artifacts.ts +65 -0
- package/crew/utils/config.ts +104 -0
- package/crew/utils/discover.ts +170 -0
- package/crew/utils/install.ts +373 -0
- package/crew/utils/progress.ts +107 -0
- package/crew/utils/result.ts +16 -0
- package/crew/utils/truncate.ts +79 -0
- package/crew-overlay.ts +259 -0
- package/handlers.ts +799 -0
- package/index.ts +591 -0
- package/lib.ts +232 -0
- package/overlay.ts +687 -0
- package/package.json +20 -0
- package/skills/pi-messenger-crew/SKILL.md +140 -0
- package/store.ts +1068 -0
- 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
|
+
}
|