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.
- package/AGENT_GUIDE.md +345 -0
- package/dist/index.js +259 -48
- package/dist/index.js.map +1 -1
- package/dist/server.js +978 -75
- package/dist/server.js.map +1 -1
- package/dist/src/a2a-server.js +2 -1
- package/dist/src/a2a-server.js.map +1 -1
- package/dist/src/event-transport.d.ts +48 -0
- package/dist/src/event-transport.js +156 -0
- package/dist/src/event-transport.js.map +1 -0
- package/dist/src/models.d.ts +59 -11
- package/dist/src/models.js +1 -1
- package/dist/src/models.js.map +1 -1
- package/dist/src/network-client.d.ts +3 -1
- package/dist/src/network-client.js +87 -14
- package/dist/src/network-client.js.map +1 -1
- package/dist/src/reverse-control.d.ts +74 -0
- package/dist/src/reverse-control.js +609 -0
- package/dist/src/reverse-control.js.map +1 -0
- package/dist/src/state.d.ts +50 -4
- package/dist/src/state.js +492 -43
- package/dist/src/state.js.map +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -7
- package/scripts/cli.cjs +28 -11
- package/skills/eacn3-bid/SKILL.md +2 -2
- package/skills/eacn3-bid-zh/SKILL.md +2 -2
- package/skills/eacn3-bounty/SKILL.md +7 -7
- package/skills/eacn3-bounty-zh/SKILL.md +7 -7
- package/skills/eacn3-budget/SKILL.md +1 -1
- package/skills/eacn3-budget-zh/SKILL.md +1 -1
- package/skills/eacn3-clarify/SKILL.md +1 -1
- package/skills/eacn3-clarify-zh/SKILL.md +1 -1
- package/skills/eacn3-collect/SKILL.md +1 -1
- package/skills/eacn3-collect-zh/SKILL.md +1 -1
- package/skills/eacn3-dashboard/SKILL.md +3 -3
- package/skills/eacn3-dashboard-zh/SKILL.md +3 -3
- package/skills/eacn3-delegate/SKILL.md +1 -1
- package/skills/eacn3-delegate-zh/SKILL.md +1 -1
- package/skills/eacn3-execute/SKILL.md +1 -1
- package/skills/eacn3-execute-zh/SKILL.md +1 -1
- package/skills/eacn3-invite/SKILL.md +1 -1
- package/skills/eacn3-invite-zh/SKILL.md +1 -1
- package/skills/eacn3-task/SKILL.md +1 -1
- package/skills/eacn3-task-zh/SKILL.md +1 -1
package/dist/src/state.d.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local state persistence —
|
|
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 —
|
|
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,
|
|
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
|
|
13
|
-
const
|
|
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
|
-
*
|
|
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
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|