clawmatrix 0.2.8 → 0.2.11

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.8",
3
+ "version": "0.2.11",
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
@@ -153,6 +153,8 @@ export class AcpProxy {
153
153
  private warmPool = new Map<string, AcpSession[]>();
154
154
  // Track pending prewarm timers so they can be cancelled on dispose
155
155
  private prewarmTimers = new Set<ReturnType<typeof setTimeout>>();
156
+ // Track pending retry timers for sendResponse so they can be cancelled on destroy
157
+ private retryTimers = new Set<ReturnType<typeof setTimeout>>();
156
158
  private disposed = false;
157
159
  // Agent daemon pool: long-lived process per agent type, reused across sessions
158
160
  private daemons = new Map<string, AgentDaemon>();
@@ -584,7 +586,7 @@ export class AcpProxy {
584
586
  let newSession: AcpSession;
585
587
  if (resumeAcpSessionId) {
586
588
  debug("acp", `Session "${sessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
587
- const effectiveCwd = cwd ?? process.cwd();
589
+ const effectiveCwd = cwd || process.cwd();
588
590
  try {
589
591
  newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
590
592
  } catch (resumeErr) {
@@ -611,7 +613,7 @@ export class AcpProxy {
611
613
  });
612
614
  }
613
615
  if (mode === "oneshot" && task) {
614
- this.returnToReusePool(newSession, cwd ?? process.cwd());
616
+ this.returnToReusePool(newSession, cwd || process.cwd());
615
617
  }
616
618
  } else {
617
619
  // Re-create native session
@@ -671,7 +673,7 @@ export class AcpProxy {
671
673
  });
672
674
  }
673
675
  if (mode === "oneshot" && task) {
674
- this.returnToReusePool(session, cwd ?? process.cwd());
676
+ this.returnToReusePool(session, cwd || process.cwd());
675
677
  }
676
678
  } else {
677
679
  // Create new native OpenClaw session
@@ -778,7 +780,8 @@ export class AcpProxy {
778
780
  /** Handle resume session request (receiver side): resume an ACP session and track it. */
779
781
  async handleResumeRequest(frame: AcpResumeRequest): Promise<void> {
780
782
  const { id, from, payload } = frame;
781
- const { agent, acpSessionId, cwd } = payload;
783
+ const { agent, acpSessionId } = payload;
784
+ const cwd = payload.cwd || process.cwd();
782
785
 
783
786
  try {
784
787
  // Enforce concurrent session limit
@@ -1089,6 +1092,30 @@ export class AcpProxy {
1089
1092
  }
1090
1093
  } catch { /* no claude projects directory */ }
1091
1094
 
1095
+ // 3. Search Codex sessions (~/.codex/sessions/YYYY/MM/DD/rollout-*-{sessionId}.jsonl)
1096
+ // Reverse sort so we search recent dates first (most likely to match)
1097
+ const codexSessionsDir = join(homedir(), ".codex", "sessions");
1098
+ try {
1099
+ const years = (await readdir(codexSessionsDir)).sort().reverse();
1100
+ for (const year of years) {
1101
+ const monthsDir = join(codexSessionsDir, year);
1102
+ let months: string[];
1103
+ try { months = (await readdir(monthsDir)).sort().reverse(); } catch { continue; }
1104
+ for (const month of months) {
1105
+ const daysDir = join(monthsDir, month);
1106
+ let days: string[];
1107
+ try { days = (await readdir(daysDir)).sort().reverse(); } catch { continue; }
1108
+ for (const day of days) {
1109
+ const dayDir = join(daysDir, day);
1110
+ let files: string[];
1111
+ try { files = await readdir(dayDir); } catch { continue; }
1112
+ const match = files.find((f) => f.endsWith(`-${sessionId}.jsonl`));
1113
+ if (match) return join(dayDir, match);
1114
+ }
1115
+ }
1116
+ }
1117
+ } catch { /* no codex sessions directory */ }
1118
+
1092
1119
  return null;
1093
1120
  }
1094
1121
 
@@ -1221,6 +1248,7 @@ export class AcpProxy {
1221
1248
  },
1222
1249
  availableModes,
1223
1250
  currentModeId: response.modes?.currentModeId,
1251
+
1224
1252
  };
1225
1253
 
1226
1254
  return session;
@@ -1313,7 +1341,7 @@ export class AcpProxy {
1313
1341
  // ── Internal: ACP session management (receiver) ────────────────
1314
1342
 
1315
1343
  private async createSession(agent: string, cwd: string | undefined, from: string): Promise<AcpSession> {
1316
- const effectiveCwd = cwd ?? process.cwd();
1344
+ const effectiveCwd = cwd || process.cwd();
1317
1345
 
1318
1346
  // 1. Try daemon pool first (long-lived process, instant newSession)
1319
1347
  try {
@@ -1499,6 +1527,7 @@ export class AcpProxy {
1499
1527
  setStreamCallback: (cb) => { streamCallback = cb; },
1500
1528
  availableModes,
1501
1529
  currentModeId: response.modes?.currentModeId,
1530
+
1502
1531
  };
1503
1532
 
1504
1533
  this.monitorProcess(session);
@@ -1752,9 +1781,21 @@ export class AcpProxy {
1752
1781
  cwd: string,
1753
1782
  from: string,
1754
1783
  ): Promise<AcpSession> {
1755
- return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
1756
- conn.unstable_resumeSession({ sessionId: acpSessionId, cwd }),
1757
- );
1784
+ return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", async (conn, effectiveCwd) => {
1785
+ // Try session/load first (supported by Codex, Claude Code, and most ACP agents).
1786
+ // It restores conversation history and replays it via notifications.
1787
+ try {
1788
+ const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
1789
+ debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
1790
+ return { sessionId: acpSessionId, modes: loadResp.modes };
1791
+ } catch (loadErr) {
1792
+ debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
1793
+ }
1794
+
1795
+ // Fallback to session/resume (unstable, supported by Claude Code).
1796
+ // Resumes without replaying history — faster but less widely supported.
1797
+ return conn.unstable_resumeSession({ sessionId: acpSessionId, cwd: effectiveCwd });
1798
+ });
1758
1799
  }
1759
1800
 
1760
1801
  /** Read all session stores from disk (OpenClaw + Claude Code). */
@@ -1995,11 +2036,14 @@ export class AcpProxy {
1995
2036
  const retryDelays = [2_000, 5_000, 10_000];
1996
2037
  let attempt = 0;
1997
2038
  const retry = () => {
1998
- if (attempt >= retryDelays.length) {
1999
- console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
2039
+ if (this.disposed || attempt >= retryDelays.length) {
2040
+ if (!this.disposed) {
2041
+ console.error(`[clawmatrix:acp] Failed to deliver acp_res to ${to} after ${retryDelays.length} retries`);
2042
+ }
2000
2043
  return;
2001
2044
  }
2002
- setTimeout(() => {
2045
+ const timer = setTimeout(() => {
2046
+ this.retryTimers.delete(timer);
2003
2047
  frame.timestamp = Date.now();
2004
2048
  if (this.peerManager.sendTo(to, frame)) {
2005
2049
  debug("acp", `acp_res to ${to} delivered on retry ${attempt + 1}`);
@@ -2008,6 +2052,7 @@ export class AcpProxy {
2008
2052
  retry();
2009
2053
  }
2010
2054
  }, retryDelays[attempt]);
2055
+ this.retryTimers.add(timer);
2011
2056
  };
2012
2057
  retry();
2013
2058
  }
@@ -2048,6 +2093,10 @@ export class AcpProxy {
2048
2093
 
2049
2094
  this.disposed = true;
2050
2095
 
2096
+ // Cancel pending retry timers
2097
+ for (const timer of this.retryTimers) clearTimeout(timer);
2098
+ this.retryTimers.clear();
2099
+
2051
2100
  // Cancel pending prewarm timers
2052
2101
  for (const timer of this.prewarmTimers) clearTimeout(timer);
2053
2102
  this.prewarmTimers.clear();
@@ -2256,6 +2305,62 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2256
2305
  // history.jsonl missing or unreadable — skip
2257
2306
  }
2258
2307
 
2308
+ // 3. Codex session history (~/.codex/history.jsonl + session_index.jsonl)
2309
+ try {
2310
+ const codexDir = join(homedir(), ".codex");
2311
+ // Read session index for titles
2312
+ const titleMap = new Map<string, string>();
2313
+ try {
2314
+ const indexContent = await readFileText(join(codexDir, "session_index.jsonl"));
2315
+ for (const line of indexContent.split("\n")) {
2316
+ if (!line.trim()) continue;
2317
+ try {
2318
+ const entry = JSON.parse(line) as { id?: string; thread_name?: string };
2319
+ if (entry.id && entry.thread_name) titleMap.set(entry.id, entry.thread_name);
2320
+ } catch { /* skip */ }
2321
+ }
2322
+ } catch { /* index missing */ }
2323
+
2324
+ const codexHistoryPath = join(codexDir, "history.jsonl");
2325
+ const codexContent = await readFileTail(codexHistoryPath, 512 * 1024);
2326
+ const codexSessions = new Map<string, { title: string; updatedAt: number }>();
2327
+
2328
+ for (const line of codexContent.split("\n")) {
2329
+ if (!line.trim()) continue;
2330
+ try {
2331
+ const entry = JSON.parse(line) as { session_id?: string; ts?: number; text?: string };
2332
+ if (!entry.session_id) continue;
2333
+ const existing = codexSessions.get(entry.session_id);
2334
+ if (!existing) {
2335
+ const title = (entry.text ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
2336
+ codexSessions.set(entry.session_id, {
2337
+ title,
2338
+ updatedAt: entry.ts ? entry.ts * 1000 : 0, // ts is in seconds
2339
+ });
2340
+ } else if (entry.ts && entry.ts * 1000 > existing.updatedAt) {
2341
+ existing.updatedAt = entry.ts * 1000;
2342
+ }
2343
+ } catch { /* skip */ }
2344
+ }
2345
+
2346
+ const codexResults: AcpSessionInfo[] = [];
2347
+ const existingIds2 = new Set(results.map((r) => r.sessionId));
2348
+ for (const [sessionId, info] of codexSessions) {
2349
+ if (existingIds2.has(sessionId)) continue;
2350
+ codexResults.push({
2351
+ sessionId,
2352
+ cwd: "",
2353
+ title: titleMap.get(sessionId) ?? info.title,
2354
+ updatedAt: info.updatedAt ? new Date(info.updatedAt).toISOString() : undefined,
2355
+ agent: "codex",
2356
+ });
2357
+ }
2358
+ codexResults.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
2359
+ results.push(...codexResults);
2360
+ } catch {
2361
+ // Codex history missing or unreadable — skip
2362
+ }
2363
+
2259
2364
  return results;
2260
2365
  }
2261
2366
 
@@ -2444,7 +2549,12 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
2444
2549
  for (const line of lines) {
2445
2550
  try {
2446
2551
  const parsed = JSON.parse(line);
2447
- const msg = parsed?.message;
2552
+
2553
+ // Support both OpenClaw/Claude format (parsed.message) and Codex format (parsed.payload with type=response_item)
2554
+ let msg: Record<string, unknown> | undefined = parsed?.message;
2555
+ if (!msg && parsed?.type === "response_item" && parsed?.payload?.role) {
2556
+ msg = parsed.payload;
2557
+ }
2448
2558
  if (!msg) continue;
2449
2559
 
2450
2560
  const role = typeof msg.role === "string" ? msg.role : "";
@@ -14,8 +14,10 @@ import { ModelProxy } from "./model-proxy.ts";
14
14
  import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
15
15
  import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
16
16
  import { TerminalManager } from "./terminal.ts";
17
+ import { FileTransferManager } from "./file-transfer.ts";
17
18
  import { WebHandler } from "./web.ts";
18
19
  import { KnowledgeSync } from "./knowledge-sync.ts";
20
+ import { HealthTracker } from "./health-tracker.ts";
19
21
  import { SentinelManager } from "./sentinel-manager.ts";
20
22
  import type {
21
23
  AnyClusterFrame,
@@ -28,6 +30,9 @@ import type {
28
30
  HandoffInputRequired,
29
31
  HandoffInput,
30
32
  KnowledgeSyncFrame,
33
+ HealthSyncFrame,
34
+ AvailabilityRequest,
35
+ AvailabilityResponse,
31
36
  ModelRequest,
32
37
  ModelResponse,
33
38
  ModelStreamChunk,
@@ -61,6 +66,11 @@ import type {
61
66
  TerminalResize,
62
67
  TerminalCloseRequest,
63
68
  TerminalCloseResponse,
69
+ FileTransferInit,
70
+ FileTransferAck,
71
+ FileTransferChunk,
72
+ FileTransferChunkAck,
73
+ FileTransferComplete,
64
74
  } from "./types.ts";
65
75
 
66
76
  function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
@@ -84,7 +94,9 @@ export class ClusterRuntime {
84
94
  readonly toolProxy: ToolProxy;
85
95
  readonly acpProxy: AcpProxy | null;
86
96
  readonly terminalManager: TerminalManager;
97
+ readonly fileTransferManager: FileTransferManager | null;
87
98
  knowledgeSync: KnowledgeSync | null = null;
99
+ healthTracker: HealthTracker;
88
100
  webHandler: WebHandler | null = null;
89
101
  private sentinelManager: SentinelManager | null = null;
90
102
  private logger: PluginLogger;
@@ -104,6 +116,13 @@ export class ClusterRuntime {
104
116
  const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
105
117
  this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
106
118
  this.terminalManager = new TerminalManager(config, this.peerManager);
119
+ this.fileTransferManager = config.fileTransfer?.enabled
120
+ ? new FileTransferManager(config, this.peerManager)
121
+ : null;
122
+ this.healthTracker = new HealthTracker({
123
+ nodeId: config.nodeId,
124
+ peerManager: this.peerManager,
125
+ });
107
126
  }
108
127
 
109
128
  start() {
@@ -115,11 +134,15 @@ export class ClusterRuntime {
115
134
  this.peerManager.on("peerConnected", (nodeId) => {
116
135
  this.logger.info(`[clawmatrix] Peer connected: ${nodeId}`);
117
136
  this.refreshDiscoveredModels();
137
+ this.healthTracker.recordPeerOnline(nodeId, "direct");
138
+ this.healthTracker.initPeerSync(nodeId);
118
139
  });
119
140
 
120
141
  this.peerManager.on("peerDisconnected", (nodeId) => {
121
142
  this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
122
143
  this.refreshDiscoveredModels();
144
+ this.healthTracker.recordPeerOffline(nodeId);
145
+ this.healthTracker.removePeerSync(nodeId);
123
146
  });
124
147
 
125
148
  this.peerManager.on("peerCapabilitiesChanged", () => {
@@ -132,6 +155,7 @@ export class ClusterRuntime {
132
155
  this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
133
156
  // Enable satellite tool routing through WebHandler
134
157
  this.toolProxy.setSatelliteHandler(this.webHandler);
158
+ this.webHandler.setHealthTracker(this.healthTracker);
135
159
  this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
136
160
  }
137
161
 
@@ -166,8 +190,9 @@ export class ClusterRuntime {
166
190
  }
167
191
 
168
192
  // 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) => {
193
+ // Check both ClawMatrix and OpenClaw configs (consistent with acpProxy creation above)
194
+ if (this.acpProxy && (!this.config.acp?.agents || this.config.acp.agents.length === 0)) {
195
+ AcpProxy.detectAvailableAgents(this.config.acp?.commands).then((detected) => {
171
196
  if (detected.length > 0) {
172
197
  this.logger.info(`[clawmatrix] Auto-detected ACP agents: ${detected.map((a) => a.id).join(", ")}`);
173
198
  this.peerManager.updateAcpAgents(detected);
@@ -177,6 +202,11 @@ export class ClusterRuntime {
177
202
  });
178
203
  }
179
204
 
205
+ // Health tracker (always enabled, no config gate)
206
+ this.healthTracker.start().catch((err) => {
207
+ this.logger.error(`[clawmatrix] Health tracker failed to start: ${err}`);
208
+ });
209
+
180
210
  // Start subsystems
181
211
  this.peerManager.start();
182
212
  this.modelProxy.start();
@@ -213,12 +243,14 @@ export class ClusterRuntime {
213
243
  // NOTE: intentionally do NOT stop sentinel here.
214
244
  // Sentinel must survive gateway shutdown — that's its entire purpose.
215
245
  // It will be replaced by killOldSentinel() on next gateway start.
246
+ await this.healthTracker.stop();
216
247
  await this.knowledgeSync?.stop();
217
248
  this.webHandler?.destroy();
218
249
  this.handoffManager.destroy();
219
250
  this.acpProxy?.destroy();
220
251
  this.terminalManager.destroy();
221
252
  this.modelProxy.stop();
253
+ this.fileTransferManager?.destroy();
222
254
  this.toolProxy.destroy();
223
255
  await this.peerManager.stop();
224
256
  this.logger.info(`[clawmatrix] Node "${this.config.nodeId}" stopped`);
@@ -316,6 +348,23 @@ export class ClusterRuntime {
316
348
  this.logger.error(`[clawmatrix] Knowledge sync error: ${err}`);
317
349
  });
318
350
  break;
351
+ case "health_sync":
352
+ this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
353
+ break;
354
+ case "availability_req": {
355
+ const af = frame as AvailabilityRequest;
356
+ const range = af.payload.range ?? "24h";
357
+ const data = this.healthTracker.getAvailability(range);
358
+ this.peerManager.sendTo(af.from, {
359
+ type: "availability_res",
360
+ id: af.id,
361
+ from: this.config.nodeId,
362
+ to: af.from,
363
+ timestamp: Date.now(),
364
+ payload: { success: true, data },
365
+ } as AvailabilityResponse);
366
+ break;
367
+ }
319
368
  case "acp_req":
320
369
  if (this.acpProxy) {
321
370
  this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
@@ -472,6 +521,23 @@ export class ClusterRuntime {
472
521
  frame as TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse,
473
522
  );
474
523
  break;
524
+ case "file_transfer_init":
525
+ this.fileTransferManager?.handleInit(frame as FileTransferInit).catch((err) => {
526
+ this.logger.error(`[clawmatrix] File transfer init error: ${err}`);
527
+ });
528
+ break;
529
+ case "file_transfer_ack":
530
+ this.fileTransferManager?.handleAck(frame as FileTransferAck);
531
+ break;
532
+ case "file_transfer_chunk":
533
+ this.fileTransferManager?.handleChunk(frame as FileTransferChunk);
534
+ break;
535
+ case "file_transfer_chunk_ack":
536
+ this.fileTransferManager?.handleChunkAck(frame as FileTransferChunkAck);
537
+ break;
538
+ case "file_transfer_complete":
539
+ this.fileTransferManager?.handleComplete(frame as FileTransferComplete);
540
+ break;
475
541
  }
476
542
  }
477
543
 
package/src/config.ts CHANGED
@@ -134,6 +134,14 @@ const TerminalConfigSchema = z.object({
134
134
  allowFrom: z.array(z.string()).default([]),
135
135
  }).optional();
136
136
 
137
+ const FileTransferConfigSchema = z.object({
138
+ enabled: z.boolean().default(false),
139
+ chunkSize: z.number().default(262_144), // 256KB
140
+ maxFileSize: z.number().default(104_857_600), // 100MB
141
+ timeout: z.number().default(300_000), // 5min per-chunk
142
+ allowedPaths: z.array(z.string()).default([]), // empty = no restriction
143
+ }).optional();
144
+
137
145
  const AcpConfigSchema = z.object({
138
146
  enabled: z.boolean().default(false),
139
147
  /** ACP agents available on this node. Advertised to peers via capabilities. */
@@ -169,6 +177,7 @@ const RawClawMatrixConfigSchema = z.object({
169
177
  knowledge: KnowledgeConfigSchema,
170
178
  terminal: TerminalConfigSchema,
171
179
  acp: AcpConfigSchema,
180
+ fileTransfer: FileTransferConfigSchema,
172
181
  peerApproval: z.union([
173
182
  z.boolean(), // true = required mode, false = disabled
174
183
  PeerApprovalConfigSchema,