clawmatrix 0.2.9 → 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.9",
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>();
@@ -1246,6 +1248,7 @@ export class AcpProxy {
1246
1248
  },
1247
1249
  availableModes,
1248
1250
  currentModeId: response.modes?.currentModeId,
1251
+
1249
1252
  };
1250
1253
 
1251
1254
  return session;
@@ -1524,6 +1527,7 @@ export class AcpProxy {
1524
1527
  setStreamCallback: (cb) => { streamCallback = cb; },
1525
1528
  availableModes,
1526
1529
  currentModeId: response.modes?.currentModeId,
1530
+
1527
1531
  };
1528
1532
 
1529
1533
  this.monitorProcess(session);
@@ -1777,9 +1781,21 @@ export class AcpProxy {
1777
1781
  cwd: string,
1778
1782
  from: string,
1779
1783
  ): Promise<AcpSession> {
1780
- return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
1781
- conn.unstable_resumeSession({ sessionId: acpSessionId, cwd }),
1782
- );
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
+ });
1783
1799
  }
1784
1800
 
1785
1801
  /** Read all session stores from disk (OpenClaw + Claude Code). */
@@ -2020,11 +2036,14 @@ export class AcpProxy {
2020
2036
  const retryDelays = [2_000, 5_000, 10_000];
2021
2037
  let attempt = 0;
2022
2038
  const retry = () => {
2023
- if (attempt >= retryDelays.length) {
2024
- 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
+ }
2025
2043
  return;
2026
2044
  }
2027
- setTimeout(() => {
2045
+ const timer = setTimeout(() => {
2046
+ this.retryTimers.delete(timer);
2028
2047
  frame.timestamp = Date.now();
2029
2048
  if (this.peerManager.sendTo(to, frame)) {
2030
2049
  debug("acp", `acp_res to ${to} delivered on retry ${attempt + 1}`);
@@ -2033,6 +2052,7 @@ export class AcpProxy {
2033
2052
  retry();
2034
2053
  }
2035
2054
  }, retryDelays[attempt]);
2055
+ this.retryTimers.add(timer);
2036
2056
  };
2037
2057
  retry();
2038
2058
  }
@@ -2073,6 +2093,10 @@ export class AcpProxy {
2073
2093
 
2074
2094
  this.disposed = true;
2075
2095
 
2096
+ // Cancel pending retry timers
2097
+ for (const timer of this.retryTimers) clearTimeout(timer);
2098
+ this.retryTimers.clear();
2099
+
2076
2100
  // Cancel pending prewarm timers
2077
2101
  for (const timer of this.prewarmTimers) clearTimeout(timer);
2078
2102
  this.prewarmTimers.clear();
@@ -14,6 +14,7 @@ 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";
19
20
  import { HealthTracker } from "./health-tracker.ts";
@@ -30,6 +31,8 @@ import type {
30
31
  HandoffInput,
31
32
  KnowledgeSyncFrame,
32
33
  HealthSyncFrame,
34
+ AvailabilityRequest,
35
+ AvailabilityResponse,
33
36
  ModelRequest,
34
37
  ModelResponse,
35
38
  ModelStreamChunk,
@@ -63,6 +66,11 @@ import type {
63
66
  TerminalResize,
64
67
  TerminalCloseRequest,
65
68
  TerminalCloseResponse,
69
+ FileTransferInit,
70
+ FileTransferAck,
71
+ FileTransferChunk,
72
+ FileTransferChunkAck,
73
+ FileTransferComplete,
66
74
  } from "./types.ts";
67
75
 
68
76
  function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
@@ -86,6 +94,7 @@ export class ClusterRuntime {
86
94
  readonly toolProxy: ToolProxy;
87
95
  readonly acpProxy: AcpProxy | null;
88
96
  readonly terminalManager: TerminalManager;
97
+ readonly fileTransferManager: FileTransferManager | null;
89
98
  knowledgeSync: KnowledgeSync | null = null;
90
99
  healthTracker: HealthTracker;
91
100
  webHandler: WebHandler | null = null;
@@ -107,6 +116,9 @@ export class ClusterRuntime {
107
116
  const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
108
117
  this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
109
118
  this.terminalManager = new TerminalManager(config, this.peerManager);
119
+ this.fileTransferManager = config.fileTransfer?.enabled
120
+ ? new FileTransferManager(config, this.peerManager)
121
+ : null;
110
122
  this.healthTracker = new HealthTracker({
111
123
  nodeId: config.nodeId,
112
124
  peerManager: this.peerManager,
@@ -238,6 +250,7 @@ export class ClusterRuntime {
238
250
  this.acpProxy?.destroy();
239
251
  this.terminalManager.destroy();
240
252
  this.modelProxy.stop();
253
+ this.fileTransferManager?.destroy();
241
254
  this.toolProxy.destroy();
242
255
  await this.peerManager.stop();
243
256
  this.logger.info(`[clawmatrix] Node "${this.config.nodeId}" stopped`);
@@ -338,6 +351,20 @@ export class ClusterRuntime {
338
351
  case "health_sync":
339
352
  this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
340
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
+ }
341
368
  case "acp_req":
342
369
  if (this.acpProxy) {
343
370
  this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
@@ -494,6 +521,23 @@ export class ClusterRuntime {
494
521
  frame as TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse,
495
522
  );
496
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;
497
541
  }
498
542
  }
499
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,
@@ -0,0 +1,671 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, writeFile, stat, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { debug } from "./debug.ts";
5
+ import type { PeerManager } from "./peer-manager.ts";
6
+ import type { ClawMatrixConfig } from "./config.ts";
7
+ import type {
8
+ FileTransferInit,
9
+ FileTransferAck,
10
+ FileTransferChunk,
11
+ FileTransferChunkAck,
12
+ FileTransferComplete,
13
+ } from "./types.ts";
14
+
15
+ interface FileTransferConfig {
16
+ enabled: boolean;
17
+ chunkSize: number;
18
+ maxFileSize: number;
19
+ timeout: number;
20
+ allowedPaths: string[];
21
+ }
22
+
23
+ interface PendingTransfer {
24
+ sessionId: string;
25
+ direction: "push" | "pull";
26
+ filePath: string;
27
+ targetPath: string;
28
+ remoteNode: string;
29
+ resolve: (result: TransferResult) => void;
30
+ reject: (err: Error) => void;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ // Push state
33
+ fileData?: Buffer;
34
+ chunkSize?: number;
35
+ totalChunks?: number;
36
+ sentChunks?: number;
37
+ // Pull state
38
+ chunks?: Map<number, Buffer>;
39
+ expectedSize?: number;
40
+ expectedChunks?: number;
41
+ expectedChecksum?: string;
42
+ receivedChunks?: number;
43
+ }
44
+
45
+ interface ReceivingTransfer {
46
+ sessionId: string;
47
+ direction: "push" | "pull";
48
+ filePath: string;
49
+ targetPath: string;
50
+ fromNode: string;
51
+ fileSize: number;
52
+ totalChunks: number;
53
+ chunkSize: number;
54
+ checksum: string;
55
+ chunks: Map<number, Buffer>;
56
+ receivedChunks: number;
57
+ timer: ReturnType<typeof setTimeout>;
58
+ /** Cached file data for pull-mode serving to avoid re-reading per chunk */
59
+ cachedData?: Buffer;
60
+ }
61
+
62
+ export interface TransferResult {
63
+ success: boolean;
64
+ bytesTransferred: number;
65
+ error?: string;
66
+ }
67
+
68
+ export class FileTransferManager {
69
+ private config: FileTransferConfig;
70
+ private nodeId: string;
71
+ private peerManager: PeerManager;
72
+ private pending = new Map<string, PendingTransfer>();
73
+ private receiving = new Map<string, ReceivingTransfer>();
74
+
75
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
76
+ this.nodeId = config.nodeId;
77
+ this.peerManager = peerManager;
78
+ const ft = config.fileTransfer;
79
+ this.config = {
80
+ enabled: ft?.enabled ?? false,
81
+ chunkSize: ft?.chunkSize ?? 262_144,
82
+ maxFileSize: ft?.maxFileSize ?? 104_857_600,
83
+ timeout: ft?.timeout ?? 300_000,
84
+ allowedPaths: ft?.allowedPaths ?? [],
85
+ };
86
+ }
87
+
88
+ // ── Public API ──────────────────────────────────────────────────
89
+
90
+ async pushFile(remoteNode: string, localPath: string, remotePath: string): Promise<TransferResult> {
91
+ this.ensureEnabled();
92
+ const resolvedPath = path.resolve(localPath);
93
+ this.validatePath(resolvedPath);
94
+
95
+ const fileData = await readFile(resolvedPath);
96
+ if (fileData.length > this.config.maxFileSize) {
97
+ throw new Error(`File too large: ${fileData.length} bytes (max ${this.config.maxFileSize})`);
98
+ }
99
+
100
+ const checksum = createHash("sha256").update(fileData).digest("hex");
101
+ const totalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
102
+ const sessionId = crypto.randomUUID();
103
+
104
+ return new Promise<TransferResult>((resolve, reject) => {
105
+ const timer = this.createTimer(sessionId, "pending");
106
+
107
+ this.pending.set(sessionId, {
108
+ sessionId,
109
+ direction: "push",
110
+ filePath: resolvedPath,
111
+ targetPath: remotePath,
112
+ remoteNode,
113
+ resolve,
114
+ reject,
115
+ timer,
116
+ fileData,
117
+ chunkSize: this.config.chunkSize,
118
+ totalChunks,
119
+ sentChunks: 0,
120
+ });
121
+
122
+ this.peerManager.sendTo(remoteNode, {
123
+ type: "file_transfer_init",
124
+ id: sessionId,
125
+ from: this.nodeId,
126
+ to: remoteNode,
127
+ timestamp: Date.now(),
128
+ payload: {
129
+ sessionId,
130
+ direction: "push",
131
+ filePath: resolvedPath,
132
+ targetPath: remotePath,
133
+ fileSize: fileData.length,
134
+ totalChunks,
135
+ chunkSize: this.config.chunkSize,
136
+ checksum,
137
+ },
138
+ } as FileTransferInit);
139
+ });
140
+ }
141
+
142
+ async pullFile(remoteNode: string, remotePath: string, localPath: string): Promise<TransferResult> {
143
+ this.ensureEnabled();
144
+ const resolvedPath = path.resolve(localPath);
145
+ this.validatePath(resolvedPath);
146
+
147
+ const sessionId = crypto.randomUUID();
148
+
149
+ return new Promise<TransferResult>((resolve, reject) => {
150
+ const timer = this.createTimer(sessionId, "pending");
151
+
152
+ this.pending.set(sessionId, {
153
+ sessionId,
154
+ direction: "pull",
155
+ filePath: remotePath,
156
+ targetPath: resolvedPath,
157
+ remoteNode,
158
+ resolve,
159
+ reject,
160
+ timer,
161
+ chunks: new Map(),
162
+ receivedChunks: 0,
163
+ });
164
+
165
+ this.peerManager.sendTo(remoteNode, {
166
+ type: "file_transfer_init",
167
+ id: sessionId,
168
+ from: this.nodeId,
169
+ to: remoteNode,
170
+ timestamp: Date.now(),
171
+ payload: {
172
+ sessionId,
173
+ direction: "pull",
174
+ filePath: remotePath,
175
+ targetPath: resolvedPath,
176
+ fileSize: 0,
177
+ totalChunks: 0,
178
+ chunkSize: this.config.chunkSize,
179
+ checksum: "",
180
+ },
181
+ } as FileTransferInit);
182
+ });
183
+ }
184
+
185
+ // ── Frame handlers (called by cluster-service dispatch) ─────────
186
+
187
+ async handleInit(frame: FileTransferInit): Promise<void> {
188
+ const { sessionId, direction, filePath, targetPath, fileSize, totalChunks, chunkSize, checksum } = frame.payload;
189
+
190
+ // Validate enabled
191
+ if (!this.config.enabled) {
192
+ this.sendAck(frame.from, sessionId, frame.id, false, "File transfer not enabled on this node");
193
+ return;
194
+ }
195
+
196
+ if (direction === "push") {
197
+ // Remote wants to push a file to us
198
+ const resolvedTarget = path.resolve(targetPath);
199
+ if (!this.isPathAllowed(resolvedTarget)) {
200
+ this.sendAck(frame.from, sessionId, frame.id, false, "Target path not allowed");
201
+ return;
202
+ }
203
+ if (fileSize > this.config.maxFileSize) {
204
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileSize} bytes (max ${this.config.maxFileSize})`);
205
+ return;
206
+ }
207
+
208
+ // Accept and prepare to receive chunks
209
+ const timer = this.createTimer(sessionId, "receiving");
210
+ this.receiving.set(sessionId, {
211
+ sessionId,
212
+ direction: "push",
213
+ filePath,
214
+ targetPath: resolvedTarget,
215
+ fromNode: frame.from,
216
+ fileSize,
217
+ totalChunks,
218
+ chunkSize,
219
+ checksum,
220
+ chunks: new Map(),
221
+ receivedChunks: 0,
222
+ timer,
223
+ });
224
+
225
+ this.sendAck(frame.from, sessionId, frame.id, true);
226
+ } else {
227
+ // Remote wants to pull a file from us
228
+ const resolvedSource = path.resolve(filePath);
229
+ if (!this.isPathAllowed(resolvedSource)) {
230
+ this.sendAck(frame.from, sessionId, frame.id, false, "Source path not allowed");
231
+ return;
232
+ }
233
+
234
+ let fileData: Buffer;
235
+ try {
236
+ fileData = await readFile(resolvedSource);
237
+ } catch (err) {
238
+ this.sendAck(frame.from, sessionId, frame.id, false, `Cannot read file: ${err instanceof Error ? err.message : String(err)}`);
239
+ return;
240
+ }
241
+
242
+ if (fileData.length > this.config.maxFileSize) {
243
+ this.sendAck(frame.from, sessionId, frame.id, false, `File too large: ${fileData.length} bytes`);
244
+ return;
245
+ }
246
+
247
+ const fileChecksum = createHash("sha256").update(fileData).digest("hex");
248
+ const fileTotalChunks = Math.ceil(fileData.length / this.config.chunkSize) || 1;
249
+
250
+ // Send ack with file metadata
251
+ this.peerManager.sendTo(frame.from, {
252
+ type: "file_transfer_ack",
253
+ id: frame.id,
254
+ from: this.nodeId,
255
+ to: frame.from,
256
+ timestamp: Date.now(),
257
+ payload: {
258
+ sessionId,
259
+ accepted: true,
260
+ fileSize: fileData.length,
261
+ totalChunks: fileTotalChunks,
262
+ checksum: fileChecksum,
263
+ },
264
+ } as FileTransferAck);
265
+
266
+ // Store as a "receiving" entry for tracking (we are the sender in pull mode)
267
+ const timer = this.createTimer(sessionId, "receiving");
268
+ this.receiving.set(sessionId, {
269
+ sessionId,
270
+ direction: "pull",
271
+ filePath: resolvedSource,
272
+ targetPath,
273
+ fromNode: frame.from,
274
+ fileSize: fileData.length,
275
+ totalChunks: fileTotalChunks,
276
+ chunkSize: this.config.chunkSize,
277
+ checksum: fileChecksum,
278
+ chunks: new Map(),
279
+ receivedChunks: 0,
280
+ timer,
281
+ cachedData: fileData,
282
+ });
283
+
284
+ // Start sending chunks (stop-and-wait: send first, wait for ack)
285
+ this.sendChunkFromReceiving(sessionId, 0, fileData);
286
+ }
287
+ }
288
+
289
+ handleAck(frame: FileTransferAck): void {
290
+ const { sessionId, accepted, error, fileSize, totalChunks, checksum } = frame.payload;
291
+ const transfer = this.pending.get(sessionId);
292
+ if (!transfer) return;
293
+
294
+ this.resetTimer(sessionId, "pending");
295
+
296
+ if (!accepted) {
297
+ this.completePending(sessionId, { success: false, bytesTransferred: 0, error: error || "Transfer rejected" });
298
+ return;
299
+ }
300
+
301
+ if (transfer.direction === "push") {
302
+ // Start sending chunks
303
+ this.sendNextChunk(sessionId);
304
+ } else {
305
+ // Pull mode: store expected metadata
306
+ transfer.expectedSize = fileSize;
307
+ transfer.expectedChunks = totalChunks;
308
+ transfer.expectedChecksum = checksum;
309
+ // Wait for chunks from remote
310
+ }
311
+ }
312
+
313
+ handleChunk(frame: FileTransferChunk): void {
314
+ const { sessionId, chunkIndex, data } = frame.payload;
315
+
316
+ // Check if this is a pull we initiated (we're receiving)
317
+ const pending = this.pending.get(sessionId);
318
+ if (pending && pending.direction === "pull") {
319
+ this.resetTimer(sessionId, "pending");
320
+ const buf = Buffer.from(data, "base64");
321
+ pending.chunks!.set(chunkIndex, buf);
322
+ pending.receivedChunks = (pending.receivedChunks ?? 0) + 1;
323
+
324
+ // Send chunk ack
325
+ this.peerManager.sendTo(pending.remoteNode, {
326
+ type: "file_transfer_chunk_ack",
327
+ id: frame.id,
328
+ from: this.nodeId,
329
+ to: pending.remoteNode,
330
+ timestamp: Date.now(),
331
+ payload: { sessionId, chunkIndex, success: true },
332
+ } as FileTransferChunkAck);
333
+
334
+ // Check if all chunks received
335
+ if (pending.receivedChunks === pending.expectedChunks) {
336
+ this.finalizePull(sessionId).catch((err) => {
337
+ this.completePending(sessionId, {
338
+ success: false,
339
+ bytesTransferred: 0,
340
+ error: err instanceof Error ? err.message : String(err),
341
+ });
342
+ });
343
+ }
344
+ return;
345
+ }
346
+
347
+ // Otherwise, this is a push from remote (we're receiving)
348
+ const receiving = this.receiving.get(sessionId);
349
+ if (!receiving || receiving.direction !== "push") return;
350
+
351
+ this.resetTimer(sessionId, "receiving");
352
+ const buf = Buffer.from(data, "base64");
353
+ receiving.chunks.set(chunkIndex, buf);
354
+ receiving.receivedChunks++;
355
+
356
+ // Send chunk ack
357
+ this.peerManager.sendTo(receiving.fromNode, {
358
+ type: "file_transfer_chunk_ack",
359
+ id: frame.id,
360
+ from: this.nodeId,
361
+ to: receiving.fromNode,
362
+ timestamp: Date.now(),
363
+ payload: { sessionId, chunkIndex, success: true },
364
+ } as FileTransferChunkAck);
365
+
366
+ // Check if all chunks received
367
+ if (receiving.receivedChunks === receiving.totalChunks) {
368
+ this.finalizePushReceive(sessionId).catch((err) => {
369
+ debug("file-transfer", `finalizePushReceive error: ${err}`);
370
+ });
371
+ }
372
+ }
373
+
374
+ handleChunkAck(frame: FileTransferChunkAck): void {
375
+ const { sessionId, chunkIndex, success, error } = frame.payload;
376
+
377
+ // Push mode: we sent a chunk, got ack, send next
378
+ const pending = this.pending.get(sessionId);
379
+ if (pending && pending.direction === "push") {
380
+ this.resetTimer(sessionId, "pending");
381
+ if (!success) {
382
+ this.completePending(sessionId, {
383
+ success: false,
384
+ bytesTransferred: chunkIndex * (pending.chunkSize ?? this.config.chunkSize),
385
+ error: error || `Chunk ${chunkIndex} rejected`,
386
+ });
387
+ return;
388
+ }
389
+ pending.sentChunks = chunkIndex + 1;
390
+ if (pending.sentChunks! < pending.totalChunks!) {
391
+ this.sendNextChunk(sessionId);
392
+ }
393
+ // If all sent, wait for file_transfer_complete from receiver
394
+ return;
395
+ }
396
+
397
+ // Pull mode (we are serving): got chunk ack, send next
398
+ const receiving = this.receiving.get(sessionId);
399
+ if (receiving && receiving.direction === "pull") {
400
+ this.resetTimer(sessionId, "receiving");
401
+ if (!success) {
402
+ this.cleanupReceiving(sessionId);
403
+ return;
404
+ }
405
+ const nextIndex = chunkIndex + 1;
406
+ if (nextIndex < receiving.totalChunks) {
407
+ if (receiving.cachedData) {
408
+ this.sendChunkFromReceiving(sessionId, nextIndex, receiving.cachedData);
409
+ } else {
410
+ // Fallback: read file if cached data is missing
411
+ readFile(receiving.filePath).then((data) => {
412
+ receiving.cachedData = data;
413
+ this.sendChunkFromReceiving(sessionId, nextIndex, data);
414
+ }).catch(() => {
415
+ this.cleanupReceiving(sessionId);
416
+ });
417
+ }
418
+ }
419
+ // If all chunks sent, wait for file_transfer_complete from puller
420
+ }
421
+ }
422
+
423
+ handleComplete(frame: FileTransferComplete): void {
424
+ const { sessionId, success, error, bytesTransferred } = frame.payload;
425
+
426
+ // Pending transfer completed
427
+ const pending = this.pending.get(sessionId);
428
+ if (pending) {
429
+ this.completePending(sessionId, {
430
+ success,
431
+ bytesTransferred: bytesTransferred ?? 0,
432
+ error: error || undefined,
433
+ });
434
+ return;
435
+ }
436
+
437
+ // Receiving transfer completed (pull mode serving — remote confirms)
438
+ const receiving = this.receiving.get(sessionId);
439
+ if (receiving) {
440
+ this.cleanupReceiving(sessionId);
441
+ }
442
+ }
443
+
444
+ destroy(): void {
445
+ for (const [id, transfer] of this.pending) {
446
+ clearTimeout(transfer.timer);
447
+ transfer.reject(new Error("FileTransferManager destroyed"));
448
+ }
449
+ this.pending.clear();
450
+
451
+ for (const [id, transfer] of this.receiving) {
452
+ clearTimeout(transfer.timer);
453
+ }
454
+ this.receiving.clear();
455
+ }
456
+
457
+ // ── Internal helpers ────────────────────────────────────────────
458
+
459
+ private ensureEnabled(): void {
460
+ if (!this.config.enabled) {
461
+ throw new Error("File transfer is not enabled");
462
+ }
463
+ }
464
+
465
+ private validatePath(resolvedPath: string): void {
466
+ if (!this.isPathAllowed(resolvedPath)) {
467
+ throw new Error(`Path not allowed: ${resolvedPath}`);
468
+ }
469
+ }
470
+
471
+ private isPathAllowed(resolvedPath: string): boolean {
472
+ // TODO(security): allowedPaths 为空时默认允许所有路径,当前仅用于受信任网络。
473
+ // 开放到非受信环境前需改为默认拒绝,或要求显式配置 allowedPaths。
474
+ if (this.config.allowedPaths.length === 0) return true;
475
+ const normalized = path.resolve(resolvedPath);
476
+ return this.config.allowedPaths.some((allowed) => {
477
+ const resolvedAllowed = path.resolve(allowed);
478
+ return normalized === resolvedAllowed || normalized.startsWith(resolvedAllowed + path.sep);
479
+ });
480
+ }
481
+
482
+ private sendAck(to: string, sessionId: string, frameId: string, accepted: boolean, error?: string): void {
483
+ this.peerManager.sendTo(to, {
484
+ type: "file_transfer_ack",
485
+ id: frameId,
486
+ from: this.nodeId,
487
+ to,
488
+ timestamp: Date.now(),
489
+ payload: { sessionId, accepted, error },
490
+ } as FileTransferAck);
491
+ }
492
+
493
+ private sendNextChunk(sessionId: string): void {
494
+ const transfer = this.pending.get(sessionId);
495
+ if (!transfer || !transfer.fileData) return;
496
+
497
+ const chunkIndex = transfer.sentChunks!;
498
+ const start = chunkIndex * transfer.chunkSize!;
499
+ const end = Math.min(start + transfer.chunkSize!, transfer.fileData.length);
500
+ const chunk = transfer.fileData.subarray(start, end);
501
+
502
+ this.peerManager.sendTo(transfer.remoteNode, {
503
+ type: "file_transfer_chunk",
504
+ id: transfer.sessionId,
505
+ from: this.nodeId,
506
+ to: transfer.remoteNode,
507
+ timestamp: Date.now(),
508
+ payload: {
509
+ sessionId,
510
+ chunkIndex,
511
+ data: chunk.toString("base64"),
512
+ },
513
+ } as FileTransferChunk);
514
+ }
515
+
516
+ private sendChunkFromReceiving(sessionId: string, chunkIndex: number, fileData: Buffer): void {
517
+ const receiving = this.receiving.get(sessionId);
518
+ if (!receiving) return;
519
+
520
+ const start = chunkIndex * receiving.chunkSize;
521
+ const end = Math.min(start + receiving.chunkSize, fileData.length);
522
+ const chunk = fileData.subarray(start, end);
523
+
524
+ this.peerManager.sendTo(receiving.fromNode, {
525
+ type: "file_transfer_chunk",
526
+ id: sessionId,
527
+ from: this.nodeId,
528
+ to: receiving.fromNode,
529
+ timestamp: Date.now(),
530
+ payload: {
531
+ sessionId,
532
+ chunkIndex,
533
+ data: chunk.toString("base64"),
534
+ },
535
+ } as FileTransferChunk);
536
+ }
537
+
538
+ private async finalizePushReceive(sessionId: string): Promise<void> {
539
+ const receiving = this.receiving.get(sessionId);
540
+ if (!receiving) return;
541
+
542
+ // Reassemble file
543
+ const buffers: Buffer[] = [];
544
+ for (let i = 0; i < receiving.totalChunks; i++) {
545
+ const chunk = receiving.chunks.get(i);
546
+ if (!chunk) {
547
+ this.sendComplete(receiving.fromNode, sessionId, false, "Missing chunk " + i);
548
+ this.cleanupReceiving(sessionId);
549
+ return;
550
+ }
551
+ buffers.push(chunk);
552
+ }
553
+
554
+ const assembled = Buffer.concat(buffers);
555
+
556
+ // Verify checksum
557
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
558
+ if (actualChecksum !== receiving.checksum) {
559
+ this.sendComplete(receiving.fromNode, sessionId, false, `Checksum mismatch: expected ${receiving.checksum}, got ${actualChecksum}`);
560
+ this.cleanupReceiving(sessionId);
561
+ return;
562
+ }
563
+
564
+ // Write file
565
+ try {
566
+ await mkdir(path.dirname(receiving.targetPath), { recursive: true });
567
+ await writeFile(receiving.targetPath, assembled);
568
+ } catch (err) {
569
+ this.sendComplete(receiving.fromNode, sessionId, false, `Write error: ${err instanceof Error ? err.message : String(err)}`);
570
+ this.cleanupReceiving(sessionId);
571
+ return;
572
+ }
573
+
574
+ this.sendComplete(receiving.fromNode, sessionId, true, undefined, assembled.length);
575
+ this.cleanupReceiving(sessionId);
576
+ }
577
+
578
+ private async finalizePull(sessionId: string): Promise<void> {
579
+ const transfer = this.pending.get(sessionId);
580
+ if (!transfer) return;
581
+
582
+ // Reassemble file
583
+ const buffers: Buffer[] = [];
584
+ for (let i = 0; i < transfer.expectedChunks!; i++) {
585
+ const chunk = transfer.chunks!.get(i);
586
+ if (!chunk) {
587
+ throw new Error(`Missing chunk ${i}`);
588
+ }
589
+ buffers.push(chunk);
590
+ }
591
+
592
+ const assembled = Buffer.concat(buffers);
593
+
594
+ // Verify checksum
595
+ const actualChecksum = createHash("sha256").update(assembled).digest("hex");
596
+ if (actualChecksum !== transfer.expectedChecksum) {
597
+ // Notify remote of failure
598
+ this.sendComplete(transfer.remoteNode, sessionId, false, `Checksum mismatch: expected ${transfer.expectedChecksum}, got ${actualChecksum}`);
599
+ this.completePending(sessionId, {
600
+ success: false,
601
+ bytesTransferred: 0,
602
+ error: `Checksum mismatch`,
603
+ });
604
+ return;
605
+ }
606
+
607
+ // Write file
608
+ await mkdir(path.dirname(transfer.targetPath), { recursive: true });
609
+ await writeFile(transfer.targetPath, assembled);
610
+
611
+ // Notify remote of success
612
+ this.sendComplete(transfer.remoteNode, sessionId, true, undefined, assembled.length);
613
+ this.completePending(sessionId, { success: true, bytesTransferred: assembled.length });
614
+ }
615
+
616
+ private sendComplete(to: string, sessionId: string, success: boolean, error?: string, bytesTransferred?: number): void {
617
+ this.peerManager.sendTo(to, {
618
+ type: "file_transfer_complete",
619
+ id: sessionId,
620
+ from: this.nodeId,
621
+ to,
622
+ timestamp: Date.now(),
623
+ payload: { sessionId, success, error, bytesTransferred },
624
+ } as FileTransferComplete);
625
+ }
626
+
627
+ private completePending(sessionId: string, result: TransferResult): void {
628
+ const transfer = this.pending.get(sessionId);
629
+ if (!transfer) return;
630
+ clearTimeout(transfer.timer);
631
+ this.pending.delete(sessionId);
632
+ transfer.resolve(result);
633
+ }
634
+
635
+ private cleanupReceiving(sessionId: string): void {
636
+ const receiving = this.receiving.get(sessionId);
637
+ if (!receiving) return;
638
+ clearTimeout(receiving.timer);
639
+ this.receiving.delete(sessionId);
640
+ }
641
+
642
+ private createTimer(sessionId: string, mapType: "pending" | "receiving"): ReturnType<typeof setTimeout> {
643
+ return setTimeout(() => {
644
+ if (mapType === "pending") {
645
+ const transfer = this.pending.get(sessionId);
646
+ if (transfer) {
647
+ this.pending.delete(sessionId);
648
+ transfer.reject(new Error("File transfer timeout"));
649
+ }
650
+ } else {
651
+ this.receiving.delete(sessionId);
652
+ }
653
+ }, this.config.timeout);
654
+ }
655
+
656
+ private resetTimer(sessionId: string, mapType: "pending" | "receiving"): void {
657
+ if (mapType === "pending") {
658
+ const transfer = this.pending.get(sessionId);
659
+ if (transfer) {
660
+ clearTimeout(transfer.timer);
661
+ transfer.timer = this.createTimer(sessionId, "pending");
662
+ }
663
+ } else {
664
+ const receiving = this.receiving.get(sessionId);
665
+ if (receiving) {
666
+ clearTimeout(receiving.timer);
667
+ receiving.timer = this.createTimer(sessionId, "receiving");
668
+ }
669
+ }
670
+ }
671
+ }
@@ -132,12 +132,17 @@ export class HealthTracker {
132
132
  // ── Event recording ─────────────────────────────────────────
133
133
 
134
134
  recordEvent(event: HealthEvent) {
135
+ // Strip undefined values — Automerge rejects them
136
+ const clean: Record<string, unknown> = {};
137
+ for (const [k, v] of Object.entries(event)) {
138
+ if (v !== undefined) clean[k] = v;
139
+ }
135
140
  this.doc = Automerge.change(this.doc, (d) => {
136
141
  if (!d.nodes[this.nodeId]) {
137
142
  d.nodes[this.nodeId] = { events: [], lastUpdated: 0 };
138
143
  }
139
144
  const entry = d.nodes[this.nodeId]!;
140
- entry.events.push({ ...event });
145
+ entry.events.push(clean as HealthEvent);
141
146
  entry.lastUpdated = Date.now();
142
147
  });
143
148
  this.scheduleSave();
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ import { createClusterDiagnosticTool } from "./tools/cluster-diagnostic.ts";
16
16
  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
+ import { createClusterTransferTool } from "./tools/cluster-transfer.ts";
19
20
  import { registerClusterCli } from "./cli.ts";
20
21
  import { spawnProcess } from "./compat.ts";
21
22
 
@@ -302,6 +303,7 @@ const plugin = {
302
303
  api.registerTool(createClusterAcpTool(), { optional: true });
303
304
  api.registerTool(createClusterTerminalTool(), { optional: true });
304
305
  api.registerTool(createClusterToolInvokeTool(), { optional: true });
306
+ api.registerTool(createClusterTransferTool(), { optional: true });
305
307
 
306
308
  // Wire up peer approval with OpenClaw channel API
307
309
  if (config.peerApproval.enabled) {
@@ -330,20 +332,23 @@ const plugin = {
330
332
 
331
333
  const proc = spawnProcess(
332
334
  ["openclaw", "gateway", "call", "send", "--json", "--params", JSON.stringify(sendParams)],
333
- { stdout: "pipe", stderr: "pipe" },
335
+ { stdout: "ignore", stderr: "pipe" },
334
336
  );
335
- const code = await proc.exited;
336
- if (code !== 0) {
337
- const stderrChunks: Uint8Array[] = [];
337
+ // Collect stderr concurrently with waiting for exit to avoid pipe deadlock
338
+ const stderrPromise = (async () => {
339
+ const chunks: Uint8Array[] = [];
338
340
  if (proc.stderr) {
339
341
  const reader = proc.stderr.getReader();
340
342
  while (true) {
341
343
  const { done, value } = await reader.read();
342
344
  if (done) break;
343
- stderrChunks.push(value);
345
+ chunks.push(value);
344
346
  }
345
347
  }
346
- const errMsg = Buffer.concat(stderrChunks).toString("utf-8").trim();
348
+ return Buffer.concat(chunks).toString("utf-8").trim();
349
+ })();
350
+ const [code, errMsg] = await Promise.all([proc.exited, stderrPromise]);
351
+ if (code !== 0) {
347
352
  throw new Error(`gateway send failed (exit ${code}): ${errMsg}`);
348
353
  }
349
354
  });
@@ -128,6 +128,8 @@ export class KnowledgeSync {
128
128
  // ── FS / Watcher ───────────────────────────────────────────────
129
129
  private watcher: FSWatcher | null = null;
130
130
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
131
+ private localChangesRunning = false;
132
+ private localChangesQueued = false;
131
133
  /** Paths recently written by exportFileToFs — suppress watcher re-trigger. Stores {content, timestamp}. */
132
134
  private writtenByExport = new Map<string, { content: string; ts: number }>();
133
135
  /** Deferred git commit timer — batches multiple remote syncs into one commit. */
@@ -474,6 +476,27 @@ export class KnowledgeSync {
474
476
  }
475
477
 
476
478
  private async handleLocalChanges() {
479
+ // Mutex: if already running, mark queued and return — the running
480
+ // invocation will re-run when it finishes to pick up new changes.
481
+ if (this.localChangesRunning) {
482
+ this.localChangesQueued = true;
483
+ return;
484
+ }
485
+ this.localChangesRunning = true;
486
+ try {
487
+ await this.handleLocalChangesInner();
488
+ } finally {
489
+ this.localChangesRunning = false;
490
+ if (this.localChangesQueued) {
491
+ this.localChangesQueued = false;
492
+ this.handleLocalChanges().catch((err) => {
493
+ debug(TAG, `queued local change handling error: ${err}`);
494
+ });
495
+ }
496
+ }
497
+ }
498
+
499
+ private async handleLocalChangesInner() {
477
500
  // Only process files in pendingChanges (incremental)
478
501
  const changesToProcess = new Set(this.pendingChanges);
479
502
  this.pendingChanges.clear();
@@ -802,7 +825,13 @@ export class KnowledgeSync {
802
825
  if (this.isIgnored(relPath)) return;
803
826
 
804
827
  const content = doc.content ?? "";
805
- const absPath = path.join(this.opts.workspacePath, relPath);
828
+ const absPath = path.resolve(this.opts.workspacePath, relPath);
829
+
830
+ // Prevent path traversal (e.g. relPath = "../../etc/passwd")
831
+ if (!absPath.startsWith(this.opts.workspacePath + path.sep) && absPath !== this.opts.workspacePath) {
832
+ debug(TAG, `blocked path traversal attempt: ${relPath}`);
833
+ return;
834
+ }
806
835
 
807
836
  let currentContent: string | null = null;
808
837
  try {
@@ -46,6 +46,9 @@ const SKIP_DEDUP_TYPES = new Set([
46
46
  // Terminal
47
47
  "terminal_open_res", "terminal_data", "terminal_resize",
48
48
  "terminal_close", "terminal_close_res",
49
+ // File transfer
50
+ "file_transfer_chunk", "file_transfer_chunk_ack",
51
+ "file_transfer_ack", "file_transfer_complete",
49
52
  ]);
50
53
 
51
54
  /** Classify WebSocket close code into a human-readable reason. */
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { fork, type ChildProcess } from "node:child_process";
10
10
  import { join, dirname } from "node:path";
11
- import { existsSync, readFileSync, mkdirSync, openSync } from "node:fs";
11
+ import { existsSync, readFileSync, mkdirSync, openSync, closeSync } from "node:fs";
12
12
  import { homedir, tmpdir } from "node:os";
13
13
  import type { ClawMatrixConfig } from "./config.ts";
14
14
 
@@ -42,6 +42,9 @@ export class SentinelManager {
42
42
  execArgv: this.resolveExecArgv(),
43
43
  });
44
44
 
45
+ // Close the log fd in the parent — the child has its own copy
46
+ closeSync(logFd);
47
+
45
48
  // Send config to sentinel via IPC (includes gateway PID for health checks)
46
49
  // If sentinel has no explicit listenPort but the gateway is a listener,
47
50
  // inherit the gateway's port for automatic takeover when gateway dies.
@@ -83,7 +86,7 @@ export class SentinelManager {
83
86
  }, 1000);
84
87
  }
85
88
 
86
- stop() {
89
+ async stop() {
87
90
  // IPC is disconnected shortly after start, so use PID file for shutdown
88
91
  if (existsSync(this.pidFile)) {
89
92
  try {
@@ -92,13 +95,11 @@ export class SentinelManager {
92
95
  process.kill(pid, "SIGTERM");
93
96
  // Wait briefly for the process to exit so the next start()
94
97
  // doesn't race with a still-dying sentinel
95
- const deadline = Date.now() + 3_000;
96
- while (Date.now() < deadline) {
98
+ for (let i = 0; i < 60; i++) {
97
99
  try {
98
100
  process.kill(pid, 0);
99
- // Still alive — brief spin
100
- const waitUntil = Date.now() + 50;
101
- while (Date.now() < waitUntil) { /* spin */ }
101
+ // Still alive — async wait
102
+ await new Promise((r) => setTimeout(r, 50));
102
103
  } catch {
103
104
  break; // exited
104
105
  }
package/src/terminal.ts CHANGED
@@ -109,7 +109,8 @@ export class TerminalManager {
109
109
  return;
110
110
  }
111
111
 
112
- // Check allowFrom
112
+ // TODO(security): allowFrom 为空时默认允许所有已认证 peer 打开终端会话。
113
+ // 当前仅用于受信任网络。开放前需改为默认拒绝或要求显式配置。
113
114
  if (termConfig?.allowFrom && termConfig.allowFrom.length > 0) {
114
115
  if (!termConfig.allowFrom.includes(frame.from)) {
115
116
  this.peerManager.sendTo(frame.from, {
@@ -88,6 +88,8 @@ export function createClusterDiagnosticTool(): AnyAgentTool {
88
88
  };
89
89
  }
90
90
 
91
+ // TODO(security): exec 允许任何已认证 peer 在远程 sentinel 执行任意命令,无 allowlist 或 capability check。
92
+ // 当前仅用于受信任网络。开放前需添加命令白名单或 per-peer 授权。
91
93
  if (action === "exec") {
92
94
  if (!command) {
93
95
  return {
@@ -0,0 +1,91 @@
1
+ import type { AnyAgentTool } from "openclaw/plugin-sdk";
2
+ import { getClusterRuntime } from "../cluster-service.ts";
3
+
4
+ export function createClusterTransferTool(): AnyAgentTool {
5
+ return {
6
+ name: "cluster_transfer",
7
+ label: "Cluster File Transfer",
8
+ description:
9
+ "Transfer a file between the local node and a remote cluster node. " +
10
+ "Supports large files (up to 100MB) with chunked transfer and SHA-256 integrity check. " +
11
+ "Specify source_node to pull from remote, or target_node to push to remote.",
12
+ parameters: {
13
+ type: "object",
14
+ properties: {
15
+ source_node: {
16
+ type: "string",
17
+ description: "Source nodeId (omit for local). Exactly one of source_node or target_node must be provided.",
18
+ },
19
+ source_path: {
20
+ type: "string",
21
+ description: "File path on the source node",
22
+ },
23
+ target_node: {
24
+ type: "string",
25
+ description: "Target nodeId (omit for local). Exactly one of source_node or target_node must be provided.",
26
+ },
27
+ target_path: {
28
+ type: "string",
29
+ description: "File path on the target node",
30
+ },
31
+ },
32
+ required: ["source_path", "target_path"],
33
+ },
34
+ async execute(_toolCallId, params) {
35
+ const { source_node, source_path, target_node, target_path } = params as {
36
+ source_node?: string;
37
+ source_path: string;
38
+ target_node?: string;
39
+ target_path: string;
40
+ };
41
+
42
+ // Validate: exactly one of source_node or target_node must be provided
43
+ if (source_node && target_node) {
44
+ return {
45
+ content: [{ type: "text" as const, text: "Error: Provide either source_node or target_node, not both." }],
46
+ details: { error: true },
47
+ };
48
+ }
49
+ if (!source_node && !target_node) {
50
+ return {
51
+ content: [{ type: "text" as const, text: "Error: Provide either source_node (to pull) or target_node (to push)." }],
52
+ details: { error: true },
53
+ };
54
+ }
55
+
56
+ try {
57
+ const runtime = getClusterRuntime();
58
+ const ftm = runtime.fileTransferManager;
59
+ if (!ftm) {
60
+ return {
61
+ content: [{ type: "text" as const, text: "Error: File transfer is not enabled on this node." }],
62
+ details: { error: true },
63
+ };
64
+ }
65
+
66
+ let result;
67
+ if (source_node) {
68
+ // Pull: remote → local
69
+ result = await ftm.pullFile(source_node, source_path, target_path);
70
+ } else {
71
+ // Push: local → remote
72
+ result = await ftm.pushFile(target_node!, source_path, target_path);
73
+ }
74
+
75
+ const text = result.success
76
+ ? `Transfer complete: ${result.bytesTransferred} bytes transferred.`
77
+ : `Transfer failed: ${result.error}`;
78
+
79
+ return {
80
+ content: [{ type: "text" as const, text }],
81
+ details: result,
82
+ };
83
+ } catch (err) {
84
+ return {
85
+ content: [{ type: "text" as const, text: `Transfer error: ${err instanceof Error ? err.message : String(err)}` }],
86
+ details: { error: true },
87
+ };
88
+ }
89
+ },
90
+ };
91
+ }
package/src/types.ts CHANGED
@@ -317,6 +317,68 @@ export interface ToolBatchResponse extends ClusterFrame {
317
317
  };
318
318
  }
319
319
 
320
+ // ── File transfer ─────────────────────────────────────────────────
321
+ export interface FileTransferInit extends ClusterFrame {
322
+ type: "file_transfer_init";
323
+ id: string;
324
+ payload: {
325
+ sessionId: string;
326
+ direction: "push" | "pull";
327
+ filePath: string;
328
+ targetPath: string;
329
+ fileSize: number;
330
+ totalChunks: number;
331
+ chunkSize: number;
332
+ checksum: string; // SHA-256 hex
333
+ };
334
+ }
335
+
336
+ export interface FileTransferAck extends ClusterFrame {
337
+ type: "file_transfer_ack";
338
+ id: string;
339
+ payload: {
340
+ sessionId: string;
341
+ accepted: boolean;
342
+ error?: string;
343
+ // Pull mode: responder includes file metadata
344
+ fileSize?: number;
345
+ totalChunks?: number;
346
+ checksum?: string;
347
+ };
348
+ }
349
+
350
+ export interface FileTransferChunk extends ClusterFrame {
351
+ type: "file_transfer_chunk";
352
+ id: string;
353
+ payload: {
354
+ sessionId: string;
355
+ chunkIndex: number;
356
+ data: string; // base64-encoded
357
+ };
358
+ }
359
+
360
+ export interface FileTransferChunkAck extends ClusterFrame {
361
+ type: "file_transfer_chunk_ack";
362
+ id: string;
363
+ payload: {
364
+ sessionId: string;
365
+ chunkIndex: number;
366
+ success: boolean;
367
+ error?: string;
368
+ };
369
+ }
370
+
371
+ export interface FileTransferComplete extends ClusterFrame {
372
+ type: "file_transfer_complete";
373
+ id: string;
374
+ payload: {
375
+ sessionId: string;
376
+ success: boolean;
377
+ error?: string;
378
+ bytesTransferred?: number;
379
+ };
380
+ }
381
+
320
382
  // ── Device info ───────────────────────────────────────────────────
321
383
  export interface DeviceInfo {
322
384
  os: string; // e.g. "Darwin 24.6.0", "Linux 6.1.0"
@@ -419,6 +481,24 @@ export interface HealthSyncFrame extends ClusterFrame {
419
481
  };
420
482
  }
421
483
 
484
+ export interface AvailabilityRequest extends ClusterFrame {
485
+ type: "availability_req";
486
+ id: string;
487
+ payload: {
488
+ range: "24h" | "7d" | "90d";
489
+ };
490
+ }
491
+
492
+ export interface AvailabilityResponse extends ClusterFrame {
493
+ type: "availability_res";
494
+ id: string;
495
+ payload: {
496
+ success: boolean;
497
+ data?: unknown;
498
+ error?: string;
499
+ };
500
+ }
501
+
422
502
  // ── Diagnostic (sentinel) ────────────────────────────────────────
423
503
  export interface DiagnosticExec extends ClusterFrame {
424
504
  type: "diagnostic_exec";
@@ -840,4 +920,11 @@ export type AnyClusterFrame =
840
920
  | TerminalResize
841
921
  | TerminalCloseRequest
842
922
  | TerminalCloseResponse
843
- | HealthSyncFrame;
923
+ | HealthSyncFrame
924
+ | AvailabilityRequest
925
+ | AvailabilityResponse
926
+ | FileTransferInit
927
+ | FileTransferAck
928
+ | FileTransferChunk
929
+ | FileTransferChunkAck
930
+ | FileTransferComplete;