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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmatrix",
3
- "version": "0.2.5",
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
@@ -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
- this.lastPingSentAt = Date.now();
469
- this.send({
470
- type: "ping",
471
- from: this.nodeId,
472
- timestamp: this.lastPingSentAt,
473
- } 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
+
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
- respond(true, mergeSentinelPeers(allPeers, runtime));
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().length;
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.",
@@ -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: ${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
  }
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 ────────────────────────────────────