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/store.ts ADDED
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * Pi Messenger - File Storage Operations
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import { randomUUID } from "node:crypto";
7
+ import { join } from "node:path";
8
+ import { execSync } from "node:child_process";
9
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ type AgentRegistration,
12
+ type AgentMailMessage,
13
+ type ReservationConflict,
14
+ type MessengerState,
15
+ type Dirs,
16
+ type ClaimEntry,
17
+ type CompletionEntry,
18
+ type SpecClaims,
19
+ type SpecCompletions,
20
+ type AllClaims,
21
+ type AllCompletions,
22
+ MAX_WATCHER_RETRIES,
23
+ isProcessAlive,
24
+ generateMemorableName,
25
+ isValidAgentName,
26
+ pathMatchesReservation,
27
+ } from "./lib.js";
28
+
29
+ // =============================================================================
30
+ // Agents Cache (Fix 1: Reduce disk I/O)
31
+ // =============================================================================
32
+
33
+ interface AgentsCache {
34
+ allAgents: AgentRegistration[];
35
+ filtered: Map<string, AgentRegistration[]>; // keyed by excluded agent name
36
+ timestamp: number;
37
+ registryPath: string;
38
+ }
39
+
40
+ const AGENTS_CACHE_TTL_MS = 1000;
41
+ let agentsCache: AgentsCache | null = null;
42
+
43
+ export function invalidateAgentsCache(): void {
44
+ agentsCache = null;
45
+ }
46
+
47
+ // =============================================================================
48
+ // Message Processing Guard (Fix 3: Prevent race conditions)
49
+ // =============================================================================
50
+
51
+ let isProcessingMessages = false;
52
+ let pendingProcessArgs: {
53
+ state: MessengerState;
54
+ dirs: Dirs;
55
+ deliverFn: (msg: AgentMailMessage) => void;
56
+ } | null = null;
57
+
58
+ // =============================================================================
59
+ // File System Helpers
60
+ // =============================================================================
61
+
62
+ function ensureDirSync(dir: string): void {
63
+ if (!fs.existsSync(dir)) {
64
+ fs.mkdirSync(dir, { recursive: true });
65
+ }
66
+ }
67
+
68
+ function getGitBranch(cwd: string): string | undefined {
69
+ try {
70
+ const result = execSync('git branch --show-current', {
71
+ cwd,
72
+ encoding: 'utf-8',
73
+ timeout: 2000,
74
+ stdio: ['pipe', 'pipe', 'pipe']
75
+ }).trim();
76
+
77
+ if (result) return result;
78
+
79
+ const sha = execSync('git rev-parse --short HEAD', {
80
+ cwd,
81
+ encoding: 'utf-8',
82
+ timeout: 2000,
83
+ stdio: ['pipe', 'pipe', 'pipe']
84
+ }).trim();
85
+
86
+ return sha ? `@${sha}` : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ const LOCK_STALE_MS = 10000;
93
+
94
+ async function withSwarmLock<T>(baseDir: string, fn: () => T): Promise<T> {
95
+ const lockPath = join(baseDir, "swarm.lock");
96
+ const maxRetries = 50;
97
+ const retryDelay = 100;
98
+
99
+ for (let i = 0; i < maxRetries; i++) {
100
+ try {
101
+ const stat = fs.statSync(lockPath);
102
+ const ageMs = Date.now() - stat.mtimeMs;
103
+ if (ageMs > LOCK_STALE_MS) {
104
+ try {
105
+ const pid = parseInt(fs.readFileSync(lockPath, "utf-8").trim(), 10);
106
+ if (!pid || !isProcessAlive(pid)) {
107
+ fs.unlinkSync(lockPath);
108
+ }
109
+ } catch {
110
+ try {
111
+ fs.unlinkSync(lockPath);
112
+ } catch {
113
+ // Ignore
114
+ }
115
+ }
116
+ }
117
+ } catch {
118
+ // Lock doesn't exist
119
+ }
120
+
121
+ try {
122
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR);
123
+ fs.writeSync(fd, String(process.pid));
124
+ fs.closeSync(fd);
125
+ break;
126
+ } catch (err) {
127
+ const code = (err as NodeJS.ErrnoException).code;
128
+ if (code === "EEXIST") {
129
+ if (i === maxRetries - 1) {
130
+ throw new Error("Failed to acquire swarm lock");
131
+ }
132
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
133
+ continue;
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ try {
140
+ return fn();
141
+ } finally {
142
+ try {
143
+ fs.unlinkSync(lockPath);
144
+ } catch {
145
+ // Ignore
146
+ }
147
+ }
148
+ }
149
+
150
+ // =============================================================================
151
+ // Registry Operations
152
+ // =============================================================================
153
+
154
+ export function getRegistrationPath(state: MessengerState, dirs: Dirs): string {
155
+ return join(dirs.registry, `${state.agentName}.json`);
156
+ }
157
+
158
+ export function getActiveAgents(state: MessengerState, dirs: Dirs): AgentRegistration[] {
159
+ const now = Date.now();
160
+ const excludeName = state.agentName;
161
+ const myCwd = process.cwd();
162
+ const scopeToFolder = state.scopeToFolder;
163
+
164
+ // Cache key includes scopeToFolder and cwd for proper cache invalidation
165
+ const cacheKey = scopeToFolder ? `${excludeName}:${myCwd}` : excludeName;
166
+
167
+ // Return cached if valid (Fix 1)
168
+ if (
169
+ agentsCache &&
170
+ agentsCache.registryPath === dirs.registry &&
171
+ now - agentsCache.timestamp < AGENTS_CACHE_TTL_MS
172
+ ) {
173
+ // Check if we have a cached filtered result for this cache key
174
+ const cachedFiltered = agentsCache.filtered.get(cacheKey);
175
+ if (cachedFiltered) return cachedFiltered;
176
+
177
+ // Create and cache filtered result
178
+ let filtered = agentsCache.allAgents.filter(a => a.name !== excludeName);
179
+ if (scopeToFolder) {
180
+ filtered = filtered.filter(a => a.cwd === myCwd);
181
+ }
182
+ agentsCache.filtered.set(cacheKey, filtered);
183
+ return filtered;
184
+ }
185
+
186
+ // Read from disk
187
+ const allAgents: AgentRegistration[] = [];
188
+
189
+ if (!fs.existsSync(dirs.registry)) {
190
+ agentsCache = { allAgents, filtered: new Map(), timestamp: now, registryPath: dirs.registry };
191
+ return allAgents;
192
+ }
193
+
194
+ let files: string[];
195
+ try {
196
+ files = fs.readdirSync(dirs.registry);
197
+ } catch {
198
+ return allAgents;
199
+ }
200
+
201
+ for (const file of files) {
202
+ if (!file.endsWith(".json")) continue;
203
+
204
+ try {
205
+ const content = fs.readFileSync(join(dirs.registry, file), "utf-8");
206
+ const reg: AgentRegistration = JSON.parse(content);
207
+
208
+ if (!isProcessAlive(reg.pid)) {
209
+ try {
210
+ fs.unlinkSync(join(dirs.registry, file));
211
+ } catch {
212
+ // Ignore cleanup errors
213
+ }
214
+ continue;
215
+ }
216
+
217
+ allAgents.push(reg);
218
+ } catch {
219
+ // Ignore malformed registrations
220
+ }
221
+ }
222
+
223
+ // Cache the full list and create filtered result
224
+ let filtered = allAgents.filter(a => a.name !== excludeName);
225
+ if (scopeToFolder) {
226
+ filtered = filtered.filter(a => a.cwd === myCwd);
227
+ }
228
+ const filteredMap = new Map<string, AgentRegistration[]>();
229
+ filteredMap.set(cacheKey, filtered);
230
+
231
+ agentsCache = { allAgents, filtered: filteredMap, timestamp: now, registryPath: dirs.registry };
232
+
233
+ return filtered;
234
+ }
235
+
236
+ export function findAvailableName(baseName: string, dirs: Dirs): string | null {
237
+ const basePath = join(dirs.registry, `${baseName}.json`);
238
+ if (!fs.existsSync(basePath)) return baseName;
239
+
240
+ try {
241
+ const existing: AgentRegistration = JSON.parse(fs.readFileSync(basePath, "utf-8"));
242
+ if (!isProcessAlive(existing.pid) || existing.pid === process.pid) {
243
+ return baseName;
244
+ }
245
+ } catch {
246
+ return baseName;
247
+ }
248
+
249
+ for (let i = 2; i <= 99; i++) {
250
+ const altName = `${baseName}${i}`;
251
+ const altPath = join(dirs.registry, `${altName}.json`);
252
+
253
+ if (!fs.existsSync(altPath)) return altName;
254
+
255
+ try {
256
+ const altReg: AgentRegistration = JSON.parse(fs.readFileSync(altPath, "utf-8"));
257
+ if (!isProcessAlive(altReg.pid)) return altName;
258
+ } catch {
259
+ return altName;
260
+ }
261
+ }
262
+
263
+ return null;
264
+ }
265
+
266
+ export function register(state: MessengerState, dirs: Dirs, ctx: ExtensionContext): boolean {
267
+ if (state.registered) return true;
268
+
269
+ ensureDirSync(dirs.registry);
270
+
271
+ if (!state.agentName) {
272
+ state.agentName = generateMemorableName();
273
+ }
274
+
275
+ const isExplicitName = !!process.env.PI_AGENT_NAME;
276
+ const maxAttempts = isExplicitName ? 1 : 3;
277
+
278
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
279
+ // Validate and find available name
280
+ if (isExplicitName) {
281
+ if (!isValidAgentName(state.agentName)) {
282
+ if (ctx.hasUI) {
283
+ ctx.ui.notify(`Invalid agent name "${state.agentName}" - use only letters, numbers, underscore, hyphen`, "error");
284
+ }
285
+ return false;
286
+ }
287
+ const regPath = join(dirs.registry, `${state.agentName}.json`);
288
+ if (fs.existsSync(regPath)) {
289
+ try {
290
+ const existing: AgentRegistration = JSON.parse(fs.readFileSync(regPath, "utf-8"));
291
+ if (isProcessAlive(existing.pid) && existing.pid !== process.pid) {
292
+ if (ctx.hasUI) {
293
+ ctx.ui.notify(`Agent name "${state.agentName}" already in use (PID ${existing.pid})`, "error");
294
+ }
295
+ return false;
296
+ }
297
+ } catch {
298
+ // Malformed, proceed to overwrite
299
+ }
300
+ }
301
+ } else {
302
+ const availableName = findAvailableName(state.agentName, dirs);
303
+ if (!availableName) {
304
+ if (ctx.hasUI) {
305
+ ctx.ui.notify("Could not find available agent name after 99 attempts", "error");
306
+ }
307
+ return false;
308
+ }
309
+ state.agentName = availableName;
310
+ }
311
+
312
+ const regPath = getRegistrationPath(state, dirs);
313
+ if (fs.existsSync(regPath)) {
314
+ try {
315
+ fs.unlinkSync(regPath);
316
+ } catch {
317
+ // Ignore
318
+ }
319
+ }
320
+
321
+ ensureDirSync(getMyInbox(state, dirs));
322
+
323
+ const gitBranch = getGitBranch(process.cwd());
324
+ const registration: AgentRegistration = {
325
+ name: state.agentName,
326
+ pid: process.pid,
327
+ sessionId: ctx.sessionManager.getSessionId(),
328
+ cwd: process.cwd(),
329
+ model: ctx.model?.id ?? "unknown",
330
+ startedAt: new Date().toISOString(),
331
+ gitBranch,
332
+ spec: state.spec
333
+ };
334
+
335
+ try {
336
+ fs.writeFileSync(regPath, JSON.stringify(registration, null, 2));
337
+ } catch (err) {
338
+ if (ctx.hasUI) {
339
+ const msg = err instanceof Error ? err.message : "unknown error";
340
+ ctx.ui.notify(`Failed to register: ${msg}`, "error");
341
+ }
342
+ return false;
343
+ }
344
+
345
+ // Verify we own the registration (guards against race condition where
346
+ // two agents try to claim the same name simultaneously)
347
+ let verified = false;
348
+ let verifyError = false;
349
+ try {
350
+ const written: AgentRegistration = JSON.parse(fs.readFileSync(regPath, "utf-8"));
351
+ verified = written.pid === process.pid;
352
+ } catch {
353
+ // Read failed - could be I/O error or file was overwritten/deleted
354
+ verifyError = true;
355
+ }
356
+
357
+ if (verified) {
358
+ state.registered = true;
359
+ state.gitBranch = gitBranch;
360
+ invalidateAgentsCache();
361
+ return true;
362
+ }
363
+
364
+ // Verification failed - clean up our write attempt if file still contains our data
365
+ // (handles I/O error case where we wrote successfully but couldn't read back)
366
+ if (verifyError) {
367
+ try {
368
+ const checkContent = fs.readFileSync(regPath, "utf-8");
369
+ const checkReg: AgentRegistration = JSON.parse(checkContent);
370
+ if (checkReg.pid === process.pid) {
371
+ fs.unlinkSync(regPath);
372
+ }
373
+ } catch {
374
+ // Best effort cleanup
375
+ }
376
+ }
377
+
378
+ // Another agent claimed this name - retry with fresh lookup (auto-generated only)
379
+ if (isExplicitName) {
380
+ if (ctx.hasUI) {
381
+ ctx.ui.notify(`Agent name "${state.agentName}" was claimed by another agent`, "error");
382
+ }
383
+ return false;
384
+ }
385
+ invalidateAgentsCache();
386
+ }
387
+
388
+ // Exhausted retries
389
+ if (ctx.hasUI) {
390
+ ctx.ui.notify("Failed to register after multiple attempts due to name conflicts", "error");
391
+ }
392
+ return false;
393
+ }
394
+
395
+ export function updateRegistration(state: MessengerState, dirs: Dirs, ctx: ExtensionContext): void {
396
+ if (!state.registered) return;
397
+
398
+ const regPath = getRegistrationPath(state, dirs);
399
+ if (!fs.existsSync(regPath)) return;
400
+
401
+ try {
402
+ const reg: AgentRegistration = JSON.parse(fs.readFileSync(regPath, "utf-8"));
403
+ reg.model = ctx.model?.id ?? reg.model;
404
+ reg.reservations = state.reservations.length > 0 ? state.reservations : undefined;
405
+ if (state.spec) {
406
+ reg.spec = state.spec;
407
+ } else {
408
+ delete reg.spec;
409
+ }
410
+ fs.writeFileSync(regPath, JSON.stringify(reg, null, 2));
411
+ } catch {
412
+ // Ignore errors
413
+ }
414
+ }
415
+
416
+ export function unregister(state: MessengerState, dirs: Dirs): void {
417
+ if (!state.registered) return;
418
+
419
+ try {
420
+ fs.unlinkSync(getRegistrationPath(state, dirs));
421
+ } catch {
422
+ // Ignore errors
423
+ }
424
+ state.registered = false;
425
+ invalidateAgentsCache();
426
+ }
427
+
428
+ export type RenameResult =
429
+ | { success: true; oldName: string; newName: string }
430
+ | { success: false; error: "not_registered" | "invalid_name" | "name_taken" | "same_name" | "race_lost" };
431
+
432
+ export function renameAgent(
433
+ state: MessengerState,
434
+ dirs: Dirs,
435
+ ctx: ExtensionContext,
436
+ newName: string,
437
+ deliverFn: (msg: AgentMailMessage) => void
438
+ ): RenameResult {
439
+ if (!state.registered) {
440
+ return { success: false, error: "not_registered" };
441
+ }
442
+
443
+ if (!isValidAgentName(newName)) {
444
+ return { success: false, error: "invalid_name" };
445
+ }
446
+
447
+ if (newName === state.agentName) {
448
+ return { success: false, error: "same_name" };
449
+ }
450
+
451
+ const newRegPath = join(dirs.registry, `${newName}.json`);
452
+ if (fs.existsSync(newRegPath)) {
453
+ try {
454
+ const existing: AgentRegistration = JSON.parse(fs.readFileSync(newRegPath, "utf-8"));
455
+ if (isProcessAlive(existing.pid) && existing.pid !== process.pid) {
456
+ return { success: false, error: "name_taken" };
457
+ }
458
+ } catch {
459
+ // Malformed file, we can overwrite
460
+ }
461
+ }
462
+
463
+ const oldName = state.agentName;
464
+ const oldRegPath = getRegistrationPath(state, dirs);
465
+ const oldInbox = getMyInbox(state, dirs);
466
+ const newInbox = join(dirs.inbox, newName);
467
+
468
+ processAllPendingMessages(state, dirs, deliverFn);
469
+
470
+ const gitBranch = getGitBranch(process.cwd());
471
+ const registration: AgentRegistration = {
472
+ name: newName,
473
+ pid: process.pid,
474
+ sessionId: ctx.sessionManager.getSessionId(),
475
+ cwd: process.cwd(),
476
+ model: ctx.model?.id ?? "unknown",
477
+ startedAt: new Date().toISOString(),
478
+ reservations: state.reservations.length > 0 ? state.reservations : undefined,
479
+ gitBranch,
480
+ spec: state.spec
481
+ };
482
+
483
+ ensureDirSync(dirs.registry);
484
+
485
+ try {
486
+ fs.writeFileSync(join(dirs.registry, `${newName}.json`), JSON.stringify(registration, null, 2));
487
+ } catch (err) {
488
+ return { success: false, error: "invalid_name" as const };
489
+ }
490
+
491
+ // Verify we own the new registration (guards against race condition)
492
+ let verified = false;
493
+ let verifyError = false;
494
+ try {
495
+ const written: AgentRegistration = JSON.parse(fs.readFileSync(newRegPath, "utf-8"));
496
+ verified = written.pid === process.pid;
497
+ } catch {
498
+ verifyError = true;
499
+ }
500
+
501
+ if (!verified) {
502
+ // Clean up our write attempt if file still contains our data (I/O error case)
503
+ if (verifyError) {
504
+ try {
505
+ const checkReg: AgentRegistration = JSON.parse(fs.readFileSync(newRegPath, "utf-8"));
506
+ if (checkReg.pid === process.pid) {
507
+ fs.unlinkSync(newRegPath);
508
+ }
509
+ } catch {
510
+ // Best effort cleanup
511
+ }
512
+ }
513
+ return { success: false, error: "race_lost" };
514
+ }
515
+
516
+ try {
517
+ fs.unlinkSync(oldRegPath);
518
+ } catch {
519
+ // Ignore - old file might already be gone
520
+ }
521
+
522
+ state.agentName = newName;
523
+
524
+ if (fs.existsSync(newInbox)) {
525
+ try {
526
+ const staleFiles = fs.readdirSync(newInbox).filter(f => f.endsWith(".json"));
527
+ for (const file of staleFiles) {
528
+ try {
529
+ fs.unlinkSync(join(newInbox, file));
530
+ } catch {
531
+ // Ignore
532
+ }
533
+ }
534
+ } catch {
535
+ // Ignore
536
+ }
537
+ }
538
+ ensureDirSync(newInbox);
539
+
540
+ try {
541
+ fs.rmdirSync(oldInbox);
542
+ } catch {
543
+ // Ignore - might have new messages or not exist
544
+ }
545
+
546
+ state.gitBranch = gitBranch;
547
+ invalidateAgentsCache();
548
+ return { success: true, oldName, newName };
549
+ }
550
+
551
+ export function getConflictsWithOtherAgents(
552
+ filePath: string,
553
+ state: MessengerState,
554
+ dirs: Dirs
555
+ ): ReservationConflict[] {
556
+ const conflicts: ReservationConflict[] = [];
557
+ const agents = getActiveAgents(state, dirs);
558
+
559
+ for (const agent of agents) {
560
+ if (!agent.reservations) continue;
561
+ for (const res of agent.reservations) {
562
+ if (pathMatchesReservation(filePath, res.pattern)) {
563
+ conflicts.push({
564
+ path: filePath,
565
+ agent: agent.name,
566
+ pattern: res.pattern,
567
+ reason: res.reason,
568
+ registration: agent
569
+ });
570
+ }
571
+ }
572
+ }
573
+
574
+ return conflicts;
575
+ }
576
+
577
+ // =============================================================================
578
+ // Swarm Coordination
579
+ // =============================================================================
580
+
581
+ const CLAIMS_FILE = "claims.json";
582
+ const COMPLETIONS_FILE = "completions.json";
583
+
584
+ function readClaimsSync(dirs: Dirs): AllClaims {
585
+ const path = join(dirs.base, CLAIMS_FILE);
586
+ if (!fs.existsSync(path)) return {};
587
+ try {
588
+ const raw = fs.readFileSync(path, "utf-8");
589
+ const parsed = JSON.parse(raw) as AllClaims;
590
+ if (parsed && typeof parsed === "object") return parsed;
591
+ } catch {
592
+ // Ignore
593
+ }
594
+ return {};
595
+ }
596
+
597
+ function readCompletionsSync(dirs: Dirs): AllCompletions {
598
+ const path = join(dirs.base, COMPLETIONS_FILE);
599
+ if (!fs.existsSync(path)) return {};
600
+ try {
601
+ const raw = fs.readFileSync(path, "utf-8");
602
+ const parsed = JSON.parse(raw) as AllCompletions;
603
+ if (parsed && typeof parsed === "object") return parsed;
604
+ } catch {
605
+ // Ignore
606
+ }
607
+ return {};
608
+ }
609
+
610
+ function writeClaimsSync(dirs: Dirs, claims: AllClaims): void {
611
+ ensureDirSync(dirs.base);
612
+ const target = join(dirs.base, CLAIMS_FILE);
613
+ const temp = join(dirs.base, `${CLAIMS_FILE}.tmp-${process.pid}-${Date.now()}`);
614
+ fs.writeFileSync(temp, JSON.stringify(claims, null, 2));
615
+ fs.renameSync(temp, target);
616
+ }
617
+
618
+ function writeCompletionsSync(dirs: Dirs, completions: AllCompletions): void {
619
+ ensureDirSync(dirs.base);
620
+ const target = join(dirs.base, COMPLETIONS_FILE);
621
+ const temp = join(dirs.base, `${COMPLETIONS_FILE}.tmp-${process.pid}-${Date.now()}`);
622
+ fs.writeFileSync(temp, JSON.stringify(completions, null, 2));
623
+ fs.renameSync(temp, target);
624
+ }
625
+
626
+ function isClaimStale(claim: ClaimEntry, dirs: Dirs): boolean {
627
+ if (!isProcessAlive(claim.pid)) return true;
628
+ const regPath = join(dirs.registry, `${claim.agent}.json`);
629
+ if (!fs.existsSync(regPath)) return true;
630
+ try {
631
+ const reg: AgentRegistration = JSON.parse(fs.readFileSync(regPath, "utf-8"));
632
+ if (!isProcessAlive(reg.pid)) return true;
633
+ if (reg.sessionId !== claim.sessionId) return true;
634
+ } catch {
635
+ return true;
636
+ }
637
+ return false;
638
+ }
639
+
640
+ function cleanupStaleClaims(claims: AllClaims, dirs: Dirs): number {
641
+ let removed = 0;
642
+ for (const [spec, tasks] of Object.entries(claims)) {
643
+ for (const [taskId, claim] of Object.entries(tasks)) {
644
+ if (isClaimStale(claim, dirs)) {
645
+ delete tasks[taskId];
646
+ removed++;
647
+ }
648
+ }
649
+ if (Object.keys(tasks).length === 0) {
650
+ delete claims[spec];
651
+ }
652
+ }
653
+ return removed;
654
+ }
655
+
656
+ function filterStaleClaims(claims: AllClaims, dirs: Dirs): AllClaims {
657
+ const filtered: AllClaims = {};
658
+ for (const [spec, tasks] of Object.entries(claims)) {
659
+ const filteredTasks: SpecClaims = {};
660
+ for (const [taskId, claim] of Object.entries(tasks)) {
661
+ if (!isClaimStale(claim, dirs)) {
662
+ filteredTasks[taskId] = claim;
663
+ }
664
+ }
665
+ if (Object.keys(filteredTasks).length > 0) {
666
+ filtered[spec] = filteredTasks;
667
+ }
668
+ }
669
+ return filtered;
670
+ }
671
+
672
+ function findAgentClaim(claims: AllClaims, agent: string): { spec: string; taskId: string } | null {
673
+ for (const [spec, tasks] of Object.entries(claims)) {
674
+ for (const [taskId, claim] of Object.entries(tasks)) {
675
+ if (claim.agent === agent) {
676
+ return { spec, taskId };
677
+ }
678
+ }
679
+ }
680
+ return null;
681
+ }
682
+
683
+ export function getClaims(dirs: Dirs): AllClaims {
684
+ const claims = readClaimsSync(dirs);
685
+ return filterStaleClaims(claims, dirs);
686
+ }
687
+
688
+ export function getClaimsForSpec(dirs: Dirs, specPath: string): SpecClaims {
689
+ const claims = getClaims(dirs);
690
+ return claims[specPath] ?? {};
691
+ }
692
+
693
+ export function getCompletions(dirs: Dirs): AllCompletions {
694
+ return readCompletionsSync(dirs);
695
+ }
696
+
697
+ export function getCompletionsForSpec(dirs: Dirs, specPath: string): SpecCompletions {
698
+ const completions = getCompletions(dirs);
699
+ return completions[specPath] ?? {};
700
+ }
701
+
702
+ export function getAgentCurrentClaim(
703
+ dirs: Dirs,
704
+ agent: string
705
+ ): { spec: string; taskId: string; reason?: string } | null {
706
+ const claims = getClaims(dirs);
707
+ for (const [spec, tasks] of Object.entries(claims)) {
708
+ for (const [taskId, claim] of Object.entries(tasks)) {
709
+ if (claim.agent === agent) {
710
+ return { spec, taskId, reason: claim.reason };
711
+ }
712
+ }
713
+ }
714
+ return null;
715
+ }
716
+
717
+ export type ClaimResult =
718
+ | { success: true; claimedAt: string }
719
+ | { success: false; error: "already_claimed"; conflict: ClaimEntry }
720
+ | { success: false; error: "already_have_claim"; existing: { spec: string; taskId: string } };
721
+
722
+ export function isClaimSuccess(r: ClaimResult): r is { success: true; claimedAt: string } {
723
+ return r.success === true;
724
+ }
725
+ export function isClaimAlreadyClaimed(r: ClaimResult): r is { success: false; error: "already_claimed"; conflict: ClaimEntry } {
726
+ return "error" in r && r.error === "already_claimed";
727
+ }
728
+ export function isClaimAlreadyHaveClaim(r: ClaimResult): r is { success: false; error: "already_have_claim"; existing: { spec: string; taskId: string } } {
729
+ return "error" in r && r.error === "already_have_claim";
730
+ }
731
+
732
+ export async function claimTask(
733
+ dirs: Dirs,
734
+ specPath: string,
735
+ taskId: string,
736
+ agent: string,
737
+ sessionId: string,
738
+ pid: number,
739
+ reason?: string
740
+ ): Promise<ClaimResult> {
741
+ return withSwarmLock(dirs.base, () => {
742
+ const claims = readClaimsSync(dirs);
743
+ const removed = cleanupStaleClaims(claims, dirs);
744
+
745
+ const existing = findAgentClaim(claims, agent);
746
+ if (existing) {
747
+ if (removed > 0) writeClaimsSync(dirs, claims);
748
+ return { success: false, error: "already_have_claim", existing };
749
+ }
750
+
751
+ const existingClaim = claims[specPath]?.[taskId];
752
+ if (existingClaim) {
753
+ if (removed > 0) writeClaimsSync(dirs, claims);
754
+ return { success: false, error: "already_claimed", conflict: existingClaim };
755
+ }
756
+
757
+ if (!claims[specPath]) claims[specPath] = {};
758
+ const newClaim: ClaimEntry = {
759
+ agent,
760
+ sessionId,
761
+ pid,
762
+ claimedAt: new Date().toISOString(),
763
+ reason
764
+ };
765
+ claims[specPath][taskId] = newClaim;
766
+ writeClaimsSync(dirs, claims);
767
+ return { success: true, claimedAt: newClaim.claimedAt };
768
+ });
769
+ }
770
+
771
+ export type UnclaimResult =
772
+ | { success: true }
773
+ | { success: false; error: "not_claimed" }
774
+ | { success: false; error: "not_your_claim"; claimedBy: string };
775
+
776
+ export function isUnclaimSuccess(r: UnclaimResult): r is { success: true } {
777
+ return r.success === true;
778
+ }
779
+ export function isUnclaimNotYours(r: UnclaimResult): r is { success: false; error: "not_your_claim"; claimedBy: string } {
780
+ return "error" in r && r.error === "not_your_claim";
781
+ }
782
+
783
+ export async function unclaimTask(
784
+ dirs: Dirs,
785
+ specPath: string,
786
+ taskId: string,
787
+ agent: string
788
+ ): Promise<UnclaimResult> {
789
+ return withSwarmLock(dirs.base, () => {
790
+ const claims = readClaimsSync(dirs);
791
+ const removed = cleanupStaleClaims(claims, dirs);
792
+
793
+ const claim = claims[specPath]?.[taskId];
794
+ if (!claim) {
795
+ if (removed > 0) writeClaimsSync(dirs, claims);
796
+ return { success: false, error: "not_claimed" };
797
+ }
798
+ if (claim.agent !== agent) {
799
+ if (removed > 0) writeClaimsSync(dirs, claims);
800
+ return { success: false, error: "not_your_claim", claimedBy: claim.agent };
801
+ }
802
+
803
+ delete claims[specPath][taskId];
804
+ if (Object.keys(claims[specPath]).length === 0) {
805
+ delete claims[specPath];
806
+ }
807
+ writeClaimsSync(dirs, claims);
808
+ return { success: true };
809
+ });
810
+ }
811
+
812
+ export type CompleteResult =
813
+ | { success: true; completedAt: string }
814
+ | { success: false; error: "not_claimed" }
815
+ | { success: false; error: "not_your_claim"; claimedBy: string }
816
+ | { success: false; error: "already_completed"; completion: CompletionEntry };
817
+
818
+ export function isCompleteSuccess(r: CompleteResult): r is { success: true; completedAt: string } {
819
+ return r.success === true;
820
+ }
821
+ export function isCompleteAlreadyCompleted(r: CompleteResult): r is { success: false; error: "already_completed"; completion: CompletionEntry } {
822
+ return "error" in r && r.error === "already_completed";
823
+ }
824
+ export function isCompleteNotYours(r: CompleteResult): r is { success: false; error: "not_your_claim"; claimedBy: string } {
825
+ return "error" in r && r.error === "not_your_claim";
826
+ }
827
+
828
+ export async function completeTask(
829
+ dirs: Dirs,
830
+ specPath: string,
831
+ taskId: string,
832
+ agent: string,
833
+ notes?: string
834
+ ): Promise<CompleteResult> {
835
+ return withSwarmLock(dirs.base, () => {
836
+ const claims = readClaimsSync(dirs);
837
+ const completions = readCompletionsSync(dirs);
838
+ const removed = cleanupStaleClaims(claims, dirs);
839
+
840
+ const existingCompletion = completions[specPath]?.[taskId];
841
+ if (existingCompletion) {
842
+ if (removed > 0) writeClaimsSync(dirs, claims);
843
+ return { success: false, error: "already_completed", completion: existingCompletion };
844
+ }
845
+
846
+ const claim = claims[specPath]?.[taskId];
847
+ if (!claim) {
848
+ if (removed > 0) writeClaimsSync(dirs, claims);
849
+ return { success: false, error: "not_claimed" };
850
+ }
851
+ if (claim.agent !== agent) {
852
+ if (removed > 0) writeClaimsSync(dirs, claims);
853
+ return { success: false, error: "not_your_claim", claimedBy: claim.agent };
854
+ }
855
+
856
+ delete claims[specPath][taskId];
857
+ if (Object.keys(claims[specPath]).length === 0) {
858
+ delete claims[specPath];
859
+ }
860
+
861
+ if (!completions[specPath]) completions[specPath] = {};
862
+ const completion: CompletionEntry = {
863
+ completedBy: agent,
864
+ completedAt: new Date().toISOString(),
865
+ notes
866
+ };
867
+ completions[specPath][taskId] = completion;
868
+
869
+ // Write completions first - if claims write fails, we at least have the completion
870
+ // recorded (the important part). The stale claim will be cleaned up eventually.
871
+ writeCompletionsSync(dirs, completions);
872
+ writeClaimsSync(dirs, claims);
873
+ return { success: true, completedAt: completion.completedAt };
874
+ });
875
+ }
876
+
877
+ // =============================================================================
878
+ // Messaging Operations
879
+ // =============================================================================
880
+
881
+ export function getMyInbox(state: MessengerState, dirs: Dirs): string {
882
+ return join(dirs.inbox, state.agentName);
883
+ }
884
+
885
+ export function processAllPendingMessages(
886
+ state: MessengerState,
887
+ dirs: Dirs,
888
+ deliverFn: (msg: AgentMailMessage) => void
889
+ ): void {
890
+ if (!state.registered) return;
891
+
892
+ // Fix 3: Prevent concurrent processing
893
+ if (isProcessingMessages) {
894
+ pendingProcessArgs = { state, dirs, deliverFn };
895
+ return;
896
+ }
897
+
898
+ isProcessingMessages = true;
899
+
900
+ try {
901
+ const inbox = getMyInbox(state, dirs);
902
+ if (!fs.existsSync(inbox)) return;
903
+
904
+ let files: string[];
905
+ try {
906
+ files = fs.readdirSync(inbox).filter(f => f.endsWith(".json")).sort();
907
+ } catch {
908
+ return;
909
+ }
910
+
911
+ for (const file of files) {
912
+ const msgPath = join(inbox, file);
913
+ try {
914
+ const content = fs.readFileSync(msgPath, "utf-8");
915
+ const msg: AgentMailMessage = JSON.parse(content);
916
+ deliverFn(msg);
917
+ fs.unlinkSync(msgPath);
918
+ } catch {
919
+ // On any failure (read, parse, deliver), delete to avoid infinite retry loops
920
+ try {
921
+ fs.unlinkSync(msgPath);
922
+ } catch {
923
+ // Already gone or can't delete
924
+ }
925
+ }
926
+ }
927
+ } finally {
928
+ isProcessingMessages = false;
929
+
930
+ // Re-process if new calls came in while we were processing
931
+ if (pendingProcessArgs) {
932
+ const args = pendingProcessArgs;
933
+ pendingProcessArgs = null;
934
+ processAllPendingMessages(args.state, args.dirs, args.deliverFn);
935
+ }
936
+ }
937
+ }
938
+
939
+ export function sendMessageToAgent(
940
+ state: MessengerState,
941
+ dirs: Dirs,
942
+ to: string,
943
+ text: string,
944
+ replyTo?: string
945
+ ): AgentMailMessage {
946
+ const targetInbox = join(dirs.inbox, to);
947
+ ensureDirSync(targetInbox);
948
+
949
+ const msg: AgentMailMessage = {
950
+ id: randomUUID(),
951
+ from: state.agentName,
952
+ to,
953
+ text,
954
+ timestamp: new Date().toISOString(),
955
+ replyTo: replyTo ?? null
956
+ };
957
+
958
+ const random = Math.random().toString(36).substring(2, 8);
959
+ const msgFile = join(targetInbox, `${Date.now()}-${random}.json`);
960
+ fs.writeFileSync(msgFile, JSON.stringify(msg, null, 2));
961
+
962
+ return msg;
963
+ }
964
+
965
+ // =============================================================================
966
+ // Watcher
967
+ // =============================================================================
968
+
969
+ const WATCHER_DEBOUNCE_MS = 50;
970
+
971
+ export function startWatcher(
972
+ state: MessengerState,
973
+ dirs: Dirs,
974
+ deliverFn: (msg: AgentMailMessage) => void
975
+ ): void {
976
+ if (!state.registered) return;
977
+ if (state.watcher) return;
978
+ if (state.watcherRetries >= MAX_WATCHER_RETRIES) return;
979
+
980
+ const inbox = getMyInbox(state, dirs);
981
+ ensureDirSync(inbox);
982
+
983
+ processAllPendingMessages(state, dirs, deliverFn);
984
+
985
+ function scheduleRetry(): void {
986
+ state.watcherRetries++;
987
+ if (state.watcherRetries < MAX_WATCHER_RETRIES) {
988
+ const delay = Math.min(1000 * Math.pow(2, state.watcherRetries - 1), 30000);
989
+ state.watcherRetryTimer = setTimeout(() => {
990
+ state.watcherRetryTimer = null;
991
+ startWatcher(state, dirs, deliverFn);
992
+ }, delay);
993
+ }
994
+ }
995
+
996
+ try {
997
+ state.watcher = fs.watch(inbox, () => {
998
+ // Fix 2: Debounce rapid events
999
+ if (state.watcherDebounceTimer) {
1000
+ clearTimeout(state.watcherDebounceTimer);
1001
+ }
1002
+ state.watcherDebounceTimer = setTimeout(() => {
1003
+ state.watcherDebounceTimer = null;
1004
+ processAllPendingMessages(state, dirs, deliverFn);
1005
+ }, WATCHER_DEBOUNCE_MS);
1006
+ });
1007
+ } catch {
1008
+ scheduleRetry();
1009
+ return;
1010
+ }
1011
+
1012
+ state.watcher.on("error", () => {
1013
+ stopWatcher(state);
1014
+ scheduleRetry();
1015
+ });
1016
+
1017
+ state.watcherRetries = 0;
1018
+ }
1019
+
1020
+ export function stopWatcher(state: MessengerState): void {
1021
+ if (state.watcherDebounceTimer) {
1022
+ clearTimeout(state.watcherDebounceTimer);
1023
+ state.watcherDebounceTimer = null;
1024
+ }
1025
+ if (state.watcherRetryTimer) {
1026
+ clearTimeout(state.watcherRetryTimer);
1027
+ state.watcherRetryTimer = null;
1028
+ }
1029
+ if (state.watcher) {
1030
+ state.watcher.close();
1031
+ state.watcher = null;
1032
+ }
1033
+ }
1034
+
1035
+ // =============================================================================
1036
+ // Target Validation
1037
+ // =============================================================================
1038
+
1039
+ export type TargetValidation =
1040
+ | { valid: true }
1041
+ | { valid: false; error: "invalid_name" | "not_found" | "not_active" | "invalid_registration" };
1042
+
1043
+ export function validateTargetAgent(to: string, dirs: Dirs): TargetValidation {
1044
+ if (!isValidAgentName(to)) {
1045
+ return { valid: false, error: "invalid_name" };
1046
+ }
1047
+
1048
+ const targetReg = join(dirs.registry, `${to}.json`);
1049
+ if (!fs.existsSync(targetReg)) {
1050
+ return { valid: false, error: "not_found" };
1051
+ }
1052
+
1053
+ try {
1054
+ const reg: AgentRegistration = JSON.parse(fs.readFileSync(targetReg, "utf-8"));
1055
+ if (!isProcessAlive(reg.pid)) {
1056
+ try {
1057
+ fs.unlinkSync(targetReg);
1058
+ } catch {
1059
+ // Ignore cleanup errors
1060
+ }
1061
+ return { valid: false, error: "not_active" };
1062
+ }
1063
+ } catch {
1064
+ return { valid: false, error: "invalid_registration" };
1065
+ }
1066
+
1067
+ return { valid: true };
1068
+ }