clawmatrix 0.1.14 → 0.1.16

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
@@ -1,22 +1,120 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { PeerManager } from "./peer-manager.ts";
3
+ import type { HandoffManager } from "./handoff.ts";
3
4
  import type { ClawMatrixConfig } from "./config.ts";
5
+ import type { SatelliteContext, IngestedEvent } from "./types.ts";
4
6
  import { timingSafeEqual } from "./auth.ts";
5
7
  import { renderDashboard } from "./web-ui.ts";
8
+ import { readBody } from "./http-utils.ts";
6
9
 
7
10
  const COOKIE_NAME = "clawmatrix_token";
8
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
+ }
9
35
 
10
36
  export class WebHandler {
11
37
  private config: ClawMatrixConfig;
12
38
  private peerManager: PeerManager;
39
+ private handoffManager: HandoffManager;
13
40
  private token: string;
14
41
  private startTime = Date.now();
15
-
16
- constructor(config: ClawMatrixConfig, peerManager: PeerManager) {
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;
51
+
52
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, handoffManager: HandoffManager) {
17
53
  this.config = config;
18
54
  this.peerManager = peerManager;
55
+ this.handoffManager = handoffManager;
19
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;
20
118
  }
21
119
 
22
120
  /** Handle an HTTP request. Returns true if handled, false to fall through. */
@@ -37,46 +135,84 @@ export class WebHandler {
37
135
  return true;
38
136
  }
39
137
 
40
- // All /api/* routes require auth
138
+ // All /api/* routes require auth (async)
41
139
  if (path.startsWith("/api/")) {
42
- if (!this.checkAuth(req)) {
43
- res.writeHead(401, { "Content-Type": "application/json" });
44
- res.end(JSON.stringify({ error: "Unauthorized" }));
45
- return true;
46
- }
140
+ this.handleAuthenticatedRoute(req, res, path);
141
+ return true;
142
+ }
47
143
 
48
- if (path === "/api/status" && req.method === "GET") {
49
- this.handleStatus(res);
50
- return true;
51
- }
144
+ return false;
145
+ }
52
146
 
53
- if (path === "/api/chat" && req.method === "POST") {
54
- this.handleChat(req, res);
55
- return true;
56
- }
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
+ }
57
153
 
58
- if (path === "/api/logout" && req.method === "POST") {
59
- res.writeHead(200, {
60
- "Content-Type": "application/json",
61
- "Set-Cookie": `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`,
154
+ if (path === "/api/status" && req.method === "GET") {
155
+ this.handleStatus(res);
156
+ return;
157
+ }
62
158
 
63
- });
64
- res.end(JSON.stringify({ ok: true }));
65
- return true;
66
- }
159
+ if (path === "/api/chat" && req.method === "POST") {
160
+ this.handleChat(req, res);
161
+ return;
162
+ }
67
163
 
68
- res.writeHead(404, { "Content-Type": "application/json" });
69
- res.end(JSON.stringify({ error: "Not found" }));
70
- return true;
164
+ if (path === "/api/handoff" && req.method === "POST") {
165
+ this.handleHandoff(req, res);
166
+ return;
71
167
  }
72
168
 
73
- return false;
169
+ if (path === "/api/events/ingest" && req.method === "POST") {
170
+ this.handleEventIngest(req, res);
171
+ return;
172
+ }
173
+
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" }));
74
210
  }
75
211
 
76
- private checkAuth(req: IncomingMessage): boolean {
212
+ private async checkAuth(req: IncomingMessage): Promise<boolean> {
77
213
  // Check Authorization header
78
214
  const authHeader = req.headers.authorization;
79
- if (authHeader?.startsWith("Bearer ") && timingSafeEqual(authHeader.slice(7), this.token)) {
215
+ if (authHeader?.startsWith("Bearer ") && await timingSafeEqual(authHeader.slice(7), this.token)) {
80
216
  return true;
81
217
  }
82
218
 
@@ -84,7 +220,8 @@ export class WebHandler {
84
220
  const cookies = req.headers.cookie ?? "";
85
221
  const match = cookies.split(";").find((c) => c.trim().startsWith(`${COOKIE_NAME}=`));
86
222
  if (match) {
87
- const value = match.trim().slice(COOKIE_NAME.length + 1);
223
+ const raw = match.trim().slice(COOKIE_NAME.length + 1);
224
+ const value = decodeURIComponent(raw);
88
225
  return timingSafeEqual(value, this.token);
89
226
  }
90
227
 
@@ -92,19 +229,40 @@ export class WebHandler {
92
229
  }
93
230
 
94
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
+
95
243
  try {
96
244
  const body = await readBody(req);
97
245
  const { token } = JSON.parse(body);
98
246
 
99
- 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
+
100
255
  res.writeHead(403, { "Content-Type": "application/json" });
101
256
  res.end(JSON.stringify({ error: "Invalid token" }));
102
257
  return;
103
258
  }
104
259
 
260
+ // Success — clear rate limit for this IP
261
+ this.loginAttempts.delete(ip);
262
+
105
263
  res.writeHead(200, {
106
264
  "Content-Type": "application/json",
107
- "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`,
108
266
  });
109
267
  res.end(JSON.stringify({ ok: true }));
110
268
  } catch {
@@ -122,9 +280,25 @@ export class WebHandler {
122
280
  tags: this.config.tags,
123
281
  connection: "self" as const,
124
282
  online: true,
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
+ ],
125
293
  };
126
294
 
127
- 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) => ({
128
302
  nodeId: p.nodeId,
129
303
  agents: p.agents,
130
304
  models: p.models,
@@ -134,8 +308,40 @@ export class WebHandler {
134
308
  online: this.peerManager.canReach(p.nodeId),
135
309
  lastSeen: p.lastSeen,
136
310
  latencyMs: p.latencyMs,
311
+ directPeers: p.directPeers.length > 0 ? p.directPeers : undefined,
312
+ deviceInfo: p.deviceInfo,
313
+ toolProxy: p.toolProxy,
314
+ clusterTools: CLUSTER_TOOLS,
137
315
  }));
138
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
+
139
345
  res.writeHead(200, { "Content-Type": "application/json" });
140
346
  res.end(JSON.stringify({
141
347
  nodeId: this.config.nodeId,
@@ -207,24 +413,494 @@ export class WebHandler {
207
413
  }
208
414
  }
209
415
  }
210
- }
416
+ private async handleHandoff(req: IncomingMessage, res: ServerResponse) {
417
+ try {
418
+ const body = await readBody(req);
419
+ const { nodeId, agent, task } = JSON.parse(body);
211
420
 
212
- const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
213
-
214
- function readBody(req: IncomingMessage): Promise<string> {
215
- return new Promise((resolve, reject) => {
216
- const chunks: Buffer[] = [];
217
- let size = 0;
218
- req.on("data", (chunk: Buffer) => {
219
- size += chunk.length;
220
- if (size > MAX_BODY_SIZE) {
221
- req.destroy();
222
- reject(new Error("Request body too large"));
421
+ if (!nodeId || !agent || !task) {
422
+ res.writeHead(400, { "Content-Type": "application/json" });
423
+ res.end(JSON.stringify({ error: "nodeId, agent and task required" }));
223
424
  return;
224
425
  }
225
- chunks.push(chunk);
426
+
427
+ // SSE headers
428
+ res.writeHead(200, {
429
+ "Content-Type": "text/event-stream",
430
+ "Cache-Control": "no-cache",
431
+ "Connection": "keep-alive",
432
+ });
433
+
434
+ let closed = false;
435
+ req.on("close", () => { closed = true; });
436
+
437
+ const result = await this.handoffManager.handoff(agent, task, undefined, {
438
+ nodeId,
439
+ onStream: (delta) => {
440
+ if (closed) return;
441
+ res.write(`data: ${JSON.stringify({ type: "delta", content: delta })}\n\n`);
442
+ },
443
+ });
444
+
445
+ if (!closed) {
446
+ res.write(`data: ${JSON.stringify({ type: "done", ...result })}\n\n`);
447
+ }
448
+ res.end();
449
+ } catch (err) {
450
+ if (!res.headersSent) {
451
+ res.writeHead(500, { "Content-Type": "application/json" });
452
+ res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Internal error" }));
453
+ } else {
454
+ res.write(`data: ${JSON.stringify({ type: "error", error: err instanceof Error ? err.message : "Internal error" })}\n\n`);
455
+ res.end();
456
+ }
457
+ }
458
+ }
459
+ // ── Satellite integration ────────────────────────────────────────
460
+
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);
226
529
  });
227
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
228
- req.on("error", reject);
229
- });
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);
553
+ }
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);
695
+ });
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
+ }
230
905
  }
906
+