clawmatrix 0.1.15 → 0.1.17

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/web.ts CHANGED
@@ -2,11 +2,36 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
3
  import type { HandoffManager } from "./handoff.ts";
4
4
  import type { ClawMatrixConfig } from "./config.ts";
5
+ import type { SatelliteContext, IngestedEvent } from "./types.ts";
5
6
  import { timingSafeEqual } from "./auth.ts";
6
7
  import { renderDashboard } from "./web-ui.ts";
8
+ import { readBody } from "./http-utils.ts";
7
9
 
8
10
  const COOKIE_NAME = "clawmatrix_token";
9
11
  const SESSION_MAX_AGE = 86400 * 7; // 7 days
12
+ const MAX_EVENTS = 100; // ring buffer for satellite polling
13
+ const LOGIN_RATE_WINDOW = 60_000; // 1 minute
14
+ const LOGIN_RATE_MAX = 10; // max attempts per window per IP
15
+ const MAX_INGESTED_EVENTS = 500; // ring buffer for ingested events
16
+ const INGESTED_EVENT_TTL = 86400_000; // 24 hours
17
+ const SATELLITE_TOOL_TIMEOUT = 120_000; // 2 min timeout for satellite tool requests
18
+
19
+ interface SatelliteEvent {
20
+ ts: number;
21
+ type: "peer_online" | "peer_offline" | "handoff_done" | "context_update" | "event_ingested";
22
+ data: Record<string, unknown>;
23
+ }
24
+
25
+ interface PendingSatelliteTool {
26
+ id: string;
27
+ tool: string;
28
+ params: Record<string, unknown>;
29
+ from: string;
30
+ ts: number;
31
+ resolve: (result: Record<string, unknown>) => void;
32
+ reject: (error: Error) => void;
33
+ timer: ReturnType<typeof setTimeout>;
34
+ }
10
35
 
11
36
  export class WebHandler {
12
37
  private config: ClawMatrixConfig;
@@ -14,12 +39,82 @@ export class WebHandler {
14
39
  private handoffManager: HandoffManager;
15
40
  private token: string;
16
41
  private startTime = Date.now();
42
+ private satelliteEvents: SatelliteEvent[] = [];
43
+ private satelliteContexts = new Map<string, SatelliteContext>(); // nodeId → context
44
+ private knownPeerState = new Map<string, boolean>(); // nodeId → online
45
+ private satelliteToolQueue = new Map<string, PendingSatelliteTool[]>(); // nodeId → pending tools
46
+ private ingestedEvents: IngestedEvent[] = []; // ring buffer for ingested events
47
+ private loginAttempts = new Map<string, { count: number; resetAt: number }>(); // IP → rate limit
48
+ private loginCleanupTimer: ReturnType<typeof setInterval> | null = null;
49
+ private onPeerConnected: (nodeId: string) => void;
50
+ private onPeerDisconnected: (nodeId: string) => void;
17
51
 
18
52
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, handoffManager: HandoffManager) {
19
53
  this.config = config;
20
54
  this.peerManager = peerManager;
21
55
  this.handoffManager = handoffManager;
22
56
  this.token = config.web!.token;
57
+
58
+ // Periodically clean up stale login rate-limit entries
59
+ this.loginCleanupTimer = setInterval(() => {
60
+ const now = Date.now();
61
+ for (const [ip, entry] of this.loginAttempts) {
62
+ if (now > entry.resetAt) this.loginAttempts.delete(ip);
63
+ }
64
+ }, LOGIN_RATE_WINDOW);
65
+
66
+ // Listen for peer changes in real-time and push satellite events immediately
67
+ this.onPeerConnected = (nodeId: string) => {
68
+ const route = peerManager.router.getRoute(nodeId);
69
+ this.pushSatelliteEvent({
70
+ ts: Date.now(),
71
+ type: "peer_online",
72
+ data: {
73
+ nodeId,
74
+ agents: route?.agents.map(a => a.id) ?? [],
75
+ models: route?.models.map(m => m.id) ?? [],
76
+ },
77
+ });
78
+ this.knownPeerState.set(nodeId, true);
79
+ };
80
+
81
+ this.onPeerDisconnected = (nodeId: string) => {
82
+ this.pushSatelliteEvent({
83
+ ts: Date.now(),
84
+ type: "peer_offline",
85
+ data: { nodeId },
86
+ });
87
+ this.knownPeerState.set(nodeId, false);
88
+ };
89
+
90
+ peerManager.on("peerConnected", this.onPeerConnected);
91
+ peerManager.on("peerDisconnected", this.onPeerDisconnected);
92
+ }
93
+
94
+ /** Clean up timers and pending requests on shutdown. */
95
+ destroy() {
96
+ // Remove event listeners to prevent post-destroy callbacks
97
+ this.peerManager.removeListener("peerConnected", this.onPeerConnected);
98
+ this.peerManager.removeListener("peerDisconnected", this.onPeerDisconnected);
99
+
100
+ if (this.loginCleanupTimer) {
101
+ clearInterval(this.loginCleanupTimer);
102
+ this.loginCleanupTimer = null;
103
+ }
104
+ // Clear all satellite tool timeouts
105
+ for (const [, queue] of this.satelliteToolQueue) {
106
+ for (const entry of queue) {
107
+ clearTimeout(entry.timer);
108
+ entry.reject(new Error("Shutting down"));
109
+ }
110
+ }
111
+ this.satelliteToolQueue.clear();
112
+ // Clear long-poll waiters
113
+ for (const w of this.satelliteWaiters) {
114
+ clearTimeout(w.timer);
115
+ try { w.res.end(); } catch { /* already closed */ }
116
+ }
117
+ this.satelliteWaiters.length = 0;
23
118
  }
24
119
 
25
120
  /** Handle an HTTP request. Returns true if handled, false to fall through. */
@@ -40,51 +135,84 @@ export class WebHandler {
40
135
  return true;
41
136
  }
42
137
 
43
- // All /api/* routes require auth
138
+ // All /api/* routes require auth (async)
44
139
  if (path.startsWith("/api/")) {
45
- if (!this.checkAuth(req)) {
46
- res.writeHead(401, { "Content-Type": "application/json" });
47
- res.end(JSON.stringify({ error: "Unauthorized" }));
48
- return true;
49
- }
140
+ this.handleAuthenticatedRoute(req, res, path);
141
+ return true;
142
+ }
50
143
 
51
- if (path === "/api/status" && req.method === "GET") {
52
- this.handleStatus(res);
53
- return true;
54
- }
144
+ return false;
145
+ }
55
146
 
56
- if (path === "/api/chat" && req.method === "POST") {
57
- this.handleChat(req, res);
58
- return true;
59
- }
147
+ private async handleAuthenticatedRoute(req: IncomingMessage, res: ServerResponse, path: string) {
148
+ if (!await this.checkAuth(req)) {
149
+ res.writeHead(401, { "Content-Type": "application/json" });
150
+ res.end(JSON.stringify({ error: "Unauthorized" }));
151
+ return;
152
+ }
60
153
 
61
- if (path === "/api/handoff" && req.method === "POST") {
62
- this.handleHandoff(req, res);
63
- return true;
64
- }
154
+ if (path === "/api/status" && req.method === "GET") {
155
+ this.handleStatus(res);
156
+ return;
157
+ }
65
158
 
66
- if (path === "/api/logout" && req.method === "POST") {
67
- res.writeHead(200, {
68
- "Content-Type": "application/json",
69
- "Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`,
159
+ if (path === "/api/chat" && req.method === "POST") {
160
+ this.handleChat(req, res);
161
+ return;
162
+ }
70
163
 
71
- });
72
- res.end(JSON.stringify({ ok: true }));
73
- return true;
74
- }
164
+ if (path === "/api/handoff" && req.method === "POST") {
165
+ this.handleHandoff(req, res);
166
+ return;
167
+ }
75
168
 
76
- res.writeHead(404, { "Content-Type": "application/json" });
77
- res.end(JSON.stringify({ error: "Not found" }));
78
- return true;
169
+ if (path === "/api/events/ingest" && req.method === "POST") {
170
+ this.handleEventIngest(req, res);
171
+ return;
79
172
  }
80
173
 
81
- return false;
174
+ if (path === "/api/events" && req.method === "GET") {
175
+ this.handleEventQuery(req, res);
176
+ return;
177
+ }
178
+
179
+ if (path === "/api/events/consume" && req.method === "POST") {
180
+ this.handleEventConsume(req, res);
181
+ return;
182
+ }
183
+
184
+ if (path === "/api/satellite/poll" && req.method === "GET") {
185
+ this.handleSatellitePoll(req, res);
186
+ return;
187
+ }
188
+
189
+ if (path === "/api/satellite/context" && req.method === "POST") {
190
+ this.handleSatelliteContext(req, res);
191
+ return;
192
+ }
193
+
194
+ if (path === "/api/satellite/tool/result" && req.method === "POST") {
195
+ this.handleSatelliteToolResult(req, res);
196
+ return;
197
+ }
198
+
199
+ if (path === "/api/logout" && req.method === "POST") {
200
+ res.writeHead(200, {
201
+ "Content-Type": "application/json",
202
+ "Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`,
203
+ });
204
+ res.end(JSON.stringify({ ok: true }));
205
+ return;
206
+ }
207
+
208
+ res.writeHead(404, { "Content-Type": "application/json" });
209
+ res.end(JSON.stringify({ error: "Not found" }));
82
210
  }
83
211
 
84
- private checkAuth(req: IncomingMessage): boolean {
212
+ private async checkAuth(req: IncomingMessage): Promise<boolean> {
85
213
  // Check Authorization header
86
214
  const authHeader = req.headers.authorization;
87
- if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
215
+ if (authHeader?.startsWith("Bearer ") && await timingSafeEqual(authHeader.slice(7), this.token)) {
88
216
  return true;
89
217
  }
90
218
 
@@ -92,7 +220,8 @@ export class WebHandler {
92
220
  const cookies = req.headers.cookie ?? "";
93
221
  const match = cookies.split(";").find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
94
222
  if (match) {
95
- const value = match.trim().slice(COOKIE_NAME.length + 1);
223
+ const raw = match.trim().slice(COOKIE_NAME.length + 1);
224
+ const value = decodeURIComponent(raw);
96
225
  return timingSafeEqual(value, this.token);
97
226
  }
98
227
 
@@ -100,19 +229,40 @@ export class WebHandler {
100
229
  }
101
230
 
102
231
  private async handleLogin(req: IncomingMessage, res: ServerResponse) {
232
+ // Rate limiting by IP
233
+ const ip = req.socket.remoteAddress ?? "unknown";
234
+ const now = Date.now();
235
+ let entry = this.loginAttempts.get(ip);
236
+ if (entry && now > entry.resetAt) entry = undefined;
237
+ if (entry && entry.count >= LOGIN_RATE_MAX) {
238
+ res.writeHead(429, { "Content-Type": "application/json" });
239
+ res.end(JSON.stringify({ error: "Too many login attempts, try again later" }));
240
+ return;
241
+ }
242
+
103
243
  try {
104
244
  const body = await readBody(req);
105
245
  const { token } = JSON.parse(body);
106
246
 
107
- if (!token || !timingSafeEqual(String(token), this.token)) {
247
+ if (!token || !await timingSafeEqual(String(token), this.token)) {
248
+ // Track failed attempt
249
+ if (!entry) {
250
+ entry = { count: 0, resetAt: now + LOGIN_RATE_WINDOW };
251
+ this.loginAttempts.set(ip, entry);
252
+ }
253
+ entry.count++;
254
+
108
255
  res.writeHead(403, { "Content-Type": "application/json" });
109
256
  res.end(JSON.stringify({ error: "Invalid token" }));
110
257
  return;
111
258
  }
112
259
 
260
+ // Success — clear rate limit for this IP
261
+ this.loginAttempts.delete(ip);
262
+
113
263
  res.writeHead(200, {
114
264
  "Content-Type": "application/json",
115
- "Set-Cookie": `${COOKIE_NAME}=${token}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Strict`,
265
+ "Set-Cookie": `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; Max-Age=${SESSION_MAX_AGE}; HttpOnly; SameSite=Strict`,
116
266
  });
117
267
  res.end(JSON.stringify({ ok: true }));
118
268
  } catch {
@@ -131,9 +281,24 @@ export class WebHandler {
131
281
  connection: "self" as const,
132
282
  online: true,
133
283
  deviceInfo: this.peerManager.localDeviceInfo,
284
+ toolProxy: this.config.toolProxy ? {
285
+ enabled: this.config.toolProxy.enabled,
286
+ allow: this.config.toolProxy.allow,
287
+ deny: this.config.toolProxy.deny,
288
+ } : undefined,
289
+ clusterTools: [
290
+ "cluster_handoff", "cluster_send", "cluster_peers",
291
+ "cluster_exec", "cluster_read", "cluster_write", "cluster_tool",
292
+ ],
134
293
  };
135
294
 
136
- const peerNodes = peers.map((p) => ({
295
+ // All mesh peers share these cluster tools (they all run the ClawMatrix plugin)
296
+ const CLUSTER_TOOLS = [
297
+ "cluster_handoff", "cluster_send", "cluster_peers",
298
+ "cluster_exec", "cluster_read", "cluster_write", "cluster_tool",
299
+ ];
300
+
301
+ const peerNodes: Record<string, unknown>[] = peers.map((p) => ({
137
302
  nodeId: p.nodeId,
138
303
  agents: p.agents,
139
304
  models: p.models,
@@ -145,8 +310,38 @@ export class WebHandler {
145
310
  latencyMs: p.latencyMs,
146
311
  directPeers: p.directPeers.length > 0 ? p.directPeers : undefined,
147
312
  deviceInfo: p.deviceInfo,
313
+ toolProxy: p.toolProxy,
314
+ clusterTools: CLUSTER_TOOLS,
148
315
  }));
149
316
 
317
+ // Merge satellite nodes into peers list
318
+ for (const sat of this.getSatelliteContexts()) {
319
+ peerNodes.push({
320
+ nodeId: sat.nodeId,
321
+ agents: [],
322
+ models: [],
323
+ tags: [],
324
+ connection: "satellite" as const,
325
+ online: true,
326
+ toolProxy: sat.tools?.length ? {
327
+ enabled: true,
328
+ allow: sat.tools,
329
+ deny: [],
330
+ } : undefined,
331
+ // Satellite-specific fields
332
+ ssid: sat.ssid,
333
+ ip: sat.ip,
334
+ router: sat.router,
335
+ cellular: sat.cellular,
336
+ country: sat.country,
337
+ battery: sat.battery,
338
+ charging: sat.charging,
339
+ platform: sat.platform,
340
+ location: sat.location,
341
+ lastSeen: sat.ts,
342
+ });
343
+ }
344
+
150
345
  res.writeHead(200, { "Content-Type": "application/json" });
151
346
  res.end(JSON.stringify({
152
347
  nodeId: this.config.nodeId,
@@ -261,24 +456,451 @@ export class WebHandler {
261
456
  }
262
457
  }
263
458
  }
264
- }
459
+ // ── Satellite integration ────────────────────────────────────────
265
460
 
266
- const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
267
-
268
- function readBody(req: IncomingMessage): Promise<string> {
269
- return new Promise((resolve, reject) => {
270
- const chunks: Buffer[] = [];
271
- let size = 0;
272
- req.on("data", (chunk: Buffer) => {
273
- size += chunk.length;
274
- if (size > MAX_BODY_SIZE) {
275
- req.destroy();
276
- reject(new Error("Request body too large"));
277
- return;
461
+ /** Sync knownPeerState with current routes (no event emission — events come from real-time listeners). */
462
+ private syncPeerState() {
463
+ const peers = this.peerManager.router.getAllPeers();
464
+ for (const p of peers) {
465
+ const online = this.peerManager.canReach(p.nodeId);
466
+ this.knownPeerState.set(p.nodeId, online);
467
+ }
468
+ // Remove peers no longer in routes
469
+ for (const [nodeId] of this.knownPeerState) {
470
+ if (!peers.some(p => p.nodeId === nodeId)) {
471
+ this.knownPeerState.delete(nodeId);
472
+ }
473
+ }
474
+ }
475
+
476
+ private pushSatelliteEvent(event: SatelliteEvent) {
477
+ this.satelliteEvents.push(event);
478
+ if (this.satelliteEvents.length > MAX_EVENTS) {
479
+ this.satelliteEvents.splice(0, this.satelliteEvents.length - MAX_EVENTS);
480
+ }
481
+ // Wake up any long-polling clients immediately
482
+ if (this.satelliteWaiters.length > 0) {
483
+ this.flushSatelliteWaiters();
484
+ }
485
+ }
486
+
487
+ /** Notify satellite of handoff completion. Called externally. */
488
+ notifyHandoffDone(nodeId: string, agent: string, success: boolean, summary?: string) {
489
+ this.pushSatelliteEvent({
490
+ ts: Date.now(),
491
+ type: "handoff_done",
492
+ data: { nodeId, agent, success, summary },
493
+ });
494
+ }
495
+
496
+ /** Pending long-poll waiters. */
497
+ private satelliteWaiters: Array<{ res: ServerResponse; since: number; nodeId?: string; timer: ReturnType<typeof setTimeout> }> = [];
498
+
499
+ /** GET /api/satellite/poll?since=<ts>&wait=<seconds>&nodeId=<id> — poll with optional long-poll */
500
+ private handleSatellitePoll(req: IncomingMessage, res: ServerResponse) {
501
+ this.syncPeerState();
502
+
503
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
504
+ const since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0;
505
+ const wait = Math.min(Math.max(parseInt(url.searchParams.get("wait") ?? "0", 10) || 0, 0), 55);
506
+ const nodeId = url.searchParams.get("nodeId") || undefined;
507
+
508
+ const events = this.satelliteEvents.filter(e => e.ts > since);
509
+ const hasPendingTools = nodeId ? (this.satelliteToolQueue.get(nodeId)?.length ?? 0) > 0 : false;
510
+
511
+ // If there are already events/tools or no wait requested, respond immediately
512
+ if (events.length > 0 || hasPendingTools || wait === 0) {
513
+ this.sendSatellitePollResponse(res, since, nodeId);
514
+ return;
515
+ }
516
+
517
+ // Long poll: hold the connection until events arrive or timeout
518
+ const timer = setTimeout(() => {
519
+ this.removeSatelliteWaiter(res);
520
+ this.sendSatellitePollResponse(res, since, nodeId);
521
+ }, wait * 1000);
522
+
523
+ this.satelliteWaiters.push({ res, since, nodeId, timer });
524
+
525
+ // If client disconnects, clean up
526
+ req.on("close", () => {
527
+ this.removeSatelliteWaiter(res);
528
+ clearTimeout(timer);
529
+ });
530
+ }
531
+
532
+ /** Flush all waiting long-poll clients (called when new events are pushed). */
533
+ private flushSatelliteWaiters(targetNodeId?: string) {
534
+ if (targetNodeId) {
535
+ // Only flush waiters for this specific satellite node
536
+ const toFlush: typeof this.satelliteWaiters = [];
537
+ this.satelliteWaiters = this.satelliteWaiters.filter(w => {
538
+ if (w.nodeId === targetNodeId) {
539
+ toFlush.push(w);
540
+ return false;
541
+ }
542
+ return true;
543
+ });
544
+ for (const w of toFlush) {
545
+ clearTimeout(w.timer);
546
+ this.sendSatellitePollResponse(w.res, w.since, w.nodeId);
547
+ }
548
+ } else {
549
+ const waiters = this.satelliteWaiters.splice(0);
550
+ for (const w of waiters) {
551
+ clearTimeout(w.timer);
552
+ this.sendSatellitePollResponse(w.res, w.since, w.nodeId);
278
553
  }
279
- chunks.push(chunk);
554
+ }
555
+ }
556
+
557
+ private removeSatelliteWaiter(res: ServerResponse) {
558
+ const idx = this.satelliteWaiters.findIndex(w => w.res === res);
559
+ if (idx >= 0) this.satelliteWaiters.splice(idx, 1);
560
+ }
561
+
562
+ private sendSatellitePollResponse(res: ServerResponse, since: number, satelliteNodeId?: string) {
563
+ this.syncPeerState();
564
+ const peers = this.peerManager.router.getAllPeers();
565
+ const events = this.satelliteEvents.filter(e => e.ts > since);
566
+
567
+ // Include pending tool requests for this satellite node
568
+ let pendingTools: Array<{ id: string; tool: string; params: Record<string, unknown> }> | undefined;
569
+ if (satelliteNodeId) {
570
+ const queue = this.satelliteToolQueue.get(satelliteNodeId);
571
+ if (queue && queue.length > 0) {
572
+ pendingTools = queue.map(t => ({ id: t.id, tool: t.tool, params: t.params }));
573
+ }
574
+ }
575
+
576
+ // Merge satellite nodes into peers list
577
+ const allPeers: Record<string, unknown>[] = peers.map(p => ({
578
+ nodeId: p.nodeId,
579
+ online: this.peerManager.canReach(p.nodeId),
580
+ agents: p.agents.map(a => a.id),
581
+ models: p.models.map(m => m.id),
582
+ tags: p.tags,
583
+ latencyMs: p.latencyMs,
584
+ connection: p.connection ? "direct" : "relay",
585
+ }));
586
+
587
+ for (const sat of this.getSatelliteContexts()) {
588
+ allPeers.push({
589
+ nodeId: sat.nodeId,
590
+ online: true,
591
+ agents: [],
592
+ models: [],
593
+ tags: [],
594
+ connection: "satellite",
595
+ tools: sat.tools ?? [],
596
+ });
597
+ }
598
+
599
+ const status = {
600
+ nodeId: this.config.nodeId,
601
+ uptime: Math.floor((Date.now() - this.startTime) / 1000),
602
+ peers: allPeers,
603
+ events,
604
+ pendingTools,
605
+ ts: Date.now(),
606
+ };
607
+
608
+ try {
609
+ if (res.writableEnded) return;
610
+ res.writeHead(200, { "Content-Type": "application/json" });
611
+ res.end(JSON.stringify(status));
612
+ } catch {
613
+ // Client already disconnected
614
+ }
615
+ }
616
+
617
+ /** POST /api/satellite/context — receive network context from satellite */
618
+ private async handleSatelliteContext(req: IncomingMessage, res: ServerResponse) {
619
+ try {
620
+ const body = await readBody(req);
621
+ const ctx = JSON.parse(body);
622
+
623
+ const satCtx: SatelliteContext = {
624
+ nodeId: ctx.nodeId || "unknown",
625
+ ssid: ctx.ssid || undefined,
626
+ ip: ctx.ip || undefined,
627
+ router: ctx.router || undefined,
628
+ cellular: !ctx.ssid,
629
+ country: ctx.country || undefined,
630
+ tools: Array.isArray(ctx.tools) ? ctx.tools : undefined,
631
+ ts: Date.now(),
632
+ // Extended context
633
+ battery: typeof ctx.battery === "number" ? ctx.battery : undefined,
634
+ charging: typeof ctx.charging === "boolean" ? ctx.charging : undefined,
635
+ platform: ctx.platform || undefined,
636
+ location: ctx.location || undefined,
637
+ };
638
+
639
+ this.satelliteContexts.set(satCtx.nodeId, satCtx);
640
+
641
+ // Propagate to PeerManager so it gets gossiped to all mesh nodes
642
+ this.peerManager.satelliteContexts = this.getSatelliteContexts();
643
+
644
+ this.pushSatelliteEvent({
645
+ ts: Date.now(),
646
+ type: "context_update",
647
+ data: { ...satCtx },
648
+ });
649
+
650
+ res.writeHead(200, { "Content-Type": "application/json" });
651
+ res.end(JSON.stringify({ ok: true }));
652
+ } catch {
653
+ res.writeHead(400, { "Content-Type": "application/json" });
654
+ res.end(JSON.stringify({ error: "Invalid request" }));
655
+ }
656
+ }
657
+
658
+ // ── Satellite tool proxy ────────────────────────────────────────
659
+
660
+ /** Check if a nodeId is a known satellite node. */
661
+ isSatelliteNode(nodeId: string): boolean {
662
+ const ctx = this.satelliteContexts.get(nodeId);
663
+ if (!ctx) return false;
664
+ // Stale check (10 min)
665
+ return Date.now() - ctx.ts < 600_000;
666
+ }
667
+
668
+ /** Queue a tool request for a satellite node. Returns a promise that resolves when the satellite responds. */
669
+ queueToolForSatellite(
670
+ nodeId: string,
671
+ id: string,
672
+ tool: string,
673
+ params: Record<string, unknown>,
674
+ timeout?: number,
675
+ ): Promise<Record<string, unknown>> {
676
+ // Check if satellite supports this tool
677
+ const ctx = this.satelliteContexts.get(nodeId);
678
+ if (ctx?.tools && !ctx.tools.includes(tool)) {
679
+ return Promise.reject(new Error(`Satellite "${nodeId}" does not support tool "${tool}"`));
680
+ }
681
+
682
+ return new Promise((resolve, reject) => {
683
+ const timer = setTimeout(() => {
684
+ this.removeSatelliteTool(nodeId, id);
685
+ reject(new Error(`Satellite tool "${tool}" timed out on "${nodeId}"`));
686
+ }, timeout ?? SATELLITE_TOOL_TIMEOUT);
687
+
688
+ const entry: PendingSatelliteTool = { id, tool, params, from: this.config.nodeId, ts: Date.now(), resolve, reject, timer };
689
+ const queue = this.satelliteToolQueue.get(nodeId) ?? [];
690
+ queue.push(entry);
691
+ this.satelliteToolQueue.set(nodeId, queue);
692
+
693
+ // Wake up any long-polling satellite client for this node
694
+ this.flushSatelliteWaiters(nodeId);
280
695
  });
281
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
282
- req.on("error", reject);
283
- });
696
+ }
697
+
698
+ private removeSatelliteTool(nodeId: string, id: string) {
699
+ const queue = this.satelliteToolQueue.get(nodeId);
700
+ if (!queue) return;
701
+ const idx = queue.findIndex(t => t.id === id);
702
+ if (idx >= 0) {
703
+ clearTimeout(queue[idx].timer);
704
+ queue.splice(idx, 1);
705
+ }
706
+ if (queue.length === 0) this.satelliteToolQueue.delete(nodeId);
707
+ }
708
+
709
+ /** POST /api/satellite/tool/result — satellite posts tool execution result */
710
+ private async handleSatelliteToolResult(req: IncomingMessage, res: ServerResponse) {
711
+ try {
712
+ const body = await readBody(req);
713
+ const { id, success, result, error } = JSON.parse(body);
714
+
715
+ if (!id) {
716
+ res.writeHead(400, { "Content-Type": "application/json" });
717
+ res.end(JSON.stringify({ error: "id required" }));
718
+ return;
719
+ }
720
+
721
+ // Find and resolve the pending tool request
722
+ let found = false;
723
+ for (const [nodeId, queue] of this.satelliteToolQueue) {
724
+ const idx = queue.findIndex(t => t.id === id);
725
+ if (idx >= 0) {
726
+ const pending = queue[idx];
727
+ clearTimeout(pending.timer);
728
+ queue.splice(idx, 1);
729
+ if (queue.length === 0) this.satelliteToolQueue.delete(nodeId);
730
+
731
+ if (success && result) {
732
+ pending.resolve(typeof result === "object" ? result : { result });
733
+ } else {
734
+ pending.reject(new Error(error ?? "Satellite tool execution failed"));
735
+ }
736
+ found = true;
737
+ break;
738
+ }
739
+ }
740
+
741
+ res.writeHead(200, { "Content-Type": "application/json" });
742
+ res.end(JSON.stringify({ ok: found }));
743
+ } catch {
744
+ res.writeHead(400, { "Content-Type": "application/json" });
745
+ res.end(JSON.stringify({ error: "Invalid request" }));
746
+ }
747
+ }
748
+
749
+ // ── Event ingestion (Shortcuts automations, etc.) ──────────────
750
+
751
+ /** POST /api/events/ingest — receive events from external sources (e.g. Apple Shortcuts) */
752
+ private async handleEventIngest(req: IncomingMessage, res: ServerResponse) {
753
+ try {
754
+ const body = await readBody(req);
755
+ const raw = JSON.parse(body);
756
+
757
+ // Support single event or batch
758
+ const items: Array<Record<string, unknown>> = Array.isArray(raw) ? raw : [raw];
759
+ const ingested: IngestedEvent[] = [];
760
+
761
+ for (const item of items) {
762
+ if (!item.type) continue;
763
+ const event: IngestedEvent = {
764
+ id: crypto.randomUUID(),
765
+ source: String(item.source || "unknown"),
766
+ type: String(item.type),
767
+ data: (typeof item.data === "object" && item.data !== null ? item.data : { value: item.data ?? item }) as Record<string, unknown>,
768
+ ts: typeof item.ts === "number" ? Math.min(item.ts, Date.now()) : Date.now(),
769
+ consumed: false,
770
+ };
771
+ this.ingestedEvents.push(event);
772
+ ingested.push(event);
773
+ }
774
+
775
+ // Trim ring buffer
776
+ if (this.ingestedEvents.length > MAX_INGESTED_EVENTS) {
777
+ this.ingestedEvents.splice(0, this.ingestedEvents.length - MAX_INGESTED_EVENTS);
778
+ }
779
+
780
+ // Also push as satellite event so polling clients get notified
781
+ for (const event of ingested) {
782
+ this.pushSatelliteEvent({
783
+ ts: event.ts,
784
+ type: "event_ingested" as SatelliteEvent["type"],
785
+ data: { id: event.id, source: event.source, type: event.type },
786
+ });
787
+ }
788
+
789
+ res.writeHead(200, { "Content-Type": "application/json" });
790
+ res.end(JSON.stringify({ ok: true, count: ingested.length, ids: ingested.map(e => e.id) }));
791
+ } catch {
792
+ res.writeHead(400, { "Content-Type": "application/json" });
793
+ res.end(JSON.stringify({ error: "Invalid request" }));
794
+ }
795
+ }
796
+
797
+ /** GET /api/events?type=<type>&source=<source>&since=<ts>&unconsumed=true&limit=<n> */
798
+ private handleEventQuery(req: IncomingMessage, res: ServerResponse) {
799
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
800
+ const type = url.searchParams.get("type") || undefined;
801
+ const source = url.searchParams.get("source") || undefined;
802
+ const since = parseInt(url.searchParams.get("since") ?? "0", 10) || 0;
803
+ const unconsumed = url.searchParams.get("unconsumed") === "true";
804
+ const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 200);
805
+
806
+ this.evictStaleEvents();
807
+
808
+ let events = this.ingestedEvents;
809
+ if (since > 0) events = events.filter(e => e.ts > since);
810
+ if (type) events = events.filter(e => e.type === type);
811
+ if (source) events = events.filter(e => e.source === source);
812
+ if (unconsumed) events = events.filter(e => !e.consumed);
813
+
814
+ // Return newest first, limited
815
+ const result = events.slice(-limit).reverse();
816
+
817
+ res.writeHead(200, { "Content-Type": "application/json" });
818
+ res.end(JSON.stringify({ events: result, total: events.length }));
819
+ }
820
+
821
+ /** POST /api/events/consume — mark events as consumed by id(s) */
822
+ private async handleEventConsume(req: IncomingMessage, res: ServerResponse) {
823
+ try {
824
+ const body = await readBody(req);
825
+ const { ids } = JSON.parse(body);
826
+ if (!Array.isArray(ids)) {
827
+ res.writeHead(400, { "Content-Type": "application/json" });
828
+ res.end(JSON.stringify({ error: "ids array required" }));
829
+ return;
830
+ }
831
+
832
+ const idSet = new Set<string>(ids);
833
+ let consumed = 0;
834
+ for (const event of this.ingestedEvents) {
835
+ if (idSet.has(event.id) && !event.consumed) {
836
+ event.consumed = true;
837
+ consumed++;
838
+ }
839
+ }
840
+
841
+ res.writeHead(200, { "Content-Type": "application/json" });
842
+ res.end(JSON.stringify({ ok: true, consumed }));
843
+ } catch {
844
+ res.writeHead(400, { "Content-Type": "application/json" });
845
+ res.end(JSON.stringify({ error: "Invalid request" }));
846
+ }
847
+ }
848
+
849
+ /** Evict events older than TTL. Centralizes stale-event cleanup. */
850
+ private evictStaleEvents() {
851
+ const cutoff = Date.now() - INGESTED_EVENT_TTL;
852
+ const firstValid = this.ingestedEvents.findIndex(e => e.ts > cutoff);
853
+ if (firstValid > 0) {
854
+ this.ingestedEvents.splice(0, firstValid);
855
+ } else if (firstValid === -1 && this.ingestedEvents.length > 0) {
856
+ this.ingestedEvents.length = 0;
857
+ }
858
+ }
859
+
860
+ /** Get unconsumed events (for agent context injection). */
861
+ getUnconsumedEvents(limit = 10): IngestedEvent[] {
862
+ this.evictStaleEvents();
863
+ return this.ingestedEvents.filter(e => !e.consumed).slice(-limit);
864
+ }
865
+
866
+ /** Get all ingested events (for tool queries). */
867
+ queryEvents(opts?: { type?: string; source?: string; since?: number; unconsumed?: boolean; limit?: number }): IngestedEvent[] {
868
+ this.evictStaleEvents();
869
+
870
+ let events = this.ingestedEvents;
871
+ if (opts?.since) events = events.filter(e => e.ts > opts.since!);
872
+ if (opts?.type) events = events.filter(e => e.type === opts.type);
873
+ if (opts?.source) events = events.filter(e => e.source === opts.source);
874
+ if (opts?.unconsumed) events = events.filter(e => !e.consumed);
875
+ return events.slice(-(opts?.limit ?? 50)).reverse();
876
+ }
877
+
878
+ /** Mark events as consumed by id(s). */
879
+ consumeEvents(ids: string[]): number {
880
+ const idSet = new Set(ids);
881
+ let consumed = 0;
882
+ for (const event of this.ingestedEvents) {
883
+ if (idSet.has(event.id) && !event.consumed) {
884
+ event.consumed = true;
885
+ consumed++;
886
+ }
887
+ }
888
+ return consumed;
889
+ }
890
+
891
+ /** Get all satellite contexts (for use by other components). */
892
+ getSatelliteContexts(): SatelliteContext[] {
893
+ // Filter out stale contexts (> 10 minutes)
894
+ const now = Date.now();
895
+ const result: SatelliteContext[] = [];
896
+ for (const [nodeId, ctx] of this.satelliteContexts) {
897
+ if (now - ctx.ts < 600_000) {
898
+ result.push(ctx);
899
+ } else {
900
+ this.satelliteContexts.delete(nodeId);
901
+ }
902
+ }
903
+ return result;
904
+ }
284
905
  }
906
+