clawmatrix 0.2.6 → 0.2.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Decentralized mesh cluster plugin for OpenClaw — inter-gateway communication, model proxy, task handoff, and tool proxy.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp-proxy.ts CHANGED
@@ -1614,7 +1614,7 @@ export class AcpProxy {
1614
1614
  }
1615
1615
 
1616
1616
  /** Built-in ACP agent commands (same as OpenClaw's acpx backend). */
1617
- private static readonly BUILTIN_ACP_COMMANDS: Record<string, string[]> = {
1617
+ static readonly BUILTIN_ACP_COMMANDS: Record<string, string[]> = {
1618
1618
  claude: ["npx", "-y", "@zed-industries/claude-agent-acp"],
1619
1619
  codex: ["npx", "-y", "@zed-industries/codex-acp"],
1620
1620
  gemini: ["gemini"],
@@ -1622,6 +1622,69 @@ export class AcpProxy {
1622
1622
  pi: ["npx", "-y", "pi-acp"],
1623
1623
  };
1624
1624
 
1625
+ /** Human-readable descriptions for built-in ACP agents. */
1626
+ private static readonly BUILTIN_ACP_DESCRIPTIONS: Record<string, string> = {
1627
+ claude: "Claude Code",
1628
+ codex: "OpenAI Codex CLI",
1629
+ gemini: "Gemini CLI",
1630
+ opencode: "OpenCode",
1631
+ pi: "Pi",
1632
+ };
1633
+
1634
+ /** Binary names to check for each agent (may differ from agent id). */
1635
+ private static readonly AGENT_BINARY_NAMES: Record<string, string> = {
1636
+ claude: "claude",
1637
+ codex: "codex",
1638
+ gemini: "gemini",
1639
+ opencode: "opencode",
1640
+ pi: "pi",
1641
+ };
1642
+
1643
+ /**
1644
+ * Auto-detect which ACP agents are installed by checking if their binaries
1645
+ * exist in PATH. Also checks config-defined custom commands.
1646
+ */
1647
+ static async detectAvailableAgents(
1648
+ configCommands?: Record<string, string[]>,
1649
+ ): Promise<import("./types.ts").AcpAgentInfo[]> {
1650
+ const detected: import("./types.ts").AcpAgentInfo[] = [];
1651
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
1652
+
1653
+ // Check built-in agents
1654
+ const checks = Object.keys(AcpProxy.BUILTIN_ACP_COMMANDS).map(async (agent) => {
1655
+ const binary = AcpProxy.AGENT_BINARY_NAMES[agent] ?? agent;
1656
+ try {
1657
+ const proc = spawnProcess([whichCmd, binary], { stdout: "ignore", stderr: "ignore" });
1658
+ const code = await proc.exited;
1659
+ if (code === 0) {
1660
+ detected.push({
1661
+ id: agent,
1662
+ description: AcpProxy.BUILTIN_ACP_DESCRIPTIONS[agent] ?? agent,
1663
+ });
1664
+ }
1665
+ } catch { /* binary not found */ }
1666
+ });
1667
+
1668
+ // Check custom config commands
1669
+ if (configCommands) {
1670
+ for (const [agent, cmd] of Object.entries(configCommands)) {
1671
+ if (agent in AcpProxy.BUILTIN_ACP_COMMANDS) continue; // already checked above
1672
+ checks.push((async () => {
1673
+ try {
1674
+ const proc = spawnProcess([whichCmd, cmd[0]!], { stdout: "ignore", stderr: "ignore" });
1675
+ const code = await proc.exited;
1676
+ if (code === 0) {
1677
+ detected.push({ id: agent, description: agent });
1678
+ }
1679
+ } catch { /* binary not found */ }
1680
+ })());
1681
+ }
1682
+ }
1683
+
1684
+ await Promise.all(checks);
1685
+ return detected;
1686
+ }
1687
+
1625
1688
  private resolveCommand(agent: string): string[] {
1626
1689
  // Check config overrides first
1627
1690
  const commands = this.config.acp?.commands;
@@ -165,6 +165,18 @@ export class ClusterRuntime {
165
165
  }
166
166
  }
167
167
 
168
+ // Auto-detect ACP agents if ACP is enabled but no agents are explicitly configured
169
+ if (this.acpProxy && this.config.acp?.enabled && (!this.config.acp.agents || this.config.acp.agents.length === 0)) {
170
+ AcpProxy.detectAvailableAgents(this.config.acp.commands).then((detected) => {
171
+ if (detected.length > 0) {
172
+ this.logger.info(`[clawmatrix] Auto-detected ACP agents: ${detected.map((a) => a.id).join(", ")}`);
173
+ this.peerManager.updateAcpAgents(detected);
174
+ }
175
+ }).catch((err) => {
176
+ this.logger.error(`[clawmatrix] ACP agent detection failed: ${err}`);
177
+ });
178
+ }
179
+
168
180
  // Start subsystems
169
181
  this.peerManager.start();
170
182
  this.modelProxy.start();
package/src/compat.ts CHANGED
@@ -118,12 +118,38 @@ export function spawnPty(
118
118
  const pty = loadPty();
119
119
  if (!pty) throw new Error("node-pty is not available — install it with: npm install node-pty");
120
120
 
121
+ // Filter out undefined values from env — node-pty's C code (posix_spawnp)
122
+ // cannot handle undefined entries and will fail silently.
123
+ const baseEnv = process.env as Record<string, string | undefined>;
124
+ const mergedEnv: Record<string, string> = {};
125
+ for (const [k, v] of Object.entries(baseEnv)) {
126
+ if (v !== undefined) mergedEnv[k] = v;
127
+ }
128
+ if (opts.env) {
129
+ for (const [k, v] of Object.entries(opts.env)) {
130
+ if (v !== undefined) mergedEnv[k] = v;
131
+ }
132
+ }
133
+
134
+ // Validate cwd exists to give a clear error instead of cryptic posix_spawnp failure
135
+ if (opts.cwd) {
136
+ try {
137
+ const fs = require("node:fs");
138
+ if (!fs.existsSync(opts.cwd)) {
139
+ throw new Error(`cwd does not exist: ${opts.cwd}`);
140
+ }
141
+ } catch (e) {
142
+ if (e instanceof Error && e.message.startsWith("cwd")) throw e;
143
+ // fs check failed, proceed anyway
144
+ }
145
+ }
146
+
121
147
  const proc = pty.spawn(shell, args, {
122
148
  name: "xterm-256color",
123
149
  cols: opts.cols ?? 80,
124
150
  rows: opts.rows ?? 24,
125
151
  cwd: opts.cwd,
126
- env: opts.env ? { ...process.env, ...opts.env } as Record<string, string> : process.env as Record<string, string>,
152
+ env: mergedEnv,
127
153
  });
128
154
 
129
155
  return {
package/src/connection.ts CHANGED
@@ -82,6 +82,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
82
82
  private pendingNonce: string | null = null;
83
83
  private closed = false;
84
84
  private lastPingSentAt = 0;
85
+ /** Timestamp of the last frame received from the remote side. */
86
+ private lastReceivedAt = 0;
85
87
  /** Exponential moving average of heartbeat RTT in milliseconds. */
86
88
  latencyMs = 0;
87
89
 
@@ -190,6 +192,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
190
192
  private async onRawMessage(data: unknown) {
191
193
  const str = typeof data === "string" ? data : String(data);
192
194
  if (!str.length) return;
195
+ this.lastReceivedAt = Date.now();
193
196
 
194
197
  let frame: AnyClusterFrame | undefined;
195
198
 
@@ -477,24 +480,54 @@ export class Connection extends EventEmitter<ConnectionEvents> {
477
480
  }
478
481
 
479
482
  // ── Heartbeat ──────────────────────────────────────────────────
483
+ /** Maximum silence duration before declaring the connection dead. */
484
+ private static readonly RECEIVE_TIMEOUT = HEARTBEAT_BASE * HEARTBEAT_TIMEOUT_COUNT + HEARTBEAT_JITTER * HEARTBEAT_TIMEOUT_COUNT;
485
+
480
486
  private startHeartbeat() {
487
+ this.lastReceivedAt = Date.now();
481
488
  const scheduleNext = () => {
482
489
  const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
483
490
  this.heartbeatTimer = setTimeout(() => {
484
491
  if (this.closed) return;
492
+
493
+ // Watchdog: if no data received for a long time, the connection is dead
494
+ // regardless of what the heartbeat ping/pong state says.
495
+ const silenceMs = Date.now() - this.lastReceivedAt;
496
+ if (this.lastReceivedAt > 0 && silenceMs > Connection.RECEIVE_TIMEOUT) {
497
+ debug("heartbeat", `No data received for ${Math.round(silenceMs / 1000)}s from ${this.remoteNodeId ?? "unknown"}, closing`);
498
+ this.close(4002, "receive timeout");
499
+ return;
500
+ }
501
+
485
502
  // Increment before checking: this ping is about to be sent and
486
503
  // counts as outstanding until a pong arrives.
487
504
  this.missedPongs++;
488
505
  if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
506
+ debug("heartbeat", `${HEARTBEAT_TIMEOUT_COUNT} missed pongs from ${this.remoteNodeId ?? "unknown"}, closing`);
489
507
  this.close(4002, "heartbeat timeout");
490
508
  return;
491
509
  }
492
- this.lastPingSentAt = Date.now();
493
- this.send({
494
- type: "ping",
495
- from: this.nodeId,
496
- timestamp: this.lastPingSentAt,
497
- } as AnyClusterFrame);
510
+
511
+ // Send ping — wrapped in try-catch to prevent breaking the heartbeat chain.
512
+ // If send fails, the connection is dead; close it.
513
+ try {
514
+ if (this.transport.readyState !== WebSocket.OPEN) {
515
+ debug("heartbeat", `Transport not open (state=${this.transport.readyState}) for ${this.remoteNodeId ?? "unknown"}, closing`);
516
+ this.close(4002, "transport closed");
517
+ return;
518
+ }
519
+ this.lastPingSentAt = Date.now();
520
+ this.send({
521
+ type: "ping",
522
+ from: this.nodeId,
523
+ timestamp: this.lastPingSentAt,
524
+ } as AnyClusterFrame);
525
+ } catch (err) {
526
+ debug("heartbeat", `Ping send failed for ${this.remoteNodeId ?? "unknown"}: ${err}`);
527
+ this.close(4002, "ping send failed");
528
+ return;
529
+ }
530
+
498
531
  scheduleNext();
499
532
  }, interval);
500
533
  };
@@ -132,6 +132,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
132
132
  this.approvalManager = new PeerApprovalManager(approvalConfig, stateDir);
133
133
  }
134
134
 
135
+ /** Update locally advertised ACP agents and re-broadcast to all peers. */
136
+ updateAcpAgents(agents: import("./types.ts").AcpAgentInfo[]) {
137
+ this.localCapabilities.acpAgents = agents;
138
+ this.router.updateLocalAcpAgents(agents);
139
+ // Re-sync all connected peers so they learn the updated capabilities
140
+ for (const conn of this.router.getDirectConnections()) {
141
+ this.sendPeerSync(conn);
142
+ }
143
+ }
144
+
135
145
  // ── Lifecycle ──────────────────────────────────────────────────
136
146
  async start() {
137
147
  await this.approvalManager.load();
@@ -477,10 +487,22 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
477
487
 
478
488
  private completePeerJoin(conn: Connection, caps: NodeCapabilities) {
479
489
  const nodeId = conn.remoteNodeId!;
490
+
491
+ // If there's an existing connection for this nodeId (e.g. peer reconnected
492
+ // while old TCP hadn't closed yet), close it AFTER overwriting the route so
493
+ // the stale-close guard in onPeerDisconnected correctly skips cleanup.
494
+ const oldRoute = this.router.getRoute(nodeId);
495
+ const oldConn = oldRoute?.connection;
496
+
480
497
  // Same-nodeId 连接(如 Mac/iOS 桌面客户端)也正常注册路由,
481
498
  // 使得 sendTo(nodeId) 能将响应帧路由回客户端连接。
482
499
  this.router.addDirectPeer(nodeId, conn, caps);
483
500
 
501
+ if (oldConn && oldConn !== conn && oldConn.isOpen) {
502
+ debug("peer", `completePeerJoin(${nodeId}): closing replaced connection`);
503
+ oldConn.close(1000, "replaced by new connection");
504
+ }
505
+
484
506
  conn.on("message", (frame) => this.onFrame(frame, conn));
485
507
  conn.on("latency", (ms) => this.router.updateLatency(nodeId, ms));
486
508
  conn.on("close", () => this.onPeerDisconnected(conn));
@@ -520,6 +542,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
520
542
  const nodeId = conn.remoteNodeId;
521
543
  if (!nodeId) return;
522
544
 
545
+ // Guard: only remove the peer if THIS connection is still the active one
546
+ // in the router. When a peer reconnects, the new connection replaces the
547
+ // old one in the route table. If the old connection's close event fires
548
+ // afterwards, it must NOT remove the new connection's route.
549
+ const currentRoute = this.router.getRoute(nodeId);
550
+ if (currentRoute?.connection && currentRoute.connection !== conn) {
551
+ debug("peer", `onPeerDisconnected(${nodeId}): stale connection close, current route has different connection — skipping cleanup`);
552
+ return;
553
+ }
554
+
523
555
  // Same-nodeId 本地客户端断开:仅清理路由,不广播 peer_leave
524
556
  if (nodeId === this.config.nodeId) {
525
557
  this.router.removePeer(nodeId);
package/src/router.ts CHANGED
@@ -52,6 +52,11 @@ export class Router {
52
52
  this.rotateTimer = setInterval(() => this.rotateSeenFrames(), ROTATE_INTERVAL);
53
53
  }
54
54
 
55
+ /** Update locally advertised ACP agents (used after auto-detection). */
56
+ updateLocalAcpAgents(agents: AcpAgentInfo[]) {
57
+ this.localAcpAgents = agents;
58
+ }
59
+
55
60
  /** Stop periodic cleanup. Call on shutdown. */
56
61
  destroy() {
57
62
  if (this.rotateTimer) {
package/src/terminal.ts CHANGED
@@ -204,13 +204,15 @@ export class TerminalManager {
204
204
  payload: { success: true, sessionId },
205
205
  } as TerminalOpenResponse);
206
206
  } catch (err) {
207
+ const errMsg = err instanceof Error ? err.message : String(err);
208
+ debug("terminal", `PTY spawn failed: shell=${shell} cwd=${frame.payload.cwd ?? "(default)"} error=${errMsg}`);
207
209
  this.peerManager.sendTo(frame.from, {
208
210
  type: "terminal_open_res",
209
211
  id: frame.id,
210
212
  from: nodeId,
211
213
  to: frame.from,
212
214
  timestamp: Date.now(),
213
- payload: { success: false, error: `Failed to spawn PTY: ${err instanceof Error ? err.message : String(err)}` },
215
+ payload: { success: false, error: `Failed to spawn PTY (shell=${shell}): ${errMsg}` },
214
216
  } as TerminalOpenResponse);
215
217
  }
216
218
  }