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/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
|
+
}
|