clawmatrix 0.3.1 → 0.4.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/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import { createClusterAcpTool } from "./tools/cluster-acp.ts";
17
17
  import { createClusterTerminalTool } from "./tools/cluster-terminal.ts";
18
18
  import { createClusterToolInvokeTool } from "./tools/cluster-tool.ts";
19
19
  import { createClusterTransferTool } from "./tools/cluster-transfer.ts";
20
- import { registerClusterCli } from "./cli.ts";
20
+ import { createClusterNotifyTool } from "./tools/cluster-notify.ts";
21
21
  import { spawnProcess } from "./compat.ts";
22
22
 
23
23
  /**
@@ -304,6 +304,7 @@ const plugin = {
304
304
  api.registerTool(createClusterTerminalTool(), { optional: true });
305
305
  api.registerTool(createClusterToolInvokeTool(), { optional: true });
306
306
  api.registerTool(createClusterTransferTool(), { optional: true });
307
+ api.registerTool(createClusterNotifyTool(), { optional: true });
307
308
 
308
309
  // Wire up peer approval with OpenClaw channel API
309
310
  if (config.peerApproval.enabled) {
@@ -729,14 +730,455 @@ const plugin = {
729
730
  },
730
731
  );
731
732
 
733
+ // ── Send / Handoff gateway methods ──────────────────────────────
734
+
735
+ api.registerGatewayMethod(
736
+ "clawmatrix.send",
737
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
738
+ try {
739
+ const runtime = getClusterRuntime();
740
+ const { node, message } = (params ?? {}) as { node?: string; message?: string };
741
+ if (!node || !message) {
742
+ respond(false, { error: "Missing required params: node, message" });
743
+ return;
744
+ }
745
+ const route = runtime.peerManager.router.resolveAgent(node);
746
+ if (!route) {
747
+ respond(false, { error: `No reachable agent for target "${node}"` });
748
+ return;
749
+ }
750
+ const sent = runtime.peerManager.sendTo(route.nodeId, {
751
+ type: "send",
752
+ from: runtime.config.nodeId,
753
+ to: route.nodeId,
754
+ timestamp: Date.now(),
755
+ payload: { target: node, message },
756
+ });
757
+ respond(true, { sent, nodeId: route.nodeId });
758
+ } catch {
759
+ respond(false, { error: "ClawMatrix service not running" });
760
+ }
761
+ },
762
+ );
763
+
764
+ api.registerGatewayMethod(
765
+ "clawmatrix.handoff",
766
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
767
+ try {
768
+ const runtime = getClusterRuntime();
769
+ const { target, task, context } = (params ?? {}) as {
770
+ target?: string; task?: string; context?: string;
771
+ };
772
+ if (!target || !task) {
773
+ respond(false, { error: "Missing required params: target, task" });
774
+ return;
775
+ }
776
+ const result = await runtime.handoffManager.handoff(target, task, context);
777
+ respond(true, result);
778
+ } catch (err) {
779
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
780
+ }
781
+ },
782
+ );
783
+
784
+ // ── ACP gateway methods ──────────────────────────────────────────
785
+
786
+ api.registerGatewayMethod(
787
+ "clawmatrix.acp.list",
788
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
789
+ try {
790
+ const runtime = getClusterRuntime();
791
+ if (!runtime.acpProxy) {
792
+ respond(false, { error: "ACP proxy not available" });
793
+ return;
794
+ }
795
+ const { node, agent, cwd } = (params ?? {}) as { node?: string; agent?: string; cwd?: string };
796
+ if (!node) {
797
+ respond(false, { error: "Missing required param: node" });
798
+ return;
799
+ }
800
+ const result = await runtime.acpProxy.listSessions(node, { agent, cwd });
801
+ respond(true, result);
802
+ } catch (err) {
803
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
804
+ }
805
+ },
806
+ );
807
+
808
+ api.registerGatewayMethod(
809
+ "clawmatrix.acp.prompt",
810
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
811
+ try {
812
+ const runtime = getClusterRuntime();
813
+ if (!runtime.acpProxy) {
814
+ respond(false, { error: "ACP proxy not available" });
815
+ return;
816
+ }
817
+ const { node, agent, task, sessionId, mode, cwd } = (params ?? {}) as {
818
+ node?: string; agent?: string; task?: string; sessionId?: string;
819
+ mode?: "oneshot" | "persistent"; cwd?: string;
820
+ };
821
+ if (!node) {
822
+ respond(false, { error: "Missing required param: node" });
823
+ return;
824
+ }
825
+ if (!agent && !sessionId) {
826
+ respond(false, { error: "Missing required param: agent (or sessionId for follow-up)" });
827
+ return;
828
+ }
829
+ const result = await runtime.acpProxy.prompt(node, agent ?? "", task ?? "", { sessionId, mode, cwd });
830
+ respond(true, result);
831
+ } catch (err) {
832
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
833
+ }
834
+ },
835
+ );
836
+
837
+ api.registerGatewayMethod(
838
+ "clawmatrix.acp.resume",
839
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
840
+ try {
841
+ const runtime = getClusterRuntime();
842
+ if (!runtime.acpProxy) {
843
+ respond(false, { error: "ACP proxy not available" });
844
+ return;
845
+ }
846
+ const { node, agent, acpSessionId, cwd } = (params ?? {}) as {
847
+ node?: string; agent?: string; acpSessionId?: string; cwd?: string;
848
+ };
849
+ if (!node || !agent || !acpSessionId) {
850
+ respond(false, { error: "Missing required params: node, agent, acpSessionId" });
851
+ return;
852
+ }
853
+ const result = await runtime.acpProxy.resumeSession(node, agent, acpSessionId, cwd ?? process.cwd());
854
+ respond(true, result);
855
+ } catch (err) {
856
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
857
+ }
858
+ },
859
+ );
860
+
861
+ api.registerGatewayMethod(
862
+ "clawmatrix.acp.cancel",
863
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
864
+ try {
865
+ const runtime = getClusterRuntime();
866
+ if (!runtime.acpProxy) {
867
+ respond(false, { error: "ACP proxy not available" });
868
+ return;
869
+ }
870
+ const { node, sessionId } = (params ?? {}) as { node?: string; sessionId?: string };
871
+ if (!node || !sessionId) {
872
+ respond(false, { error: "Missing required params: node, sessionId" });
873
+ return;
874
+ }
875
+ const result = await runtime.acpProxy.cancelSession(node, sessionId);
876
+ respond(true, result);
877
+ } catch (err) {
878
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
879
+ }
880
+ },
881
+ );
882
+
883
+ api.registerGatewayMethod(
884
+ "clawmatrix.acp.close",
885
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
886
+ try {
887
+ const runtime = getClusterRuntime();
888
+ if (!runtime.acpProxy) {
889
+ respond(false, { error: "ACP proxy not available" });
890
+ return;
891
+ }
892
+ const { node, sessionId } = (params ?? {}) as { node?: string; sessionId?: string };
893
+ if (!node || !sessionId) {
894
+ respond(false, { error: "Missing required params: node, sessionId" });
895
+ return;
896
+ }
897
+ const result = await runtime.acpProxy.closeSession(node, sessionId);
898
+ respond(true, result);
899
+ } catch (err) {
900
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
901
+ }
902
+ },
903
+ );
904
+
905
+ // ── Diagnostic gateway method ────────────────────────────────────
906
+
907
+ api.registerGatewayMethod(
908
+ "clawmatrix.diagnostic",
909
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
910
+ try {
911
+ const runtime = getClusterRuntime();
912
+ const { node, action, command, timeout = 30 } = (params ?? {}) as {
913
+ node?: string; action?: string; command?: string; timeout?: number;
914
+ };
915
+ if (!node || !action) {
916
+ respond(false, { error: "Missing required params: node, action" });
917
+ return;
918
+ }
919
+ const sentinelNodeId = node.endsWith(":sentinel") ? node : `${node}:sentinel`;
920
+ const route = runtime.peerManager.router.getRoute(sentinelNodeId);
921
+ if (!route) {
922
+ respond(false, { error: `Sentinel "${sentinelNodeId}" is not reachable` });
923
+ return;
924
+ }
925
+ const id = (await import("node:crypto")).randomUUID();
926
+ const timeoutMs = timeout * 1000;
927
+
928
+ const frameType = action === "exec" ? "diagnostic_exec" : "diagnostic_status";
929
+ const responseType = action === "exec" ? "diagnostic_exec_res" : "diagnostic_status_res";
930
+ const frame = {
931
+ type: frameType,
932
+ id,
933
+ from: runtime.config.nodeId,
934
+ to: sentinelNodeId,
935
+ timestamp: Date.now(),
936
+ ...(action === "exec" ? { payload: { command, timeout } } : {}),
937
+ };
938
+
939
+ const result = await new Promise((resolve, reject) => {
940
+ const timer = setTimeout(() => {
941
+ cleanup();
942
+ reject(new Error(`Diagnostic timed out after ${timeoutMs}ms`));
943
+ }, timeoutMs + (action === "exec" ? 5000 : 0));
944
+
945
+ const handler = (incoming: any) => {
946
+ if (incoming.type === responseType && incoming.id === id) {
947
+ cleanup();
948
+ resolve(incoming.payload);
949
+ }
950
+ };
951
+
952
+ const cleanup = () => {
953
+ clearTimeout(timer);
954
+ runtime.peerManager.off("frame", handler);
955
+ };
956
+
957
+ runtime.peerManager.on("frame", handler);
958
+ const sent = runtime.peerManager.router.sendTo(sentinelNodeId, frame as any);
959
+ if (!sent) {
960
+ cleanup();
961
+ reject(new Error(`No route to ${sentinelNodeId}`));
962
+ }
963
+ });
964
+
965
+ respond(true, result);
966
+ } catch (err) {
967
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
968
+ }
969
+ },
970
+ );
971
+
972
+ // ── Notify gateway method ────────────────────────────────────────
973
+
974
+ api.registerGatewayMethod(
975
+ "clawmatrix.notify",
976
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
977
+ try {
978
+ const runtime = getClusterRuntime();
979
+ const { action = "start", taskId: providedTaskId, title, detail, progress, tool, success = true } = (params ?? {}) as {
980
+ action?: string; taskId?: string; title?: string; detail?: string;
981
+ progress?: number; tool?: string; success?: boolean;
982
+ };
983
+
984
+ const peers = runtime.peerManager.router.getAllPeers();
985
+ const mobileTargets = peers.filter((p) =>
986
+ p.tags.some((t: string) => t === "mobile" || t === "ios" || t === "phone"),
987
+ );
988
+
989
+ if (mobileTargets.length === 0) {
990
+ respond(false, undefined, { code: "NO_MOBILE_PEERS", message: "No mobile peers connected" });
991
+ return;
992
+ }
993
+
994
+ const { randomUUID } = require("node:crypto");
995
+ const taskId = providedTaskId || randomUUID();
996
+ const now = Date.now();
997
+
998
+ let status: string;
999
+ if (action === "start") status = "started";
1000
+ else if (action === "end") status = success ? "completed" : "failed";
1001
+ else status = "progress";
1002
+
1003
+ const frame = {
1004
+ type: "task_activity",
1005
+ from: runtime.config.nodeId,
1006
+ timestamp: now,
1007
+ payload: {
1008
+ taskId,
1009
+ taskType: "notify",
1010
+ status,
1011
+ agent: title || runtime.config.nodeId,
1012
+ nodeId: runtime.config.nodeId,
1013
+ title: title || runtime.config.nodeId,
1014
+ detail,
1015
+ startedAt: now,
1016
+ elapsedMs: 0,
1017
+ progress,
1018
+ tool,
1019
+ },
1020
+ };
1021
+
1022
+ for (const target of mobileTargets) {
1023
+ runtime.peerManager.sendTo(target.nodeId, { ...frame, to: target.nodeId });
1024
+ }
1025
+
1026
+ respond(true, { taskId, action, targets: mobileTargets.length });
1027
+ } catch {
1028
+ respond(false, undefined, { code: "SERVICE_ERROR", message: "ClawMatrix service not running" });
1029
+ }
1030
+ },
1031
+ );
1032
+
1033
+ // ── Terminal gateway methods ──────────────────────────────────────
1034
+
1035
+ api.registerGatewayMethod(
1036
+ "clawmatrix.terminal.list",
1037
+ ({ respond }: GatewayRequestHandlerOptions) => {
1038
+ try {
1039
+ const runtime = getClusterRuntime();
1040
+ respond(true, runtime.terminalManager.listSessions());
1041
+ } catch {
1042
+ respond(false, { error: "ClawMatrix service not running" });
1043
+ }
1044
+ },
1045
+ );
1046
+
1047
+ api.registerGatewayMethod(
1048
+ "clawmatrix.terminal.open",
1049
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1050
+ try {
1051
+ const runtime = getClusterRuntime();
1052
+ const { node, shell, cols, rows, cwd } = (params ?? {}) as {
1053
+ node?: string; shell?: string; cols?: number; rows?: number; cwd?: string;
1054
+ };
1055
+ if (!node) {
1056
+ respond(false, { error: "Missing required param: node" });
1057
+ return;
1058
+ }
1059
+ const sessionId = await runtime.terminalManager.open(node, { shell, cols, rows, cwd });
1060
+ await new Promise((r) => setTimeout(r, 500));
1061
+ const initial = runtime.terminalManager.readOutput(sessionId);
1062
+ respond(true, { sessionId, nodeId: node, initialOutput: initial.data });
1063
+ } catch (err) {
1064
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1065
+ }
1066
+ },
1067
+ );
1068
+
1069
+ api.registerGatewayMethod(
1070
+ "clawmatrix.terminal.input",
1071
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1072
+ try {
1073
+ const runtime = getClusterRuntime();
1074
+ const { sessionId, data } = (params ?? {}) as { sessionId?: string; data?: string };
1075
+ if (!sessionId || !data) {
1076
+ respond(false, { error: "Missing required params: sessionId, data" });
1077
+ return;
1078
+ }
1079
+ runtime.terminalManager.sendInput(sessionId, data);
1080
+ respond(true, { sent: true });
1081
+ } catch (err) {
1082
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1083
+ }
1084
+ },
1085
+ );
1086
+
1087
+ api.registerGatewayMethod(
1088
+ "clawmatrix.terminal.read",
1089
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1090
+ try {
1091
+ const runtime = getClusterRuntime();
1092
+ const { sessionId } = (params ?? {}) as { sessionId?: string };
1093
+ if (!sessionId) {
1094
+ respond(false, { error: "Missing required param: sessionId" });
1095
+ return;
1096
+ }
1097
+ const output = runtime.terminalManager.readOutput(sessionId);
1098
+ respond(true, output);
1099
+ } catch (err) {
1100
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1101
+ }
1102
+ },
1103
+ );
1104
+
1105
+ api.registerGatewayMethod(
1106
+ "clawmatrix.terminal.resize",
1107
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1108
+ try {
1109
+ const runtime = getClusterRuntime();
1110
+ const { sessionId, cols, rows } = (params ?? {}) as { sessionId?: string; cols?: number; rows?: number };
1111
+ if (!sessionId) {
1112
+ respond(false, { error: "Missing required param: sessionId" });
1113
+ return;
1114
+ }
1115
+ runtime.terminalManager.resize(sessionId, cols ?? 80, rows ?? 24);
1116
+ respond(true, { resized: true });
1117
+ } catch (err) {
1118
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1119
+ }
1120
+ },
1121
+ );
1122
+
1123
+ api.registerGatewayMethod(
1124
+ "clawmatrix.terminal.close",
1125
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
1126
+ try {
1127
+ const runtime = getClusterRuntime();
1128
+ const { sessionId } = (params ?? {}) as { sessionId?: string };
1129
+ if (!sessionId) {
1130
+ respond(false, { error: "Missing required param: sessionId" });
1131
+ return;
1132
+ }
1133
+ runtime.terminalManager.close(sessionId);
1134
+ respond(true, { closed: true });
1135
+ } catch (err) {
1136
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1137
+ }
1138
+ },
1139
+ );
1140
+
1141
+ // ── File transfer gateway method ─────────────────────────────────
1142
+
1143
+ api.registerGatewayMethod(
1144
+ "clawmatrix.transfer",
1145
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
1146
+ try {
1147
+ const runtime = getClusterRuntime();
1148
+ const ftm = runtime.fileTransferManager;
1149
+ if (!ftm) {
1150
+ respond(false, { error: "File transfer not enabled" });
1151
+ return;
1152
+ }
1153
+ const { source_node, source_path, target_node, target_path } = (params ?? {}) as {
1154
+ source_node?: string; source_path?: string; target_node?: string; target_path?: string;
1155
+ };
1156
+ if (!source_path || !target_path) {
1157
+ respond(false, { error: "Missing required params: source_path, target_path" });
1158
+ return;
1159
+ }
1160
+ if ((source_node && target_node) || (!source_node && !target_node)) {
1161
+ respond(false, { error: "Provide exactly one of source_node (pull) or target_node (push)" });
1162
+ return;
1163
+ }
1164
+ let result;
1165
+ if (source_node) {
1166
+ result = await ftm.pullFile(source_node, source_path, target_path);
1167
+ } else {
1168
+ result = await ftm.pushFile(target_node!, source_path, target_path);
1169
+ }
1170
+ respond(true, result);
1171
+ } catch (err) {
1172
+ respond(false, { error: err instanceof Error ? err.message : String(err) });
1173
+ }
1174
+ },
1175
+ );
1176
+
732
1177
  // Log model selection on each LLM call (fire-and-forget)
733
1178
  api.on("llm_input", (event) => {
734
1179
  api.logger.debug(`[clawmatrix] llm_input: provider=${event.provider} model=${event.model}`);
735
1180
  });
736
1181
 
737
- // CLI subcommand
738
- api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
739
-
740
1182
  // Auto-install global `clawmatrix` shim next to the `openclaw` binary.
741
1183
  // Runs once on plugin load; non-blocking, best-effort.
742
1184
  installGlobalCliShim(api.logger);
@@ -824,17 +1266,11 @@ const plugin = {
824
1266
  // Rebuild system context only when peer count changes
825
1267
  if (peerCount !== cachedPeerCount) {
826
1268
  cachedPeerCount = peerCount;
827
- const lines: string[] = [];
828
1269
  if (peerCount === 0) {
829
- lines.push("[ClawMatrix] No peers online. Use cluster_peers to check cluster status.");
1270
+ cachedSystemContext = `[ClawMatrix] node="${config.nodeId}" no peers online.`;
830
1271
  } else {
831
- lines.push(
832
- `[ClawMatrix Cluster] YOU ARE node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}. ${peerCount} peer(s) online.`,
833
- ...(config.agents.length > 0 ? [`Role: ${config.agents[0]!.description}`] : []),
834
- "Use cluster_peers to see topology. Always tell user which remote node you're targeting before calling cluster tools.",
835
- );
1272
+ cachedSystemContext = `[ClawMatrix] node="${config.nodeId}"${config.tags.length ? ` tags=${config.tags.join(",")}` : ""}, ${peerCount} peer(s) online.${config.agents.length > 0 ? ` Role: ${config.agents[0]!.description}` : ""}`;
836
1273
  }
837
- cachedSystemContext = lines.join("\n");
838
1274
  }
839
1275
 
840
1276
  // Per-turn: only push pending events (agent must react proactively)
@@ -962,19 +1398,16 @@ function installGlobalCliShim(logger: { info: (msg: string) => void; warn: (msg:
962
1398
  const fs = require("node:fs") as typeof import("node:fs");
963
1399
  const path = require("node:path") as typeof import("node:path");
964
1400
 
965
- // Find the real directory where `openclaw` lives (resolve symlinks)
966
- const openclawBin = process.argv[0]; // node process running openclaw
967
- // Walk up to find the openclaw binary: it's in the same bin dir as node,
968
- // or we can resolve it from process.env.PATH
1401
+ // Find the bin directory where `openclaw` symlink lives (NOT the realpath).
1402
+ // e.g. fnm puts symlinks in .../bin/openclaw -> ../lib/node_modules/openclaw/openclaw.mjs
1403
+ // We want the .../bin/ directory so clawmatrix shim is on PATH.
969
1404
  let binDir: string | null = null;
970
1405
  const envPath = process.env.PATH ?? "";
971
1406
  for (const dir of envPath.split(path.delimiter)) {
972
1407
  const candidate = path.join(dir, "openclaw");
973
1408
  try {
974
1409
  fs.accessSync(candidate, fs.constants.X_OK);
975
- // Resolve symlinks to get the real bin directory
976
- const realPath = fs.realpathSync(candidate);
977
- binDir = path.dirname(realPath);
1410
+ binDir = dir;
978
1411
  break;
979
1412
  } catch {
980
1413
  // Not in this dir
@@ -984,20 +1417,30 @@ function installGlobalCliShim(logger: { info: (msg: string) => void; warn: (msg:
984
1417
 
985
1418
  const shimPath = path.join(binDir, "clawmatrix");
986
1419
 
987
- // Skip if shim already exists and is our shim (check marker comment)
1420
+ // CRITICAL: If shimPath is a symlink (e.g. fnm/npm may create one),
1421
+ // writeFileSync follows the symlink and overwrites the TARGET file.
1422
+ // This previously destroyed openclaw.mjs. Always remove stale symlinks first.
988
1423
  try {
989
- const existing = fs.readFileSync(shimPath, "utf-8");
990
- if (existing.includes("clawmatrix-shim")) return; // already installed
1424
+ const stat = fs.lstatSync(shimPath);
1425
+ if (stat.isSymbolicLink()) {
1426
+ fs.unlinkSync(shimPath);
1427
+ // Proceed to create a regular file below
1428
+ } else if (stat.isFile()) {
1429
+ // Regular file — check if it's already our shim
1430
+ const existing = fs.readFileSync(shimPath, "utf-8");
1431
+ if (existing.includes("clawmatrix-shim")) return; // already installed
1432
+ }
991
1433
  } catch {
992
1434
  // File doesn't exist, proceed to create
993
1435
  }
994
1436
 
995
- const shim = [
996
- "#!/usr/bin/env sh",
997
- "# clawmatrix-shim: auto-installed by clawmatrix plugin",
998
- 'exec openclaw clawmatrix "$@"',
999
- "",
1000
- ].join("\n");
1437
+ // Standalone CLI: bypasses openclaw plugin loading entirely.
1438
+ // Resolves cli/bin/clawmatrix.mjs relative to the plugin directory.
1439
+ const cliScript = path.resolve(__dirname, "..", "cli", "bin", "clawmatrix.mjs");
1440
+ const shim = `#!/usr/bin/env sh
1441
+ # clawmatrix-shim: auto-installed by clawmatrix plugin
1442
+ exec node "${cliScript}" "$@"
1443
+ `;
1001
1444
 
1002
1445
  fs.writeFileSync(shimPath, shim, { mode: 0o755 });
1003
1446
  logger.info(`[clawmatrix] Installed global CLI shim: ${shimPath}`);
@@ -87,6 +87,20 @@ async function pMap<T, R>(items: T[], fn: (item: T) => Promise<R>, concurrency:
87
87
  return results;
88
88
  }
89
89
 
90
+ /** Race a promise against a timeout; calls onTimeout before rejecting. */
91
+ function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout?: () => void): Promise<T> {
92
+ return new Promise((resolve, reject) => {
93
+ const timer = setTimeout(() => {
94
+ onTimeout?.();
95
+ reject(new Error(`Timed out after ${ms}ms`));
96
+ }, ms);
97
+ promise.then(
98
+ (v) => { clearTimeout(timer); resolve(v); },
99
+ (e) => { clearTimeout(timer); reject(e); },
100
+ );
101
+ });
102
+ }
103
+
90
104
  async function streamToString(stream: ReadableStream | null): Promise<string> {
91
105
  if (!stream) return "";
92
106
  const reader = stream.getReader();
@@ -1074,20 +1088,20 @@ export class KnowledgeSync {
1074
1088
  }
1075
1089
 
1076
1090
  private async gitCommit(message: string) {
1091
+ const GIT_TIMEOUT_MS = 3000;
1077
1092
  try {
1078
1093
  const add = spawnProcess(["git", "add", "-A"], {
1079
1094
  cwd: this.opts.workspacePath,
1080
1095
  stdout: "pipe",
1081
1096
  stderr: "pipe",
1082
1097
  });
1083
- await add.exited;
1084
-
1098
+ await withTimeout(add.exited, GIT_TIMEOUT_MS, () => add.kill());
1085
1099
  const diff = spawnProcess(["git", "diff", "--cached", "--quiet"], {
1086
1100
  cwd: this.opts.workspacePath,
1087
1101
  stdout: "pipe",
1088
1102
  stderr: "pipe",
1089
1103
  });
1090
- const diffCode = await diff.exited;
1104
+ const diffCode = await withTimeout(diff.exited, GIT_TIMEOUT_MS, () => diff.kill());
1091
1105
  if (diffCode === 0) return;
1092
1106
 
1093
1107
  const commit = spawnProcess(
@@ -1098,7 +1112,7 @@ export class KnowledgeSync {
1098
1112
  stderr: "pipe",
1099
1113
  },
1100
1114
  );
1101
- const commitCode = await commit.exited;
1115
+ const commitCode = await withTimeout(commit.exited, GIT_TIMEOUT_MS, () => commit.kill());
1102
1116
  if (commitCode !== 0) {
1103
1117
  const stderr = await streamToString(commit.stderr);
1104
1118
  debug(TAG, `git commit failed (exit ${commitCode}): ${stderr}`);
@@ -43,6 +43,7 @@ interface PendingModelReq {
43
43
  stream: boolean;
44
44
  responseFormat: ResponseFormat;
45
45
  model?: string;
46
+ targetNodeId?: string;
46
47
  controller?: ReadableStreamDefaultController;
47
48
  encoder?: TextEncoder;
48
49
  /** Whether real content (not just setup events) has been sent to the stream. */
@@ -356,12 +357,25 @@ export class ModelProxy {
356
357
  this.httpServer.listen(this.config.proxyPort, "127.0.0.1");
357
358
  }
358
359
 
360
+ /** Check if there are pending model requests targeting a specific node. */
361
+ hasPendingForNode(nodeId: string): boolean {
362
+ for (const p of this.pending.values()) {
363
+ if (p.targetNodeId === nodeId) return true;
364
+ }
365
+ return false;
366
+ }
367
+
359
368
  stop() {
360
369
  if (this.cacheCleanupTimer) {
361
370
  clearInterval(this.cacheCleanupTimer);
362
371
  this.cacheCleanupTimer = null;
363
372
  }
364
373
  if (this.httpServer) {
374
+ // Force-close all keep-alive connections so the port is released immediately
375
+ const server = this.httpServer as typeof this.httpServer & { closeAllConnections?: () => void };
376
+ if (typeof server.closeAllConnections === "function") {
377
+ server.closeAllConnections();
378
+ }
365
379
  this.httpServer.close();
366
380
  this.httpServer = null;
367
381
  }
@@ -648,6 +662,7 @@ export class ModelProxy {
648
662
  this.pending.set(requestId, {
649
663
  resolve: () => {}, reject: () => {},
650
664
  timer, stream: true, responseFormat, model,
665
+ targetNodeId,
651
666
  controller, encoder,
652
667
  hasContent: false,
653
668
  failoverCandidates,
@@ -822,6 +837,7 @@ export class ModelProxy {
822
837
  this.pending.set(requestId, {
823
838
  resolve: resolve as (v: unknown) => void,
824
839
  reject, timer, stream: false, responseFormat,
840
+ targetNodeId,
825
841
  });
826
842
 
827
843
  const sent = this.peerManager.sendTo(targetNodeId, frame);