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 +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 +39 -6
- package/src/peer-manager.ts +32 -0
- package/src/router.ts +5 -0
- package/src/terminal.ts +3 -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
|
@@ -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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
};
|
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();
|
|
@@ -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: ${
|
|
215
|
+
payload: { success: false, error: `Failed to spawn PTY (shell=${shell}): ${errMsg}` },
|
|
214
216
|
} as TerminalOpenResponse);
|
|
215
217
|
}
|
|
216
218
|
}
|