eacn3 0.5.0 → 0.6.1

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/dist/server.js CHANGED
@@ -11,9 +11,27 @@ import { z } from "zod";
11
11
  import { EACN3_DEFAULT_NETWORK_ENDPOINT } from "./src/models.js";
12
12
  import * as state from "./src/state.js";
13
13
  import * as net from "./src/network-client.js";
14
- import * as ws from "./src/event-transport.js";
14
+ import * as transport from "./src/event-transport.js";
15
15
  import * as a2a from "./src/a2a-server.js";
16
16
  import * as rc from "./src/reverse-control.js";
17
+ import { appendFileSync, mkdirSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ // ---------------------------------------------------------------------------
21
+ // Activity log — file-based traceability
22
+ // ---------------------------------------------------------------------------
23
+ const __log_dir = join(dirname(fileURLToPath(import.meta.url)), "..", "logs");
24
+ let __activity_log = null;
25
+ function _writeActivityLog(line) {
26
+ try {
27
+ if (!__activity_log) {
28
+ mkdirSync(__log_dir, { recursive: true });
29
+ __activity_log = join(__log_dir, "activity.log");
30
+ }
31
+ appendFileSync(__activity_log, line + "\n");
32
+ }
33
+ catch { }
34
+ }
17
35
  // ---------------------------------------------------------------------------
18
36
  // Helper: MCP text result
19
37
  // ---------------------------------------------------------------------------
@@ -33,15 +51,19 @@ function ok(data) {
33
51
  function err(message) {
34
52
  return { content: [{ type: "text", text: JSON.stringify({ error: message }) }] };
35
53
  }
36
- /** Log MCP tool calls to stderr for traceability. */
54
+ /** Log MCP tool calls to stderr AND activity.log for traceability. */
37
55
  function logToolCall(toolName, params) {
38
56
  const ts = new Date().toISOString();
39
- console.error(`[MCP] ${ts} CALL ${toolName} params=${JSON.stringify(params)}`);
57
+ const line = `[MCP] ${ts} CALL ${toolName} params=${JSON.stringify(params)}`;
58
+ console.error(line);
59
+ _writeActivityLog(line);
40
60
  }
41
61
  function logToolResult(toolName, success, detail) {
42
62
  const ts = new Date().toISOString();
43
63
  const tag = success ? "OK" : "ERR";
44
- console.error(`[MCP] ${ts} ${tag} ${toolName}${detail ? ` ${detail}` : ""}`);
64
+ const line = `[MCP] ${ts} ${tag} ${toolName}${detail ? ` ${detail}` : ""}`;
65
+ console.error(line);
66
+ _writeActivityLog(line);
45
67
  }
46
68
  /**
47
69
  * Resolve agent ID: use provided value, or auto-inject from state.
@@ -58,18 +80,117 @@ function resolveAgentId(provided) {
58
80
  throw new Error("No agents registered. Call eacn3_register_agent first.");
59
81
  throw new Error(`Multiple agents registered (${agents.map(a => a.agent_id).join(", ")}). Specify agent_id explicitly.`);
60
82
  }
83
+ /**
84
+ * Detect whether an API error indicates the server/agent registration is stale
85
+ * on the backend (e.g. "server not found", "agent not found", 404 on discovery).
86
+ */
87
+ function isStaleRegistrationError(e) {
88
+ const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
89
+ return (msg.includes("404") ||
90
+ msg.includes("not found") ||
91
+ msg.includes("not registered") ||
92
+ msg.includes("unknown server") ||
93
+ msg.includes("unknown agent"));
94
+ }
95
+ /**
96
+ * Wrap a tool's async handler to auto-reconnect on stale registration errors.
97
+ * If the first attempt fails because the backend forgot our server/agent,
98
+ * trigger autoReconnect() and retry once.
99
+ */
100
+ async function withAutoReconnect(fn) {
101
+ try {
102
+ return await fn();
103
+ }
104
+ catch (e) {
105
+ if (isStaleRegistrationError(e) && !reconnecting) {
106
+ console.error(`[EACN3] stale registration detected, auto-reconnecting before retry...`);
107
+ await autoReconnect();
108
+ // Retry once after reconnect
109
+ return await fn();
110
+ }
111
+ throw e;
112
+ }
113
+ }
61
114
  // ---------------------------------------------------------------------------
62
- // Heartbeat background interval
115
+ // Heartbeat background interval with auto-reconnect
63
116
  // ---------------------------------------------------------------------------
64
117
  let heartbeatInterval = null;
118
+ let heartbeatConsecutiveFailures = 0;
119
+ let reconnecting = false;
120
+ const HEARTBEAT_FAIL_THRESHOLD = 2; // trigger reconnect after 2 consecutive heartbeat failures
121
+ /**
122
+ * Attempt to re-register server and agents with the network.
123
+ * Called when heartbeat failures indicate the backend has forgotten us.
124
+ */
125
+ async function autoReconnect() {
126
+ if (reconnecting)
127
+ return;
128
+ reconnecting = true;
129
+ console.error("[EACN3] auto-reconnect: heartbeat failures exceeded threshold, re-registering...");
130
+ const s = state.getState();
131
+ try {
132
+ // Try heartbeat once more — maybe transient
133
+ try {
134
+ await net.heartbeat();
135
+ heartbeatConsecutiveFailures = 0;
136
+ console.error("[EACN3] auto-reconnect: heartbeat recovered, no re-registration needed");
137
+ return;
138
+ }
139
+ catch { /* still failing */ }
140
+ // Re-register server
141
+ const res = await net.registerServer("0.5.1", "plugin://local", "plugin-user");
142
+ const newSid = res.server_id;
143
+ s.server_card = {
144
+ server_id: newSid,
145
+ version: "0.5.1",
146
+ endpoint: "plugin://local",
147
+ owner: "plugin-user",
148
+ status: "online",
149
+ };
150
+ state.saveServerData();
151
+ // Re-register all owned agents with the new server_id
152
+ for (const agent of state.listAgents()) {
153
+ agent.server_id = newSid;
154
+ try {
155
+ await net.registerAgent(agent);
156
+ console.error(`[EACN3] auto-reconnect: re-registered agent ${agent.agent_id}`);
157
+ }
158
+ catch (e) {
159
+ console.error(`[EACN3] auto-reconnect: failed to re-register agent ${agent.agent_id}: ${e.message}`);
160
+ }
161
+ }
162
+ state.save();
163
+ heartbeatConsecutiveFailures = 0;
164
+ console.error("[EACN3] auto-reconnect: completed successfully");
165
+ }
166
+ catch (e) {
167
+ console.error(`[EACN3] auto-reconnect: failed: ${e.message}`);
168
+ }
169
+ finally {
170
+ reconnecting = false;
171
+ }
172
+ }
65
173
  function startHeartbeat() {
66
174
  if (heartbeatInterval)
67
175
  return;
176
+ heartbeatConsecutiveFailures = 0;
177
+ // Wire up the connection-degraded callback from network-client
178
+ net.setConnectionDegradedCallback(() => {
179
+ console.error("[EACN3] connection degraded (multiple request failures), scheduling reconnect");
180
+ autoReconnect();
181
+ });
68
182
  heartbeatInterval = setInterval(async () => {
69
183
  try {
70
184
  await net.heartbeat();
185
+ heartbeatConsecutiveFailures = 0;
186
+ }
187
+ catch (e) {
188
+ heartbeatConsecutiveFailures++;
189
+ console.error(`[EACN3] heartbeat failed (${heartbeatConsecutiveFailures}/${HEARTBEAT_FAIL_THRESHOLD}): ${e.message}`);
190
+ if (heartbeatConsecutiveFailures >= HEARTBEAT_FAIL_THRESHOLD) {
191
+ autoReconnect();
192
+ }
71
193
  }
72
- catch { /* silent */ }
73
194
  }, 60_000);
74
195
  }
75
196
  function stopHeartbeat() {
@@ -77,11 +198,14 @@ function stopHeartbeat() {
77
198
  clearInterval(heartbeatInterval);
78
199
  heartbeatInterval = null;
79
200
  }
201
+ heartbeatConsecutiveFailures = 0;
80
202
  }
81
203
  // ---------------------------------------------------------------------------
82
204
  // MCP Server
83
205
  // ---------------------------------------------------------------------------
84
- const server = new McpServer({ name: "eacn3", version: "0.5.0" });
206
+ const server = new McpServer({ name: "eacn3", version: "0.5.1" }, {
207
+ capabilities: { logging: {} },
208
+ });
85
209
  // ═══════════════════════════════════════════════════════════════════════════
86
210
  // Health / Cluster (2)
87
211
  // ═══════════════════════════════════════════════════════════════════════════
@@ -89,12 +213,15 @@ const server = new McpServer({ name: "eacn3", version: "0.5.0" });
89
213
  server.tool("eacn3_health", "Check if a network node is alive and responding. No prerequisites — works before eacn3_connect. Returns {status: 'ok'} on success. Use this to verify an endpoint before connecting.", {
90
214
  endpoint: z.string().optional().describe("Node URL to probe. Defaults to configured network endpoint."),
91
215
  }, async (params) => {
216
+ logToolCall("eacn3_health", params);
92
217
  const target = params.endpoint ?? state.getState().network_endpoint;
93
218
  try {
94
219
  const health = await net.checkHealth(target);
220
+ logToolResult("eacn3_health", true);
95
221
  return ok({ endpoint: target, ...health });
96
222
  }
97
223
  catch (e) {
224
+ logToolResult("eacn3_health", false, e.message);
98
225
  return err(`Health check failed for ${target}: ${e.message}`);
99
226
  }
100
227
  });
@@ -115,7 +242,7 @@ server.tool("eacn3_cluster_status", "Retrieve the full cluster topology includin
115
242
  // Server Management (4)
116
243
  // ═══════════════════════════════════════════════════════════════════════════
117
244
  // #1 eacn3_connect
118
- server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your FIRST call. Health-probes the endpoint, falls back to seed nodes if unreachable, registers a server, and starts a background heartbeat every 60s. Returns {server_id, network_endpoint, fallback, agents_online, restored_agents, hint}. Side effects: starts event polling for any previously registered agents. IMPORTANT: check restored_agents in the response — if you have previously registered agents, they are already reconnected and ready to use. You do NOT need to re-register them. Only call eacn3_register_agent if you need a NEW agent.", {
245
+ server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your FIRST call. Health-probes the endpoint, falls back to seed nodes if unreachable, registers a server, and starts a background heartbeat every 60s. Returns {server_id, network_endpoint, fallback, available_agents, hint}. IMPORTANT: agents are NOT auto-restored. Check available_agents — if you have a previous agent, call eacn3_claim_agent(agent_id) to resume it. Otherwise call eacn3_register_agent() to create a new one. Only one agent per session.", {
119
246
  network_endpoint: z.string().optional().describe(`Network URL. Defaults to ${EACN3_DEFAULT_NETWORK_ENDPOINT}`),
120
247
  seed_nodes: z.array(z.string()).optional().describe("Additional seed node URLs for fallback"),
121
248
  }, async (params) => {
@@ -143,11 +270,11 @@ server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your
143
270
  }
144
271
  catch {
145
272
  // Server no longer known to network — re-register
146
- const res = await net.registerServer("0.5.0", "plugin://local", "plugin-user");
273
+ const res = await net.registerServer("0.5.1", "plugin://local", "plugin-user");
147
274
  sid = res.server_id;
148
275
  s.server_card = {
149
276
  server_id: sid,
150
- version: "0.5.0",
277
+ version: "0.5.1",
151
278
  endpoint: "plugin://local",
152
279
  owner: "plugin-user",
153
280
  status: "online",
@@ -163,48 +290,30 @@ server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your
163
290
  }
164
291
  }
165
292
  else {
166
- const res = await net.registerServer("0.5.0", "plugin://local", "plugin-user");
293
+ const res = await net.registerServer("0.5.1", "plugin://local", "plugin-user");
167
294
  sid = res.server_id;
168
295
  s.server_card = {
169
296
  server_id: sid,
170
- version: "0.5.0",
297
+ version: "0.5.1",
171
298
  endpoint: "plugin://local",
172
299
  owner: "plugin-user",
173
300
  status: "online",
174
301
  };
175
302
  }
176
- state.save();
303
+ state.saveServerData();
177
304
  // Start background heartbeat
178
305
  startHeartbeat();
179
- // Reconnect WS for all existing agents; re-register if network lost them
180
- for (const agent of Object.values(s.agents)) {
181
- try {
182
- await net.getAgentInfo(agent.agent_id);
183
- }
184
- catch {
185
- // Agent not found on network (e.g. server restarted with in-memory DB)
186
- try {
187
- await net.registerAgent(agent);
188
- }
189
- catch { /* best-effort */ }
190
- }
191
- ws.connect(agent.agent_id);
192
- }
193
- const restoredAgents = Object.values(s.agents).map((a) => ({
194
- agent_id: a.agent_id,
195
- name: a.name,
196
- domains: a.domains,
197
- tier: a.tier,
198
- }));
306
+ // List agents available on disk (from previous sessions) do NOT auto-restore.
307
+ // Each session must explicitly claim an agent via eacn3_claim_agent.
308
+ const availableAgents = state.listAvailableAgents();
199
309
  return ok({
200
310
  connected: true,
201
311
  server_id: sid,
202
312
  network_endpoint: endpoint,
203
313
  fallback,
204
- agents_online: restoredAgents.length,
205
- restored_agents: restoredAgents,
206
- hint: restoredAgents.length > 0
207
- ? "You have previously registered agents restored and reconnected. You can use them directly without re-registering. Call eacn3_list_my_agents() for full details."
314
+ available_agents: availableAgents,
315
+ hint: availableAgents.length > 0
316
+ ? "Previous agents found on disk. Call eacn3_claim_agent(agent_id) to resume one, or eacn3_register_agent() to create a new one."
208
317
  : "No previous agents found. Register a new agent with eacn3_register_agent().",
209
318
  toolkit: {
210
319
  workflow: {
@@ -212,9 +321,8 @@ server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your
212
321
  eacn3_get_events: "Drain all pending events at once (bulk alternative to next).",
213
322
  },
214
323
  team: {
215
- eacn3_team_setup: "Form a team of agents around a shared git repo. Creates handshake tasks between all pairs.",
216
- eacn3_team_status: "Check team formation progress — which handshakes are done, which peer branches are known.",
217
- eacn3_team_set_branch: "Record your git branch name for a team after creating it.",
324
+ eacn3_team_setup: "Form a team of agents around a shared git repo via ACK message exchange.",
325
+ eacn3_team_status: "Check team formation progress — which ACKs are exchanged, which peer branches are known.",
218
326
  },
219
327
  task: {
220
328
  eacn3_create_task: "Publish a task (supports team_id to auto-inject team preamble).",
@@ -227,16 +335,16 @@ server.tool("eacn3_connect", "Connect to the EACN3 network — this must be your
227
335
  });
228
336
  });
229
337
  // #2 eacn3_disconnect
230
- server.tool("eacn3_disconnect", "Disconnect from the EACN3 network and stop event polling. Requires: eacn3_connect first. Side effects: active tasks will timeout and hurt reputation. Server identity and agent registrations are preserved — on next eacn3_connect they will be automatically reconnected. Returns {disconnected: true}. Only call at end of session.", {}, async () => {
338
+ server.tool("eacn3_disconnect", "Disconnect from the EACN3 network. Requires: eacn3_connect first. Side effects: active tasks will timeout and hurt reputation. Server identity is preserved — on next eacn3_connect you can claim your agent back via eacn3_claim_agent. Returns {disconnected: true}. Only call at end of session.", {}, async () => {
231
339
  stopHeartbeat();
232
- ws.disconnectAll();
340
+ transport.disconnectAll();
233
341
  // Do NOT call unregisterServer — it cascade-deletes all agents on the network side.
234
342
  // We only go offline; identity is preserved for reconnection.
235
343
  const s = state.getState();
236
- if (s.server_card) {
344
+ // Don't write server.json — other sessions may still be using this server.
345
+ // Just clean up this session's in-memory state.
346
+ if (s.server_card)
237
347
  s.server_card.status = "offline";
238
- }
239
- state.save();
240
348
  return ok({ disconnected: true });
241
349
  });
242
350
  // #3 eacn3_heartbeat
@@ -266,8 +374,45 @@ server.tool("eacn3_server_info", "Get current server connection state, including
266
374
  });
267
375
  });
268
376
  // ═══════════════════════════════════════════════════════════════════════════
269
- // Agent Management (7)
377
+ // Agent Management (8)
270
378
  // ═══════════════════════════════════════════════════════════════════════════
379
+ // #4b eacn3_claim_agent
380
+ server.tool("eacn3_claim_agent", "Claim a previously registered agent from disk into this session. Use this to resume an agent listed in available_agents from eacn3_connect. The agent is re-registered on the network and event transport is started. Only one agent per session.", {
381
+ agent_id: z.string().describe("ID of the agent to claim (from available_agents)"),
382
+ }, async (params) => {
383
+ logToolCall("eacn3_claim_agent", params);
384
+ if (state.listAgents().length > 0) {
385
+ return err("This session already has an agent. Only one agent per session.");
386
+ }
387
+ const agent = state.claimAgent(params.agent_id);
388
+ if (!agent) {
389
+ return err(`Agent ${params.agent_id} not found on disk. Use eacn3_register_agent to create a new one.`);
390
+ }
391
+ // Re-register on network with current session's server_id
392
+ const s = state.getState();
393
+ const oldServerId = agent.server_id;
394
+ if (s.server_card)
395
+ agent.server_id = s.server_card.server_id;
396
+ try {
397
+ await net.registerAgent(agent);
398
+ }
399
+ catch {
400
+ agent.server_id = oldServerId; // Revert on failure to stay consistent with network
401
+ }
402
+ // Start event transport
403
+ transport.connect(agent.agent_id);
404
+ // Initialize reverse control
405
+ rc.configure(agent.agent_id);
406
+ state.save();
407
+ logToolResult("eacn3_claim_agent", true, agent.agent_id);
408
+ return ok({
409
+ claimed: true,
410
+ agent_id: agent.agent_id,
411
+ name: agent.name,
412
+ domains: agent.domains,
413
+ tier: agent.tier,
414
+ });
415
+ });
271
416
  // #5 eacn3_register_agent
272
417
  // Inlines: adapter (AgentCard assembly) + registry (validate + persist + DHT)
273
418
  server.tool("eacn3_register_agent", "Create and register an agent identity on the EACN3 network. Requires: eacn3_connect first. Assembles an AgentCard, registers it with the network, persists it locally, and starts polling for push events (task_broadcast, subtask_completed, etc.). Returns {agent_id, seeds, domains}. Domains control which task broadcasts you receive — be specific (e.g. 'python-coding' not 'coding').", {
@@ -334,8 +479,8 @@ server.tool("eacn3_register_agent", "Create and register an agent identity on th
334
479
  const res = await net.registerAgent(card);
335
480
  // Persist locally
336
481
  state.addAgent(card);
337
- // Start polling for push events
338
- ws.connect(agentId);
482
+ // Register agent for on-demand event fetching
483
+ transport.connect(agentId);
339
484
  // Configure reverse control for this agent
340
485
  if (params.reverse_control?.enabled !== false) {
341
486
  const rcPolicies = {};
@@ -367,9 +512,8 @@ server.tool("eacn3_register_agent", "Create and register an agent identity on th
367
512
  eacn3_get_events: "Drain all pending events at once (bulk alternative to next).",
368
513
  },
369
514
  team: {
370
- eacn3_team_setup: "Form a team of agents around a shared git repo. Creates handshake tasks between all pairs.",
371
- eacn3_team_status: "Check team formation progress — which handshakes are done, which peer branches are known.",
372
- eacn3_team_set_branch: "Record your git branch name for a team after creating it.",
515
+ eacn3_team_setup: "Form a team of agents around a shared git repo via ACK message exchange.",
516
+ eacn3_team_status: "Check team formation progress — which ACKs are exchanged, which peer branches are known.",
373
517
  },
374
518
  task: {
375
519
  eacn3_create_task: "Publish a task (supports team_id to auto-inject team preamble).",
@@ -425,11 +569,11 @@ server.tool("eacn3_update_agent", "Update a registered agent's mutable fields: n
425
569
  return ok({ updated: true, agent_id, ...res });
426
570
  });
427
571
  // #8 eacn3_unregister_agent
428
- server.tool("eacn3_unregister_agent", "Remove an agent from the network and stop its event polling. Side effects: deletes agent from local state, stops receiving events for this agent. Active tasks assigned to this agent will timeout and hurt reputation. Returns {unregistered: true, agent_id}.", {
572
+ server.tool("eacn3_unregister_agent", "Remove an agent from the network. Side effects: deletes agent from local state. Active tasks assigned to this agent will timeout and hurt reputation. Returns {unregistered: true, agent_id}.", {
429
573
  agent_id: z.string(),
430
574
  }, async (params) => {
431
575
  const res = await net.unregisterAgent(params.agent_id);
432
- ws.disconnect(params.agent_id);
576
+ transport.disconnect(params.agent_id);
433
577
  rc.unconfigure(params.agent_id);
434
578
  state.removeAgent(params.agent_id);
435
579
  // Stop A2A server if no agents remain
@@ -439,7 +583,7 @@ server.tool("eacn3_unregister_agent", "Remove an agent from the network and stop
439
583
  return ok({ unregistered: true, agent_id: params.agent_id, ...res });
440
584
  });
441
585
  // #9 eacn3_list_my_agents
442
- server.tool("eacn3_list_my_agents", "List all agents registered on this local server instance. Returns {count, agents[]} where each agent includes agent_id, name, domains, tier, and polling_active (event polling status). No network call — reads local state only. Use to check which agents are active and receiving events.", {}, async () => {
586
+ server.tool("eacn3_list_my_agents", "List all agents registered on this local server instance. Returns {count, agents[]} where each agent includes agent_id, name, domains, tier, and registered status. No network call — reads local state only. Use to check which agents are active.", {}, async () => {
443
587
  const agents = state.listAgents();
444
588
  return ok({
445
589
  count: agents.length,
@@ -447,8 +591,8 @@ server.tool("eacn3_list_my_agents", "List all agents registered on this local se
447
591
  agent_id: a.agent_id,
448
592
  name: a.name,
449
593
  domains: a.domains,
450
- connected: ws.isConnected(a.agent_id),
451
- transport: ws.getTransportStatus(a.agent_id),
594
+ connected: transport.isConnected(a.agent_id),
595
+ transport: transport.getTransportStatus(a.agent_id),
452
596
  })),
453
597
  });
454
598
  });
@@ -579,7 +723,7 @@ server.tool("eacn3_create_task", "Publish a new task to the EACN3 network for ot
579
723
  ].filter(Boolean).join("\n");
580
724
  finalDescription = `${preamble}\n${params.description}`;
581
725
  }
582
- const task = await net.createTask({
726
+ const task = await withAutoReconnect(() => net.createTask({
583
727
  task_id: taskId,
584
728
  initiator_id: initiatorId,
585
729
  content: {
@@ -594,7 +738,7 @@ server.tool("eacn3_create_task", "Publish a new task to the EACN3 network for ot
594
738
  human_contact: params.human_contact,
595
739
  level: params.level ?? "general",
596
740
  invited_agent_ids: params.invited_agent_ids,
597
- });
741
+ }));
598
742
  // Track locally
599
743
  state.updateTask({
600
744
  task_id: taskId,
@@ -605,6 +749,11 @@ server.tool("eacn3_create_task", "Publish a new task to the EACN3 network for ot
605
749
  description_summary: params.description.slice(0, 100),
606
750
  created_at: new Date().toISOString(),
607
751
  });
752
+ // If team task: reply to pending reverse handshakes with branch + task details
753
+ if (activeTeam) {
754
+ const taskSummary = { task_id: taskId, description: params.description.slice(0, 500) };
755
+ await replyPendingHandshakes(initiatorId, activeTeam, taskSummary);
756
+ }
608
757
  return ok({
609
758
  task_id: taskId,
610
759
  status: task.status,
@@ -629,25 +778,6 @@ server.tool("eacn3_select_result", "Pick the winning result for a task, triggeri
629
778
  }, async (params) => {
630
779
  const initiatorId = resolveAgentId(params.initiator_id);
631
780
  const res = await net.selectResult(params.task_id, initiatorId, params.agent_id);
632
- // If this was a team handshake task, extract peer branch and update local team state
633
- const teams = state.getTeamsForAgent(initiatorId);
634
- for (const team of teams) {
635
- const handshakeTarget = Object.entries(team.handshake_out).find(([, taskId]) => taskId === params.task_id);
636
- if (handshakeTarget) {
637
- // Fetch the result to get the branch name
638
- try {
639
- const taskData = await net.getTask(params.task_id);
640
- const results = taskData?.results ?? [];
641
- const winnerResult = results.find((r) => r.agent_id === params.agent_id);
642
- const branch = winnerResult?.content?.branch;
643
- if (branch) {
644
- state.updateTeamPeerBranch(team.team_id, handshakeTarget[0], branch);
645
- }
646
- }
647
- catch { /* best effort */ }
648
- break;
649
- }
650
- }
651
781
  return ok(res);
652
782
  });
653
783
  // #19 eacn3_close_task
@@ -763,7 +893,7 @@ server.tool("eacn3_submit_bid", "Bid on an open task by specifying your confiden
763
893
  const agentId = resolveAgentId(params.agent_id);
764
894
  // Tier/level filtering and invite bypass are handled server-side in matcher.check_bid().
765
895
  // No client-side pre-flight — the network returns "rejected" with reason for tier mismatches.
766
- const res = await net.submitBid(params.task_id, agentId, params.confidence, params.price);
896
+ const res = await withAutoReconnect(() => net.submitBid(params.task_id, agentId, params.confidence, params.price));
767
897
  // Track locally if not rejected (status could be "executing", "waiting_execution", etc.)
768
898
  if (res.status && res.status !== "rejected") {
769
899
  state.updateTask({
@@ -786,7 +916,7 @@ server.tool("eacn3_submit_result", "Submit your completed work for a task you ar
786
916
  agent_id: z.string().optional().describe("Executor agent ID (auto-injected if omitted)"),
787
917
  }, async (params) => {
788
918
  const agentId = resolveAgentId(params.agent_id);
789
- const res = await net.submitResult(params.task_id, agentId, params.content);
919
+ const res = await withAutoReconnect(() => net.submitResult(params.task_id, agentId, params.content));
790
920
  // Auto-report reputation event (what logger used to do)
791
921
  try {
792
922
  await net.reportEvent(agentId, "task_completed");
@@ -802,7 +932,7 @@ server.tool("eacn3_reject_task", "Abandon a task you accepted, freeing your exec
802
932
  agent_id: z.string().optional().describe("Executor agent ID (auto-injected if omitted)"),
803
933
  }, async (params) => {
804
934
  const agentId = resolveAgentId(params.agent_id);
805
- const res = await net.rejectTask(params.task_id, agentId, params.reason);
935
+ const res = await withAutoReconnect(() => net.rejectTask(params.task_id, agentId, params.reason));
806
936
  // Auto-report reputation event
807
937
  try {
808
938
  await net.reportEvent(agentId, "task_rejected");
@@ -821,7 +951,7 @@ server.tool("eacn3_create_subtask", "Delegate part of your work by creating a ch
821
951
  initiator_id: z.string().optional().describe("Agent ID of the executor creating the subtask (auto-injected if omitted)"),
822
952
  }, async (params) => {
823
953
  const initiatorId = resolveAgentId(params.initiator_id);
824
- const task = await net.createSubtask(params.parent_task_id, initiatorId, { description: params.description }, params.domains, params.budget, params.deadline, params.level);
954
+ const task = await withAutoReconnect(() => net.createSubtask(params.parent_task_id, initiatorId, { description: params.description }, params.domains, params.budget, params.deadline, params.level));
825
955
  return ok({
826
956
  subtask_id: task.id,
827
957
  parent_task_id: params.parent_task_id,
@@ -878,6 +1008,7 @@ server.tool("eacn3_send_message", "Send a direct agent-to-agent message. Deliver
878
1008
  from: senderId,
879
1009
  content: params.content,
880
1010
  }),
1011
+ signal: AbortSignal.timeout(10_000),
881
1012
  });
882
1013
  if (res.ok) {
883
1014
  return ok({ sent: true, to: targetId, from: senderId, method: "a2a_direct" });
@@ -971,19 +1102,21 @@ server.tool("eacn3_list_sessions", "List all agents you have active message sess
971
1102
  });
972
1103
  // ═══════════════════════════════════════════════════════════════════════════
973
1104
  // #34 eacn3_get_events
974
- server.tool("eacn3_get_events", "Drain the in-memory event buffer for a specific agent, returning its pending events and clearing them. Returns {count, events[], reverse_control} where event types include: task_broadcast, bid_request_confirmation, bid_result, discussion_update, subtask_completed, task_collected, task_timeout, adjudication_task, direct_message. With reverse_control enabled, high-priority events may already have been handled via LLM sampling — check reverse_control.status for details. Call periodically in your main loop.", {
1105
+ server.tool("eacn3_get_events", "Fetch pending events from the network for a specific agent, plus any locally buffered synthetic events. Returns {count, events[], reverse_control} where event types include: task_broadcast, bid_request_confirmation, bid_result, discussion_update, subtask_completed, task_collected, task_timeout, adjudication_task, direct_message. With reverse_control enabled, high-priority events may already have been handled via LLM sampling — check reverse_control.status for details.", {
975
1106
  agent_id: z.string().optional().describe("Agent ID to drain events for (auto-injected if omitted)"),
976
1107
  }, async (params) => {
977
1108
  const agentId = resolveAgentId(params.agent_id);
978
- const events = state.drainEvents(agentId);
1109
+ const networkEvents = await transport.fetchEvents(agentId, 0);
1110
+ const localEvents = state.drainEvents(agentId);
1111
+ const events = [...networkEvents, ...localEvents].filter((e) => !e._handled);
979
1112
  return ok({
980
1113
  count: events.length,
981
1114
  events,
982
1115
  reverse_control: rc.getStatus(),
983
1116
  });
984
1117
  });
985
- // #39 eacn3_await_events — long-polling reverse control
986
- server.tool("eacn3_await_events", "Block until a network event arrives or timeout expires, then return with the event AND a suggested action. This is the reverse-control mechanism when MCP sampling is unavailable (e.g. OpenClaw). Instead of polling eacn3_get_events in a loop, call this it waits for the network to push something, then tells you exactly what to do. Returns {event, suggested_action, suggested_tool, suggested_params, urgency} per event, or {timeout: true}. Prefer this over eacn3_get_events for reactive agent loops.", {
1118
+ // #39 eacn3_await_events — on-demand long-polling
1119
+ server.tool("eacn3_await_events", "Fetch events from the network with a configurable wait time. First checks locally buffered synthetic events, then does a single long-poll to the network. Returns {event, suggested_action, suggested_tool, suggested_params, urgency} per event, or {timeout: true}. Prefer this over eacn3_get_events for reactive agent loops.", {
987
1120
  agent_id: z.string().optional().describe("Agent ID to await events for (auto-injected if omitted)"),
988
1121
  timeout_seconds: z.number().optional().describe("Max seconds to wait (1-120). Default 30."),
989
1122
  event_types: z.array(z.string()).optional().describe("Only return for these event types. Default: all."),
@@ -991,27 +1124,29 @@ server.tool("eacn3_await_events", "Block until a network event arrives or timeou
991
1124
  const agentId = resolveAgentId(params.agent_id);
992
1125
  const timeoutSec = Math.min(Math.max(params.timeout_seconds ?? 30, 1), 120);
993
1126
  const filterTypes = params.event_types;
994
- // Check immediate buffered events
995
- const immediate = drainMatchingEvents(agentId, filterTypes);
1127
+ // Check locally buffered synthetic events first
1128
+ const immediate = drainMatchingEvents(agentId, filterTypes).filter((e) => !e._handled);
996
1129
  if (immediate.length > 0) {
997
1130
  return ok(buildAwaitResponse(immediate));
998
1131
  }
999
- // Long-poll
1000
- const result = await new Promise((resolve) => {
1001
- const deadline = setTimeout(() => { cleanup(); resolve([]); }, timeoutSec * 1000);
1002
- const poll = setInterval(() => {
1003
- const events = drainMatchingEvents(agentId, filterTypes);
1004
- if (events.length > 0) {
1005
- cleanup();
1006
- resolve(events);
1007
- }
1008
- }, 500);
1009
- function cleanup() { clearTimeout(deadline); clearInterval(poll); }
1010
- });
1011
- if (result.length === 0) {
1132
+ // Single long-poll to network with the agent's requested timeout
1133
+ const networkEvents = await transport.fetchEvents(agentId, timeoutSec);
1134
+ const localAfter = drainMatchingEvents(agentId, filterTypes);
1135
+ const all = [...networkEvents, ...localAfter].filter((e) => !e._handled);
1136
+ // Filter by event types if specified
1137
+ const filtered = filterTypes && filterTypes.length > 0
1138
+ ? all.filter((e) => filterTypes.includes(e.type))
1139
+ : all;
1140
+ // Put back non-matching events
1141
+ if (filterTypes && filterTypes.length > 0) {
1142
+ const remaining = all.filter((e) => !filterTypes.includes(e.type));
1143
+ if (remaining.length > 0)
1144
+ state.pushEvents(agentId, remaining);
1145
+ }
1146
+ if (filtered.length === 0) {
1012
1147
  return ok({ timeout: true, waited_seconds: timeoutSec, hint: "No events arrived. Call again to keep waiting, or proceed with other work." });
1013
1148
  }
1014
- return ok(buildAwaitResponse(result));
1149
+ return ok(buildAwaitResponse(filtered));
1015
1150
  });
1016
1151
  // #41 eacn3_next — single-event non-blocking poll
1017
1152
  const URGENCY_ORDER = {
@@ -1030,22 +1165,6 @@ function buildNextAction(event) {
1030
1165
  const payload = event.payload;
1031
1166
  switch (event.type) {
1032
1167
  case "task_broadcast": {
1033
- // Detect team handshake tasks
1034
- const desc = String(payload?.description ?? "");
1035
- if (desc.startsWith("Team handshake:") || payload?.budget === 0) {
1036
- // Check if this looks like a handshake (0-budget, team-coordination domain)
1037
- const domains = payload.domains ?? [];
1038
- if (domains.includes("team-coordination")) {
1039
- return {
1040
- action: "handshake",
1041
- description: `Team handshake from another agent on task ${event.task_id}. ` +
1042
- `Bid with price=0 confidence=1, then submit_result with your branch name: ` +
1043
- `{branch: "agent/your-id", _handshake_ack: true, team_id: "..."}.`,
1044
- tool: "eacn3_submit_bid",
1045
- params: { task_id: event.task_id, confidence: 1, price: 0 },
1046
- };
1047
- }
1048
- }
1049
1168
  return {
1050
1169
  action: "bid",
1051
1170
  description: `New task [${(payload.domains ?? []).join(", ")}] budget=${payload.budget ?? "?"}. Evaluate and bid.`,
@@ -1125,7 +1244,10 @@ server.tool("eacn3_next", "Non-blocking single-step work dispatcher: returns the
1125
1244
  agent_id: z.string().optional().describe("Agent ID (auto-injected if omitted)"),
1126
1245
  }, async (params) => {
1127
1246
  const agentId = resolveAgentId(params.agent_id);
1128
- const events = state.drainEvents(agentId);
1247
+ // Fetch from network (non-blocking) + drain local synthetic events
1248
+ const networkEvents = await transport.fetchEvents(agentId, 0);
1249
+ const localEvents = state.drainEvents(agentId);
1250
+ const events = [...networkEvents, ...localEvents].filter((e) => !e._handled);
1129
1251
  if (events.length === 0) {
1130
1252
  // Build context-aware prompts based on agent's current task state
1131
1253
  const tasks = Object.values(state.getState().local_tasks).filter((t) => t.agent_id === agentId);
@@ -1186,128 +1308,95 @@ server.tool("eacn3_next", "Non-blocking single-step work dispatcher: returns the
1186
1308
  ...next,
1187
1309
  });
1188
1310
  });
1189
- // #42 eacn3_team_setup — human-facing team coordination toolkit
1190
- server.tool("eacn3_team_setup", "Human-facing toolkit: form a team of agents around a shared git repo. " +
1191
- "Creates 0-budget handshake tasks between every pair of agents (both directions). " +
1192
- "Each agent creates its own operation branch and learns peers' branches through handshake responses. " +
1193
- "Set peers_running_next=true if other agents already have next loops this agent will notify them via messages first.", {
1311
+ // #42 eacn3_team_setup — task-based team handshake (fully automatic)
1312
+ server.tool("eacn3_team_setup", "Form a team of agents around a shared git repo. " +
1313
+ "Creates handshake tasks (0-budget, 30-min deadline) to exchange branch info with each peer. " +
1314
+ "Peers auto-bid and auto-reply; handshake is purely for branch exchange. " +
1315
+ "After team is ready, use eacn3_create_task with team_id to publish work for the team.", {
1194
1316
  agent_ids: z.array(z.string()).min(2).describe("Agent IDs to form a team"),
1195
1317
  git_repo: z.string().describe("Git repo URL for recording operations"),
1196
- peers_running_next: z.boolean().optional().describe("If true, notify peers via messages before handshakes"),
1318
+ my_branch: z.string().describe("This agent's operation branch name"),
1197
1319
  }, async (params) => {
1198
- const localAgents = state.listAgents();
1199
- const localIds = new Set(localAgents.map((a) => a.agent_id));
1200
- // Find which of the requested agents are local
1201
- const myAgents = params.agent_ids.filter((id) => localIds.has(id));
1202
- if (myAgents.length === 0) {
1203
- return err("None of the specified agent_ids are registered on this server");
1320
+ // Only the calling agent creates outgoing handshakes — peers join via autoHandshakeRespond
1321
+ const myId = resolveAgentId(undefined);
1322
+ if (!state.getAgent(myId)) {
1323
+ return err(`Agent ${myId} is not registered on this server`);
1324
+ }
1325
+ if (!params.agent_ids.includes(myId)) {
1326
+ return err(`Your agent ID ${myId} must be included in agent_ids`);
1204
1327
  }
1205
1328
  const teamId = `team-${Date.now().toString(36)}`;
1206
- const results = [];
1207
- for (const myId of myAgents) {
1208
- const peers = params.agent_ids.filter((id) => id !== myId);
1209
- // Save team info
1210
- const teamInfo = {
1211
- team_id: teamId,
1212
- git_repo: params.git_repo,
1213
- agent_ids: params.agent_ids,
1214
- my_agent_id: myId,
1215
- peer_branches: {},
1216
- handshake_out: {},
1217
- handshake_in: {},
1218
- status: "forming",
1219
- };
1220
- // If peers are running next loops, notify them first
1221
- if (params.peers_running_next) {
1222
- for (const peerId of peers) {
1223
- try {
1224
- const msg = JSON.stringify({
1225
- _team_notify: true,
1226
- team_id: teamId,
1227
- git_repo: params.git_repo,
1228
- team_members: params.agent_ids,
1229
- message: `Team setup initiated. You will receive handshake tasks shortly. Create your branch in ${params.git_repo} and respond with your branch name.`,
1230
- });
1231
- await net.relayMessage({
1232
- to: { network_id: "", server_id: "", agent_id: peerId },
1233
- from: { network_id: "", server_id: state.getServerId() ?? "", agent_id: myId },
1234
- content: msg,
1235
- });
1236
- }
1237
- catch { /* best-effort notification */ }
1238
- }
1329
+ const peers = params.agent_ids.filter((id) => id !== myId);
1330
+ const teamInfo = {
1331
+ team_id: teamId,
1332
+ git_repo: params.git_repo,
1333
+ agent_ids: params.agent_ids,
1334
+ my_agent_id: myId,
1335
+ my_branch: params.my_branch,
1336
+ peer_branches: {},
1337
+ ack_out: {},
1338
+ ack_in: {},
1339
+ is_initiator: true,
1340
+ status: "forming",
1341
+ };
1342
+ const tasksCreated = [];
1343
+ const failed = [];
1344
+ for (const peerId of peers) {
1345
+ try {
1346
+ const taskId = `t-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1347
+ const handshakeDeadline = new Date(Date.now() + 30 * 60 * 1000).toISOString();
1348
+ const task = await net.createTask({
1349
+ task_id: taskId,
1350
+ initiator_id: myId,
1351
+ content: { description: `Team handshake: ${myId} → ${peerId} [team=${teamId}] [repo=${params.git_repo}] [members=${params.agent_ids.join(",")}]` },
1352
+ domains: ["team-coordination"],
1353
+ budget: 0,
1354
+ deadline: handshakeDeadline,
1355
+ max_concurrent_bidders: 1,
1356
+ max_depth: 0,
1357
+ invited_agent_ids: [peerId],
1358
+ });
1359
+ teamInfo.ack_out[peerId] = task.id;
1360
+ tasksCreated.push(task.id);
1361
+ state.updateTask({
1362
+ task_id: task.id,
1363
+ agent_id: myId,
1364
+ role: "initiator",
1365
+ status: task.status,
1366
+ domains: ["team-coordination"],
1367
+ description_summary: `Handshake → ${peerId}`,
1368
+ created_at: new Date().toISOString(),
1369
+ });
1239
1370
  }
1240
- // Create 0-budget handshake task for each peer
1241
- const handshakesCreated = [];
1242
- for (const peerId of peers) {
1243
- try {
1244
- const taskId = `t-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1245
- const content = {
1246
- _handshake: true,
1247
- team_id: teamId,
1248
- git_repo: params.git_repo,
1249
- from_agent: myId,
1250
- team_members: params.agent_ids,
1251
- };
1252
- const task = await net.createTask({
1253
- task_id: taskId,
1254
- initiator_id: myId,
1255
- content: { description: `Team handshake: ${myId} → ${peerId}` },
1256
- domains: ["team-coordination"],
1257
- budget: 0,
1258
- max_concurrent_bidders: 1,
1259
- max_depth: 0,
1260
- invited_agent_ids: [peerId],
1261
- });
1262
- // Update task content with handshake marker via discussions
1263
- try {
1264
- await net.updateDiscussions(task.id, myId, JSON.stringify(content));
1265
- }
1266
- catch { /* content is in description as fallback */ }
1267
- teamInfo.handshake_out[peerId] = task.id;
1268
- handshakesCreated.push(task.id);
1269
- state.updateTask({
1270
- task_id: task.id,
1271
- agent_id: myId,
1272
- role: "initiator",
1273
- status: task.status,
1274
- domains: ["team-coordination"],
1275
- description_summary: `Handshake → ${peerId}`,
1276
- created_at: new Date().toISOString(),
1277
- });
1278
- }
1279
- catch (e) {
1280
- handshakesCreated.push(`FAILED(${peerId}): ${e.message ?? e}`);
1281
- }
1371
+ catch (e) {
1372
+ failed.push(`${peerId}: ${e.message ?? e}`);
1282
1373
  }
1283
- state.addTeam(teamInfo);
1284
- results.push({ agent_id: myId, handshakes_created: handshakesCreated });
1285
1374
  }
1375
+ state.addTeam(teamInfo);
1286
1376
  return ok({
1287
1377
  team_id: teamId,
1288
1378
  git_repo: params.git_repo,
1289
1379
  agent_ids: params.agent_ids,
1290
- local_agents: myAgents,
1291
- results,
1380
+ my_agent_id: myId,
1381
+ my_branch: params.my_branch,
1382
+ tasks_created: tasksCreated,
1383
+ failed,
1292
1384
  next_steps: [
1293
- "Each agent should now create their operation branch in the git repo.",
1294
- "When receiving handshake tasks (task_broadcast with _handshake marker), " +
1295
- "bid with price=0 and confidence=1, then submit_result with {branch: 'your-branch-name', _handshake_ack: true, team_id: '...'}.",
1296
- "When your handshake tasks get results, select_result to complete the handshake.",
1297
- "Team is ready when all peer branches are known.",
1385
+ "Handshake tasks are auto-processed peers auto-bid and reply, results are auto-selected.",
1386
+ "Call eacn3_team_status to check progress. Use eacn3_team_retry_ack if a peer is unresponsive.",
1298
1387
  ],
1299
1388
  });
1300
1389
  });
1301
1390
  // #43 eacn3_team_status — check team formation progress
1302
- server.tool("eacn3_team_status", "Check the current status of a team: which handshakes are complete, which peer branches are known, and whether the team is ready.", {
1391
+ server.tool("eacn3_team_status", "Check team formation progress: which handshake tasks are complete, which peer branches are known, and whether the team is ready. If peers are unresponsive, use eacn3_team_retry_ack.", {
1303
1392
  team_id: z.string().describe("Team ID from eacn3_team_setup"),
1304
1393
  }, async (params) => {
1305
1394
  const team = state.getTeam(params.team_id);
1306
1395
  if (!team)
1307
1396
  return err(`Team ${params.team_id} not found`);
1308
1397
  const peers = team.agent_ids.filter((id) => id !== team.my_agent_id);
1309
- const handshakes_complete = peers.filter((id) => id in team.peer_branches);
1310
- const handshakes_pending = peers.filter((id) => !(id in team.peer_branches));
1398
+ const connected = peers.filter((id) => id in team.peer_branches);
1399
+ const pending = peers.filter((id) => !(id in team.peer_branches));
1311
1400
  return ok({
1312
1401
  team_id: team.team_id,
1313
1402
  git_repo: team.git_repo,
@@ -1315,21 +1404,45 @@ server.tool("eacn3_team_status", "Check the current status of a team: which hand
1315
1404
  my_agent_id: team.my_agent_id,
1316
1405
  my_branch: team.my_branch ?? null,
1317
1406
  peer_branches: team.peer_branches,
1318
- handshakes_complete: handshakes_complete,
1319
- handshakes_pending: handshakes_pending,
1407
+ ack_out: team.ack_out,
1408
+ ack_in: team.ack_in,
1409
+ connected,
1410
+ pending,
1320
1411
  ready: team.status === "ready",
1321
1412
  });
1322
1413
  });
1323
- // #44 eacn3_team_set_branchrecord this agent's operation branch
1324
- server.tool("eacn3_team_set_branch", "Record this agent's git branch name for a team. Call after creating your branch in the git repo.", {
1414
+ // #45 eacn3_team_retry_ackre-create handshake task for unresponsive peer
1415
+ server.tool("eacn3_team_retry_ack", "Re-create a handshake task for a specific peer who hasn't responded. Use when eacn3_team_status shows a peer in 'pending'.", {
1325
1416
  team_id: z.string().describe("Team ID"),
1326
- branch: z.string().describe("Branch name (e.g. 'agent/my-agent-id')"),
1417
+ peer_id: z.string().describe("Agent ID of the unresponsive peer"),
1327
1418
  }, async (params) => {
1328
1419
  const team = state.getTeam(params.team_id);
1329
1420
  if (!team)
1330
1421
  return err(`Team ${params.team_id} not found`);
1331
- state.setTeamBranch(params.team_id, params.branch);
1332
- return ok({ team_id: params.team_id, branch: params.branch, message: "Branch recorded" });
1422
+ if (!team.agent_ids.includes(params.peer_id)) {
1423
+ return err(`${params.peer_id} is not a member of team ${params.team_id}`);
1424
+ }
1425
+ try {
1426
+ const taskId = `t-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1427
+ const handshakeDeadline = new Date(Date.now() + 30 * 60 * 1000).toISOString();
1428
+ const task = await net.createTask({
1429
+ task_id: taskId,
1430
+ initiator_id: team.my_agent_id,
1431
+ content: { description: `Team handshake: ${team.my_agent_id} → ${params.peer_id} [team=${team.team_id}] [repo=${team.git_repo}] [members=${team.agent_ids.join(",")}]` },
1432
+ domains: ["team-coordination"],
1433
+ budget: 0,
1434
+ deadline: handshakeDeadline,
1435
+ max_concurrent_bidders: 1,
1436
+ max_depth: 0,
1437
+ invited_agent_ids: [params.peer_id],
1438
+ });
1439
+ team.ack_out[params.peer_id] = task.id;
1440
+ state.addTeam(team); // persist updated ack_out
1441
+ return ok({ team_id: team.team_id, peer_id: params.peer_id, task_id: task.id, message: "Handshake task re-created" });
1442
+ }
1443
+ catch (e) {
1444
+ return err(`Failed to create handshake task for ${params.peer_id}: ${e.message ?? e}`);
1445
+ }
1333
1446
  });
1334
1447
  // #40 eacn3_reverse_control_status
1335
1448
  server.tool("eacn3_reverse_control_status", "Get the current status of the MCP reverse control engine. Shows whether sampling is available, which agents are configured, pending directive count, and rate limiting info. Use for debugging reverse control behavior.", {}, async () => {
@@ -1386,7 +1499,11 @@ function buildAwaitResponse(events) {
1386
1499
  // WS Event Callbacks — auto-actions when events arrive
1387
1500
  // ---------------------------------------------------------------------------
1388
1501
  function registerEventCallbacks() {
1389
- ws.setEventCallback((agentId, event) => {
1502
+ transport.setEventCallback((agentId, event) => {
1503
+ // Skip if agent not claimed in this session — events are on-demand only,
1504
+ // but guard against edge cases.
1505
+ if (!state.getAgent(agentId))
1506
+ return;
1390
1507
  const taskId = event.task_id;
1391
1508
  // --- Reverse Control: try to handle event proactively ---
1392
1509
  // This runs async; if it handles the event, it may take action
@@ -1427,12 +1544,39 @@ function registerEventCallbacks() {
1427
1544
  // Bid exceeded budget — mark in local state for initiator to handle
1428
1545
  // The event stays in the buffer for /eacn3-bounty to surface
1429
1546
  break;
1430
- case "task_broadcast":
1431
- // New task available — auto-evaluate bid if agent has matching domains
1432
- autoBidEvaluate(agentId, event).catch(() => { });
1547
+ case "task_broadcast": {
1548
+ const bPayload = event.payload;
1549
+ const bDomains = bPayload?.domains ?? [];
1550
+ const bDesc = String(bPayload?.content?.description ?? "");
1551
+ if (bDomains.includes("team-coordination") && bDesc.startsWith("Team handshake:")) {
1552
+ event._handled = true;
1553
+ autoHandshakeRespond(agentId, event).catch((e) => { console.error(`[handshake] autoHandshakeRespond failed for ${agentId}:`, e); });
1554
+ }
1555
+ else {
1556
+ autoBidEvaluate(agentId, event).catch(() => { });
1557
+ }
1558
+ break;
1559
+ }
1560
+ case "bid_result": {
1561
+ const brPayload = event.payload;
1562
+ if (brPayload?.accepted) {
1563
+ const match = state.findTeamByHandshakeTask(event.task_id);
1564
+ if (match && match.direction === "in") {
1565
+ event._handled = true;
1566
+ autoHandshakeSubmit(agentId, event).catch((e) => { console.error(`[handshake] autoHandshakeSubmit failed for ${agentId}:`, e); });
1567
+ }
1568
+ }
1569
+ break;
1570
+ }
1571
+ case "result_submitted": {
1572
+ const match = state.findTeamByHandshakeTask(event.task_id);
1573
+ if (match && match.direction === "out") {
1574
+ event._handled = true;
1575
+ autoHandshakeSelect(agentId, event).catch((e) => { console.error(`[handshake] autoHandshakeSelect failed for ${agentId}:`, e); });
1576
+ }
1433
1577
  break;
1578
+ }
1434
1579
  case "direct_message": {
1435
- // Another agent sent a direct message — store in session
1436
1580
  const payload = event.payload;
1437
1581
  const from = payload?.from;
1438
1582
  const content = payload?.content;
@@ -1451,6 +1595,198 @@ function registerEventCallbacks() {
1451
1595
  });
1452
1596
  }
1453
1597
  // ---------------------------------------------------------------------------
1598
+ // Team handshake auto-handling
1599
+ // ---------------------------------------------------------------------------
1600
+ /**
1601
+ * Auto-respond to an incoming handshake task_broadcast:
1602
+ * 1. Parse team info from description
1603
+ * 2. Create local team record if needed, auto-set branch to agent/{id}
1604
+ * 3. Auto-bid on the incoming task
1605
+ * 4. Create outgoing handshake tasks to all peers we haven't ACKed yet
1606
+ */
1607
+ async function autoHandshakeRespond(agentId, event) {
1608
+ const payload = event.payload;
1609
+ const desc = String(payload?.content?.description ?? "");
1610
+ const teamMatch = desc.match(/\[team=([^\]]+)\]/);
1611
+ const repoMatch = desc.match(/\[repo=([^\]]+)\]/);
1612
+ const membersMatch = desc.match(/\[members=([^\]]+)\]/);
1613
+ const fromMatch = desc.match(/^Team handshake:\s*(\S+)/);
1614
+ if (!teamMatch)
1615
+ return;
1616
+ const teamId = teamMatch[1];
1617
+ const gitRepo = repoMatch?.[1] ?? "";
1618
+ const members = membersMatch?.[1]?.split(",") ?? [];
1619
+ const fromAgent = fromMatch?.[1] ?? "";
1620
+ // Ensure local team record exists
1621
+ let team = state.getTeamsForAgent(agentId).find((t) => t.team_id === teamId);
1622
+ if (!team) {
1623
+ const teamInfo = {
1624
+ team_id: teamId,
1625
+ git_repo: gitRepo,
1626
+ agent_ids: members,
1627
+ my_agent_id: agentId,
1628
+ my_branch: `agent/${agentId}`,
1629
+ peer_branches: {},
1630
+ ack_out: {},
1631
+ ack_in: {},
1632
+ status: "forming",
1633
+ };
1634
+ state.addTeam(teamInfo);
1635
+ team = state.getTeamsForAgent(agentId).find((t) => t.team_id === teamId);
1636
+ }
1637
+ // Auto-set branch if not yet set
1638
+ if (!team.my_branch) {
1639
+ state.setTeamBranch(teamId, `agent/${agentId}`);
1640
+ team.my_branch = `agent/${agentId}`;
1641
+ }
1642
+ // Record incoming handshake task
1643
+ state.recordAckIn(teamId, agentId, fromAgent, event.task_id);
1644
+ // If this agent is the team initiator (called eacn3_team_setup), DON'T auto-respond.
1645
+ // The initiator replies later via replyPendingHandshakes (called by create_task)
1646
+ // so it can include task details in the response.
1647
+ if (team.is_initiator)
1648
+ return;
1649
+ // Auto-bid on the incoming task
1650
+ try {
1651
+ await net.submitBid(event.task_id, agentId, 0, 1);
1652
+ }
1653
+ catch (e) {
1654
+ console.error(`[handshake] auto-bid failed for ${agentId} on task ${event.task_id}:`, e);
1655
+ return; // Can't proceed without a bid
1656
+ }
1657
+ // Create outgoing tasks to peers we haven't ACKed yet
1658
+ await createOutgoingHandshakes(team, agentId);
1659
+ }
1660
+ /**
1661
+ * Auto-submit result after handshake bid is accepted (bid_result event).
1662
+ * Refuses to submit if branch is not set.
1663
+ */
1664
+ async function autoHandshakeSubmit(agentId, event) {
1665
+ const taskId = event.task_id;
1666
+ const match = state.findTeamByHandshakeTask(taskId);
1667
+ if (!match || match.direction !== "in")
1668
+ return;
1669
+ try {
1670
+ await net.submitResult(taskId, agentId, {
1671
+ _handshake_ack: true,
1672
+ team_id: match.team.team_id,
1673
+ branch: match.team.my_branch ?? `agent/${agentId}`,
1674
+ });
1675
+ }
1676
+ catch (e) {
1677
+ console.error(`[handshake] auto-submit failed for ${agentId} on task ${taskId}:`, e);
1678
+ }
1679
+ }
1680
+ /**
1681
+ * Create outgoing handshake tasks to all peers we haven't ACKed yet.
1682
+ */
1683
+ async function createOutgoingHandshakes(team, agentId) {
1684
+ const peers = team.agent_ids.filter((id) => id !== agentId);
1685
+ for (const peerId of peers) {
1686
+ if (team.ack_out[peerId])
1687
+ continue;
1688
+ try {
1689
+ const taskId = `t-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
1690
+ const handshakeDeadline = new Date(Date.now() + 30 * 60 * 1000).toISOString();
1691
+ const task = await net.createTask({
1692
+ task_id: taskId,
1693
+ initiator_id: agentId,
1694
+ content: { description: `Team handshake: ${agentId} → ${peerId} [team=${team.team_id}] [repo=${team.git_repo}] [members=${team.agent_ids.join(",")}]` },
1695
+ domains: ["team-coordination"],
1696
+ budget: 0,
1697
+ deadline: handshakeDeadline,
1698
+ max_concurrent_bidders: 1,
1699
+ max_depth: 0,
1700
+ invited_agent_ids: [peerId],
1701
+ });
1702
+ team.ack_out[peerId] = task.id;
1703
+ state.addTeam(team);
1704
+ }
1705
+ catch (e) {
1706
+ console.error(`[handshake] createOutgoingHandshake ${agentId} → ${peerId} failed:`, e);
1707
+ }
1708
+ }
1709
+ }
1710
+ /**
1711
+ * Auto-select result for handshake tasks when a result is submitted.
1712
+ * Only acts on tasks in our ack_out (i.e., tasks we created as initiator).
1713
+ */
1714
+ async function autoHandshakeSelect(agentId, event) {
1715
+ const taskId = event.task_id;
1716
+ const match = state.findTeamByHandshakeTask(taskId);
1717
+ if (!match || match.direction !== "out")
1718
+ return; // Not our outgoing handshake
1719
+ const payload = event.payload;
1720
+ const resultAgentId = payload?.agent_id;
1721
+ if (!resultAgentId)
1722
+ return;
1723
+ // Auto-select the result
1724
+ try {
1725
+ await net.selectResult(taskId, agentId, resultAgentId);
1726
+ }
1727
+ catch (e) {
1728
+ console.error(`[handshake] auto-select failed for ${agentId} on task ${taskId}:`, e);
1729
+ }
1730
+ // Extract branch from the result
1731
+ try {
1732
+ const taskData = await net.getTask(taskId);
1733
+ const results = taskData?.results ?? [];
1734
+ const result = results.find((r) => r.agent_id === resultAgentId);
1735
+ const branch = result?.content?.branch;
1736
+ if (branch) {
1737
+ state.updateTeamPeerBranch(match.team.team_id, match.peerId, branch);
1738
+ }
1739
+ // If the reply carries a team task, buffer it as a synthetic event for the agent
1740
+ const teamTask = result?.content?.team_task;
1741
+ if (teamTask && teamTask.task_id) {
1742
+ state.pushEvents(agentId, [{
1743
+ msg_id: crypto.randomUUID().replace(/-/g, ""),
1744
+ type: "direct_message",
1745
+ task_id: teamTask.task_id,
1746
+ payload: {
1747
+ from: match.peerId,
1748
+ content: JSON.stringify({ _team_task: true, ...teamTask }),
1749
+ },
1750
+ received_at: Date.now(),
1751
+ }]);
1752
+ }
1753
+ }
1754
+ catch (e) {
1755
+ console.error(`[handshake] branch extraction failed for task ${taskId}:`, e);
1756
+ }
1757
+ }
1758
+ /**
1759
+ * Reply to pending reverse handshakes when creating a team task.
1760
+ * Uses ack_in (recorded by autoHandshakeRespond) to find task IDs
1761
+ * that the initiator hasn't responded to yet. Bids and submits result
1762
+ * with branch + task details.
1763
+ */
1764
+ async function replyPendingHandshakes(agentId, team, taskSummary) {
1765
+ for (const [peerId, taskId] of Object.entries(team.ack_in)) {
1766
+ // Bid on the reverse handshake task
1767
+ try {
1768
+ await net.submitBid(taskId, agentId, 0, 1);
1769
+ }
1770
+ catch (e) {
1771
+ console.error(`[handshake] replyPendingHandshakes bid failed for ${agentId} → ${peerId} (task ${taskId}):`, e);
1772
+ continue;
1773
+ }
1774
+ // Submit result with branch + task details
1775
+ // On a 0-budget invited task, bid is typically auto-accepted
1776
+ try {
1777
+ await net.submitResult(taskId, agentId, {
1778
+ _handshake_ack: true,
1779
+ team_id: team.team_id,
1780
+ branch: team.my_branch ?? `agent/${agentId}`,
1781
+ team_task: taskSummary,
1782
+ });
1783
+ }
1784
+ catch (e) {
1785
+ console.error(`[handshake] replyPendingHandshakes submit failed for ${agentId} → ${peerId} (task ${taskId}):`, e);
1786
+ }
1787
+ }
1788
+ }
1789
+ // ---------------------------------------------------------------------------
1454
1790
  // Auto-bid evaluation — communication layer auto-filter per agent.md:172-193
1455
1791
  // ---------------------------------------------------------------------------
1456
1792
  async function autoBidEvaluate(agentId, event) {
@@ -1481,6 +1817,29 @@ async function autoBidEvaluate(agentId, event) {
1481
1817
  }]);
1482
1818
  }
1483
1819
  // ---------------------------------------------------------------------------
1820
+ // Global crash handlers — log to file so post-mortem is possible
1821
+ // ---------------------------------------------------------------------------
1822
+ const __crash_dir = join(dirname(fileURLToPath(import.meta.url)), "..", "logs");
1823
+ function writeCrashLog(label, err) {
1824
+ try {
1825
+ mkdirSync(__crash_dir, { recursive: true });
1826
+ const ts = new Date().toISOString();
1827
+ const msg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
1828
+ const line = `[${ts}] ${label}: ${msg}\n`;
1829
+ appendFileSync(join(__crash_dir, "crash.log"), line);
1830
+ console.error(`[EACN3] ${label}:`, msg);
1831
+ }
1832
+ catch { /* last resort — nothing we can do */ }
1833
+ }
1834
+ process.on("uncaughtException", (err) => {
1835
+ writeCrashLog("uncaughtException", err);
1836
+ process.exit(1);
1837
+ });
1838
+ process.on("unhandledRejection", (reason) => {
1839
+ writeCrashLog("unhandledRejection", reason);
1840
+ process.exit(1);
1841
+ });
1842
+ // ---------------------------------------------------------------------------
1484
1843
  // Start
1485
1844
  // ---------------------------------------------------------------------------
1486
1845
  async function main() {