@agentuity/runtime 0.0.95 → 0.0.96

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.
Files changed (72) hide show
  1. package/AGENTS.md +3 -1
  2. package/dist/_events.d.ts +64 -0
  3. package/dist/_events.d.ts.map +1 -0
  4. package/dist/_events.js +92 -0
  5. package/dist/_events.js.map +1 -0
  6. package/dist/_idle.d.ts +1 -1
  7. package/dist/_idle.d.ts.map +1 -1
  8. package/dist/_idle.js +2 -16
  9. package/dist/_idle.js.map +1 -1
  10. package/dist/_server.d.ts +30 -13
  11. package/dist/_server.d.ts.map +1 -1
  12. package/dist/_server.js +39 -572
  13. package/dist/_server.js.map +1 -1
  14. package/dist/_services.d.ts.map +1 -1
  15. package/dist/_services.js +4 -2
  16. package/dist/_services.js.map +1 -1
  17. package/dist/_standalone.d.ts.map +1 -1
  18. package/dist/_standalone.js +2 -1
  19. package/dist/_standalone.js.map +1 -1
  20. package/dist/agent.d.ts.map +1 -1
  21. package/dist/agent.js +13 -17
  22. package/dist/agent.js.map +1 -1
  23. package/dist/app.d.ts +58 -171
  24. package/dist/app.d.ts.map +1 -1
  25. package/dist/app.js +119 -218
  26. package/dist/app.js.map +1 -1
  27. package/dist/index.d.ts +11 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +18 -3
  30. package/dist/index.js.map +1 -1
  31. package/dist/middleware.d.ts +29 -0
  32. package/dist/middleware.d.ts.map +1 -0
  33. package/dist/middleware.js +200 -0
  34. package/dist/middleware.js.map +1 -0
  35. package/dist/router.d.ts.map +1 -1
  36. package/dist/router.js +5 -2
  37. package/dist/router.js.map +1 -1
  38. package/dist/services/local/vector.d.ts.map +1 -1
  39. package/dist/services/local/vector.js +3 -2
  40. package/dist/services/local/vector.js.map +1 -1
  41. package/dist/services/thread/local.d.ts +20 -0
  42. package/dist/services/thread/local.d.ts.map +1 -0
  43. package/dist/services/thread/local.js +76 -0
  44. package/dist/services/thread/local.js.map +1 -0
  45. package/dist/session.d.ts +60 -8
  46. package/dist/session.d.ts.map +1 -1
  47. package/dist/session.js +186 -54
  48. package/dist/session.js.map +1 -1
  49. package/dist/web.d.ts +8 -0
  50. package/dist/web.d.ts.map +1 -0
  51. package/dist/web.js +66 -0
  52. package/dist/web.js.map +1 -0
  53. package/dist/workbench.d.ts +2 -0
  54. package/dist/workbench.d.ts.map +1 -1
  55. package/dist/workbench.js +192 -39
  56. package/dist/workbench.js.map +1 -1
  57. package/package.json +10 -10
  58. package/src/_events.ts +142 -0
  59. package/src/_idle.ts +2 -18
  60. package/src/_server.ts +48 -681
  61. package/src/_services.ts +4 -2
  62. package/src/_standalone.ts +2 -1
  63. package/src/agent.ts +11 -14
  64. package/src/app.ts +164 -246
  65. package/src/index.ts +42 -4
  66. package/src/middleware.ts +252 -0
  67. package/src/router.ts +6 -2
  68. package/src/services/local/vector.ts +3 -2
  69. package/src/services/thread/local.ts +106 -0
  70. package/src/session.ts +238 -59
  71. package/src/web.ts +75 -0
  72. package/src/workbench.ts +226 -38
package/src/session.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  /** biome-ignore-all lint/suspicious/noExplicitAny: anys are great */
3
3
  import type { Context } from 'hono';
4
- import { getCookie, setCookie } from 'hono/cookie';
4
+ import { getSignedCookie, setSignedCookie } from 'hono/cookie';
5
5
  import { type Env, fireEvent } from './app';
6
6
  import type { AppState } from './index';
7
7
  import { getServiceUrls } from '@agentuity/server';
8
- import { WebSocket } from 'ws';
9
8
  import { internal } from './logger/internal';
9
+ import { timingSafeEqual } from 'node:crypto';
10
10
 
11
11
  export type ThreadEventName = 'destroyed';
12
12
  export type SessionEventName = 'completed';
@@ -229,13 +229,13 @@ export interface ThreadIDProvider {
229
229
  * thrd_ such as `thrd_212c16896b974ffeb21a748f0eeba620`. The max length of the
230
230
  * string is 64 characters and the min length is 32 characters long
231
231
  * (including the prefix). The characters after the prefix must match the
232
- * regular expression [a-zA-Z0-9-].
232
+ * regular expression [a-zA-Z0-9].
233
233
  *
234
234
  * @param appState - The app state from createApp setup function
235
235
  * @param ctx - Hono request context
236
- * @returns The thread id to use
236
+ * @returns The thread id to use (can be async for signed cookies)
237
237
  */
238
- getThreadId(appState: AppState, ctx: Context<Env>): string;
238
+ getThreadId(appState: AppState, ctx: Context<Env>): string | Promise<string>;
239
239
  }
240
240
 
241
241
  /**
@@ -255,7 +255,7 @@ export interface ThreadIDProvider {
255
255
  * }
256
256
  *
257
257
  * async restore(ctx: Context<Env>): Promise<Thread> {
258
- * const threadId = getCookie(ctx, 'atid') || generateId('thrd');
258
+ * const threadId = ctx.req.header('x-thread-id') || getCookie(ctx, 'atid') || generateId('thrd');
259
259
  * const data = await this.redis.get(`thread:${threadId}`);
260
260
  * const thread = new DefaultThread(this, threadId);
261
261
  * if (data) {
@@ -454,21 +454,197 @@ export function generateId(prefix?: string): string {
454
454
  }
455
455
 
456
456
  /**
457
- * DefaultThreadIDProvider will look for a cookie named `atid` and use that as
458
- * the thread id or if not found, generate a new one.
457
+ * Validates a thread ID against runtime constraints:
458
+ * - Must start with 'thrd_'
459
+ * - Must be at least 32 characters long (including prefix)
460
+ * - Must be less than 64 characters long
461
+ * - Must contain only [a-zA-Z0-9] after 'thrd_' prefix (no dashes for maximum randomness)
462
+ */
463
+ export function isValidThreadId(threadId: string): boolean {
464
+ if (!threadId.startsWith('thrd_')) {
465
+ return false;
466
+ }
467
+ if (threadId.length < 32 || threadId.length > 64) {
468
+ return false;
469
+ }
470
+ const validThreadIdCharacters = /^[a-zA-Z0-9]+$/;
471
+ if (!validThreadIdCharacters.test(threadId.substring(5))) {
472
+ return false;
473
+ }
474
+ return true;
475
+ }
476
+
477
+ /**
478
+ * Validates a thread ID and throws detailed error messages for debugging.
479
+ * @param threadId The thread ID to validate
480
+ * @throws Error with detailed message if validation fails
481
+ */
482
+ export function validateThreadIdOrThrow(threadId: string): void {
483
+ if (!threadId) {
484
+ throw new Error(`the ThreadIDProvider returned an empty thread id for getThreadId`);
485
+ }
486
+ if (!threadId.startsWith('thrd_')) {
487
+ throw new Error(
488
+ `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must start with the prefix 'thrd_'.`
489
+ );
490
+ }
491
+ if (threadId.length > 64) {
492
+ throw new Error(
493
+ `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be less than 64 characters long.`
494
+ );
495
+ }
496
+ if (threadId.length < 32) {
497
+ throw new Error(
498
+ `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be at least 32 characters long.`
499
+ );
500
+ }
501
+ const validThreadIdCharacters = /^[a-zA-Z0-9]+$/;
502
+ if (!validThreadIdCharacters.test(threadId.substring(5))) {
503
+ throw new Error(
504
+ `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must contain only characters that match the regular expression [a-zA-Z0-9].`
505
+ );
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Determines if the connection is secure (HTTPS) by checking the request protocol
511
+ * and x-forwarded-proto header (for reverse proxy scenarios).
512
+ * Defaults to false (HTTP) if unable to determine.
513
+ */
514
+ export function isSecureConnection(ctx: Context<Env>): boolean {
515
+ // Check x-forwarded-proto header first (reverse proxy)
516
+ const forwardedProto = ctx.req.header('x-forwarded-proto');
517
+ if (forwardedProto) {
518
+ return forwardedProto === 'https';
519
+ }
520
+
521
+ // Check the request URL protocol if available
522
+ try {
523
+ if (ctx.req.url) {
524
+ const url = new URL(ctx.req.url);
525
+ return url.protocol === 'https:';
526
+ }
527
+ } catch {
528
+ // Fall through to default
529
+ }
530
+
531
+ // Default to HTTP (e.g., for localhost development)
532
+ return false;
533
+ }
534
+
535
+ /**
536
+ * Signs a thread ID using HMAC SHA-256 and returns it in the format: threadId;signature
537
+ * Format: thrd_abc123;base64signature
538
+ */
539
+ export async function signThreadId(threadId: string, secret: string): Promise<string> {
540
+ const hasher = new Bun.CryptoHasher('sha256', secret);
541
+ hasher.update(threadId);
542
+ const signatureBase64 = hasher.digest('base64');
543
+
544
+ return `${threadId};${signatureBase64}`;
545
+ }
546
+
547
+ /**
548
+ * Verifies a signed thread ID header and returns the thread ID if valid, or undefined if invalid.
549
+ * Expected format: thrd_abc123;base64signature
550
+ */
551
+ export async function verifySignedThreadId(
552
+ signedValue: string,
553
+ secret: string
554
+ ): Promise<string | undefined> {
555
+ const parts = signedValue.split(';');
556
+ if (parts.length !== 2) {
557
+ return undefined;
558
+ }
559
+
560
+ const [threadId, providedSignature] = parts;
561
+
562
+ // Validate both parts exist
563
+ if (!threadId || !providedSignature) {
564
+ return undefined;
565
+ }
566
+
567
+ // Validate thread ID format before verifying signature
568
+ if (!isValidThreadId(threadId)) {
569
+ return undefined;
570
+ }
571
+
572
+ // Re-sign the thread ID and compare signatures
573
+ const expectedSigned = await signThreadId(threadId, secret);
574
+ const expectedSignature = expectedSigned.split(';')[1];
575
+
576
+ // Validate signature exists
577
+ if (!expectedSignature) {
578
+ return undefined;
579
+ }
580
+
581
+ // Constant-time comparison to prevent timing attacks
582
+ // Check lengths match first (fail fast if different lengths)
583
+ if (providedSignature.length !== expectedSignature.length) {
584
+ return undefined;
585
+ }
586
+
587
+ try {
588
+ // Convert to Buffers for constant-time comparison
589
+ const providedBuffer = Buffer.from(providedSignature, 'base64');
590
+ const expectedBuffer = Buffer.from(expectedSignature, 'base64');
591
+
592
+ if (timingSafeEqual(providedBuffer, expectedBuffer)) {
593
+ return threadId;
594
+ }
595
+ } catch {
596
+ // Comparison failed or buffer conversion error
597
+ return undefined;
598
+ }
599
+
600
+ return undefined;
601
+ }
602
+
603
+ /**
604
+ * DefaultThreadIDProvider will look for an HTTP header `x-thread-id` first,
605
+ * then fall back to a signed cookie named `atid`, and use that as the thread id.
606
+ * If not found, generate a new one. Validates incoming thread IDs against
607
+ * runtime constraints. Uses AGENTUITY_SDK_KEY for signing, falls back to 'agentuity'.
459
608
  */
460
609
  export class DefaultThreadIDProvider implements ThreadIDProvider {
461
- getThreadId(_appState: AppState, ctx: Context<Env>): string {
462
- const cookie = getCookie(ctx);
610
+ private getSecret(): string {
611
+ return process.env.AGENTUITY_SDK_KEY || 'agentuity';
612
+ }
613
+
614
+ async getThreadId(_appState: AppState, ctx: Context<Env>): Promise<string> {
463
615
  let threadId: string | undefined;
616
+ const secret = this.getSecret();
617
+
618
+ // Check signed header first
619
+ const headerValue = ctx.req.header('x-thread-id');
620
+ if (headerValue) {
621
+ const verifiedThreadId = await verifySignedThreadId(headerValue, secret);
622
+ if (verifiedThreadId) {
623
+ threadId = verifiedThreadId;
624
+ }
625
+ }
464
626
 
465
- if (cookie.atid?.startsWith('thrd_')) {
466
- threadId = cookie.atid;
627
+ // Fall back to signed cookie
628
+ if (!threadId) {
629
+ const cookieValue = await getSignedCookie(ctx, secret, 'atid');
630
+ if (cookieValue && typeof cookieValue === 'string' && isValidThreadId(cookieValue)) {
631
+ threadId = cookieValue;
632
+ }
467
633
  }
468
634
 
469
635
  threadId = threadId || generateId('thrd');
470
636
 
471
- setCookie(ctx, 'atid', threadId);
637
+ await setSignedCookie(ctx, 'atid', threadId, secret, {
638
+ httpOnly: true,
639
+ secure: isSecureConnection(ctx),
640
+ sameSite: 'Lax',
641
+ path: '/',
642
+ maxAge: 604800, // 1 week in seconds
643
+ });
644
+
645
+ // Set signed header in response
646
+ const signedHeader = await signThreadId(threadId, secret);
647
+ ctx.header('x-thread-id', signedHeader);
472
648
  return threadId;
473
649
  }
474
650
  }
@@ -623,6 +799,22 @@ export class DefaultSession implements Session {
623
799
  * @internal
624
800
  * @experimental
625
801
  */
802
+ /**
803
+ * Configuration options for ThreadWebSocketClient
804
+ */
805
+ export interface ThreadWebSocketClientOptions {
806
+ /** Connection timeout in milliseconds (default: 10000) */
807
+ connectionTimeoutMs?: number;
808
+ /** Request timeout in milliseconds (default: 10000) */
809
+ requestTimeoutMs?: number;
810
+ /** Base delay for reconnection backoff in milliseconds (default: 1000) */
811
+ reconnectBaseDelayMs?: number;
812
+ /** Maximum delay for reconnection backoff in milliseconds (default: 30000) */
813
+ reconnectMaxDelayMs?: number;
814
+ /** Maximum number of reconnection attempts (default: 5) */
815
+ maxReconnectAttempts?: number;
816
+ }
817
+
626
818
  export class ThreadWebSocketClient {
627
819
  private ws: WebSocket | null = null;
628
820
  private authenticated = false;
@@ -631,7 +823,7 @@ export class ThreadWebSocketClient {
631
823
  { resolve: (data?: string) => void; reject: (err: Error) => void }
632
824
  >();
633
825
  private reconnectAttempts = 0;
634
- private maxReconnectAttempts = 5;
826
+ private maxReconnectAttempts: number;
635
827
  private apiKey: string;
636
828
  private wsUrl: string;
637
829
  private wsConnecting: Promise<void> | null = null;
@@ -639,10 +831,19 @@ export class ThreadWebSocketClient {
639
831
  private isDisposed = false;
640
832
  private initialConnectResolve: (() => void) | null = null;
641
833
  private initialConnectReject: ((err: Error) => void) | null = null;
834
+ private connectionTimeoutMs: number;
835
+ private requestTimeoutMs: number;
836
+ private reconnectBaseDelayMs: number;
837
+ private reconnectMaxDelayMs: number;
642
838
 
643
- constructor(apiKey: string, wsUrl: string) {
839
+ constructor(apiKey: string, wsUrl: string, options: ThreadWebSocketClientOptions = {}) {
644
840
  this.apiKey = apiKey;
645
841
  this.wsUrl = wsUrl;
842
+ this.connectionTimeoutMs = options.connectionTimeoutMs ?? 10_000;
843
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
844
+ this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? 1_000;
845
+ this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 30_000;
846
+ this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;
646
847
  }
647
848
 
648
849
  async connect(): Promise<void> {
@@ -659,20 +860,20 @@ export class ThreadWebSocketClient {
659
860
  const rejectFn = this.initialConnectReject || reject;
660
861
  this.initialConnectResolve = null;
661
862
  this.initialConnectReject = null;
662
- rejectFn(new Error('WebSocket connection timeout (10s)'));
663
- }, 10_000);
863
+ rejectFn(new Error(`WebSocket connection timeout (${this.connectionTimeoutMs}ms)`));
864
+ }, this.connectionTimeoutMs);
664
865
 
665
866
  try {
666
867
  this.ws = new WebSocket(this.wsUrl);
667
868
 
668
- this.ws.on('open', () => {
869
+ this.ws.addEventListener('open', () => {
669
870
  // Send authentication (do NOT clear timeout yet - wait for auth response)
670
871
  this.ws?.send(JSON.stringify({ authorization: this.apiKey }));
671
872
  });
672
873
 
673
- this.ws.on('message', (data: any) => {
874
+ this.ws.addEventListener('message', (event: MessageEvent) => {
674
875
  try {
675
- const message = JSON.parse(data.toString());
876
+ const message = JSON.parse(event.data);
676
877
 
677
878
  // Handle auth response
678
879
  if ('success' in message && !this.authenticated) {
@@ -715,7 +916,7 @@ export class ThreadWebSocketClient {
715
916
  }
716
917
  });
717
918
 
718
- this.ws.on('error', (err: Error) => {
919
+ this.ws.addEventListener('error', (_event: Event) => {
719
920
  clearTimeout(connectionTimeout);
720
921
  if (!this.authenticated) {
721
922
  // Don't reject immediately if we'll attempt reconnection
@@ -723,12 +924,12 @@ export class ThreadWebSocketClient {
723
924
  const rejectFn = this.initialConnectReject || reject;
724
925
  this.initialConnectResolve = null;
725
926
  this.initialConnectReject = null;
726
- rejectFn(new Error(`WebSocket error: ${err.message}`));
927
+ rejectFn(new Error(`WebSocket error`));
727
928
  }
728
929
  }
729
930
  });
730
931
 
731
- this.ws.on('close', () => {
932
+ this.ws.addEventListener('close', () => {
732
933
  clearTimeout(connectionTimeout);
733
934
  const wasAuthenticated = this.authenticated;
734
935
  this.authenticated = false;
@@ -754,7 +955,10 @@ export class ThreadWebSocketClient {
754
955
  // This handles server rollouts where connection closes before auth finishes
755
956
  if (this.reconnectAttempts < this.maxReconnectAttempts) {
756
957
  this.reconnectAttempts++;
757
- const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30_000);
958
+ const delay = Math.min(
959
+ this.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts),
960
+ this.reconnectMaxDelayMs
961
+ );
758
962
 
759
963
  internal.info(
760
964
  `WebSocket disconnected, attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`
@@ -818,13 +1022,13 @@ export class ThreadWebSocketClient {
818
1022
 
819
1023
  this.ws!.send(JSON.stringify(message));
820
1024
 
821
- // Timeout after 10 seconds
1025
+ // Timeout after configured duration
822
1026
  setTimeout(() => {
823
1027
  if (this.pendingRequests.has(requestId)) {
824
1028
  this.pendingRequests.delete(requestId);
825
1029
  reject(new Error('Request timeout'));
826
1030
  }
827
- }, 10000);
1031
+ }, this.requestTimeoutMs);
828
1032
  });
829
1033
  }
830
1034
 
@@ -862,13 +1066,13 @@ export class ThreadWebSocketClient {
862
1066
 
863
1067
  this.ws!.send(JSON.stringify(message));
864
1068
 
865
- // Timeout after 10 seconds
1069
+ // Timeout after configured duration
866
1070
  setTimeout(() => {
867
1071
  if (this.pendingRequests.has(requestId)) {
868
1072
  this.pendingRequests.delete(requestId);
869
1073
  reject(new Error('Request timeout'));
870
1074
  }
871
- }, 10_000);
1075
+ }, this.requestTimeoutMs);
872
1076
  });
873
1077
  }
874
1078
 
@@ -897,13 +1101,13 @@ export class ThreadWebSocketClient {
897
1101
 
898
1102
  this.ws!.send(JSON.stringify(message));
899
1103
 
900
- // Timeout after 10 seconds
1104
+ // Timeout after configured duration
901
1105
  setTimeout(() => {
902
1106
  if (this.pendingRequests.has(requestId)) {
903
1107
  this.pendingRequests.delete(requestId);
904
1108
  reject(new Error('Request timeout'));
905
1109
  }
906
- }, 10_000);
1110
+ }, this.requestTimeoutMs);
907
1111
  });
908
1112
  }
909
1113
 
@@ -930,8 +1134,6 @@ export class ThreadWebSocketClient {
930
1134
  }
931
1135
  }
932
1136
 
933
- const validThreadIdCharacters = /^[a-zA-Z0-9-]+$/;
934
-
935
1137
  export class DefaultThreadProvider implements ThreadProvider {
936
1138
  private appState: AppState | null = null;
937
1139
  private wsClient: ThreadWebSocketClient | null = null;
@@ -948,7 +1150,7 @@ export class DefaultThreadProvider implements ThreadProvider {
948
1150
  const serviceUrls = getServiceUrls(process.env.AGENTUITY_REGION ?? 'usc');
949
1151
  const catalystUrl = serviceUrls.catalyst;
950
1152
  const wsUrl = new URL('/thread/ws', catalystUrl.replace(/^http/, 'ws'));
951
- console.debug('connecting to %s', wsUrl);
1153
+ internal.debug('connecting to %s', wsUrl);
952
1154
 
953
1155
  this.wsClient = new ThreadWebSocketClient(apiKey, wsUrl.toString());
954
1156
  // Connect in background, don't block initialization
@@ -958,7 +1160,7 @@ export class DefaultThreadProvider implements ThreadProvider {
958
1160
  this.wsConnecting = null;
959
1161
  })
960
1162
  .catch((err) => {
961
- console.error('Failed to connect to thread WebSocket:', err);
1163
+ internal.error('Failed to connect to thread WebSocket:', err);
962
1164
  this.wsClient = null;
963
1165
  this.wsConnecting = null;
964
1166
  });
@@ -970,31 +1172,8 @@ export class DefaultThreadProvider implements ThreadProvider {
970
1172
  }
971
1173
 
972
1174
  async restore(ctx: Context<Env>): Promise<Thread> {
973
- const threadId = this.threadIDProvider!.getThreadId(this.appState!, ctx);
974
-
975
- if (!threadId) {
976
- throw new Error(`the ThreadIDProvider returned an empty thread id for getThreadId`);
977
- }
978
- if (!threadId.startsWith('thrd_')) {
979
- throw new Error(
980
- `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must start with the prefix 'thrd_'.`
981
- );
982
- }
983
- if (threadId.length > 64) {
984
- throw new Error(
985
- `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be less than 64 characters long.`
986
- );
987
- }
988
- if (threadId.length < 32) {
989
- throw new Error(
990
- `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be at least 32 characters long.`
991
- );
992
- }
993
- if (!validThreadIdCharacters.test(threadId.substring(5))) {
994
- throw new Error(
995
- `the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must contain only characters that match the regular expression [a-zA-Z0-9-].`
996
- );
997
- }
1175
+ const threadId = await this.threadIDProvider!.getThreadId(this.appState!, ctx);
1176
+ validateThreadIdOrThrow(threadId);
998
1177
 
999
1178
  // Wait for WebSocket connection if still connecting
1000
1179
  if (this.wsConnecting) {
package/src/web.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { Hono } from 'hono';
2
+ import { serveStatic } from 'hono/bun';
3
+ import { join, relative } from 'node:path';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ /**
7
+ * Create a router that serves the web application.
8
+ * In dev mode (DEV=true), serves HTML with Vite HMR scripts (Bun server proxies assets to Vite).
9
+ * In production, serves static files from .agentuity/client/.
10
+ */
11
+ export async function createWebRouter(): Promise<Hono> {
12
+ const router = new Hono();
13
+ const isDev = process.env.DEV === 'true';
14
+ const rootDir = process.cwd();
15
+
16
+ if (isDev) {
17
+ // In dev mode, serve HTML with Vite client scripts for HMR
18
+ // Bun server proxies /src/*, /@vite/*, etc. to Vite asset server
19
+ router.get('/', (c) => {
20
+ return c.html(
21
+ `<!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="UTF-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
26
+ <title>Agentuity App</title>
27
+ </head>
28
+ <body>
29
+ <div id="root"></div>
30
+
31
+ <script type="module" src="/@vite/client"></script>
32
+ <script type="module">
33
+ import RefreshRuntime from '/@react-refresh';
34
+ RefreshRuntime.injectIntoGlobalHook(window);
35
+ window.$RefreshReg$ = () => {};
36
+ window.$RefreshSig$ = () => (type) => type;
37
+ window.__vite_plugin_react_preamble_installed__ = true;
38
+ </script>
39
+
40
+ <script type="module" src="/src/web/frontend.tsx"></script>
41
+ </body>
42
+ </html>`
43
+ );
44
+ });
45
+ } else {
46
+ // Production: serve static files from .agentuity/client/
47
+ const clientDir = join(rootDir, '.agentuity', 'client');
48
+
49
+ // Verify client build exists
50
+ const indexHtmlPath = join(clientDir, 'index.html');
51
+ if (!existsSync(indexHtmlPath)) {
52
+ throw new Error(
53
+ `Client build not found. Missing ${indexHtmlPath}. Run build to generate client assets.`
54
+ );
55
+ }
56
+
57
+ // Compute relative paths for serveStatic (it expects relative paths from cwd)
58
+ let relClientDir = relative(process.cwd(), clientDir);
59
+ if (!relClientDir.startsWith('.')) {
60
+ relClientDir = './' + relClientDir;
61
+ }
62
+ let relIndexPath = relative(process.cwd(), indexHtmlPath);
63
+ if (!relIndexPath.startsWith('.')) {
64
+ relIndexPath = './' + relIndexPath;
65
+ }
66
+
67
+ // Serve static files from .agentuity/client/
68
+ router.use('/*', serveStatic({ root: relClientDir }));
69
+
70
+ // Fallback to index.html for SPA routing
71
+ router.get('*', serveStatic({ path: relIndexPath }));
72
+ }
73
+
74
+ return router;
75
+ }