clawmatrix 0.4.2 → 0.5.0

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.
@@ -15,10 +15,15 @@ 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
17
  import { FileTransferManager } from "./file-transfer.ts";
18
- import { WebHandler } from "./web.ts";
18
+ import { ApiHandler } from "./api.ts";
19
19
  import { KnowledgeSync } from "./knowledge-sync.ts";
20
20
  import { HealthTracker } from "./health-tracker.ts";
21
+ import { KanbanManager } from "./kanban.ts";
21
22
  import { SentinelManager } from "./sentinel-manager.ts";
23
+ import { AutomationManager } from "./automation.ts";
24
+ import { Store } from "./store.ts";
25
+ import { LogReplicator } from "./log-replication.ts";
26
+ import { setAuditStore } from "./audit.ts";
22
27
  import type {
23
28
  AnyClusterFrame,
24
29
  HandoffRequest,
@@ -30,7 +35,6 @@ import type {
30
35
  HandoffInputRequired,
31
36
  HandoffInput,
32
37
  KnowledgeSyncFrame,
33
- HealthSyncFrame,
34
38
  AvailabilityRequest,
35
39
  AvailabilityResponse,
36
40
  ModelRequest,
@@ -71,6 +75,9 @@ import type {
71
75
  FileTransferChunk,
72
76
  FileTransferChunkAck,
73
77
  FileTransferComplete,
78
+ KanbanSyncFrame,
79
+ KanbanNotifyFrame,
80
+ LogSyncFrame,
74
81
  } from "./types.ts";
75
82
 
76
83
  function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
@@ -96,8 +103,15 @@ export class ClusterRuntime {
96
103
  readonly terminalManager: TerminalManager;
97
104
  readonly fileTransferManager: FileTransferManager | null;
98
105
  knowledgeSync: KnowledgeSync | null = null;
106
+ /** Resolved workspace path (null if not configured). */
107
+ workspacePath: string | null = null;
99
108
  healthTracker: HealthTracker;
100
- webHandler: WebHandler | null = null;
109
+ kanbanManager: KanbanManager | null = null;
110
+ apiHandler: ApiHandler | null = null;
111
+ automationManager: AutomationManager | null = null;
112
+ store: Store | null = null;
113
+ logReplicator: LogReplicator | null = null;
114
+ private storeCleanupTimer: ReturnType<typeof setInterval> | null = null;
101
115
  private sentinelManager: SentinelManager | null = null;
102
116
  private logger: PluginLogger;
103
117
  private openclawConfig: OpenClawConfig;
@@ -113,7 +127,7 @@ export class ClusterRuntime {
113
127
  this.logger = logger;
114
128
  this.openclawConfig = openclawConfig;
115
129
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
116
- this.peerManager = new PeerManager(config, openclawVersion);
130
+ this.peerManager = new PeerManager(config, openclawVersion, openclawConfig as Record<string, unknown>);
117
131
  this.handoffManager = new HandoffManager(config, this.peerManager, gatewayInfo);
118
132
  this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
119
133
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo, logger);
@@ -126,7 +140,6 @@ export class ClusterRuntime {
126
140
  : null;
127
141
  this.healthTracker = new HealthTracker({
128
142
  nodeId: config.nodeId,
129
- peerManager: this.peerManager,
130
143
  });
131
144
  // Build agent indexes
132
145
  for (const a of config.agents) {
@@ -169,19 +182,48 @@ export class ClusterRuntime {
169
182
  this.trackRelayPeerHealth();
170
183
  });
171
184
 
172
- // Web dashboard (must be set before peerManager.start() creates the HTTP server)
173
- if (this.config.web?.enabled) {
174
- this.webHandler = new WebHandler(this.config, this.peerManager, this.handoffManager);
175
- this.peerManager.setHttpHandler((req, res) => this.webHandler!.handle(req, res));
176
- // Enable satellite tool routing through WebHandler
177
- this.toolProxy.setSatelliteHandler(this.webHandler);
178
- this.webHandler.setHealthTracker(this.healthTracker);
179
- this.logger.info(`[clawmatrix] Web dashboard enabled on listen port`);
185
+ // HTTP API (must be set before peerManager.start() creates the HTTP server)
186
+ if (this.config.listen) {
187
+ this.apiHandler = new ApiHandler(this.config, this.peerManager, this.handoffManager);
188
+ this.peerManager.setHttpHandler((req, res) => this.apiHandler!.handle(req, res));
189
+ // Enable satellite tool routing through ApiHandler
190
+ this.toolProxy.setSatelliteHandler(this.apiHandler);
191
+ this.apiHandler.setHealthTracker(this.healthTracker);
192
+ this.logger.info(`[clawmatrix] HTTP API enabled on listen port`);
180
193
  }
181
194
 
195
+ // Automation manager (always enabled — no rules = no overhead)
196
+ {
197
+ const workspacePath = this.resolveWorkspacePath();
198
+ const stateDir = workspacePath
199
+ ? path.join(workspacePath, ".clawmatrix")
200
+ : null;
201
+ if (stateDir) {
202
+ const automationsPath = path.join(stateDir, "automations.json");
203
+ this.automationManager = new AutomationManager(
204
+ automationsPath, this.handoffManager, this.peerManager, this.config.nodeId,
205
+ );
206
+ if (this.apiHandler) {
207
+ this.apiHandler.setAutomationManager(this.automationManager);
208
+ }
209
+ this.automationManager.setToolInvoker(this.toolProxy);
210
+ this.automationManager.start().catch((err) => {
211
+ this.logger.error(`[clawmatrix] Automation manager failed to start: ${err}`);
212
+ });
213
+ }
214
+ }
215
+
216
+ // Resolve workspace path (used by knowledge sync)
217
+ this.workspacePath = this.resolveWorkspacePath();
218
+
182
219
  // Knowledge sync
183
220
  if (this.config.knowledge?.enabled) {
184
- const workspacePath = this.resolveWorkspacePath();
221
+ // Advertise "knowledge" capability so peers can detect it
222
+ if (!this.config.tags.includes("knowledge")) {
223
+ this.config.tags.push("knowledge");
224
+ }
225
+
226
+ const workspacePath = this.workspacePath;
185
227
  if (workspacePath) {
186
228
  const stateDir = path.join(workspacePath, ".clawmatrix");
187
229
  this.knowledgeSync = new KnowledgeSync({
@@ -198,6 +240,9 @@ export class ClusterRuntime {
198
240
  }).catch((err) => {
199
241
  this.logger.error(`[clawmatrix] Knowledge sync failed to start: ${err}`);
200
242
  });
243
+ if (this.apiHandler) {
244
+ this.apiHandler.setKnowledgeSync(this.knowledgeSync);
245
+ }
201
246
 
202
247
  // Sync with peers on connect/disconnect
203
248
  this.peerManager.on("peerConnected", (nodeId) => {
@@ -206,6 +251,20 @@ export class ClusterRuntime {
206
251
  this.peerManager.on("peerDisconnected", (nodeId) => {
207
252
  this.knowledgeSync?.removePeerSync(nodeId);
208
253
  });
254
+
255
+ // Wire ACP tool write attribution to knowledge-sync
256
+ if (this.acpProxy) {
257
+ const ks = this.knowledgeSync;
258
+ const nodeId = this.config.nodeId;
259
+ const agentTypeMap: Record<string, string> = { claude: "Claude Code", codex: "Codex", gemini: "Gemini CLI" };
260
+ this.acpProxy.onToolWrite = (filePath, agentId) => {
261
+ ks.setPendingAttribution(filePath, {
262
+ nodeId,
263
+ agentId,
264
+ agentType: agentTypeMap[agentId] ?? agentId,
265
+ });
266
+ };
267
+ }
209
268
  }
210
269
  }
211
270
 
@@ -222,11 +281,87 @@ export class ClusterRuntime {
222
281
  });
223
282
  }
224
283
 
284
+ // SQLite store + log replication
285
+ {
286
+ const workspacePath = this.resolveWorkspacePath();
287
+ const stateDir = workspacePath
288
+ ? path.join(workspacePath, ".clawmatrix")
289
+ : path.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "clawmatrix");
290
+ try {
291
+ this.store = new Store(path.join(stateDir, "clawmatrix.db"));
292
+ this.logReplicator = new LogReplicator({
293
+ store: this.store,
294
+ nodeId: this.config.nodeId,
295
+ sendTo: (peerId, frame) => this.peerManager.sendTo(peerId, frame),
296
+ });
297
+
298
+ this.peerManager.on("peerConnected", (nodeId) => {
299
+ this.logReplicator?.initPeerSync(nodeId);
300
+ });
301
+ this.peerManager.on("peerDisconnected", (nodeId) => {
302
+ this.logReplicator?.removePeerSync(nodeId);
303
+ });
304
+
305
+ // Periodic TTL cleanup (every 24h)
306
+ const retentionMs = (this.config.store?.retentionDays ?? 90) * 86400_000;
307
+ this.storeCleanupTimer = setInterval(() => {
308
+ const cutoff = Date.now() - retentionMs;
309
+ const result = this.store?.cleanup(cutoff);
310
+ if (result) {
311
+ const total = result.audit + result.health + result.handoff + result.satellite + result.ingested + result.automation;
312
+ if (total > 0) this.logger.info(`[clawmatrix] Store cleanup: ${total} old rows removed`);
313
+ }
314
+ }, 86400_000);
315
+
316
+ // Wire subsystems to store for persistence
317
+ setAuditStore(this.store, this.config.nodeId, this.logReplicator!);
318
+ this.handoffManager.store = this.store;
319
+ this.handoffManager.logReplicator = this.logReplicator;
320
+ this.healthTracker.setStore(this.store, this.logReplicator!);
321
+ if (this.acpProxy) this.acpProxy.store = this.store;
322
+ this.apiHandler?.setStore(this.store);
323
+ this.automationManager?.setStore(this.store);
324
+
325
+ this.logger.info(`[clawmatrix] Store initialized at ${stateDir}/clawmatrix.db`);
326
+ } catch (err) {
327
+ this.logger.warn(`[clawmatrix] Store initialization failed (non-fatal): ${err}`);
328
+ }
329
+ }
330
+
225
331
  // Health tracker (always enabled, no config gate)
226
332
  this.healthTracker.start().catch((err) => {
227
333
  this.logger.error(`[clawmatrix] Health tracker failed to start: ${err}`);
228
334
  });
229
335
 
336
+ // Kanban board
337
+ if (this.config.kanban?.enabled) {
338
+ const workspacePath = this.resolveWorkspacePath();
339
+ const stateDir = workspacePath
340
+ ? path.join(workspacePath, ".clawmatrix")
341
+ : undefined;
342
+ this.kanbanManager = new KanbanManager({
343
+ nodeId: this.config.nodeId,
344
+ peerManager: this.peerManager,
345
+ prefix: this.config.kanban.prefix,
346
+ autoAssign: this.config.kanban.autoAssign,
347
+ archiveAfterMs: this.config.kanban.archiveAfterMs,
348
+ stateDir,
349
+ });
350
+ this.kanbanManager.start().catch((err) => {
351
+ this.logger.error(`[clawmatrix] Kanban manager failed to start: ${err}`);
352
+ });
353
+ if (this.apiHandler) {
354
+ this.apiHandler.setKanbanManager(this.kanbanManager);
355
+ }
356
+
357
+ this.peerManager.on("peerConnected", (nodeId) => {
358
+ this.kanbanManager?.initPeerSync(nodeId);
359
+ });
360
+ this.peerManager.on("peerDisconnected", (nodeId) => {
361
+ this.kanbanManager?.removePeerSync(nodeId);
362
+ });
363
+ }
364
+
230
365
  // Sentinel: detached subprocess for diagnostics when gateway dies.
231
366
  // Default on; starts when not explicitly disabled AND (has outbound peers OR gateway is a listener for port takeover)
232
367
  const sentinelEnabled = (this.config.sentinel?.enabled ?? true) && (this.config.peers.length > 0 || this.config.listen);
@@ -299,15 +434,21 @@ export class ClusterRuntime {
299
434
  }
300
435
 
301
436
  private async stopInternal() {
437
+ this.automationManager?.stop();
302
438
  await this.healthTracker.stop();
439
+ await this.kanbanManager?.stop();
303
440
  await this.knowledgeSync?.stop();
304
441
  this.syncCleanup();
305
442
  await this.peerManager.stop();
443
+ // Close store after all subsystems are done
444
+ if (this.storeCleanupTimer) { clearInterval(this.storeCleanupTimer); this.storeCleanupTimer = null; }
445
+ this.logReplicator?.destroy();
446
+ this.store?.close();
306
447
  }
307
448
 
308
449
  /** Synchronous cleanup that never blocks. */
309
450
  private syncCleanup() {
310
- this.webHandler?.destroy();
451
+ this.apiHandler?.destroy();
311
452
  this.handoffManager.destroy();
312
453
  this.acpProxy?.destroy();
313
454
  this.terminalManager.destroy();
@@ -496,7 +637,8 @@ export class ClusterRuntime {
496
637
  });
497
638
  break;
498
639
  case "health_sync":
499
- this.healthTracker.handleSyncMessage(frame as HealthSyncFrame);
640
+ // Legacy: Automerge health sync frames are ignored.
641
+ // Health events now sync via log_sync (LogReplicator).
500
642
  break;
501
643
  case "availability_req": {
502
644
  const af = frame as AvailabilityRequest;
@@ -720,6 +862,20 @@ export class ClusterRuntime {
720
862
  case "file_transfer_complete":
721
863
  this.fileTransferManager?.handleComplete(frame as FileTransferComplete);
722
864
  break;
865
+ case "log_sync":
866
+ this.logReplicator?.handleSyncMessage(frame as LogSyncFrame);
867
+ break;
868
+ case "kanban_sync":
869
+ this.kanbanManager?.handleSyncMessage(frame as KanbanSyncFrame);
870
+ break;
871
+ case "kanban_notify":
872
+ // Kanban notifications are consumed by mobile/web clients.
873
+ // Push to satellite events if web handler is active.
874
+ if (this.apiHandler) {
875
+ const kf = frame as KanbanNotifyFrame;
876
+ this.apiHandler.pushKanbanEvent(kf.payload);
877
+ }
878
+ break;
723
879
  }
724
880
  }
725
881
 
package/src/compat.ts CHANGED
@@ -70,6 +70,109 @@ export function spawnProcess(
70
70
  };
71
71
  }
72
72
 
73
+ // ── SQLite support ────────────────────────────────────────────────
74
+ //
75
+ // Normalized interface over bun:sqlite (Bun runtime) and better-sqlite3 (Node.js).
76
+ // Tests run with `bun test` → bun:sqlite; production runs inside OpenClaw Node.js → better-sqlite3.
77
+
78
+ export interface SqliteDatabase {
79
+ exec(sql: string): void;
80
+ prepare(sql: string): SqliteStatement;
81
+ close(): void;
82
+ transaction<T extends (...args: unknown[]) => unknown>(fn: T): T;
83
+ }
84
+
85
+ export interface SqliteStatement {
86
+ run(...params: unknown[]): SqliteRunResult;
87
+ all(...params: unknown[]): unknown[];
88
+ get(...params: unknown[]): unknown;
89
+ }
90
+
91
+ export interface SqliteRunResult {
92
+ changes: number;
93
+ lastInsertRowid: number;
94
+ }
95
+
96
+ export function openDatabase(dbPath: string): SqliteDatabase {
97
+ // Bun runtime: use bun:sqlite (built-in, no native addon needed)
98
+ if (typeof globalThis.Bun !== "undefined") {
99
+ return openBunSqlite(dbPath);
100
+ }
101
+ // Node.js runtime: use better-sqlite3
102
+ return openBetterSqlite3(dbPath);
103
+ }
104
+
105
+ function openBunSqlite(dbPath: string): SqliteDatabase {
106
+ // Dynamic require to avoid bundler issues in Node.js
107
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
108
+ const { Database } = require("bun:sqlite");
109
+ const db = new Database(dbPath);
110
+ db.exec("PRAGMA journal_mode = WAL");
111
+ db.exec("PRAGMA synchronous = NORMAL");
112
+
113
+ const changesStmt = db.prepare("SELECT changes() AS v");
114
+ const rowidStmt = db.prepare("SELECT last_insert_rowid() AS v");
115
+
116
+ return {
117
+ exec: (sql: string) => db.exec(sql),
118
+ prepare: (sql: string) => {
119
+ const stmt = db.prepare(sql);
120
+ return {
121
+ run(...params: unknown[]): SqliteRunResult {
122
+ stmt.run(...params);
123
+ const changes: number = Number((changesStmt.get() as Record<string, unknown>).v);
124
+ const lastInsertRowid: number = Number((rowidStmt.get() as Record<string, unknown>).v);
125
+ return { changes, lastInsertRowid };
126
+ },
127
+ all(...params: unknown[]): unknown[] {
128
+ return stmt.all(...params);
129
+ },
130
+ get(...params: unknown[]): unknown {
131
+ return stmt.get(...params);
132
+ },
133
+ };
134
+ },
135
+ close: () => db.close(),
136
+ transaction: <T extends (...args: unknown[]) => unknown>(fn: T): T => db.transaction(fn),
137
+ };
138
+ }
139
+
140
+ function openBetterSqlite3(dbPath: string): SqliteDatabase {
141
+ const req = createRequire(import.meta.url);
142
+ let BetterSqlite3: unknown;
143
+ try {
144
+ BetterSqlite3 = req("better-sqlite3");
145
+ } catch {
146
+ throw new Error("better-sqlite3 is not available — install it with: npm install better-sqlite3");
147
+ }
148
+
149
+ const db = new (BetterSqlite3 as new (path: string) => Record<string, unknown>)(dbPath) as Record<string, (...args: unknown[]) => unknown>;
150
+ (db.pragma as (s: string) => void)("journal_mode = WAL");
151
+ (db.pragma as (s: string) => void)("synchronous = NORMAL");
152
+
153
+ return {
154
+ exec: (sql: string) => (db.exec as (s: string) => void)(sql),
155
+ prepare: (sql: string) => {
156
+ const stmt = (db.prepare as (s: string) => Record<string, (...args: unknown[]) => unknown>)(sql);
157
+ return {
158
+ run(...params: unknown[]): SqliteRunResult {
159
+ const result = stmt.run(...params) as { changes: number; lastInsertRowid: number | bigint };
160
+ return { changes: result.changes, lastInsertRowid: Number(result.lastInsertRowid) };
161
+ },
162
+ all(...params: unknown[]): unknown[] {
163
+ return stmt.all(...params) as unknown[];
164
+ },
165
+ get(...params: unknown[]): unknown {
166
+ return stmt.get(...params);
167
+ },
168
+ };
169
+ },
170
+ close: () => (db.close as () => void)(),
171
+ transaction: <T extends (...args: unknown[]) => unknown>(fn: T): T =>
172
+ (db.transaction as (fn: T) => T)(fn),
173
+ };
174
+ }
175
+
73
176
  // ── PTY support (optional, requires node-pty) ────────────────────
74
177
 
75
178
  export interface PtyHandle {
package/src/config.ts CHANGED
@@ -49,12 +49,15 @@ const ModelInfoSchema = z.object({
49
49
  ...ModelParamsSchema,
50
50
  });
51
51
 
52
- const PeerConfigSchema = z.object({
52
+ const PeerConfigObjectSchema = z.object({
53
53
  nodeId: z.string(),
54
54
  /** Single URL or array of URLs for multi-channel connections. */
55
55
  url: z.union([z.string(), z.array(z.string()).min(1)]),
56
56
  });
57
57
 
58
+ /** Peer config: full object or URL string shorthand (nodeId derived from handshake). */
59
+ const PeerConfigSchema = z.union([z.string(), PeerConfigObjectSchema]);
60
+
58
61
  const ToolProxyConfigSchema = z.object({
59
62
  enabled: z.boolean().default(false),
60
63
  allow: z.array(z.string()).default([]),
@@ -92,11 +95,6 @@ const SentinelConfigSchema = z.object({
92
95
  listenHost: z.string().optional(),
93
96
  }).optional();
94
97
 
95
- const WebConfigSchema = z.object({
96
- enabled: z.boolean().default(false),
97
- token: z.string().min(8, "web token must be at least 8 characters"),
98
- }).optional();
99
-
100
98
  const AcpAgentInfoSchema = z.object({
101
99
  id: z.string(),
102
100
  description: z.string().default(""),
@@ -143,6 +141,16 @@ const FileTransferConfigSchema = z.object({
143
141
  allowedPaths: z.array(z.string()).default([]), // empty = no restriction
144
142
  }).optional();
145
143
 
144
+ const KanbanConfigSchema = z.object({
145
+ enabled: z.boolean().default(false),
146
+ /** Card ID prefix (e.g. "CM" → CM-1, CM-2). */
147
+ prefix: z.string().default("CM"),
148
+ /** Allow agents to auto-claim matching cards. */
149
+ autoAssign: z.boolean().default(true),
150
+ /** Archive cards older than this (ms). Default: 30 days. */
151
+ archiveAfterMs: z.number().default(30 * 24 * 60 * 60 * 1000),
152
+ }).optional();
153
+
146
154
  const AcpConfigSchema = z.object({
147
155
  enabled: z.boolean().default(false),
148
156
  /** ACP agents available on this node. Advertised to peers via capabilities. */
@@ -164,30 +172,38 @@ const RawClawMatrixConfigSchema = z.object({
164
172
  listenHost: z.string().default("0.0.0.0"),
165
173
  listenPort: z.number().int().min(0).max(65535).default(0),
166
174
  peers: z.array(PeerConfigSchema).default([]),
175
+ /** Agents provided by this node. Auto-discovered from OpenClaw if omitted. */
167
176
  agents: z.array(AgentInfoSchema).default([]),
168
177
  models: z.array(ModelInfoSchema).default([]),
169
178
  proxyModels: z.array(ProxyModelGroupSchema).default([]),
170
179
  tags: z.array(z.string()).default([]),
171
180
  proxyPort: z.number().int().min(0).max(65535).default(0),
172
- toolProxy: ToolProxyConfigSchema.optional(),
181
+ /** Tool proxy: boolean shorthand (true = enable all, false = disable) or full object. */
182
+ toolProxy: z.union([z.boolean(), ToolProxyConfigSchema]).optional(),
173
183
  handoffTimeout: z.number().positive().default(600_000),
174
184
  modelTimeout: z.number().positive().default(120_000),
185
+ modelConcurrency: z.number().positive().default(5),
175
186
  toolTimeout: z.number().positive().default(30_000),
176
187
  /** Grace period (ms) before broadcasting peer_leave after disconnect.
177
188
  * Allows brief reconnections (WiFi/cellular handoff) to be invisible to the mesh. */
178
189
  disconnectGrace: z.number().nonnegative().default(30_000),
179
190
  sentinel: SentinelConfigSchema,
180
- web: WebConfigSchema,
181
191
  knowledge: KnowledgeConfigSchema,
182
192
  terminal: TerminalConfigSchema,
193
+ kanban: KanbanConfigSchema,
183
194
  acp: AcpConfigSchema,
184
195
  fileTransfer: FileTransferConfigSchema,
196
+ store: z.object({
197
+ /** Retention period in days for replicated and local event data (default: 90). */
198
+ retentionDays: z.number().default(90),
199
+ }).optional(),
185
200
  peerApproval: z.union([
186
201
  z.boolean(), // true = required mode, false = disabled
202
+ z.enum(["required", "notify", "none"]), // string shorthand
187
203
  PeerApprovalConfigSchema,
188
204
  ]).default(true),
189
205
  e2ee: z.boolean().default(true),
190
- compression: z.boolean().default(false),
206
+ compression: z.boolean().default(true),
191
207
  });
192
208
 
193
209
  /** Flat proxy model after group expansion (used internally). */
@@ -214,15 +230,29 @@ export interface PeerApprovalConfig {
214
230
  notifyTargets: Array<{ channel: string; to: string; accountId?: string; threadId?: string | number }>;
215
231
  }
216
232
 
217
- export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels" | "peerApproval"> & {
233
+ export type ClawMatrixConfig = Omit<z.infer<typeof RawClawMatrixConfigSchema>, "proxyModels" | "peerApproval" | "toolProxy" | "peers"> & {
234
+ peers: PeerConfig[];
218
235
  proxyModels: ProxyModel[];
219
236
  peerApproval: PeerApprovalConfig;
237
+ toolProxy?: ToolProxyConfig;
220
238
  };
221
239
 
222
240
  export { RawClawMatrixConfigSchema as ClawMatrixConfigSchema };
223
- export type PeerConfig = z.infer<typeof PeerConfigSchema>;
241
+ /** Normalized peer config (always object form). */
242
+ export type PeerConfig = z.infer<typeof PeerConfigObjectSchema>;
224
243
  export type ToolProxyConfig = z.infer<typeof ToolProxyConfigSchema>;
225
244
 
245
+ /** Auto-generate a temporary nodeId from a URL for pre-handshake use.
246
+ * After handshake the real nodeId replaces this. */
247
+ function nodeIdFromUrl(url: string): string {
248
+ try {
249
+ const u = new URL(url);
250
+ return `_${u.hostname}:${u.port || (u.protocol === "wss:" ? "443" : "80")}`;
251
+ } catch {
252
+ return `_${url}`;
253
+ }
254
+ }
255
+
226
256
  /** Parse and flatten grouped proxyModels into flat array.
227
257
  * Uses passthrough() to ignore unknown fields and safeParse to avoid crashing. */
228
258
  export function parseConfig(raw: unknown): ClawMatrixConfig {
@@ -233,6 +263,14 @@ export function parseConfig(raw: unknown): ClawMatrixConfig {
233
263
  }
234
264
  const parsed = result.data;
235
265
 
266
+ // Normalize peers: string → { nodeId: derived, url: string }
267
+ const peers: PeerConfig[] = parsed.peers.map((p) => {
268
+ if (typeof p === "string") {
269
+ return { nodeId: nodeIdFromUrl(p), url: p };
270
+ }
271
+ return p;
272
+ });
273
+
236
274
  // Flatten proxy model groups
237
275
  const proxyModels: ProxyModel[] = [];
238
276
  for (const group of parsed.proxyModels) {
@@ -253,17 +291,34 @@ export function parseConfig(raw: unknown): ClawMatrixConfig {
253
291
  }
254
292
  }
255
293
 
256
- // Normalize peerApproval: boolean → object
294
+ // Normalize toolProxy: boolean → object
295
+ let toolProxy: ToolProxyConfig | undefined;
296
+ const rawToolProxy = parsed.toolProxy;
297
+ if (typeof rawToolProxy === "boolean") {
298
+ toolProxy = rawToolProxy
299
+ ? { enabled: true, allow: ["*"], deny: [], maxOutputBytes: 1_048_576 }
300
+ : { enabled: false, allow: [], deny: [], maxOutputBytes: 1_048_576 };
301
+ } else {
302
+ toolProxy = rawToolProxy;
303
+ }
304
+
305
+ // Normalize peerApproval: boolean | string → object
257
306
  let peerApproval: PeerApprovalConfig;
258
307
  const rawApproval = parsed.peerApproval;
308
+ const defaultApproval: Omit<PeerApprovalConfig, "enabled" | "mode"> = {
309
+ timeout: 1_200_000,
310
+ allowList: [],
311
+ persistPath: "approved-peers.json",
312
+ notifyTargets: [],
313
+ };
259
314
  if (typeof rawApproval === "boolean") {
315
+ peerApproval = { ...defaultApproval, enabled: rawApproval, mode: "required" };
316
+ } else if (typeof rawApproval === "string") {
317
+ // "required" | "notify" | "none"
260
318
  peerApproval = {
261
- enabled: rawApproval,
262
- mode: "required",
263
- timeout: 1_200_000,
264
- allowList: [],
265
- persistPath: "approved-peers.json",
266
- notifyTargets: [],
319
+ ...defaultApproval,
320
+ enabled: rawApproval !== "none",
321
+ mode: rawApproval === "none" ? "notify" : rawApproval,
267
322
  };
268
323
  } else if (rawApproval) {
269
324
  peerApproval = {
@@ -275,15 +330,8 @@ export function parseConfig(raw: unknown): ClawMatrixConfig {
275
330
  notifyTargets: rawApproval.notifyTargets,
276
331
  };
277
332
  } else {
278
- peerApproval = {
279
- enabled: false,
280
- mode: "notify",
281
- timeout: 1_200_000,
282
- allowList: [],
283
- persistPath: "approved-peers.json",
284
- notifyTargets: [],
285
- };
333
+ peerApproval = { ...defaultApproval, enabled: false, mode: "notify" };
286
334
  }
287
335
 
288
- return { ...parsed, proxyModels, peerApproval };
336
+ return { ...parsed, peers, proxyModels, toolProxy, peerApproval };
289
337
  }