eacn3 0.3.5 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENT_GUIDE.md +345 -0
  2. package/dist/index.js +259 -48
  3. package/dist/index.js.map +1 -1
  4. package/dist/server.js +978 -75
  5. package/dist/server.js.map +1 -1
  6. package/dist/src/a2a-server.js +2 -1
  7. package/dist/src/a2a-server.js.map +1 -1
  8. package/dist/src/event-transport.d.ts +48 -0
  9. package/dist/src/event-transport.js +156 -0
  10. package/dist/src/event-transport.js.map +1 -0
  11. package/dist/src/models.d.ts +59 -11
  12. package/dist/src/models.js +1 -1
  13. package/dist/src/models.js.map +1 -1
  14. package/dist/src/network-client.d.ts +3 -1
  15. package/dist/src/network-client.js +87 -14
  16. package/dist/src/network-client.js.map +1 -1
  17. package/dist/src/reverse-control.d.ts +74 -0
  18. package/dist/src/reverse-control.js +609 -0
  19. package/dist/src/reverse-control.js.map +1 -0
  20. package/dist/src/state.d.ts +50 -4
  21. package/dist/src/state.js +492 -43
  22. package/dist/src/state.js.map +1 -1
  23. package/openclaw.plugin.json +1 -1
  24. package/package.json +4 -7
  25. package/scripts/cli.cjs +28 -11
  26. package/skills/eacn3-bid/SKILL.md +2 -2
  27. package/skills/eacn3-bid-zh/SKILL.md +2 -2
  28. package/skills/eacn3-bounty/SKILL.md +7 -7
  29. package/skills/eacn3-bounty-zh/SKILL.md +7 -7
  30. package/skills/eacn3-budget/SKILL.md +1 -1
  31. package/skills/eacn3-budget-zh/SKILL.md +1 -1
  32. package/skills/eacn3-clarify/SKILL.md +1 -1
  33. package/skills/eacn3-clarify-zh/SKILL.md +1 -1
  34. package/skills/eacn3-collect/SKILL.md +1 -1
  35. package/skills/eacn3-collect-zh/SKILL.md +1 -1
  36. package/skills/eacn3-dashboard/SKILL.md +3 -3
  37. package/skills/eacn3-dashboard-zh/SKILL.md +3 -3
  38. package/skills/eacn3-delegate/SKILL.md +1 -1
  39. package/skills/eacn3-delegate-zh/SKILL.md +1 -1
  40. package/skills/eacn3-execute/SKILL.md +1 -1
  41. package/skills/eacn3-execute-zh/SKILL.md +1 -1
  42. package/skills/eacn3-invite/SKILL.md +1 -1
  43. package/skills/eacn3-invite-zh/SKILL.md +1 -1
  44. package/skills/eacn3-task/SKILL.md +1 -1
  45. package/skills/eacn3-task-zh/SKILL.md +1 -1
@@ -1,13 +1,27 @@
1
1
  /**
2
- * Local state persistence — reads/writes ~/.eacn3/state.json.
2
+ * Local state persistence — per-agent file isolation.
3
+ *
4
+ * Storage layout:
5
+ * ~/.eacn3/server.json ← shared server identity (rarely written)
6
+ * ~/.eacn3/agents/{agent_id}.json ← per-agent state (only one process writes)
7
+ * ~/.eacn3/events-{agent_id}.json ← per-agent events (already isolated)
8
+ *
9
+ * Each CC session owns exactly one agent and only writes to its own files.
10
+ * No cross-process file locking needed.
3
11
  */
4
- import { type EacnState, type AgentCard, type LocalTaskInfo, type PushEvent, type DirectMessage } from "./models.js";
12
+ import { type EacnState, type AgentCard, type LocalTaskInfo, type PushEvent, type DirectMessage, type TeamInfo } from "./models.js";
13
+ /**
14
+ * Persist server identity to disk. Only call this from eacn3_connect —
15
+ * server_card and network_endpoint don't change outside of connect.
16
+ */
17
+ export declare function saveServerData(): void;
5
18
  /**
6
19
  * Load state from disk. Creates default if not exists.
7
20
  */
8
21
  export declare function load(): EacnState;
9
22
  /**
10
23
  * Persist current state to disk.
24
+ * Writes server.json + per-agent files for agents known to this session.
11
25
  */
12
26
  export declare function save(): void;
13
27
  /**
@@ -18,6 +32,23 @@ export declare function getState(): EacnState;
18
32
  * Replace entire state.
19
33
  */
20
34
  export declare function setState(newState: EacnState): void;
35
+ /**
36
+ * List agents available on disk (from previous sessions).
37
+ * Does NOT load them into memory — just reads metadata for display.
38
+ */
39
+ export declare function listAvailableAgents(): Array<{
40
+ agent_id: string;
41
+ name: string;
42
+ domains: string[];
43
+ tier: string;
44
+ }>;
45
+ /**
46
+ * Claim an existing agent from disk into this session.
47
+ * Loads the agent's full data (tasks, sessions, teams) into memory and marks ownership.
48
+ * Uses an exclusive lock file to prevent two sessions from claiming the same agent.
49
+ * Returns the AgentCard, or null if not found or already claimed by another session.
50
+ */
51
+ export declare function claimAgent(agentId: string): AgentCard | null;
21
52
  export declare function addAgent(agent: AgentCard): void;
22
53
  export declare function removeAgent(agentId: string): void;
23
54
  export declare function getAgent(agentId: string): AgentCard | undefined;
@@ -26,8 +57,10 @@ export declare function updateTask(info: LocalTaskInfo): void;
26
57
  export declare function removeTask(taskId: string): void;
27
58
  export declare function updateTaskStatus(taskId: string, status: string): void;
28
59
  export declare function getTask(taskId: string): import("./models.js").LocalTaskInfo | undefined;
29
- export declare function pushEvents(events: PushEvent[]): void;
30
- export declare function drainEvents(): PushEvent[];
60
+ export declare function pushEvents(agentId: string, events: PushEvent[]): void;
61
+ export declare function drainEvents(agentId: string): PushEvent[];
62
+ /** Drain events for ALL agents at once (used by legacy callers). */
63
+ export declare function drainAllEvents(): PushEvent[];
31
64
  export declare function updateReputationCache(agentId: string, score: number): void;
32
65
  export declare function isConnected(): boolean;
33
66
  export declare function getServerId(): string | null;
@@ -45,3 +78,16 @@ export declare function getMessages(localAgentId: string, peerAgentId: string):
45
78
  * Returns peer agent IDs.
46
79
  */
47
80
  export declare function listSessions(localAgentId: string): string[];
81
+ export declare function addTeam(team: TeamInfo): void;
82
+ export declare function getTeam(teamId: string): TeamInfo | undefined;
83
+ export declare function getTeamsForAgent(agentId: string): TeamInfo[];
84
+ export declare function updateTeamPeerBranch(teamId: string, peerId: string, branch: string): void;
85
+ export declare function setTeamBranch(teamId: string, branch: string): void;
86
+ /** Find team by handshake task ID (in either ack_out or ack_in). */
87
+ export declare function findTeamByHandshakeTask(taskId: string): {
88
+ team: TeamInfo;
89
+ direction: "out" | "in";
90
+ peerId: string;
91
+ } | undefined;
92
+ /** Record an incoming handshake task for a team. */
93
+ export declare function recordAckIn(teamId: string, agentId: string, peerId: string, taskId: string): void;
package/dist/src/state.js CHANGED
@@ -1,7 +1,16 @@
1
1
  /**
2
- * Local state persistence — reads/writes ~/.eacn3/state.json.
2
+ * Local state persistence — per-agent file isolation.
3
+ *
4
+ * Storage layout:
5
+ * ~/.eacn3/server.json ← shared server identity (rarely written)
6
+ * ~/.eacn3/agents/{agent_id}.json ← per-agent state (only one process writes)
7
+ * ~/.eacn3/events-{agent_id}.json ← per-agent events (already isolated)
8
+ *
9
+ * Each CC session owns exactly one agent and only writes to its own files.
10
+ * No cross-process file locking needed.
3
11
  */
4
- import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, renameSync, unlinkSync } from "node:fs";
13
+ import { randomBytes } from "node:crypto";
5
14
  import { join } from "node:path";
6
15
  import { homedir } from "node:os";
7
16
  import { MAX_MESSAGES_PER_SESSION, createDefaultState } from "./models.js";
@@ -9,57 +18,241 @@ import { MAX_MESSAGES_PER_SESSION, createDefaultState } from "./models.js";
9
18
  // Paths
10
19
  // ---------------------------------------------------------------------------
11
20
  const EACN3_DIR = process.env.EACN3_STATE_DIR ?? join(homedir(), ".eacn3");
12
- const STATE_FILE = join(EACN3_DIR, "state.json");
13
- const STATE_BACKUP = join(EACN3_DIR, "state.json.bak");
21
+ const SERVER_FILE = join(EACN3_DIR, "server.json");
22
+ const AGENTS_DIR = join(EACN3_DIR, "agents");
23
+ /** Legacy state file — migrated on first load. */
24
+ const LEGACY_STATE_FILE = join(EACN3_DIR, "state.json");
25
+ // ---------------------------------------------------------------------------
26
+ // Atomic file write helper
27
+ // ---------------------------------------------------------------------------
28
+ function atomicWrite(filePath, data) {
29
+ const dir = join(filePath, "..");
30
+ mkdirSync(dir, { recursive: true });
31
+ const tmpFile = filePath + "." + randomBytes(4).toString("hex") + ".tmp";
32
+ writeFileSync(tmpFile, data);
33
+ renameSync(tmpFile, filePath);
34
+ }
35
+ function safeReadJSON(filePath) {
36
+ try {
37
+ if (!existsSync(filePath))
38
+ return null;
39
+ return JSON.parse(readFileSync(filePath, "utf-8"));
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
14
45
  // ---------------------------------------------------------------------------
15
46
  // Singleton state
16
47
  // ---------------------------------------------------------------------------
17
48
  let state = null;
49
+ /** Agents owned by THIS process (registered or restored in this session). */
50
+ const ownedAgentIds = new Set();
51
+ // ---------------------------------------------------------------------------
52
+ // Server data (shared, rarely written)
53
+ // ---------------------------------------------------------------------------
54
+ function loadServerData() {
55
+ return safeReadJSON(SERVER_FILE) ?? { server_card: null, network_endpoint: "" };
56
+ }
18
57
  /**
19
- * Load state from disk. Creates default if not exists.
58
+ * Persist server identity to disk. Only call this from eacn3_connect —
59
+ * server_card and network_endpoint don't change outside of connect.
20
60
  */
21
- export function load() {
22
- if (existsSync(STATE_FILE)) {
23
- try {
24
- const raw = readFileSync(STATE_FILE, "utf-8");
25
- state = JSON.parse(raw);
61
+ export function saveServerData() {
62
+ if (!state)
63
+ return;
64
+ const data = {
65
+ server_card: state.server_card,
66
+ network_endpoint: state.network_endpoint,
67
+ };
68
+ atomicWrite(SERVER_FILE, JSON.stringify(data, null, 2));
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Per-agent data
72
+ // ---------------------------------------------------------------------------
73
+ function agentFilePath(agentId) {
74
+ return join(AGENTS_DIR, `${agentId}.json`);
75
+ }
76
+ function loadAgentData(agentId) {
77
+ return safeReadJSON(agentFilePath(agentId));
78
+ }
79
+ function saveAgentData(agentId) {
80
+ if (!state)
81
+ return;
82
+ const agent = state.agents[agentId];
83
+ if (!agent)
84
+ return;
85
+ const s = state;
86
+ const prefix = `${agentId}:`;
87
+ // Collect this agent's tasks
88
+ const tasks = {};
89
+ for (const [tid, task] of Object.entries(s.local_tasks)) {
90
+ if (task.agent_id === agentId)
91
+ tasks[tid] = task;
92
+ }
93
+ // Collect this agent's sessions
94
+ const sessions = {};
95
+ if (s.active_sessions) {
96
+ for (const [key, msgs] of Object.entries(s.active_sessions)) {
97
+ if (key.startsWith(prefix))
98
+ sessions[key] = msgs;
26
99
  }
27
- catch {
28
- // Primary corrupted try backup
29
- if (existsSync(STATE_BACKUP)) {
30
- try {
31
- const bak = readFileSync(STATE_BACKUP, "utf-8");
32
- state = JSON.parse(bak);
33
- }
34
- catch {
35
- state = createDefaultState();
36
- }
37
- }
38
- else {
39
- state = createDefaultState();
40
- }
100
+ }
101
+ // Collect this agent's teams
102
+ const teams = {};
103
+ if (s.teams) {
104
+ for (const [key, team] of Object.entries(s.teams)) {
105
+ if (team.my_agent_id === agentId)
106
+ teams[key] = team;
107
+ }
108
+ }
109
+ const data = {
110
+ agent,
111
+ local_tasks: tasks,
112
+ reputation_cache: { [agentId]: s.reputation_cache[agentId] ?? 0 },
113
+ active_sessions: sessions,
114
+ teams,
115
+ };
116
+ atomicWrite(agentFilePath(agentId), JSON.stringify(data, null, 2));
117
+ }
118
+ function removeAgentFile(agentId) {
119
+ const filePath = agentFilePath(agentId);
120
+ try {
121
+ if (existsSync(filePath))
122
+ unlinkSync(filePath);
123
+ }
124
+ catch { /* best-effort */ }
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Migration from legacy state.json
128
+ // ---------------------------------------------------------------------------
129
+ function migrateLegacyState() {
130
+ if (!existsSync(LEGACY_STATE_FILE))
131
+ return;
132
+ // Rename first (atomic on all platforms) — prevents concurrent migration.
133
+ // If another process already renamed it, this throws and we skip.
134
+ const migratedPath = LEGACY_STATE_FILE + ".migrated";
135
+ try {
136
+ renameSync(LEGACY_STATE_FILE, migratedPath);
137
+ }
138
+ catch {
139
+ return;
140
+ }
141
+ let legacy;
142
+ try {
143
+ legacy = JSON.parse(readFileSync(migratedPath, "utf-8"));
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ // Only write server.json if it doesn't already exist — don't overwrite newer data
149
+ if (!existsSync(SERVER_FILE)) {
150
+ const serverData = {
151
+ server_card: legacy.server_card,
152
+ network_endpoint: legacy.network_endpoint,
153
+ };
154
+ atomicWrite(SERVER_FILE, JSON.stringify(serverData, null, 2));
155
+ }
156
+ // Write per-agent data (only if agent file doesn't exist)
157
+ for (const [agentId, agent] of Object.entries(legacy.agents ?? {})) {
158
+ const prefix = `${agentId}:`;
159
+ const tasks = {};
160
+ for (const [tid, task] of Object.entries(legacy.local_tasks ?? {})) {
161
+ if (task.agent_id === agentId)
162
+ tasks[tid] = task;
163
+ }
164
+ const sessions = {};
165
+ for (const [key, msgs] of Object.entries(legacy.active_sessions ?? {})) {
166
+ if (key.startsWith(prefix))
167
+ sessions[key] = msgs;
168
+ }
169
+ const teams = {};
170
+ for (const [key, team] of Object.entries(legacy.teams ?? {})) {
171
+ if (team.my_agent_id === agentId)
172
+ teams[key] = team;
173
+ }
174
+ const data = {
175
+ agent,
176
+ local_tasks: tasks,
177
+ reputation_cache: { [agentId]: legacy.reputation_cache?.[agentId] ?? 0 },
178
+ active_sessions: sessions,
179
+ teams,
180
+ };
181
+ // Only write if agent file doesn't exist — don't overwrite newer data
182
+ if (!existsSync(agentFilePath(agentId))) {
183
+ atomicWrite(agentFilePath(agentId), JSON.stringify(data, null, 2));
41
184
  }
42
185
  }
43
- else {
44
- state = createDefaultState();
186
+ // Migrate per-agent pending_events to event files
187
+ if (legacy.pending_events) {
188
+ for (const [agentId, events] of Object.entries(legacy.pending_events)) {
189
+ if (events.length > 0) {
190
+ agentEvents.set(agentId, events);
191
+ saveAgentEventsFile(agentId);
192
+ }
193
+ }
45
194
  }
195
+ console.error("[State] migrated legacy state.json to per-agent files");
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // Assemble / Disassemble EacnState
199
+ // ---------------------------------------------------------------------------
200
+ function assembleState() {
201
+ const server = loadServerData();
202
+ // Only load server identity — agents are NOT loaded automatically.
203
+ // Each session must explicitly claim an agent via claimAgent().
204
+ const s = createDefaultState(server.network_endpoint || undefined);
205
+ s.server_card = server.server_card;
206
+ return s;
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Public API — same interface as before
210
+ // ---------------------------------------------------------------------------
211
+ /**
212
+ * Load state from disk. Creates default if not exists.
213
+ */
214
+ export function load() {
215
+ mkdirSync(EACN3_DIR, { recursive: true });
216
+ migrateLegacyState();
217
+ state = assembleState();
46
218
  return state;
47
219
  }
220
+ /**
221
+ * Serialize save operations to prevent concurrent write races (#107).
222
+ */
223
+ let saveQueued = false;
224
+ let saving = false;
48
225
  /**
49
226
  * Persist current state to disk.
227
+ * Writes server.json + per-agent files for agents known to this session.
50
228
  */
51
229
  export function save() {
52
230
  if (!state)
53
231
  return;
54
- mkdirSync(EACN3_DIR, { recursive: true });
55
- // Backup current file before overwriting
56
- if (existsSync(STATE_FILE)) {
57
- try {
58
- copyFileSync(STATE_FILE, STATE_BACKUP);
232
+ if (saving) {
233
+ saveQueued = true;
234
+ return;
235
+ }
236
+ saving = true;
237
+ try {
238
+ for (const agentId of ownedAgentIds) {
239
+ if (state.agents[agentId]) {
240
+ saveAgentData(agentId);
241
+ // Refresh claim lock timestamp so other sessions know we're still alive
242
+ try {
243
+ writeFileSync(claimLockPath(agentId), `${process.pid}:${Date.now()}`);
244
+ }
245
+ catch { /* best-effort */ }
246
+ }
247
+ }
248
+ }
249
+ finally {
250
+ saving = false;
251
+ if (saveQueued) {
252
+ saveQueued = false;
253
+ save();
59
254
  }
60
- catch { /* best-effort */ }
61
255
  }
62
- writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
63
256
  }
64
257
  /**
65
258
  * Get current state (loads from disk if not yet loaded).
@@ -78,13 +271,154 @@ export function setState(newState) {
78
271
  // ---------------------------------------------------------------------------
79
272
  // Convenience methods
80
273
  // ---------------------------------------------------------------------------
274
+ /**
275
+ * List agents available on disk (from previous sessions).
276
+ * Does NOT load them into memory — just reads metadata for display.
277
+ */
278
+ export function listAvailableAgents() {
279
+ const result = [];
280
+ if (!existsSync(AGENTS_DIR))
281
+ return result;
282
+ try {
283
+ for (const file of readdirSync(AGENTS_DIR)) {
284
+ if (!file.endsWith(".json") || file.startsWith("."))
285
+ continue;
286
+ const agentId = file.slice(0, -5);
287
+ // Skip agents already claimed by another session (lock file exists and is fresh)
288
+ const lockPath = claimLockPath(agentId);
289
+ if (existsSync(lockPath)) {
290
+ try {
291
+ const raw = readFileSync(lockPath, "utf-8");
292
+ const ts = parseInt(raw.split(":")[1], 10);
293
+ if (Date.now() - ts < 60_000)
294
+ continue; // Active claim — skip
295
+ }
296
+ catch { /* unreadable lock — show agent */ }
297
+ }
298
+ const data = safeReadJSON(join(AGENTS_DIR, file));
299
+ if (data?.agent) {
300
+ result.push({
301
+ agent_id: data.agent.agent_id,
302
+ name: data.agent.name,
303
+ domains: data.agent.domains,
304
+ tier: data.agent.tier,
305
+ });
306
+ }
307
+ }
308
+ }
309
+ catch { /* dir unreadable */ }
310
+ return result;
311
+ }
312
+ /** Lock file path for an agent claim — prevents two sessions claiming the same agent. */
313
+ function claimLockPath(agentId) {
314
+ return join(AGENTS_DIR, `.${agentId}.lock`);
315
+ }
316
+ /**
317
+ * Claim an existing agent from disk into this session.
318
+ * Loads the agent's full data (tasks, sessions, teams) into memory and marks ownership.
319
+ * Uses an exclusive lock file to prevent two sessions from claiming the same agent.
320
+ * Returns the AgentCard, or null if not found or already claimed by another session.
321
+ */
322
+ export function claimAgent(agentId) {
323
+ const data = loadAgentData(agentId);
324
+ if (!data)
325
+ return null;
326
+ // Acquire exclusive claim lock — prevents two sessions from claiming the same agent.
327
+ // Lock file contains PID so stale locks from crashed processes can be detected.
328
+ mkdirSync(AGENTS_DIR, { recursive: true });
329
+ const lockPath = claimLockPath(agentId);
330
+ try {
331
+ writeFileSync(lockPath, `${process.pid}:${Date.now()}`, { flag: "wx" }); // O_CREAT | O_EXCL
332
+ }
333
+ catch {
334
+ // Lock exists — check if stale (process crashed)
335
+ try {
336
+ const raw = readFileSync(lockPath, "utf-8");
337
+ const ts = parseInt(raw.split(":")[1], 10);
338
+ if (Date.now() - ts > 60_000) {
339
+ // Stale lock (>60s old) — force remove and retry once
340
+ unlinkSync(lockPath);
341
+ try {
342
+ writeFileSync(lockPath, `${process.pid}:${Date.now()}`, { flag: "wx" });
343
+ }
344
+ catch {
345
+ return null;
346
+ } // Still can't lock — another session grabbed it
347
+ }
348
+ else {
349
+ return null; // Active lock from another session
350
+ }
351
+ }
352
+ catch {
353
+ return null;
354
+ }
355
+ }
356
+ const s = getState();
357
+ s.agents[agentId] = data.agent;
358
+ Object.assign(s.local_tasks, data.local_tasks);
359
+ Object.assign(s.reputation_cache, data.reputation_cache);
360
+ if (!s.active_sessions)
361
+ s.active_sessions = {};
362
+ Object.assign(s.active_sessions, data.active_sessions);
363
+ if (!s.teams)
364
+ s.teams = {};
365
+ Object.assign(s.teams, data.teams);
366
+ ownedAgentIds.add(agentId);
367
+ return data.agent;
368
+ }
81
369
  export function addAgent(agent) {
82
370
  getState().agents[agent.agent_id] = agent;
371
+ ownedAgentIds.add(agent.agent_id);
372
+ // Write claim lock for new agent
373
+ mkdirSync(AGENTS_DIR, { recursive: true });
374
+ try {
375
+ writeFileSync(claimLockPath(agent.agent_id), `${process.pid}:${Date.now()}`, { flag: "wx" });
376
+ }
377
+ catch { /* may already own */ }
83
378
  save();
84
379
  }
85
380
  export function removeAgent(agentId) {
86
- delete getState().agents[agentId];
87
- save();
381
+ const s = getState();
382
+ // Remove agent record
383
+ delete s.agents[agentId];
384
+ // Remove agent's local tasks
385
+ for (const [taskId, task] of Object.entries(s.local_tasks)) {
386
+ if (task.agent_id === agentId) {
387
+ delete s.local_tasks[taskId];
388
+ }
389
+ }
390
+ // Remove agent's reputation cache
391
+ delete s.reputation_cache[agentId];
392
+ // Remove agent's message sessions
393
+ if (s.active_sessions) {
394
+ for (const key of Object.keys(s.active_sessions)) {
395
+ if (key.startsWith(`${agentId}:`)) {
396
+ delete s.active_sessions[key];
397
+ }
398
+ }
399
+ }
400
+ // Remove agent's team records
401
+ if (s.teams) {
402
+ for (const [key, team] of Object.entries(s.teams)) {
403
+ if (team.my_agent_id === agentId) {
404
+ delete s.teams[key];
405
+ }
406
+ }
407
+ }
408
+ // Remove per-agent files, ownership and claim lock
409
+ ownedAgentIds.delete(agentId);
410
+ removeAgentFile(agentId);
411
+ try {
412
+ unlinkSync(claimLockPath(agentId));
413
+ }
414
+ catch { /* best-effort */ }
415
+ agentEvents.delete(agentId);
416
+ const evtFile = eventsFilePath(agentId);
417
+ try {
418
+ if (existsSync(evtFile))
419
+ unlinkSync(evtFile);
420
+ }
421
+ catch { /* best-effort */ }
88
422
  }
89
423
  export function getAgent(agentId) {
90
424
  return getState().agents[agentId];
@@ -110,17 +444,62 @@ export function updateTaskStatus(taskId, status) {
110
444
  export function getTask(taskId) {
111
445
  return getState().local_tasks[taskId];
112
446
  }
113
- export function pushEvents(events) {
114
- getState().pending_events.push(...events);
115
- save();
447
+ // ---------------------------------------------------------------------------
448
+ // Per-agent event files (unchanged — already isolated)
449
+ // ---------------------------------------------------------------------------
450
+ /** In-memory cache of per-agent events. */
451
+ const agentEvents = new Map();
452
+ function eventsFilePath(agentId) {
453
+ return join(EACN3_DIR, `events-${agentId}.json`);
116
454
  }
117
- export function drainEvents() {
118
- const s = getState();
119
- const events = s.pending_events;
120
- s.pending_events = [];
121
- save();
455
+ function loadAgentEventsFromFile(agentId) {
456
+ if (agentEvents.has(agentId))
457
+ return agentEvents.get(agentId);
458
+ const filePath = eventsFilePath(agentId);
459
+ try {
460
+ if (existsSync(filePath)) {
461
+ const raw = readFileSync(filePath, "utf-8");
462
+ const events = JSON.parse(raw);
463
+ agentEvents.set(agentId, events);
464
+ return events;
465
+ }
466
+ }
467
+ catch { /* corrupted — start fresh */ }
468
+ agentEvents.set(agentId, []);
469
+ return [];
470
+ }
471
+ function saveAgentEventsFile(agentId) {
472
+ const events = agentEvents.get(agentId) ?? [];
473
+ try {
474
+ mkdirSync(EACN3_DIR, { recursive: true });
475
+ const filePath = eventsFilePath(agentId);
476
+ const tmpFile = filePath + "." + randomBytes(4).toString("hex") + ".tmp";
477
+ writeFileSync(tmpFile, JSON.stringify(events));
478
+ renameSync(tmpFile, filePath);
479
+ }
480
+ catch { /* best-effort */ }
481
+ }
482
+ export function pushEvents(agentId, events) {
483
+ const existing = loadAgentEventsFromFile(agentId);
484
+ existing.push(...events);
485
+ saveAgentEventsFile(agentId);
486
+ }
487
+ export function drainEvents(agentId) {
488
+ const events = loadAgentEventsFromFile(agentId);
489
+ agentEvents.set(agentId, []);
490
+ saveAgentEventsFile(agentId);
122
491
  return events;
123
492
  }
493
+ /** Drain events for ALL agents at once (used by legacy callers). */
494
+ export function drainAllEvents() {
495
+ const all = [];
496
+ for (const [agentId, events] of agentEvents) {
497
+ all.push(...events);
498
+ agentEvents.set(agentId, []);
499
+ saveAgentEventsFile(agentId);
500
+ }
501
+ return all;
502
+ }
124
503
  export function updateReputationCache(agentId, score) {
125
504
  getState().reputation_cache[agentId] = score;
126
505
  save();
@@ -180,4 +559,74 @@ export function listSessions(localAgentId) {
180
559
  .filter((k) => k.startsWith(prefix))
181
560
  .map((k) => k.slice(prefix.length));
182
561
  }
562
+ // ---------------------------------------------------------------------------
563
+ // Team coordination
564
+ // ---------------------------------------------------------------------------
565
+ function ensureTeams() {
566
+ const s = getState();
567
+ if (!s.teams)
568
+ s.teams = {};
569
+ return s.teams;
570
+ }
571
+ export function addTeam(team) {
572
+ ensureTeams()[`${team.team_id}:${team.my_agent_id}`] = team;
573
+ save();
574
+ }
575
+ export function getTeam(teamId) {
576
+ // Try exact key first, then fallback to team_id prefix match
577
+ const teams = ensureTeams();
578
+ if (teams[teamId])
579
+ return teams[teamId];
580
+ return Object.values(teams).find((t) => t.team_id === teamId);
581
+ }
582
+ export function getTeamsForAgent(agentId) {
583
+ return Object.values(ensureTeams()).filter((t) => t.my_agent_id === agentId);
584
+ }
585
+ export function updateTeamPeerBranch(teamId, peerId, branch) {
586
+ const teams = ensureTeams();
587
+ const entries = Object.values(teams).filter((t) => t.team_id === teamId);
588
+ for (const team of entries) {
589
+ team.peer_branches[peerId] = branch;
590
+ // Check if all peers have branches → team ready
591
+ const peers = team.agent_ids.filter((id) => id !== team.my_agent_id);
592
+ if (peers.every((id) => id in team.peer_branches)) {
593
+ team.status = "ready";
594
+ }
595
+ save();
596
+ }
597
+ }
598
+ export function setTeamBranch(teamId, branch) {
599
+ const teams = ensureTeams();
600
+ let saved = false;
601
+ for (const team of Object.values(teams)) {
602
+ if (team.team_id === teamId) {
603
+ team.my_branch = branch;
604
+ saved = true;
605
+ }
606
+ }
607
+ if (saved)
608
+ save();
609
+ }
610
+ /** Find team by handshake task ID (in either ack_out or ack_in). */
611
+ export function findTeamByHandshakeTask(taskId) {
612
+ for (const team of Object.values(ensureTeams())) {
613
+ for (const [peerId, tid] of Object.entries(team.ack_out)) {
614
+ if (tid === taskId)
615
+ return { team, direction: "out", peerId };
616
+ }
617
+ for (const [peerId, tid] of Object.entries(team.ack_in)) {
618
+ if (tid === taskId)
619
+ return { team, direction: "in", peerId };
620
+ }
621
+ }
622
+ return undefined;
623
+ }
624
+ /** Record an incoming handshake task for a team. */
625
+ export function recordAckIn(teamId, agentId, peerId, taskId) {
626
+ const team = Object.values(ensureTeams()).find((t) => t.team_id === teamId && t.my_agent_id === agentId);
627
+ if (team) {
628
+ team.ack_in[peerId] = taskId;
629
+ save();
630
+ }
631
+ }
183
632
  //# sourceMappingURL=state.js.map