@ch4p/cli 0.1.5 → 0.2.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.
@@ -0,0 +1,1845 @@
1
+ import {
2
+ generateId
3
+ } from "./chunk-YSCX2QQQ.js";
4
+
5
+ // ../../packages/canvas/dist/index.js
6
+ var KNOWN_COMPONENT_TYPES = /* @__PURE__ */ new Set([
7
+ "card",
8
+ "chart",
9
+ "form",
10
+ "button",
11
+ "text_field",
12
+ "data_table",
13
+ "code_block",
14
+ "markdown",
15
+ "image",
16
+ "progress",
17
+ "status"
18
+ ]);
19
+ function isA2UIComponent(value) {
20
+ if (typeof value !== "object" || value === null) {
21
+ return false;
22
+ }
23
+ const obj = value;
24
+ return typeof obj.id === "string" && typeof obj.type === "string";
25
+ }
26
+ function validateComponentFields(value) {
27
+ const errors = [];
28
+ if (!KNOWN_COMPONENT_TYPES.has(value.type)) {
29
+ errors.push(`Unknown component type "${value.type}". Known types: ${[...KNOWN_COMPONENT_TYPES].join(", ")}.`);
30
+ return errors;
31
+ }
32
+ const obj = value;
33
+ switch (value.type) {
34
+ case "card":
35
+ if (typeof obj.title !== "string") errors.push('Card component requires a "title" string.');
36
+ if (typeof obj.body !== "string") errors.push('Card component requires a "body" string.');
37
+ break;
38
+ case "chart":
39
+ if (typeof obj.chartType !== "string") errors.push('Chart component requires a "chartType" string.');
40
+ if (!obj.data || typeof obj.data !== "object") errors.push('Chart component requires a "data" object.');
41
+ break;
42
+ case "form":
43
+ if (!Array.isArray(obj.fields)) errors.push('Form component requires a "fields" array.');
44
+ break;
45
+ case "button":
46
+ if (typeof obj.text !== "string") errors.push('Button component requires a "text" string.');
47
+ break;
48
+ case "data_table":
49
+ if (!Array.isArray(obj.columns)) errors.push('DataTable component requires a "columns" array.');
50
+ if (!Array.isArray(obj.rows)) errors.push('DataTable component requires a "rows" array.');
51
+ break;
52
+ case "code_block":
53
+ if (typeof obj.code !== "string") errors.push('CodeBlock component requires a "code" string.');
54
+ break;
55
+ case "markdown":
56
+ if (typeof obj.content !== "string") errors.push('Markdown component requires a "content" string.');
57
+ break;
58
+ case "image":
59
+ if (typeof obj.src !== "string") errors.push('Image component requires a "src" string.');
60
+ break;
61
+ case "progress":
62
+ if (typeof obj.value !== "number") errors.push('Progress component requires a numeric "value".');
63
+ break;
64
+ case "status":
65
+ if (typeof obj.state !== "string") errors.push('Status component requires a "state" string.');
66
+ break;
67
+ }
68
+ return errors;
69
+ }
70
+ var CanvasState = class {
71
+ nodes = /* @__PURE__ */ new Map();
72
+ connections = /* @__PURE__ */ new Map();
73
+ nextZIndex = 1;
74
+ listeners = [];
75
+ maxComponents;
76
+ constructor(maxComponents = 500) {
77
+ this.maxComponents = maxComponents;
78
+ }
79
+ // -----------------------------------------------------------------------
80
+ // Node CRUD
81
+ // -----------------------------------------------------------------------
82
+ /**
83
+ * Add a component to the canvas at the given position.
84
+ *
85
+ * @returns The component's `id`.
86
+ * @throws {Error} If the maximum component limit has been reached.
87
+ */
88
+ addComponent(component, position) {
89
+ if (this.nodes.size >= this.maxComponents) {
90
+ throw new Error(
91
+ `Canvas component limit reached (max ${this.maxComponents})`
92
+ );
93
+ }
94
+ const node = {
95
+ component,
96
+ position,
97
+ zIndex: this.nextZIndex++
98
+ };
99
+ this.nodes.set(component.id, node);
100
+ this.emit({
101
+ type: "add_node",
102
+ nodeId: component.id,
103
+ data: node,
104
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
105
+ });
106
+ return component.id;
107
+ }
108
+ /**
109
+ * Merge partial updates into an existing component.
110
+ *
111
+ * @returns `true` if the node existed and was updated.
112
+ */
113
+ updateComponent(id, updates) {
114
+ const node = this.nodes.get(id);
115
+ if (!node) return false;
116
+ node.component = { ...node.component, ...updates };
117
+ this.emit({
118
+ type: "update_node",
119
+ nodeId: id,
120
+ data: updates,
121
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
122
+ });
123
+ return true;
124
+ }
125
+ /**
126
+ * Remove a component and cascade-delete any connections referencing it.
127
+ *
128
+ * @returns `true` if the node existed and was removed.
129
+ */
130
+ removeComponent(id) {
131
+ if (!this.nodes.delete(id)) return false;
132
+ for (const [connId, conn] of this.connections) {
133
+ if (conn.fromId === id || conn.toId === id) {
134
+ this.connections.delete(connId);
135
+ }
136
+ }
137
+ this.emit({
138
+ type: "remove_node",
139
+ nodeId: id,
140
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
141
+ });
142
+ return true;
143
+ }
144
+ /**
145
+ * Update the position (and optionally size / rotation) of a component.
146
+ *
147
+ * @returns `true` if the node existed and was moved.
148
+ */
149
+ moveComponent(id, position) {
150
+ const node = this.nodes.get(id);
151
+ if (!node) return false;
152
+ node.position = { ...node.position, ...position };
153
+ this.emit({
154
+ type: "move_node",
155
+ nodeId: id,
156
+ data: position,
157
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
158
+ });
159
+ return true;
160
+ }
161
+ // -----------------------------------------------------------------------
162
+ // Connection CRUD
163
+ // -----------------------------------------------------------------------
164
+ /**
165
+ * Create a directed connection between two existing nodes.
166
+ *
167
+ * @returns The new connection's `id`.
168
+ * @throws {Error} If either endpoint node does not exist.
169
+ */
170
+ addConnection(fromId, toId, label, style) {
171
+ if (!this.nodes.has(fromId)) {
172
+ throw new Error(`Source node "${fromId}" does not exist`);
173
+ }
174
+ if (!this.nodes.has(toId)) {
175
+ throw new Error(`Target node "${toId}" does not exist`);
176
+ }
177
+ const id = generateId(12);
178
+ const connection = { id, fromId, toId, label, style };
179
+ this.connections.set(id, connection);
180
+ this.emit({
181
+ type: "add_connection",
182
+ connectionId: id,
183
+ data: connection,
184
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
185
+ });
186
+ return id;
187
+ }
188
+ /**
189
+ * Remove a connection by id.
190
+ *
191
+ * @returns `true` if the connection existed and was removed.
192
+ */
193
+ removeConnection(id) {
194
+ if (!this.connections.delete(id)) return false;
195
+ this.emit({
196
+ type: "remove_connection",
197
+ connectionId: id,
198
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
199
+ });
200
+ return true;
201
+ }
202
+ // -----------------------------------------------------------------------
203
+ // Bulk operations
204
+ // -----------------------------------------------------------------------
205
+ /** Remove all nodes and connections, resetting the canvas to an empty state. */
206
+ clear() {
207
+ this.nodes.clear();
208
+ this.connections.clear();
209
+ this.nextZIndex = 1;
210
+ this.emit({
211
+ type: "clear",
212
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
213
+ });
214
+ }
215
+ // -----------------------------------------------------------------------
216
+ // Queries
217
+ // -----------------------------------------------------------------------
218
+ /** Look up a single node by component id. */
219
+ getNode(id) {
220
+ return this.nodes.get(id);
221
+ }
222
+ /** Look up a single connection by id. */
223
+ getConnection(id) {
224
+ return this.connections.get(id);
225
+ }
226
+ /** Return all nodes as an array. */
227
+ getAllNodes() {
228
+ return Array.from(this.nodes.values());
229
+ }
230
+ /** Return all connections as an array. */
231
+ getAllConnections() {
232
+ return Array.from(this.connections.values());
233
+ }
234
+ /** Produce a serialisable snapshot of the entire canvas. */
235
+ getSnapshot() {
236
+ return {
237
+ nodes: this.getAllNodes(),
238
+ connections: this.getAllConnections()
239
+ };
240
+ }
241
+ /** Return the current number of nodes on the canvas. */
242
+ getNodeCount() {
243
+ return this.nodes.size;
244
+ }
245
+ // -----------------------------------------------------------------------
246
+ // Change listener
247
+ // -----------------------------------------------------------------------
248
+ /**
249
+ * Subscribe to canvas mutations.
250
+ *
251
+ * @returns An unsubscribe function.
252
+ */
253
+ onChange(listener) {
254
+ this.listeners.push(listener);
255
+ return () => {
256
+ const idx = this.listeners.indexOf(listener);
257
+ if (idx !== -1) {
258
+ this.listeners.splice(idx, 1);
259
+ }
260
+ };
261
+ }
262
+ // -----------------------------------------------------------------------
263
+ // Internal
264
+ // -----------------------------------------------------------------------
265
+ /** Notify all registered listeners of a change. */
266
+ emit(change) {
267
+ for (const listener of this.listeners) {
268
+ listener(change);
269
+ }
270
+ }
271
+ };
272
+ function encodeMessage(msg) {
273
+ return JSON.stringify(msg);
274
+ }
275
+ var PARAMETERS_SCHEMA = {
276
+ type: "object",
277
+ required: ["action"],
278
+ properties: {
279
+ action: {
280
+ type: "string",
281
+ enum: ["add", "update", "remove", "move", "connect", "clear"],
282
+ description: "The canvas operation to perform."
283
+ },
284
+ component: {
285
+ type: "object",
286
+ description: 'The A2UI component to add (required for "add" action). Must have an `id` and `type` field.'
287
+ },
288
+ position: {
289
+ type: "object",
290
+ description: 'Position on the canvas { x, y, width?, height?, rotation? }. Required for "add" and "move".',
291
+ properties: {
292
+ x: { type: "number" },
293
+ y: { type: "number" },
294
+ width: { type: "number" },
295
+ height: { type: "number" },
296
+ rotation: { type: "number" }
297
+ },
298
+ required: ["x", "y"]
299
+ },
300
+ componentId: {
301
+ type: "string",
302
+ description: 'Target component id (required for "update", "remove", "move").'
303
+ },
304
+ updates: {
305
+ type: "object",
306
+ description: 'Partial component updates (for "update" action).'
307
+ },
308
+ fromId: {
309
+ type: "string",
310
+ description: 'Source component id (for "connect" action).'
311
+ },
312
+ toId: {
313
+ type: "string",
314
+ description: 'Target component id (for "connect" action).'
315
+ },
316
+ connectionLabel: {
317
+ type: "string",
318
+ description: 'Label for the connection (for "connect" action).'
319
+ },
320
+ connectionStyle: {
321
+ type: "string",
322
+ enum: ["solid", "dashed", "dotted"],
323
+ description: 'Line style for the connection (for "connect" action).'
324
+ }
325
+ },
326
+ additionalProperties: false
327
+ };
328
+ var CanvasTool = class {
329
+ name = "canvas_render";
330
+ description = "Render visual components on the interactive canvas. Supports adding cards, charts, forms, tables, code blocks, images, markdown, progress bars, and status indicators. Components can be connected with directional edges to show relationships.";
331
+ parameters = PARAMETERS_SCHEMA;
332
+ weight = "lightweight";
333
+ // -------------------------------------------------------------------------
334
+ // Validation
335
+ // -------------------------------------------------------------------------
336
+ validate(args) {
337
+ const a = args;
338
+ const errors = [];
339
+ if (!a || typeof a !== "object") {
340
+ return { valid: false, errors: ["Arguments must be an object."] };
341
+ }
342
+ if (!a.action) {
343
+ errors.push('Missing required field "action".');
344
+ return { valid: false, errors };
345
+ }
346
+ switch (a.action) {
347
+ case "add":
348
+ if (!a.component) {
349
+ errors.push('"add" requires a "component" object.');
350
+ } else if (!isA2UIComponent(a.component)) {
351
+ errors.push('"component" must have "id" and "type" fields.');
352
+ } else {
353
+ const fieldErrors = validateComponentFields(a.component);
354
+ errors.push(...fieldErrors);
355
+ }
356
+ if (!a.position) errors.push('"add" requires a "position" object.');
357
+ else if (typeof a.position.x !== "number" || typeof a.position.y !== "number")
358
+ errors.push('"position" must have numeric "x" and "y" fields.');
359
+ break;
360
+ case "update":
361
+ if (!a.componentId) errors.push('"update" requires "componentId".');
362
+ if (!a.updates || typeof a.updates !== "object") errors.push('"update" requires "updates" object.');
363
+ break;
364
+ case "remove":
365
+ if (!a.componentId) errors.push('"remove" requires "componentId".');
366
+ break;
367
+ case "move":
368
+ if (!a.componentId) errors.push('"move" requires "componentId".');
369
+ if (!a.position) errors.push('"move" requires a "position" object.');
370
+ break;
371
+ case "connect":
372
+ if (!a.fromId) errors.push('"connect" requires "fromId".');
373
+ if (!a.toId) errors.push('"connect" requires "toId".');
374
+ break;
375
+ case "clear":
376
+ break;
377
+ default:
378
+ errors.push(`Unknown action "${String(a.action)}". Use: add, update, remove, move, connect, clear.`);
379
+ }
380
+ return { valid: errors.length === 0, errors: errors.length > 0 ? errors : void 0 };
381
+ }
382
+ // -------------------------------------------------------------------------
383
+ // Execution
384
+ // -------------------------------------------------------------------------
385
+ async execute(args, context) {
386
+ const a = args;
387
+ const canvasState = context.canvasState;
388
+ if (!canvasState) {
389
+ return {
390
+ success: false,
391
+ output: "",
392
+ error: "No canvas state available in context. Is this a canvas session?"
393
+ };
394
+ }
395
+ try {
396
+ switch (a.action) {
397
+ case "add": {
398
+ const id = canvasState.addComponent(a.component, a.position);
399
+ return {
400
+ success: true,
401
+ output: `Component "${a.component.type}" added with id "${id}" at (${a.position.x}, ${a.position.y}).`
402
+ };
403
+ }
404
+ case "update": {
405
+ const updated = canvasState.updateComponent(a.componentId, a.updates);
406
+ if (!updated) {
407
+ return { success: false, output: "", error: `Component "${a.componentId}" not found.` };
408
+ }
409
+ return { success: true, output: `Component "${a.componentId}" updated.` };
410
+ }
411
+ case "remove": {
412
+ const removed = canvasState.removeComponent(a.componentId);
413
+ if (!removed) {
414
+ return { success: false, output: "", error: `Component "${a.componentId}" not found.` };
415
+ }
416
+ return { success: true, output: `Component "${a.componentId}" removed.` };
417
+ }
418
+ case "move": {
419
+ const moved = canvasState.moveComponent(a.componentId, a.position);
420
+ if (!moved) {
421
+ return { success: false, output: "", error: `Component "${a.componentId}" not found.` };
422
+ }
423
+ return { success: true, output: `Component "${a.componentId}" moved to (${a.position.x}, ${a.position.y}).` };
424
+ }
425
+ case "connect": {
426
+ const connId = canvasState.addConnection(
427
+ a.fromId,
428
+ a.toId,
429
+ a.connectionLabel,
430
+ a.connectionStyle
431
+ );
432
+ return {
433
+ success: true,
434
+ output: `Connection created: "${a.fromId}" \u2192 "${a.toId}" (id: "${connId}").`
435
+ };
436
+ }
437
+ case "clear": {
438
+ canvasState.clear();
439
+ return { success: true, output: "Canvas cleared." };
440
+ }
441
+ default:
442
+ return { success: false, output: "", error: `Unknown action "${String(a.action)}".` };
443
+ }
444
+ } catch (err) {
445
+ return {
446
+ success: false,
447
+ output: "",
448
+ error: err instanceof Error ? err.message : "Unknown error during canvas operation."
449
+ };
450
+ }
451
+ }
452
+ // -------------------------------------------------------------------------
453
+ // AWM state snapshot (optional)
454
+ // -------------------------------------------------------------------------
455
+ async getStateSnapshot(_args, context) {
456
+ const canvasState = context.canvasState;
457
+ const snapshot = canvasState?.getSnapshot();
458
+ return {
459
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
460
+ state: {
461
+ nodeCount: snapshot?.nodes.length ?? 0,
462
+ connectionCount: snapshot?.connections.length ?? 0,
463
+ nodes: snapshot?.nodes.map((n) => ({
464
+ id: n.component.id,
465
+ type: n.component.type,
466
+ position: n.position
467
+ })) ?? []
468
+ },
469
+ description: `Canvas state: ${snapshot?.nodes.length ?? 0} nodes, ${snapshot?.connections.length ?? 0} connections`
470
+ };
471
+ }
472
+ };
473
+ var CanvasChannel = class {
474
+ id = "canvas";
475
+ name = "Canvas";
476
+ messageHandler = null;
477
+ sendFn = null;
478
+ sessionId = "";
479
+ // -----------------------------------------------------------------------
480
+ // IChannel lifecycle
481
+ // -----------------------------------------------------------------------
482
+ async start(config) {
483
+ const canvasConfig = config;
484
+ this.sessionId = canvasConfig.sessionId ?? "";
485
+ }
486
+ async stop() {
487
+ this.messageHandler = null;
488
+ this.sendFn = null;
489
+ }
490
+ // -----------------------------------------------------------------------
491
+ // IChannel messaging
492
+ // -----------------------------------------------------------------------
493
+ async send(_to, message) {
494
+ if (!this.sendFn) {
495
+ return { success: false, error: "No WebSocket client connected." };
496
+ }
497
+ this.sendFn({
498
+ type: "s2c:text:complete",
499
+ text: message.text
500
+ });
501
+ return { success: true, messageId: generateId(12) };
502
+ }
503
+ onMessage(handler) {
504
+ this.messageHandler = handler;
505
+ }
506
+ async isHealthy() {
507
+ return this.sendFn !== null;
508
+ }
509
+ // -----------------------------------------------------------------------
510
+ // Canvas-specific wiring (called by gateway / WS bridge)
511
+ // -----------------------------------------------------------------------
512
+ /**
513
+ * Inject the WebSocket send function. Called by the WS bridge once the
514
+ * WebSocket connection is established.
515
+ */
516
+ setSendFunction(fn) {
517
+ this.sendFn = fn;
518
+ }
519
+ /**
520
+ * Process an incoming C2S message from the WebSocket client and translate
521
+ * it into an {@link InboundMessage} that the agent loop can consume.
522
+ */
523
+ handleClientMessage(msg) {
524
+ if (!this.messageHandler) return;
525
+ const base = {
526
+ id: generateId(12),
527
+ channelId: this.sessionId || "canvas",
528
+ from: {
529
+ channelId: this.sessionId || "canvas",
530
+ userId: "canvas-user"
531
+ },
532
+ timestamp: /* @__PURE__ */ new Date(),
533
+ raw: msg
534
+ };
535
+ switch (msg.type) {
536
+ case "c2s:message":
537
+ this.messageHandler({ ...base, text: msg.text });
538
+ break;
539
+ case "c2s:click":
540
+ this.messageHandler({
541
+ ...base,
542
+ text: `[USER_CLICK] Component: ${msg.componentId}${msg.actionId ? `, Action: ${msg.actionId}` : ""}`
543
+ });
544
+ break;
545
+ case "c2s:input":
546
+ this.messageHandler({
547
+ ...base,
548
+ text: `[USER_INPUT] Component: ${msg.componentId}${msg.field ? `, Field: ${msg.field}` : ""}, Value: ${msg.value}`
549
+ });
550
+ break;
551
+ case "c2s:form_submit":
552
+ this.messageHandler({
553
+ ...base,
554
+ text: `[FORM_SUBMIT] Component: ${msg.componentId}, Values: ${JSON.stringify(msg.values)}`
555
+ });
556
+ break;
557
+ case "c2s:select":
558
+ this.messageHandler({
559
+ ...base,
560
+ text: `[USER_SELECT] Components: ${msg.componentIds.join(", ")}`
561
+ });
562
+ break;
563
+ case "c2s:steer":
564
+ this.messageHandler({
565
+ ...base,
566
+ text: `[STEER:${msg.steerType}] ${msg.message}`
567
+ });
568
+ break;
569
+ case "c2s:abort":
570
+ this.messageHandler({
571
+ ...base,
572
+ text: `[ABORT] ${msg.reason ?? "User requested abort"}`
573
+ });
574
+ break;
575
+ case "c2s:drag":
576
+ break;
577
+ case "c2s:ping":
578
+ break;
579
+ }
580
+ }
581
+ };
582
+
583
+ // ../../packages/gateway/dist/index.js
584
+ import { createServer } from "http";
585
+ import { WebSocketServer } from "ws";
586
+ import { createReadStream, statSync } from "fs";
587
+ import { resolve, extname, normalize, relative } from "path";
588
+ import { randomBytes, createHash } from "crypto";
589
+ var SessionManager = class {
590
+ sessions = /* @__PURE__ */ new Map();
591
+ /** Create a new session and return its state. */
592
+ createSession(config) {
593
+ const now = /* @__PURE__ */ new Date();
594
+ const state = {
595
+ config,
596
+ createdAt: now,
597
+ lastActiveAt: now,
598
+ status: "active"
599
+ };
600
+ this.sessions.set(config.sessionId, state);
601
+ return state;
602
+ }
603
+ /** Look up a session by its id. */
604
+ getSession(id) {
605
+ return this.sessions.get(id);
606
+ }
607
+ /** Mark a session as ended and remove it from the active map. */
608
+ endSession(id) {
609
+ const session = this.sessions.get(id);
610
+ if (session) {
611
+ session.status = "ended";
612
+ session.lastActiveAt = /* @__PURE__ */ new Date();
613
+ this.sessions.delete(id);
614
+ }
615
+ }
616
+ /** Return the number of active sessions. */
617
+ get size() {
618
+ return this.sessions.size;
619
+ }
620
+ /** Return all currently tracked sessions (not ended). */
621
+ listSessions() {
622
+ return [...this.sessions.values()];
623
+ }
624
+ /** Touch a session's lastActiveAt timestamp to keep it alive. */
625
+ touchSession(id) {
626
+ const session = this.sessions.get(id);
627
+ if (session) {
628
+ session.lastActiveAt = /* @__PURE__ */ new Date();
629
+ session.status = "active";
630
+ }
631
+ }
632
+ /**
633
+ * Evict sessions that have been idle longer than `maxIdleMs`.
634
+ * Returns the number of sessions evicted.
635
+ */
636
+ evictIdle(maxIdleMs) {
637
+ const cutoff = Date.now() - maxIdleMs;
638
+ let evicted = 0;
639
+ for (const [id, session] of this.sessions) {
640
+ if (session.lastActiveAt.getTime() < cutoff) {
641
+ this.sessions.delete(id);
642
+ evicted++;
643
+ }
644
+ }
645
+ return evicted;
646
+ }
647
+ };
648
+ var MessageRouter = class {
649
+ constructor(sessionManager, defaultSessionConfig, maxRouteEntries = 1e4) {
650
+ this.sessionManager = sessionManager;
651
+ this.defaultSessionConfig = defaultSessionConfig;
652
+ this.maxRouteEntries = maxRouteEntries;
653
+ }
654
+ /**
655
+ * Maps "channelId:userId" keys to session ids so subsequent messages
656
+ * from the same user on the same channel reach the same session.
657
+ */
658
+ routeMap = /* @__PURE__ */ new Map();
659
+ /**
660
+ * Safety-net cap: when the route map reaches this size, evictStale() is
661
+ * called automatically so expired entries cannot accumulate indefinitely
662
+ * even if the external eviction timer is slow or absent.
663
+ */
664
+ maxRouteEntries;
665
+ /**
666
+ * Route an inbound message to a session.
667
+ *
668
+ * Routing key priority:
669
+ * 1. Group + thread (Telegram forum topic, Discord thread): all users in the
670
+ * same thread share one session.
671
+ * 2. Group only: each user in the group gets their own session.
672
+ * 3. Private: keyed by userId (original behaviour).
673
+ *
674
+ * Returns `null` only if the message cannot be attributed to a user
675
+ * (missing channelId).
676
+ */
677
+ route(msg) {
678
+ if (!msg.channelId) return null;
679
+ const routeKey = this.buildRouteKey(msg);
680
+ const existingId = this.routeMap.get(routeKey);
681
+ if (existingId) {
682
+ const session = this.sessionManager.getSession(existingId);
683
+ if (session) {
684
+ this.sessionManager.touchSession(existingId);
685
+ return { sessionId: existingId, config: session.config };
686
+ }
687
+ this.routeMap.delete(routeKey);
688
+ }
689
+ const sessionId = generateId();
690
+ const config = {
691
+ ...this.defaultSessionConfig,
692
+ sessionId,
693
+ channelId: msg.channelId,
694
+ userId: msg.from.userId
695
+ };
696
+ const state = this.sessionManager.createSession(config);
697
+ this.routeMap.set(routeKey, sessionId);
698
+ if (this.routeMap.size > this.maxRouteEntries) {
699
+ this.evictStale();
700
+ }
701
+ return { sessionId, config: state.config };
702
+ }
703
+ /**
704
+ * Evict route entries whose sessions no longer exist in the SessionManager.
705
+ * Returns the number of stale routes removed.
706
+ */
707
+ evictStale() {
708
+ let evicted = 0;
709
+ for (const [key, sessionId] of this.routeMap) {
710
+ if (!this.sessionManager.getSession(sessionId)) {
711
+ this.routeMap.delete(key);
712
+ evicted++;
713
+ }
714
+ }
715
+ return evicted;
716
+ }
717
+ // ---------------------------------------------------------------------------
718
+ // Private helpers
719
+ // ---------------------------------------------------------------------------
720
+ buildRouteKey(msg) {
721
+ const { channelId, from } = msg;
722
+ const { userId, groupId, threadId } = from;
723
+ if (groupId && threadId) {
724
+ return `${channelId}:group:${groupId}:thread:${threadId}`;
725
+ }
726
+ if (groupId) {
727
+ return `${channelId}:group:${groupId}:user:${userId ?? "anonymous"}`;
728
+ }
729
+ return `${channelId}:${userId ?? "anonymous"}`;
730
+ }
731
+ };
732
+ var WebSocketBridge = class {
733
+ constructor(ws, canvasState, canvasChannel, sessionId) {
734
+ this.ws = ws;
735
+ this.canvasState = canvasState;
736
+ this.canvasChannel = canvasChannel;
737
+ this.sessionId = sessionId;
738
+ }
739
+ changeUnsubscribe = null;
740
+ alive = false;
741
+ messageHandler = null;
742
+ closeHandler = null;
743
+ errorHandler = null;
744
+ /** Start the bridge — subscribe to state changes and handle incoming messages. */
745
+ start() {
746
+ this.alive = true;
747
+ this.changeUnsubscribe = this.canvasState.onChange((change) => {
748
+ this.send({ type: "s2c:canvas:change", change });
749
+ });
750
+ this.send({
751
+ type: "s2c:canvas:snapshot",
752
+ snapshot: this.canvasState.getSnapshot()
753
+ });
754
+ this.send({ type: "s2c:agent:status", status: "idle" });
755
+ this.messageHandler = (data) => {
756
+ try {
757
+ const raw = Buffer.isBuffer(data) ? data.toString("utf-8") : Array.isArray(data) ? Buffer.concat(data).toString("utf-8") : Buffer.from(data).toString("utf-8");
758
+ const msg = JSON.parse(raw);
759
+ this.handleC2S(msg);
760
+ } catch {
761
+ this.send({
762
+ type: "s2c:error",
763
+ code: "PARSE_ERROR",
764
+ message: "Invalid message format"
765
+ });
766
+ }
767
+ };
768
+ this.closeHandler = () => this.stop();
769
+ this.errorHandler = () => this.stop();
770
+ this.ws.on("message", this.messageHandler);
771
+ this.ws.on("close", this.closeHandler);
772
+ this.ws.on("error", this.errorHandler);
773
+ }
774
+ /** Called by the gateway when AgentEvents arrive from the agent loop. */
775
+ handleAgentEvent(event) {
776
+ if (!this.alive) return;
777
+ switch (event.type) {
778
+ case "thinking":
779
+ this.send({ type: "s2c:agent:status", status: "thinking" });
780
+ break;
781
+ case "text":
782
+ this.send({ type: "s2c:agent:status", status: "streaming" });
783
+ this.send({
784
+ type: "s2c:text:delta",
785
+ delta: event.delta ?? "",
786
+ partial: event.partial ?? ""
787
+ });
788
+ break;
789
+ case "tool_start":
790
+ this.send({
791
+ type: "s2c:agent:status",
792
+ status: "tool_executing",
793
+ tool: event.tool
794
+ });
795
+ this.send({
796
+ type: "s2c:tool:start",
797
+ tool: event.tool ?? "",
798
+ data: event.args
799
+ });
800
+ break;
801
+ case "tool_progress":
802
+ this.send({
803
+ type: "s2c:tool:progress",
804
+ tool: event.tool ?? "",
805
+ data: event.result
806
+ });
807
+ break;
808
+ case "tool_end":
809
+ this.send({
810
+ type: "s2c:tool:end",
811
+ tool: event.tool ?? "",
812
+ data: event.result
813
+ });
814
+ break;
815
+ case "complete":
816
+ this.send({
817
+ type: "s2c:text:complete",
818
+ text: event.answer ?? ""
819
+ });
820
+ this.send({ type: "s2c:agent:status", status: "complete" });
821
+ break;
822
+ case "error":
823
+ this.send({
824
+ type: "s2c:agent:status",
825
+ status: "error",
826
+ message: event.error?.message ?? "Unknown error"
827
+ });
828
+ this.send({
829
+ type: "s2c:error",
830
+ code: "AGENT_ERROR",
831
+ message: event.error?.message ?? "Unknown error"
832
+ });
833
+ break;
834
+ case "aborted":
835
+ this.send({
836
+ type: "s2c:agent:status",
837
+ status: "idle",
838
+ message: `Aborted: ${event.reason ?? "unknown"}`
839
+ });
840
+ break;
841
+ }
842
+ }
843
+ /** Stop the bridge — unsubscribe from state changes and remove WS listeners. */
844
+ stop() {
845
+ if (!this.alive) return;
846
+ this.alive = false;
847
+ this.changeUnsubscribe?.();
848
+ this.changeUnsubscribe = null;
849
+ if (this.messageHandler) {
850
+ this.ws.off("message", this.messageHandler);
851
+ this.messageHandler = null;
852
+ }
853
+ if (this.closeHandler) {
854
+ this.ws.off("close", this.closeHandler);
855
+ this.closeHandler = null;
856
+ }
857
+ if (this.errorHandler) {
858
+ this.ws.off("error", this.errorHandler);
859
+ this.errorHandler = null;
860
+ }
861
+ }
862
+ /** Whether the bridge is currently active. */
863
+ isAlive() {
864
+ return this.alive;
865
+ }
866
+ /** Get the session ID this bridge is connected to. */
867
+ getSessionId() {
868
+ return this.sessionId;
869
+ }
870
+ // ---------------------------------------------------------------------------
871
+ // Internal
872
+ // ---------------------------------------------------------------------------
873
+ handleC2S(msg) {
874
+ switch (msg.type) {
875
+ case "c2s:ping":
876
+ this.send({ type: "s2c:pong", timestamp: (/* @__PURE__ */ new Date()).toISOString() });
877
+ break;
878
+ case "c2s:drag":
879
+ this.canvasState.moveComponent(msg.componentId, msg.position);
880
+ break;
881
+ case "c2s:abort":
882
+ this.canvasChannel.handleClientMessage(msg);
883
+ break;
884
+ default:
885
+ this.canvasChannel.handleClientMessage(msg);
886
+ break;
887
+ }
888
+ }
889
+ send(msg) {
890
+ if (this.alive && this.ws.readyState === 1) {
891
+ this.ws.send(encodeMessage(msg));
892
+ }
893
+ }
894
+ };
895
+ var MIME_TYPES = {
896
+ ".html": "text/html; charset=utf-8",
897
+ ".js": "application/javascript; charset=utf-8",
898
+ ".mjs": "application/javascript; charset=utf-8",
899
+ ".css": "text/css; charset=utf-8",
900
+ ".json": "application/json; charset=utf-8",
901
+ ".png": "image/png",
902
+ ".jpg": "image/jpeg",
903
+ ".jpeg": "image/jpeg",
904
+ ".gif": "image/gif",
905
+ ".svg": "image/svg+xml",
906
+ ".ico": "image/x-icon",
907
+ ".woff": "font/woff",
908
+ ".woff2": "font/woff2",
909
+ ".ttf": "font/ttf",
910
+ ".map": "application/json",
911
+ ".wasm": "application/wasm",
912
+ ".webp": "image/webp",
913
+ ".avif": "image/avif"
914
+ };
915
+ function serveStatic(req, res, staticDir) {
916
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
917
+ const urlPath = (req.url ?? "/").split("?")[0];
918
+ const filePath = urlPath === "/" ? "/index.html" : urlPath;
919
+ const absoluteDir = resolve(staticDir);
920
+ const absoluteFile = resolve(absoluteDir, "." + normalize(filePath));
921
+ const rel = relative(absoluteDir, absoluteFile);
922
+ if (rel.startsWith("..") || resolve(absoluteFile) !== absoluteFile.replace(/\/$/, "")) {
923
+ return false;
924
+ }
925
+ try {
926
+ const stat = statSync(absoluteFile);
927
+ if (!stat.isFile()) {
928
+ if (!urlPath.startsWith("/api") && !urlPath.startsWith("/ws")) {
929
+ return serveFallback(res, absoluteDir);
930
+ }
931
+ return false;
932
+ }
933
+ } catch {
934
+ if (!urlPath.startsWith("/api") && !urlPath.startsWith("/ws") && !urlPath.startsWith("/health") && !urlPath.startsWith("/pair") && !urlPath.startsWith("/sessions")) {
935
+ return serveFallback(res, absoluteDir);
936
+ }
937
+ return false;
938
+ }
939
+ const ext = extname(absoluteFile).toLowerCase();
940
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
941
+ const isHashed = /\.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|svg)$/i.test(absoluteFile);
942
+ const cacheControl = isHashed ? "public, max-age=31536000, immutable" : "public, max-age=0, must-revalidate";
943
+ res.setHeader("Content-Type", contentType);
944
+ res.setHeader("Cache-Control", cacheControl);
945
+ if (req.method === "HEAD") {
946
+ res.writeHead(200);
947
+ res.end();
948
+ return true;
949
+ }
950
+ res.writeHead(200);
951
+ createReadStream(absoluteFile).pipe(res);
952
+ return true;
953
+ }
954
+ function serveFallback(res, staticDir) {
955
+ const indexPath = resolve(staticDir, "index.html");
956
+ try {
957
+ statSync(indexPath);
958
+ } catch {
959
+ return false;
960
+ }
961
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
962
+ res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
963
+ res.writeHead(200);
964
+ createReadStream(indexPath).pipe(res);
965
+ return true;
966
+ }
967
+ var GatewayServer = class {
968
+ server = null;
969
+ wss = null;
970
+ port;
971
+ host;
972
+ sessionManager;
973
+ pairingManager;
974
+ defaultSessionConfig;
975
+ canvasSessionManager;
976
+ staticDir;
977
+ onCanvasConnection;
978
+ agentRegistration;
979
+ onWebhook;
980
+ onRawWebhook;
981
+ onSteer;
982
+ onGetConfig;
983
+ onSaveConfig;
984
+ preHandler;
985
+ tunnelUrl = null;
986
+ constructor(options) {
987
+ this.port = options.port;
988
+ this.host = options.host ?? "127.0.0.1";
989
+ this.sessionManager = options.sessionManager;
990
+ this.pairingManager = options.pairingManager ?? null;
991
+ this.defaultSessionConfig = options.defaultSessionConfig ?? {
992
+ engineId: "native",
993
+ model: "claude-sonnet-4-6",
994
+ provider: "anthropic"
995
+ };
996
+ this.canvasSessionManager = options.canvasSessionManager ?? null;
997
+ this.staticDir = options.staticDir ?? null;
998
+ this.onCanvasConnection = options.onCanvasConnection ?? null;
999
+ this.agentRegistration = options.agentRegistration ?? null;
1000
+ this.onWebhook = options.onWebhook ?? null;
1001
+ this.onRawWebhook = options.onRawWebhook ?? null;
1002
+ this.onSteer = options.onSteer ?? null;
1003
+ this.onGetConfig = options.onGetConfig ?? null;
1004
+ this.onSaveConfig = options.onSaveConfig ?? null;
1005
+ this.preHandler = options.preHandler ?? null;
1006
+ }
1007
+ /** Start listening on the configured port. */
1008
+ async start() {
1009
+ return new Promise((resolve2, reject) => {
1010
+ this.server = createServer((req, res) => {
1011
+ this.handleRequest(req, res).catch((err) => {
1012
+ this.sendJson(res, 500, { error: err instanceof Error ? err.message : "Internal server error" });
1013
+ });
1014
+ });
1015
+ if (this.canvasSessionManager) {
1016
+ this.wss = new WebSocketServer({ noServer: true });
1017
+ this.server.on("upgrade", (req, socket, head) => {
1018
+ this.handleUpgrade(req, socket, head);
1019
+ });
1020
+ }
1021
+ this.server.on("error", reject);
1022
+ this.server.listen(this.port, this.host, () => {
1023
+ resolve2();
1024
+ });
1025
+ });
1026
+ }
1027
+ /** Gracefully close the server. */
1028
+ async stop() {
1029
+ if (this.wss) {
1030
+ this.wss.close();
1031
+ this.wss = null;
1032
+ }
1033
+ return new Promise((resolve2, reject) => {
1034
+ if (!this.server) {
1035
+ resolve2();
1036
+ return;
1037
+ }
1038
+ this.server.close((err) => {
1039
+ this.server = null;
1040
+ if (err) reject(err);
1041
+ else resolve2();
1042
+ });
1043
+ });
1044
+ }
1045
+ /** Set the public tunnel URL (shown in GET /health). */
1046
+ setTunnelUrl(url) {
1047
+ this.tunnelUrl = url;
1048
+ }
1049
+ /**
1050
+ * Evict canvas sessions that have been idle longer than maxIdleMs.
1051
+ * Returns the number of sessions evicted, or 0 if canvas is not enabled.
1052
+ */
1053
+ evictIdleCanvas(maxIdleMs) {
1054
+ return this.canvasSessionManager?.evictIdle(maxIdleMs) ?? 0;
1055
+ }
1056
+ /** Get the bound address (useful in tests with port 0). */
1057
+ getAddress() {
1058
+ if (!this.server) return null;
1059
+ const addr = this.server.address();
1060
+ if (typeof addr === "string" || addr === null) return null;
1061
+ return { host: addr.address, port: addr.port };
1062
+ }
1063
+ // ---------------------------------------------------------------------------
1064
+ // WebSocket upgrade handling
1065
+ // ---------------------------------------------------------------------------
1066
+ handleUpgrade(req, socket, head) {
1067
+ if (!this.wss || !this.canvasSessionManager) {
1068
+ socket.destroy();
1069
+ return;
1070
+ }
1071
+ const url = req.url ?? "";
1072
+ const wsMatch = url.match(/^\/ws\/([^?]+)/);
1073
+ if (!wsMatch) {
1074
+ socket.destroy();
1075
+ return;
1076
+ }
1077
+ const sessionId = wsMatch[1];
1078
+ if (this.pairingManager) {
1079
+ const urlObj = new URL(url, `http://${req.headers.host ?? "localhost"}`);
1080
+ const token = urlObj.searchParams.get("token");
1081
+ if (!token || !this.pairingManager.validateToken(token)) {
1082
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1083
+ socket.destroy();
1084
+ return;
1085
+ }
1086
+ }
1087
+ if (!this.canvasSessionManager.hasSession(sessionId)) {
1088
+ this.canvasSessionManager.createCanvasSession(sessionId);
1089
+ }
1090
+ const entry = this.canvasSessionManager.getSession(sessionId);
1091
+ if (!entry) {
1092
+ socket.destroy();
1093
+ return;
1094
+ }
1095
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
1096
+ const bridge = new WebSocketBridge(ws, entry.canvasState, entry.canvasChannel, sessionId);
1097
+ entry.canvasChannel.setSendFunction((msg) => {
1098
+ if (ws.readyState === 1) {
1099
+ ws.send(JSON.stringify(msg));
1100
+ }
1101
+ });
1102
+ this.canvasSessionManager.setBridge(sessionId, bridge);
1103
+ bridge.start();
1104
+ this.onCanvasConnection?.(sessionId, bridge);
1105
+ this.wss.emit("connection", ws, req);
1106
+ });
1107
+ }
1108
+ // ---------------------------------------------------------------------------
1109
+ // Request handling
1110
+ // ---------------------------------------------------------------------------
1111
+ async handleRequest(req, res) {
1112
+ const method = req.method ?? "GET";
1113
+ const url = req.url ?? "/";
1114
+ res.setHeader("Access-Control-Allow-Origin", "*");
1115
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
1116
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
1117
+ if (method === "OPTIONS") {
1118
+ res.writeHead(204);
1119
+ res.end();
1120
+ return;
1121
+ }
1122
+ if (method === "GET" && url === "/health") {
1123
+ const stats = this.pairingManager?.stats();
1124
+ this.sendJson(res, 200, {
1125
+ status: "ok",
1126
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1127
+ sessions: this.sessionManager.listSessions().length,
1128
+ canvas: this.canvasSessionManager?.listSessionIds().length ?? 0,
1129
+ ...stats ? { pairing: stats } : {},
1130
+ ...this.tunnelUrl ? { tunnel: this.tunnelUrl } : {}
1131
+ });
1132
+ return;
1133
+ }
1134
+ if (method === "GET" && url === "/ready") {
1135
+ this.sendJson(res, 200, {
1136
+ ready: true,
1137
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1138
+ sessions: this.sessionManager.listSessions().length
1139
+ });
1140
+ return;
1141
+ }
1142
+ if (method === "GET" && url === "/.well-known/agent.json") {
1143
+ if (!this.agentRegistration) {
1144
+ this.sendJson(res, 404, { error: "No agent registration configured." });
1145
+ return;
1146
+ }
1147
+ this.sendJson(res, 200, this.agentRegistration);
1148
+ return;
1149
+ }
1150
+ if (method === "POST" && url === "/pair") {
1151
+ if (!this.pairingManager) {
1152
+ this.sendJson(res, 400, { error: "Pairing is not enabled on this gateway." });
1153
+ return;
1154
+ }
1155
+ const body = await this.readBody(req);
1156
+ let payload = {};
1157
+ try {
1158
+ payload = JSON.parse(body);
1159
+ } catch {
1160
+ this.sendJson(res, 400, { error: "Invalid JSON body." });
1161
+ return;
1162
+ }
1163
+ if (!payload.code) {
1164
+ this.sendJson(res, 400, { error: 'Missing "code" in request body.' });
1165
+ return;
1166
+ }
1167
+ const token = this.pairingManager.exchangeCode(payload.code, payload.label);
1168
+ if (!token) {
1169
+ this.sendJson(res, 401, { error: "Invalid or expired pairing code." });
1170
+ return;
1171
+ }
1172
+ this.sendJson(res, 200, { token, paired: true });
1173
+ return;
1174
+ }
1175
+ if (this.preHandler) {
1176
+ const handled = await this.preHandler(req, res);
1177
+ if (handled) return;
1178
+ }
1179
+ if (this.pairingManager && !this.checkAuth(req)) {
1180
+ this.sendJson(res, 401, { error: "Unauthorized. Provide a valid bearer token." });
1181
+ return;
1182
+ }
1183
+ if (method === "GET" && url === "/sessions") {
1184
+ const sessions = this.sessionManager.listSessions().map((s) => ({
1185
+ sessionId: s.config.sessionId,
1186
+ channelId: s.config.channelId,
1187
+ userId: s.config.userId,
1188
+ status: s.status,
1189
+ createdAt: s.createdAt.toISOString(),
1190
+ lastActiveAt: s.lastActiveAt.toISOString()
1191
+ }));
1192
+ this.sendJson(res, 200, { sessions });
1193
+ return;
1194
+ }
1195
+ if (method === "POST" && url === "/sessions") {
1196
+ const body = await this.readBody(req);
1197
+ let payload = {};
1198
+ try {
1199
+ payload = JSON.parse(body);
1200
+ } catch {
1201
+ this.sendJson(res, 400, { error: "Invalid JSON body." });
1202
+ return;
1203
+ }
1204
+ const sessionId = generateId(16);
1205
+ const config = {
1206
+ ...this.defaultSessionConfig,
1207
+ sessionId,
1208
+ channelId: payload.channelId,
1209
+ userId: payload.userId,
1210
+ systemPrompt: payload.systemPrompt ?? this.defaultSessionConfig.systemPrompt
1211
+ };
1212
+ const session = this.sessionManager.createSession(config);
1213
+ this.sendJson(res, 201, {
1214
+ sessionId: session.config.sessionId,
1215
+ channelId: session.config.channelId,
1216
+ userId: session.config.userId,
1217
+ status: session.status,
1218
+ createdAt: session.createdAt.toISOString()
1219
+ });
1220
+ return;
1221
+ }
1222
+ const sessionMatch = url.match(/^\/sessions\/([^/]+)(\/steer)?$/);
1223
+ if (sessionMatch) {
1224
+ const sessionId = sessionMatch[1];
1225
+ const isSteer = sessionMatch[2] === "/steer";
1226
+ if (method === "GET" && !isSteer) {
1227
+ const session = this.sessionManager.getSession(sessionId);
1228
+ if (!session) {
1229
+ this.sendJson(res, 404, { error: "Session not found" });
1230
+ return;
1231
+ }
1232
+ this.sendJson(res, 200, {
1233
+ sessionId: session.config.sessionId,
1234
+ channelId: session.config.channelId,
1235
+ userId: session.config.userId,
1236
+ status: session.status,
1237
+ createdAt: session.createdAt.toISOString(),
1238
+ lastActiveAt: session.lastActiveAt.toISOString()
1239
+ });
1240
+ return;
1241
+ }
1242
+ if (method === "POST" && isSteer) {
1243
+ const session = this.sessionManager.getSession(sessionId);
1244
+ if (!session) {
1245
+ this.sendJson(res, 404, { error: "Session not found" });
1246
+ return;
1247
+ }
1248
+ const body = await this.readBody(req);
1249
+ let payload;
1250
+ try {
1251
+ payload = JSON.parse(body);
1252
+ } catch {
1253
+ this.sendJson(res, 400, { error: "Invalid JSON body." });
1254
+ return;
1255
+ }
1256
+ if (!payload.message) {
1257
+ this.sendJson(res, 400, { error: 'Missing "message" in request body' });
1258
+ return;
1259
+ }
1260
+ this.sessionManager.touchSession(sessionId);
1261
+ if (this.onSteer) {
1262
+ this.onSteer(sessionId, payload.message);
1263
+ }
1264
+ this.sendJson(res, 200, {
1265
+ sessionId,
1266
+ steered: true,
1267
+ message: payload.message
1268
+ });
1269
+ return;
1270
+ }
1271
+ if (method === "DELETE" && !isSteer) {
1272
+ const session = this.sessionManager.getSession(sessionId);
1273
+ if (!session) {
1274
+ this.sendJson(res, 404, { error: "Session not found" });
1275
+ return;
1276
+ }
1277
+ this.sessionManager.endSession(sessionId);
1278
+ this.canvasSessionManager?.endCanvasSession(sessionId);
1279
+ this.sendJson(res, 200, { sessionId, ended: true });
1280
+ return;
1281
+ }
1282
+ }
1283
+ const webhookMatch = url.match(/^\/webhooks\/([a-zA-Z0-9_-]+)$/);
1284
+ if (method === "POST" && webhookMatch) {
1285
+ const webhookName = webhookMatch[1];
1286
+ if (!this.onWebhook && !this.onRawWebhook) {
1287
+ this.sendJson(res, 404, { error: "Webhooks are not enabled on this gateway." });
1288
+ return;
1289
+ }
1290
+ const body = await this.readBody(req);
1291
+ if (this.onRawWebhook) {
1292
+ try {
1293
+ const handled = this.onRawWebhook(webhookName, body);
1294
+ if (handled) {
1295
+ this.sendJson(res, 200, { webhook: webhookName, accepted: true });
1296
+ return;
1297
+ }
1298
+ } catch (err) {
1299
+ const errMsg = err instanceof Error ? err.message : String(err);
1300
+ this.sendJson(res, 500, { error: `Webhook handler failed: ${errMsg}` });
1301
+ return;
1302
+ }
1303
+ }
1304
+ if (!this.onWebhook) {
1305
+ this.sendJson(res, 404, { error: "Webhooks are not enabled on this gateway." });
1306
+ return;
1307
+ }
1308
+ let payload = {};
1309
+ try {
1310
+ payload = JSON.parse(body);
1311
+ } catch {
1312
+ this.sendJson(res, 400, { error: "Invalid JSON body." });
1313
+ return;
1314
+ }
1315
+ if (!payload.message) {
1316
+ this.sendJson(res, 400, { error: 'Missing "message" in request body.' });
1317
+ return;
1318
+ }
1319
+ try {
1320
+ this.onWebhook(webhookName, { message: payload.message, userId: payload.userId });
1321
+ this.sendJson(res, 200, { webhook: webhookName, accepted: true });
1322
+ } catch (err) {
1323
+ const errMsg = err instanceof Error ? err.message : String(err);
1324
+ this.sendJson(res, 500, { error: `Webhook handler failed: ${errMsg}` });
1325
+ }
1326
+ return;
1327
+ }
1328
+ if (method === "GET" && url === "/config") {
1329
+ if (!this.onGetConfig) {
1330
+ this.sendJson(res, 404, { error: "Config endpoint not configured." });
1331
+ return;
1332
+ }
1333
+ this.sendJson(res, 200, this.onGetConfig());
1334
+ return;
1335
+ }
1336
+ if (method === "PATCH" && url === "/config") {
1337
+ if (!this.onSaveConfig) {
1338
+ this.sendJson(res, 404, { error: "Config endpoint not configured." });
1339
+ return;
1340
+ }
1341
+ const body = await this.readBody(req);
1342
+ let updates;
1343
+ try {
1344
+ updates = JSON.parse(body);
1345
+ } catch {
1346
+ this.sendJson(res, 400, { error: "Invalid JSON body." });
1347
+ return;
1348
+ }
1349
+ try {
1350
+ await this.onSaveConfig(updates);
1351
+ this.sendJson(res, 200, { saved: true, restartRequired: true });
1352
+ } catch (err) {
1353
+ const errMsg = err instanceof Error ? err.message : String(err);
1354
+ this.sendJson(res, 500, { error: `Failed to save config: ${errMsg}` });
1355
+ }
1356
+ return;
1357
+ }
1358
+ if (this.staticDir && serveStatic(req, res, this.staticDir)) {
1359
+ return;
1360
+ }
1361
+ this.sendJson(res, 404, { error: "Not found" });
1362
+ }
1363
+ // ---------------------------------------------------------------------------
1364
+ // Auth
1365
+ // ---------------------------------------------------------------------------
1366
+ checkAuth(req) {
1367
+ if (req["_x402Authenticated"] === true) return true;
1368
+ if (!this.pairingManager) return true;
1369
+ const authHeader = req.headers["authorization"];
1370
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return false;
1371
+ const token = authHeader.slice(7);
1372
+ return this.pairingManager.validateToken(token);
1373
+ }
1374
+ // ---------------------------------------------------------------------------
1375
+ // Helpers
1376
+ // ---------------------------------------------------------------------------
1377
+ sendJson(res, status, data) {
1378
+ const body = JSON.stringify(data);
1379
+ res.writeHead(status, {
1380
+ "Content-Type": "application/json",
1381
+ "Content-Length": Buffer.byteLength(body)
1382
+ });
1383
+ res.end(body);
1384
+ }
1385
+ readBody(req) {
1386
+ return new Promise((resolve2, reject) => {
1387
+ const chunks = [];
1388
+ req.on("data", (chunk) => chunks.push(chunk));
1389
+ req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
1390
+ req.on("error", reject);
1391
+ });
1392
+ }
1393
+ };
1394
+ var PairingManager = class {
1395
+ codes = /* @__PURE__ */ new Map();
1396
+ clients = /* @__PURE__ */ new Map();
1397
+ codeTtlMs;
1398
+ tokenTtlMs;
1399
+ maxActiveCodes;
1400
+ maxPairedClients;
1401
+ constructor(opts = {}) {
1402
+ this.codeTtlMs = opts.codeTtlMs ?? 5 * 60 * 1e3;
1403
+ this.tokenTtlMs = opts.tokenTtlMs ?? 30 * 24 * 60 * 60 * 1e3;
1404
+ this.maxActiveCodes = opts.maxActiveCodes ?? 5;
1405
+ this.maxPairedClients = opts.maxPairedClients ?? 20;
1406
+ }
1407
+ // -----------------------------------------------------------------------
1408
+ // Code generation
1409
+ // -----------------------------------------------------------------------
1410
+ /**
1411
+ * Generate a new pairing code.
1412
+ * Returns the code object. The code string is what gets shared.
1413
+ */
1414
+ generateCode(label) {
1415
+ this.pruneExpiredCodes();
1416
+ if (this.codes.size >= this.maxActiveCodes) {
1417
+ throw new Error(
1418
+ `Maximum active pairing codes (${this.maxActiveCodes}) reached. Revoke existing codes or wait for them to expire.`
1419
+ );
1420
+ }
1421
+ const code = this.createRandomCode();
1422
+ const now = /* @__PURE__ */ new Date();
1423
+ const pairingCode = {
1424
+ code,
1425
+ createdAt: now,
1426
+ expiresAt: new Date(now.getTime() + this.codeTtlMs),
1427
+ label
1428
+ };
1429
+ this.codes.set(code, pairingCode);
1430
+ return pairingCode;
1431
+ }
1432
+ /**
1433
+ * List all active (non-expired) pairing codes.
1434
+ */
1435
+ listCodes() {
1436
+ this.pruneExpiredCodes();
1437
+ return [...this.codes.values()];
1438
+ }
1439
+ /**
1440
+ * Revoke a specific pairing code.
1441
+ */
1442
+ revokeCode(code) {
1443
+ return this.codes.delete(code);
1444
+ }
1445
+ // -----------------------------------------------------------------------
1446
+ // Code exchange → token
1447
+ // -----------------------------------------------------------------------
1448
+ /**
1449
+ * Exchange a pairing code for a session token.
1450
+ *
1451
+ * Returns the token string on success, or null if the code is invalid
1452
+ * or expired. The code is consumed (deleted) after successful exchange.
1453
+ */
1454
+ exchangeCode(code, label) {
1455
+ this.pruneExpiredCodes();
1456
+ const pairingCode = this.codes.get(code);
1457
+ if (!pairingCode) return null;
1458
+ if (/* @__PURE__ */ new Date() > pairingCode.expiresAt) {
1459
+ this.codes.delete(code);
1460
+ return null;
1461
+ }
1462
+ this.codes.delete(code);
1463
+ if (this.clients.size >= this.maxPairedClients) {
1464
+ this.evictOldestClient();
1465
+ }
1466
+ const token = randomBytes(32).toString("hex");
1467
+ const tokenHash = this.hashToken(token);
1468
+ const now = /* @__PURE__ */ new Date();
1469
+ this.clients.set(tokenHash, {
1470
+ token,
1471
+ tokenHash,
1472
+ pairedAt: now,
1473
+ lastSeenAt: now,
1474
+ expiresAt: new Date(now.getTime() + this.tokenTtlMs),
1475
+ label: label ?? pairingCode.label
1476
+ });
1477
+ return token;
1478
+ }
1479
+ // -----------------------------------------------------------------------
1480
+ // Token validation
1481
+ // -----------------------------------------------------------------------
1482
+ /**
1483
+ * Validate a bearer token.
1484
+ * Returns true if the token belongs to a paired client and has not expired.
1485
+ * Updates `lastSeenAt` on successful validation.
1486
+ * Expired tokens are evicted on discovery.
1487
+ */
1488
+ validateToken(token) {
1489
+ const tokenHash = this.hashToken(token);
1490
+ const client = this.clients.get(tokenHash);
1491
+ if (!client) return false;
1492
+ if (/* @__PURE__ */ new Date() > client.expiresAt) {
1493
+ this.clients.delete(tokenHash);
1494
+ return false;
1495
+ }
1496
+ client.lastSeenAt = /* @__PURE__ */ new Date();
1497
+ return true;
1498
+ }
1499
+ /**
1500
+ * List all paired clients (tokens are masked).
1501
+ */
1502
+ listClients() {
1503
+ this.pruneExpiredTokens();
1504
+ return [...this.clients.values()].map((c) => ({
1505
+ tokenHash: c.tokenHash,
1506
+ tokenPreview: c.token.slice(0, 8) + "...",
1507
+ pairedAt: c.pairedAt,
1508
+ lastSeenAt: c.lastSeenAt,
1509
+ expiresAt: c.expiresAt,
1510
+ label: c.label
1511
+ }));
1512
+ }
1513
+ /**
1514
+ * Revoke a paired client by token hash.
1515
+ */
1516
+ revokeClient(tokenHash) {
1517
+ return this.clients.delete(tokenHash);
1518
+ }
1519
+ /**
1520
+ * Get the number of active codes and paired clients.
1521
+ */
1522
+ stats() {
1523
+ this.pruneExpiredCodes();
1524
+ this.pruneExpiredTokens();
1525
+ return {
1526
+ activeCodes: this.codes.size,
1527
+ pairedClients: this.clients.size
1528
+ };
1529
+ }
1530
+ // -----------------------------------------------------------------------
1531
+ // Private helpers
1532
+ // -----------------------------------------------------------------------
1533
+ createRandomCode() {
1534
+ const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
1535
+ const bytes = randomBytes(6);
1536
+ let code = "";
1537
+ for (let i = 0; i < 6; i++) {
1538
+ code += chars[bytes[i] % chars.length];
1539
+ }
1540
+ return code;
1541
+ }
1542
+ hashToken(token) {
1543
+ return createHash("sha256").update(token).digest("hex");
1544
+ }
1545
+ pruneExpiredCodes() {
1546
+ const now = /* @__PURE__ */ new Date();
1547
+ for (const [code, pc] of this.codes) {
1548
+ if (now > pc.expiresAt) {
1549
+ this.codes.delete(code);
1550
+ }
1551
+ }
1552
+ }
1553
+ pruneExpiredTokens() {
1554
+ const now = /* @__PURE__ */ new Date();
1555
+ for (const [hash, client] of this.clients) {
1556
+ if (now > client.expiresAt) {
1557
+ this.clients.delete(hash);
1558
+ }
1559
+ }
1560
+ }
1561
+ evictOldestClient() {
1562
+ let oldest = null;
1563
+ for (const [hash, client] of this.clients) {
1564
+ const time = client.lastSeenAt.getTime();
1565
+ if (!oldest || time < oldest.time) {
1566
+ oldest = { hash, time };
1567
+ }
1568
+ }
1569
+ if (oldest) {
1570
+ this.clients.delete(oldest.hash);
1571
+ }
1572
+ }
1573
+ };
1574
+ var CanvasSessionManager = class {
1575
+ sessions = /* @__PURE__ */ new Map();
1576
+ /** Create a new canvas session with fresh state and channel. */
1577
+ createCanvasSession(sessionId, maxComponents) {
1578
+ const canvasState = new CanvasState(maxComponents);
1579
+ const canvasChannel = new CanvasChannel();
1580
+ const entry = { canvasState, canvasChannel, bridge: null, lastActiveAt: Date.now() };
1581
+ this.sessions.set(sessionId, entry);
1582
+ return entry;
1583
+ }
1584
+ /** Get the canvas state for a session. */
1585
+ getCanvasState(sessionId) {
1586
+ return this.sessions.get(sessionId)?.canvasState;
1587
+ }
1588
+ /** Get the canvas channel for a session. */
1589
+ getCanvasChannel(sessionId) {
1590
+ return this.sessions.get(sessionId)?.canvasChannel;
1591
+ }
1592
+ /** Get the full session entry. */
1593
+ getSession(sessionId) {
1594
+ return this.sessions.get(sessionId);
1595
+ }
1596
+ /** Associate a WebSocket bridge with a canvas session. */
1597
+ setBridge(sessionId, bridge) {
1598
+ const entry = this.sessions.get(sessionId);
1599
+ if (entry) {
1600
+ entry.bridge = bridge;
1601
+ entry.lastActiveAt = Date.now();
1602
+ }
1603
+ }
1604
+ /** Touch a session's lastActiveAt timestamp. */
1605
+ touchSession(sessionId) {
1606
+ const entry = this.sessions.get(sessionId);
1607
+ if (entry) {
1608
+ entry.lastActiveAt = Date.now();
1609
+ }
1610
+ }
1611
+ /** Check if a canvas session exists. */
1612
+ hasSession(sessionId) {
1613
+ return this.sessions.has(sessionId);
1614
+ }
1615
+ /** List all active canvas session IDs. */
1616
+ listSessionIds() {
1617
+ return [...this.sessions.keys()];
1618
+ }
1619
+ /** End and clean up a canvas session. */
1620
+ endCanvasSession(sessionId) {
1621
+ const entry = this.sessions.get(sessionId);
1622
+ if (entry) {
1623
+ entry.bridge?.stop();
1624
+ entry.bridge = null;
1625
+ this.sessions.delete(sessionId);
1626
+ }
1627
+ }
1628
+ /** End all canvas sessions. */
1629
+ endAll() {
1630
+ for (const sessionId of this.sessions.keys()) {
1631
+ this.endCanvasSession(sessionId);
1632
+ }
1633
+ }
1634
+ /**
1635
+ * Evict canvas sessions idle longer than `maxIdleMs`.
1636
+ * Returns the number of sessions evicted.
1637
+ */
1638
+ evictIdle(maxIdleMs) {
1639
+ const cutoff = Date.now() - maxIdleMs;
1640
+ let evicted = 0;
1641
+ for (const [sessionId, entry] of this.sessions) {
1642
+ if (entry.lastActiveAt < cutoff) {
1643
+ this.endCanvasSession(sessionId);
1644
+ evicted++;
1645
+ }
1646
+ }
1647
+ return evicted;
1648
+ }
1649
+ };
1650
+ var FIELD_RANGES = [
1651
+ { name: "minute", min: 0, max: 59 },
1652
+ { name: "hour", min: 0, max: 23 },
1653
+ { name: "day-of-month", min: 1, max: 31 },
1654
+ { name: "month", min: 1, max: 12 },
1655
+ { name: "day-of-week", min: 0, max: 6 }
1656
+ ];
1657
+ function parseCron(expression) {
1658
+ const trimmed = expression.trim();
1659
+ const fields = trimmed.split(/\s+/);
1660
+ if (fields.length !== 5) {
1661
+ throw new Error(`Invalid cron expression: expected 5 fields, got ${fields.length} ("${trimmed}")`);
1662
+ }
1663
+ const parsed = fields.map(
1664
+ (field, i) => parseField(field, FIELD_RANGES[i].min, FIELD_RANGES[i].max, FIELD_RANGES[i].name)
1665
+ );
1666
+ return {
1667
+ minutes: new Set(parsed[0]),
1668
+ hours: new Set(parsed[1]),
1669
+ daysOfMonth: new Set(parsed[2]),
1670
+ months: new Set(parsed[3]),
1671
+ daysOfWeek: new Set(parsed[4])
1672
+ };
1673
+ }
1674
+ function cronMatches(schedule, date) {
1675
+ return schedule.minutes.has(date.getMinutes()) && schedule.hours.has(date.getHours()) && schedule.daysOfMonth.has(date.getDate()) && schedule.months.has(date.getMonth() + 1) && schedule.daysOfWeek.has(date.getDay());
1676
+ }
1677
+ function parseField(field, min, max, name) {
1678
+ const values = /* @__PURE__ */ new Set();
1679
+ for (const part of field.split(",")) {
1680
+ const trimmedPart = part.trim();
1681
+ if (trimmedPart.length === 0) {
1682
+ throw new Error(`Empty value in ${name} field`);
1683
+ }
1684
+ const slashIndex = trimmedPart.indexOf("/");
1685
+ let rangePart = trimmedPart;
1686
+ let step = 1;
1687
+ if (slashIndex !== -1) {
1688
+ rangePart = trimmedPart.slice(0, slashIndex);
1689
+ const stepStr = trimmedPart.slice(slashIndex + 1);
1690
+ step = parseInt(stepStr, 10);
1691
+ if (isNaN(step) || step < 1) {
1692
+ throw new Error(`Invalid step "${stepStr}" in ${name} field`);
1693
+ }
1694
+ }
1695
+ if (rangePart === "*") {
1696
+ for (let v = min; v <= max; v += step) {
1697
+ values.add(v);
1698
+ }
1699
+ } else if (rangePart.includes("-")) {
1700
+ const [startStr, endStr] = rangePart.split("-");
1701
+ const start = parseInt(startStr, 10);
1702
+ const end = parseInt(endStr, 10);
1703
+ if (isNaN(start) || isNaN(end)) {
1704
+ throw new Error(`Invalid range "${rangePart}" in ${name} field`);
1705
+ }
1706
+ if (start < min || end > max || start > end) {
1707
+ throw new Error(`Range ${start}-${end} out of bounds (${min}-${max}) in ${name} field`);
1708
+ }
1709
+ for (let v = start; v <= end; v += step) {
1710
+ values.add(v);
1711
+ }
1712
+ } else {
1713
+ const val = parseInt(rangePart, 10);
1714
+ if (isNaN(val) || val < min || val > max) {
1715
+ throw new Error(`Value "${rangePart}" out of bounds (${min}-${max}) in ${name} field`);
1716
+ }
1717
+ values.add(val);
1718
+ }
1719
+ }
1720
+ if (values.size === 0) {
1721
+ throw new Error(`No valid values resolved for ${name} field`);
1722
+ }
1723
+ return [...values].sort((a, b) => a - b);
1724
+ }
1725
+ var Scheduler = class {
1726
+ jobs = /* @__PURE__ */ new Map();
1727
+ onTrigger;
1728
+ tickMs;
1729
+ timer = null;
1730
+ lastTickMinute = -1;
1731
+ constructor(opts) {
1732
+ this.onTrigger = opts.onTrigger;
1733
+ this.tickMs = opts.tickMs ?? 6e4;
1734
+ }
1735
+ /**
1736
+ * Add or replace a cron job. Validates the schedule eagerly.
1737
+ * Throws if the cron expression is invalid.
1738
+ */
1739
+ addJob(job) {
1740
+ const parsed = parseCron(job.schedule);
1741
+ this.jobs.set(job.name, { ...job, enabled: job.enabled ?? true, parsed });
1742
+ }
1743
+ /**
1744
+ * Remove a job by name. Returns true if removed.
1745
+ */
1746
+ removeJob(name) {
1747
+ return this.jobs.delete(name);
1748
+ }
1749
+ /**
1750
+ * List all registered jobs.
1751
+ */
1752
+ listJobs() {
1753
+ return [...this.jobs.values()].map(({ parsed: _p, ...rest }) => rest);
1754
+ }
1755
+ /**
1756
+ * Get the number of registered jobs.
1757
+ */
1758
+ get size() {
1759
+ return this.jobs.size;
1760
+ }
1761
+ /**
1762
+ * Start the tick loop. Idempotent — calling start() while running is a no-op.
1763
+ */
1764
+ start() {
1765
+ if (this.timer) return;
1766
+ this.timer = setInterval(() => this.tick(), this.tickMs);
1767
+ this.timer.unref();
1768
+ this.tick();
1769
+ }
1770
+ /**
1771
+ * Stop the tick loop. Safe to call when not running.
1772
+ */
1773
+ stop() {
1774
+ if (this.timer) {
1775
+ clearInterval(this.timer);
1776
+ this.timer = null;
1777
+ }
1778
+ }
1779
+ /**
1780
+ * Whether the scheduler is currently running.
1781
+ */
1782
+ get running() {
1783
+ return this.timer !== null;
1784
+ }
1785
+ // ---------------------------------------------------------------------------
1786
+ // Internal
1787
+ // ---------------------------------------------------------------------------
1788
+ tick() {
1789
+ const now = /* @__PURE__ */ new Date();
1790
+ const currentMinute = Math.floor(Date.now() / 6e4);
1791
+ if (currentMinute === this.lastTickMinute) return;
1792
+ this.lastTickMinute = currentMinute;
1793
+ for (const job of this.jobs.values()) {
1794
+ if (!job.enabled) continue;
1795
+ if (cronMatches(job.parsed, now)) {
1796
+ try {
1797
+ this.onTrigger(job);
1798
+ } catch {
1799
+ }
1800
+ }
1801
+ }
1802
+ }
1803
+ };
1804
+ var LogChannel = class {
1805
+ id = "log-channel";
1806
+ name = "log";
1807
+ messageHandler = null;
1808
+ onResponseCb;
1809
+ constructor(opts) {
1810
+ this.onResponseCb = opts?.onResponse;
1811
+ }
1812
+ // IChannel interface
1813
+ async start(_config) {
1814
+ }
1815
+ async stop() {
1816
+ this.messageHandler = null;
1817
+ }
1818
+ onMessage(handler) {
1819
+ this.messageHandler = handler;
1820
+ }
1821
+ async send(to, msg) {
1822
+ this.onResponseCb?.(to, msg);
1823
+ return { success: true };
1824
+ }
1825
+ async isHealthy() {
1826
+ return true;
1827
+ }
1828
+ /**
1829
+ * Inject a synthetic inbound message (used by scheduler and webhooks).
1830
+ */
1831
+ injectMessage(msg) {
1832
+ this.messageHandler?.(msg);
1833
+ }
1834
+ };
1835
+
1836
+ export {
1837
+ CanvasTool,
1838
+ SessionManager,
1839
+ MessageRouter,
1840
+ GatewayServer,
1841
+ PairingManager,
1842
+ CanvasSessionManager,
1843
+ Scheduler,
1844
+ LogChannel
1845
+ };