clawmatrix 0.2.5 → 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 +1 -1
- package/src/acp-proxy.ts +64 -1
- package/src/cluster-service.ts +12 -0
- package/src/compat.ts +27 -1
- package/src/connection.ts +64 -7
- package/src/handoff.ts +4 -0
- package/src/index.ts +6 -3
- package/src/peer-manager.ts +46 -0
- package/src/router.ts +15 -1
- package/src/terminal.ts +3 -1
- package/src/tool-proxy.ts +4 -1
package/package.json
CHANGED
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
|
-
|
|
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;
|
package/src/cluster-service.ts
CHANGED
|
@@ -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:
|
|
152
|
+
env: mergedEnv,
|
|
127
153
|
});
|
|
128
154
|
|
|
129
155
|
return {
|
package/src/connection.ts
CHANGED
|
@@ -29,6 +29,8 @@ const HEARTBEAT_BASE = 12_000;
|
|
|
29
29
|
const HEARTBEAT_JITTER = 6_000;
|
|
30
30
|
const HEARTBEAT_TIMEOUT_COUNT = 3;
|
|
31
31
|
const AUTH_TIMEOUT = 10_000;
|
|
32
|
+
/** Extended timeout when server signals approval is pending (must survive human review). */
|
|
33
|
+
const AUTH_PENDING_TIMEOUT = 1_200_000; // 20 minutes
|
|
32
34
|
|
|
33
35
|
export type ConnectionRole = "inbound" | "outbound";
|
|
34
36
|
|
|
@@ -80,6 +82,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
80
82
|
private pendingNonce: string | null = null;
|
|
81
83
|
private closed = false;
|
|
82
84
|
private lastPingSentAt = 0;
|
|
85
|
+
/** Timestamp of the last frame received from the remote side. */
|
|
86
|
+
private lastReceivedAt = 0;
|
|
83
87
|
/** Exponential moving average of heartbeat RTT in milliseconds. */
|
|
84
88
|
latencyMs = 0;
|
|
85
89
|
|
|
@@ -188,6 +192,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
188
192
|
private async onRawMessage(data: unknown) {
|
|
189
193
|
const str = typeof data === "string" ? data : String(data);
|
|
190
194
|
if (!str.length) return;
|
|
195
|
+
this.lastReceivedAt = Date.now();
|
|
191
196
|
|
|
192
197
|
let frame: AnyClusterFrame | undefined;
|
|
193
198
|
|
|
@@ -287,7 +292,14 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
287
292
|
this.clearAuthTimer();
|
|
288
293
|
|
|
289
294
|
if (this.deferAuthOk) {
|
|
290
|
-
// Don't send auth_ok yet — wait for completeAuth() after approval
|
|
295
|
+
// Don't send auth_ok yet — wait for completeAuth() after approval.
|
|
296
|
+
// Signal the outbound that auth is pending so it extends its timer
|
|
297
|
+
// instead of timing out after AUTH_TIMEOUT (10s).
|
|
298
|
+
if (this.sessionKey) {
|
|
299
|
+
this.send({ type: "auth_pending", from: this.nodeId, timestamp: Date.now() } as AnyClusterFrame);
|
|
300
|
+
} else {
|
|
301
|
+
this.sendRaw({ p: 1 });
|
|
302
|
+
}
|
|
291
303
|
this.emit("authenticated", this.remoteCapabilities);
|
|
292
304
|
return;
|
|
293
305
|
}
|
|
@@ -325,6 +337,8 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
325
337
|
this.clearAuthTimer();
|
|
326
338
|
|
|
327
339
|
if (this.deferAuthOk) {
|
|
340
|
+
// Signal the outbound that auth is pending (legacy plaintext path)
|
|
341
|
+
this.sendRaw({ p: 1 });
|
|
328
342
|
this.emit("authenticated", this.remoteCapabilities);
|
|
329
343
|
} else {
|
|
330
344
|
this.sendAuthOkAndStart();
|
|
@@ -379,6 +393,19 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
379
393
|
return;
|
|
380
394
|
}
|
|
381
395
|
|
|
396
|
+
// auth_pending: server verified HMAC but auth_ok is deferred (peer approval).
|
|
397
|
+
// Extend the auth timer so the connection survives while waiting for human approval.
|
|
398
|
+
if (frame.type === "auth_pending" || (frame.p === 1 && !frame.type)) {
|
|
399
|
+
debug("auth", `Received auth_pending from server, extending auth timeout to ${AUTH_PENDING_TIMEOUT}ms`);
|
|
400
|
+
this.clearAuthTimer();
|
|
401
|
+
this.authTimer = setTimeout(() => {
|
|
402
|
+
if (!this.authenticated) {
|
|
403
|
+
this.close(4003, "auth pending timeout");
|
|
404
|
+
}
|
|
405
|
+
}, AUTH_PENDING_TIMEOUT);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
382
409
|
// auth_ok (decrypted from binary envelope, or plaintext legacy)
|
|
383
410
|
if (frame.type === "auth_ok") {
|
|
384
411
|
const ok = frame as AuthOk;
|
|
@@ -453,24 +480,54 @@ export class Connection extends EventEmitter<ConnectionEvents> {
|
|
|
453
480
|
}
|
|
454
481
|
|
|
455
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
|
+
|
|
456
486
|
private startHeartbeat() {
|
|
487
|
+
this.lastReceivedAt = Date.now();
|
|
457
488
|
const scheduleNext = () => {
|
|
458
489
|
const interval = HEARTBEAT_BASE + Math.random() * HEARTBEAT_JITTER;
|
|
459
490
|
this.heartbeatTimer = setTimeout(() => {
|
|
460
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
|
+
|
|
461
502
|
// Increment before checking: this ping is about to be sent and
|
|
462
503
|
// counts as outstanding until a pong arrives.
|
|
463
504
|
this.missedPongs++;
|
|
464
505
|
if (this.missedPongs >= HEARTBEAT_TIMEOUT_COUNT) {
|
|
506
|
+
debug("heartbeat", `${HEARTBEAT_TIMEOUT_COUNT} missed pongs from ${this.remoteNodeId ?? "unknown"}, closing`);
|
|
465
507
|
this.close(4002, "heartbeat timeout");
|
|
466
508
|
return;
|
|
467
509
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
|
|
474
531
|
scheduleNext();
|
|
475
532
|
}, interval);
|
|
476
533
|
};
|
package/src/handoff.ts
CHANGED
|
@@ -137,6 +137,10 @@ export class HandoffManager {
|
|
|
137
137
|
throw new Error(`No reachable agent for target "${target}"`);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
if (route.nodeId === this.config.nodeId) {
|
|
141
|
+
throw new Error(`Target "${target}" resolves to self (${this.config.nodeId}). Cannot handoff to own node.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
140
144
|
return this.sendHandoff(route.nodeId, target, task, context, MAX_RETRIES, options?.onStream);
|
|
141
145
|
}
|
|
142
146
|
|
package/src/index.ts
CHANGED
|
@@ -467,7 +467,9 @@ const plugin = {
|
|
|
467
467
|
try {
|
|
468
468
|
const runtime = getClusterRuntime();
|
|
469
469
|
const allPeers = runtime.peerManager.router.getAllPeers();
|
|
470
|
-
|
|
470
|
+
const filtered = mergeSentinelPeers(allPeers, runtime)
|
|
471
|
+
.filter((p) => (p as { nodeId: string }).nodeId !== config.nodeId);
|
|
472
|
+
respond(true, filtered);
|
|
471
473
|
} catch {
|
|
472
474
|
respond(true, []);
|
|
473
475
|
}
|
|
@@ -633,7 +635,8 @@ const plugin = {
|
|
|
633
635
|
api.on("before_prompt_build", () => {
|
|
634
636
|
try {
|
|
635
637
|
const runtime = getClusterRuntime();
|
|
636
|
-
const peerCount = runtime.peerManager.router.getAllPeers()
|
|
638
|
+
const peerCount = runtime.peerManager.router.getAllPeers()
|
|
639
|
+
.filter((p) => p.nodeId !== config.nodeId).length;
|
|
637
640
|
|
|
638
641
|
// Rebuild system context only when peer count changes
|
|
639
642
|
if (peerCount !== cachedPeerCount) {
|
|
@@ -643,7 +646,7 @@ const plugin = {
|
|
|
643
646
|
lines.push("[ClawMatrix] No peers online. Use cluster_peers to check cluster status.");
|
|
644
647
|
} else {
|
|
645
648
|
lines.push(
|
|
646
|
-
`[ClawMatrix Cluster] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}
|
|
649
|
+
`[ClawMatrix Cluster] YOU ARE node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}. This is YOUR identity — never target yourself with cluster tools.`,
|
|
647
650
|
...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
|
|
648
651
|
`${peerCount} remote peer(s) online. Use cluster_peers to see topology, agents, and models.`,
|
|
649
652
|
"Prefer cluster_tool for device-specific tools (screenshot, battery, etc.); cluster_exec/read/write for file/shell ops; cluster_handoff for complex multi-step tasks.",
|
package/src/peer-manager.ts
CHANGED
|
@@ -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();
|
|
@@ -387,6 +397,14 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
387
397
|
// Peer's persistent public key for TOFU identity binding
|
|
388
398
|
const peerPublicKey = conn.remoteIdentityKey ?? undefined;
|
|
389
399
|
|
|
400
|
+
// Prevent self-connection: if outbound connection authenticated as our own nodeId
|
|
401
|
+
// (e.g. peer URL accidentally points to self), close immediately.
|
|
402
|
+
if (conn.role === "outbound" && nodeId === this.config.nodeId) {
|
|
403
|
+
debug("peer", `Self-connection detected (outbound to ${nodeId}), closing`);
|
|
404
|
+
conn.close(4002, "self-connection");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
390
408
|
// Peer approval check (inbound only — outbound peers are explicitly configured)
|
|
391
409
|
// Skip approval for same-nodeId connections from localhost (local clients
|
|
392
410
|
// like Mac desktop app / iOS simulator). An attacker would need to already
|
|
@@ -469,10 +487,22 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
469
487
|
|
|
470
488
|
private completePeerJoin(conn: Connection, caps: NodeCapabilities) {
|
|
471
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
|
+
|
|
472
497
|
// Same-nodeId 连接(如 Mac/iOS 桌面客户端)也正常注册路由,
|
|
473
498
|
// 使得 sendTo(nodeId) 能将响应帧路由回客户端连接。
|
|
474
499
|
this.router.addDirectPeer(nodeId, conn, caps);
|
|
475
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
|
+
|
|
476
506
|
conn.on("message", (frame) => this.onFrame(frame, conn));
|
|
477
507
|
conn.on("latency", (ms) => this.router.updateLatency(nodeId, ms));
|
|
478
508
|
conn.on("close", () => this.onPeerDisconnected(conn));
|
|
@@ -512,6 +542,16 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
512
542
|
const nodeId = conn.remoteNodeId;
|
|
513
543
|
if (!nodeId) return;
|
|
514
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
|
+
|
|
515
555
|
// Same-nodeId 本地客户端断开:仅清理路由,不广播 peer_leave
|
|
516
556
|
if (nodeId === this.config.nodeId) {
|
|
517
557
|
this.router.removePeer(nodeId);
|
|
@@ -540,6 +580,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
540
580
|
|
|
541
581
|
// ── Message handling ───────────────────────────────────────────
|
|
542
582
|
private onFrame(frame: AnyClusterFrame, from: Connection) {
|
|
583
|
+
// Ignore self-echo: frames with our own nodeId that were relayed back to us
|
|
584
|
+
if (frame.from === this.config.nodeId) return;
|
|
585
|
+
|
|
543
586
|
// Validate from field: must be the direct peer or a known node (relayed)
|
|
544
587
|
if (frame.from && frame.from !== from.remoteNodeId && !this.router.getRoute(frame.from)) {
|
|
545
588
|
debug("peer", `dropped frame type=${frame.type} from=${frame.from}: unknown origin (via ${from.remoteNodeId})`);
|
|
@@ -691,6 +734,9 @@ export class PeerManager extends EventEmitter<PeerManagerEvents> {
|
|
|
691
734
|
changed = true;
|
|
692
735
|
}
|
|
693
736
|
} else {
|
|
737
|
+
// Skip if the remote peer only knows about this node through us —
|
|
738
|
+
// using them as relay would create a routing loop.
|
|
739
|
+
if (peer.reachableVia === this.config.nodeId) continue;
|
|
694
740
|
const existing = this.router.getRoute(peer.nodeId);
|
|
695
741
|
if (!existing) changed = true;
|
|
696
742
|
this.router.addRelayPeer(peer, from.remoteNodeId!);
|
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) {
|
|
@@ -97,6 +102,9 @@ export class Router {
|
|
|
97
102
|
const relayRoute = this.routes.get(viaNodeId);
|
|
98
103
|
const estimatedLatency = (relayRoute?.latencyMs ?? 0) + (peer.latencyMs ?? 0);
|
|
99
104
|
|
|
105
|
+
// Don't overwrite a better relay route with a worse one (allow equal for capability updates)
|
|
106
|
+
if (existing?.reachableVia && existing.latencyMs < estimatedLatency) return;
|
|
107
|
+
|
|
100
108
|
this.routes.set(peer.nodeId, {
|
|
101
109
|
nodeId: peer.nodeId,
|
|
102
110
|
agents: peer.agents,
|
|
@@ -164,6 +172,8 @@ export class Router {
|
|
|
164
172
|
|
|
165
173
|
let candidates: RouteEntry[] = [];
|
|
166
174
|
for (const entry of this.routes.values()) {
|
|
175
|
+
// Skip self — never resolve to our own node
|
|
176
|
+
if (entry.nodeId === this.nodeId) continue;
|
|
167
177
|
if (isTagQuery) {
|
|
168
178
|
if (entry.agents.some((a) => a.tags.includes(tag!)) || entry.tags.includes(tag!)) {
|
|
169
179
|
candidates.push(entry);
|
|
@@ -178,7 +188,7 @@ export class Router {
|
|
|
178
188
|
// Fallback: if no agent ID or tag matched, try matching by nodeId
|
|
179
189
|
if (candidates.length === 0 && !isTagQuery) {
|
|
180
190
|
const byNode = this.routes.get(target);
|
|
181
|
-
if (byNode) candidates.push(byNode);
|
|
191
|
+
if (byNode && byNode.nodeId !== this.nodeId) candidates.push(byNode);
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
if (candidates.length === 0) return undefined;
|
|
@@ -201,6 +211,8 @@ export class Router {
|
|
|
201
211
|
const tag = target.slice(5);
|
|
202
212
|
const candidates: RouteEntry[] = [];
|
|
203
213
|
for (const entry of this.routes.values()) {
|
|
214
|
+
// Skip self — never resolve to our own node
|
|
215
|
+
if (entry.nodeId === this.nodeId) continue;
|
|
204
216
|
if (entry.tags.includes(tag)) {
|
|
205
217
|
candidates.push(entry);
|
|
206
218
|
}
|
|
@@ -215,6 +227,8 @@ export class Router {
|
|
|
215
227
|
});
|
|
216
228
|
return candidates[0];
|
|
217
229
|
}
|
|
230
|
+
// Skip self — never resolve to our own node
|
|
231
|
+
if (target === this.nodeId) return undefined;
|
|
218
232
|
return this.routes.get(target);
|
|
219
233
|
}
|
|
220
234
|
|
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: ${
|
|
215
|
+
payload: { success: false, error: `Failed to spawn PTY (shell=${shell}): ${errMsg}` },
|
|
214
216
|
} as TerminalOpenResponse);
|
|
215
217
|
}
|
|
216
218
|
}
|
package/src/tool-proxy.ts
CHANGED
|
@@ -180,7 +180,7 @@ export class ToolProxy {
|
|
|
180
180
|
to: string,
|
|
181
181
|
payload: ToolProxyResponse["payload"],
|
|
182
182
|
) {
|
|
183
|
-
this.peerManager.sendTo(to, {
|
|
183
|
+
const sent = this.peerManager.sendTo(to, {
|
|
184
184
|
type: "tool_res",
|
|
185
185
|
id,
|
|
186
186
|
from: this.config.nodeId,
|
|
@@ -188,6 +188,9 @@ export class ToolProxy {
|
|
|
188
188
|
timestamp: Date.now(),
|
|
189
189
|
payload,
|
|
190
190
|
} as ToolProxyResponse);
|
|
191
|
+
if (!sent) {
|
|
192
|
+
this.logger.warn(`[clawmatrix] Tool response dropped: id=${id} to="${to}" (no route)`);
|
|
193
|
+
}
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
// ── Gateway tool invocation ────────────────────────────────────
|