@zhin.js/client 1.1.3 → 1.1.5

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 (104) hide show
  1. package/README.md +89 -433
  2. package/client/agent-stream.ts +121 -0
  3. package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
  4. package/client/bootstrap/loadConsoleEntries.ts +40 -8
  5. package/client/console-events.ts +37 -0
  6. package/client/console-utils/remoteApi.test.ts +91 -0
  7. package/client/console-utils/remoteApi.ts +47 -2
  8. package/client/index.ts +75 -19
  9. package/client/mediaSrc.test.ts +49 -0
  10. package/client/mediaSrc.ts +84 -11
  11. package/client/message-content/MarkdownContent.tsx +128 -0
  12. package/client/message-content/markdown.test.ts +48 -0
  13. package/client/message-content/markdown.ts +247 -0
  14. package/client/persistence/idb-store.test.ts +139 -0
  15. package/client/persistence/idb-store.ts +43 -21
  16. package/client/runtime/index.ts +1 -1
  17. package/client/segments.test.ts +16 -0
  18. package/client/segments.ts +39 -0
  19. package/client/types.ts +5 -5
  20. package/client/websocket/hooks.test.ts +21 -0
  21. package/client/websocket/hooks.ts +56 -27
  22. package/client/websocket/index.ts +6 -1
  23. package/client/websocket/manager.test.ts +349 -0
  24. package/client/websocket/manager.ts +245 -54
  25. package/client/websocket/remote-settings.ts +25 -3
  26. package/client/websocket/types.ts +5 -0
  27. package/client/workroom/WorkroomRunsPage.tsx +355 -0
  28. package/client/workroom/registerWorkroomConsole.test.ts +26 -0
  29. package/client/workroom/registerWorkroomConsole.ts +17 -0
  30. package/dist/agent-stream.d.ts +17 -0
  31. package/dist/agent-stream.d.ts.map +1 -0
  32. package/dist/agent-stream.js +73 -0
  33. package/dist/agent-stream.js.map +1 -0
  34. package/dist/bootstrap/loadConsoleEntries.d.ts +10 -1
  35. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  36. package/dist/bootstrap/loadConsoleEntries.js +37 -2
  37. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  38. package/dist/console-events.d.ts +7 -0
  39. package/dist/console-events.d.ts.map +1 -0
  40. package/dist/console-events.js +26 -0
  41. package/dist/console-events.js.map +1 -0
  42. package/dist/console-utils/remoteApi.d.ts +2 -0
  43. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  44. package/dist/console-utils/remoteApi.js +46 -2
  45. package/dist/console-utils/remoteApi.js.map +1 -1
  46. package/dist/index.d.ts +9 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -3
  49. package/dist/index.js.map +1 -1
  50. package/dist/mediaSrc.d.ts +1 -1
  51. package/dist/mediaSrc.d.ts.map +1 -1
  52. package/dist/mediaSrc.js +91 -11
  53. package/dist/mediaSrc.js.map +1 -1
  54. package/dist/message-content/MarkdownContent.d.ts +11 -0
  55. package/dist/message-content/MarkdownContent.d.ts.map +1 -0
  56. package/dist/message-content/MarkdownContent.js +68 -0
  57. package/dist/message-content/MarkdownContent.js.map +1 -0
  58. package/dist/message-content/markdown.d.ts +47 -0
  59. package/dist/message-content/markdown.d.ts.map +1 -0
  60. package/dist/message-content/markdown.js +219 -0
  61. package/dist/message-content/markdown.js.map +1 -0
  62. package/dist/persistence/idb-store.d.ts +5 -2
  63. package/dist/persistence/idb-store.d.ts.map +1 -1
  64. package/dist/persistence/idb-store.js +31 -20
  65. package/dist/persistence/idb-store.js.map +1 -1
  66. package/dist/runtime/index.d.ts +1 -1
  67. package/dist/runtime/index.d.ts.map +1 -1
  68. package/dist/segments.d.ts +9 -0
  69. package/dist/segments.d.ts.map +1 -0
  70. package/dist/segments.js +35 -0
  71. package/dist/segments.js.map +1 -0
  72. package/dist/types.d.ts +4 -3
  73. package/dist/types.d.ts.map +1 -1
  74. package/dist/websocket/hooks.d.ts +9 -0
  75. package/dist/websocket/hooks.d.ts.map +1 -1
  76. package/dist/websocket/hooks.js +52 -31
  77. package/dist/websocket/hooks.js.map +1 -1
  78. package/dist/websocket/index.d.ts +1 -1
  79. package/dist/websocket/index.d.ts.map +1 -1
  80. package/dist/websocket/index.js +1 -1
  81. package/dist/websocket/index.js.map +1 -1
  82. package/dist/websocket/manager.d.ts +20 -2
  83. package/dist/websocket/manager.d.ts.map +1 -1
  84. package/dist/websocket/manager.js +222 -44
  85. package/dist/websocket/manager.js.map +1 -1
  86. package/dist/websocket/remote-settings.d.ts.map +1 -1
  87. package/dist/websocket/remote-settings.js +27 -3
  88. package/dist/websocket/remote-settings.js.map +1 -1
  89. package/dist/websocket/types.d.ts +4 -0
  90. package/dist/websocket/types.d.ts.map +1 -1
  91. package/dist/websocket/types.js.map +1 -1
  92. package/dist/workroom/WorkroomRunsPage.d.ts +2 -0
  93. package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
  94. package/dist/workroom/WorkroomRunsPage.js +111 -0
  95. package/dist/workroom/WorkroomRunsPage.js.map +1 -0
  96. package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
  97. package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
  98. package/dist/workroom/registerWorkroomConsole.js +16 -0
  99. package/dist/workroom/registerWorkroomConsole.js.map +1 -0
  100. package/package.json +18 -8
  101. package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
  102. package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
  103. package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
  104. package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
@@ -1,17 +1,32 @@
1
- import type {
2
- WebSocketMessage,
3
- WebSocketConfig,
4
- WebSocketCallbacks,
5
- } from "./types";
6
- import {
7
- ConnectionState,
8
- WebSocketError,
9
- ConnectionError,
10
- MessageError,
11
- RequestTimeoutError,
12
- } from "./types";
1
+ import { ConnectionState, WebSocketError, ConnectionError, MessageError, RequestTimeoutError, type WebSocketMessage, type WebSocketConfig, type WebSocketCallbacks } from './types';
2
+
13
3
  import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
14
4
  import { applyConsoleEvent } from "../persistence/idb-store.js";
5
+ import { fetchConsoleEventHistory } from '../console-events.js';
6
+ import {
7
+ CONSOLE_EVENT_RECOVERY_GAP_EVENT,
8
+ SIDE_EVENT_PUSH,
9
+ normalizeConsolePushMessage,
10
+ parseConsoleSseFrame,
11
+ type ConsoleEventData,
12
+ type ConsoleEventEnvelope,
13
+ type ConsoleEventHistoryPage,
14
+ type ConsoleEventHistoryQuery,
15
+ } from "@zhin.js/console-protocol";
16
+
17
+ interface ConsoleEventCursor {
18
+ readonly runtimeId: string;
19
+ readonly eventId: number;
20
+ }
21
+
22
+ export type ConsoleEventListener<Type extends string> = (
23
+ event: ConsoleEventEnvelope<Type, ConsoleEventData<Type>>,
24
+ ) => void;
25
+ export interface ConsoleEventRecoveryGap {
26
+ readonly query: ConsoleEventHistoryQuery;
27
+ readonly page: ConsoleEventHistoryPage;
28
+ }
29
+ export type ConsoleEventRecoveryGapListener = (gap: ConsoleEventRecoveryGap) => void;
15
30
 
16
31
  export class WebSocketManager {
17
32
  private ws: WebSocket | null = null;
@@ -26,7 +41,12 @@ export class WebSocketManager {
26
41
  { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
27
42
  >();
28
43
  private connectedListeners = new Set<(connected: boolean) => void>();
44
+ private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
45
+ private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
29
46
  private sseAbort: AbortController | null = null;
47
+ private eventRuntimeId = '';
48
+ private lastEventId = 0;
49
+ private durableCursorBlocked = false;
30
50
  private useRestTransport = true;
31
51
 
32
52
  constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
@@ -44,6 +64,27 @@ export class WebSocketManager {
44
64
  return () => this.connectedListeners.delete(listener);
45
65
  }
46
66
 
67
+ /** Subscribe by event name; known names infer their exact payload type. */
68
+ onConsoleEvent<Type extends string>(type: Type, listener: ConsoleEventListener<Type>): () => void {
69
+ let listeners = this.eventListeners.get(type);
70
+ if (!listeners) {
71
+ listeners = new Set();
72
+ this.eventListeners.set(type, listeners);
73
+ }
74
+ const stored = listener as ConsoleEventListener<string>;
75
+ listeners.add(stored);
76
+ return () => {
77
+ listeners?.delete(stored);
78
+ if (listeners?.size === 0) this.eventListeners.delete(type);
79
+ };
80
+ }
81
+
82
+ /** Observe a non-resumable cursor so domain views can perform a full resync. */
83
+ onConsoleEventRecoveryGap(listener: ConsoleEventRecoveryGapListener): () => void {
84
+ this.recoveryGapListeners.add(listener);
85
+ return () => this.recoveryGapListeners.delete(listener);
86
+ }
87
+
47
88
  private notifyConnection(connected: boolean) {
48
89
  for (const l of this.connectedListeners) l(connected);
49
90
  }
@@ -60,25 +101,32 @@ export class WebSocketManager {
60
101
  this.attachEventHandlers();
61
102
  } catch (error) {
62
103
  this.handleConnectionError(new ConnectionError("Failed to create WebSocket", error as Error));
104
+ this.setState(ConnectionState.RECONNECTING);
105
+ this.notifyConnection(false);
106
+ this.scheduleReconnect();
63
107
  }
64
108
  }
65
109
 
66
110
  private async connectRestSse(): Promise<void> {
111
+ // Host without http.token allows unauthenticated SSE (authenticateHttp
112
+ // returns full when TokenRegistry is empty). Still open the stream so
113
+ // local dev Console receives hmr:reload / message.receive broadcasts.
67
114
  const token = getStoredToken();
68
- if (!token) {
69
- this.setState(ConnectionState.DISCONNECTED);
70
- this.notifyConnection(false);
71
- return;
72
- }
73
115
  this.setState(ConnectionState.CONNECTING);
74
116
  this.sseAbort?.abort();
75
117
  this.sseAbort = new AbortController();
76
118
  try {
77
- const res = await fetch(resolveApiUrl("/api/events"), {
78
- headers: {
79
- Authorization: `Bearer ${token}`,
80
- Accept: "text/event-stream",
81
- },
119
+ const headers: Record<string, string> = {
120
+ Accept: "text/event-stream",
121
+ };
122
+ if (token) headers.Authorization = `Bearer ${token}`;
123
+ await this.recoverEventHistory(this.sseAbort.signal);
124
+ const params = new URLSearchParams();
125
+ if (this.eventRuntimeId) params.set('runtimeId', this.eventRuntimeId);
126
+ if (this.lastEventId > 0) params.set('after', String(this.lastEventId));
127
+ const suffix = params.size ? `?${params}` : '';
128
+ const res = await fetch(resolveApiUrl(`/api/events${suffix}`), {
129
+ headers,
82
130
  signal: this.sseAbort.signal,
83
131
  });
84
132
  if (!res.ok || !res.body) {
@@ -91,9 +139,13 @@ export class WebSocketManager {
91
139
  void this.pumpSse(res.body);
92
140
  } catch (error) {
93
141
  if ((error as Error).name === "AbortError") return;
94
- this.handleConnectionError(
95
- error instanceof ConnectionError ? error : new ConnectionError("SSE connect failed", error as Error),
96
- );
142
+ // Must set RECONNECTING (not ERROR) before scheduleReconnect — the timer
143
+ // only fires when state === RECONNECTING; handleConnectionError(ERROR)
144
+ // previously deadlocked reconnect after any transient SSE failure.
145
+ this.callbacks.onError?.(error as unknown as Event);
146
+ console.error("[Console transport] SSE connect error:", error);
147
+ this.setState(ConnectionState.RECONNECTING);
148
+ this.notifyConnection(false);
97
149
  this.scheduleReconnect();
98
150
  }
99
151
  }
@@ -107,20 +159,21 @@ export class WebSocketManager {
107
159
  const { done, value } = await reader.read();
108
160
  if (done) break;
109
161
  buffer += decoder.decode(value, { stream: true });
110
- const parts = buffer.split("\n\n");
162
+ const parts = buffer.split(/\r?\n\r?\n/u);
111
163
  buffer = parts.pop() ?? "";
112
164
  for (const part of parts) {
113
- const line = part.split("\n").find((l) => l.startsWith("data: "));
114
- if (!line) continue;
115
- const json = line.slice(6);
116
- try {
117
- const message = JSON.parse(json) as WebSocketMessage;
118
- void applyConsoleEvent(message);
119
- this.handleBroadcast(message);
120
- this.callbacks.onMessage?.(message);
121
- } catch {
122
- /* skip */
123
- }
165
+ const parsed = parseConsoleSseFrame(part);
166
+ if (!parsed) continue;
167
+ const runtimeId = parsed.runtimeId ?? this.eventRuntimeId;
168
+ const event: ConsoleEventEnvelope = Object.freeze({
169
+ runtimeId,
170
+ eventId: parsed.eventId ?? 0,
171
+ type: parsed.type,
172
+ data: parsed.data,
173
+ timestamp: parsed.timestamp ?? Date.now(),
174
+ delivery: 'live',
175
+ });
176
+ await this.deliverConsoleEvent(event);
124
177
  }
125
178
  }
126
179
  } catch {
@@ -134,6 +187,99 @@ export class WebSocketManager {
134
187
  }
135
188
  }
136
189
 
190
+ private async recoverEventHistory(signal: AbortSignal): Promise<void> {
191
+ const stored = this.eventRuntimeId
192
+ ? { runtimeId: this.eventRuntimeId, eventId: this.lastEventId }
193
+ : readEventCursor();
194
+ let runtimeId = stored?.runtimeId;
195
+ let after = stored?.eventId ?? 0;
196
+ if (runtimeId) {
197
+ this.eventRuntimeId = runtimeId;
198
+ this.lastEventId = after;
199
+ }
200
+ for (;;) {
201
+ const query = { runtimeId, after, limit: 500 } satisfies ConsoleEventHistoryQuery;
202
+ const page = await fetchConsoleEventHistory(query, { signal });
203
+ if (page.runtimeId !== runtimeId) {
204
+ runtimeId = page.runtimeId;
205
+ after = 0;
206
+ this.lastEventId = 0;
207
+ this.durableCursorBlocked = false;
208
+ }
209
+ this.eventRuntimeId = page.runtimeId;
210
+ if (page.gap) this.notifyRecoveryGap({ query, page });
211
+ for (const item of page.items) {
212
+ await this.deliverConsoleEvent(Object.freeze({ ...item, delivery: 'history' }));
213
+ after = item.eventId;
214
+ }
215
+ if (!page.hasMore) {
216
+ this.lastEventId = Math.max(after, page.latestEventId);
217
+ if (!this.durableCursorBlocked) {
218
+ writeEventCursor({ runtimeId: page.runtimeId, eventId: this.lastEventId });
219
+ }
220
+ return;
221
+ }
222
+ after = page.nextAfter;
223
+ }
224
+ }
225
+
226
+ private async deliverConsoleEvent(event: ConsoleEventEnvelope): Promise<void> {
227
+ if (event.runtimeId && event.runtimeId !== this.eventRuntimeId) {
228
+ this.eventRuntimeId = event.runtimeId;
229
+ this.lastEventId = 0;
230
+ this.durableCursorBlocked = false;
231
+ }
232
+ if (event.eventId > 0
233
+ && event.runtimeId === this.eventRuntimeId
234
+ && event.eventId <= this.lastEventId) return;
235
+ const message = normalizeConsolePushMessage({
236
+ type: event.type,
237
+ data: event.data,
238
+ runtimeId: event.runtimeId,
239
+ eventId: event.eventId || undefined,
240
+ timestamp: event.timestamp,
241
+ delivery: event.delivery,
242
+ }) as WebSocketMessage;
243
+ try {
244
+ await applyConsoleEvent(message);
245
+ } catch (error) {
246
+ this.durableCursorBlocked = true;
247
+ console.error('[Console transport] Failed to persist event:', error);
248
+ }
249
+ this.handleBroadcast(message);
250
+ try {
251
+ this.callbacks.onMessage?.(message);
252
+ } catch (error) {
253
+ console.error('[Console transport] onMessage listener failed:', error);
254
+ }
255
+ for (const listener of this.eventListeners.get(event.type) ?? []) {
256
+ try {
257
+ listener(event as ConsoleEventEnvelope<string, unknown>);
258
+ } catch (error) {
259
+ console.error('[Console transport] event listener failed:', error);
260
+ }
261
+ }
262
+ if (event.eventId > 0 && event.runtimeId) {
263
+ this.lastEventId = Math.max(this.lastEventId, event.eventId);
264
+ if (!this.durableCursorBlocked) {
265
+ writeEventCursor({ runtimeId: event.runtimeId, eventId: this.lastEventId });
266
+ }
267
+ }
268
+ }
269
+
270
+ private notifyRecoveryGap(gap: ConsoleEventRecoveryGap): void {
271
+ if (typeof window !== 'undefined') {
272
+ window.dispatchEvent(new CustomEvent(CONSOLE_EVENT_RECOVERY_GAP_EVENT, { detail: gap }));
273
+ }
274
+ for (const listener of this.recoveryGapListeners) {
275
+ try {
276
+ listener(gap);
277
+ } catch (error) {
278
+ console.error('[Console transport] recovery gap listener failed:', error);
279
+ }
280
+ }
281
+ }
282
+
137
283
  disconnect(): void {
138
284
  this.clearReconnectTimer();
139
285
  this.clearPendingRequests();
@@ -184,24 +330,23 @@ export class WebSocketManager {
184
330
  }
185
331
 
186
332
  private async sendRestRequest<T>(message: unknown): Promise<T> {
333
+ // Host may run without http.token (local smoke). Only require a token when
334
+ // one is configured client-side; otherwise POST without Authorization and
335
+ // let the Host accept (TokenRegistry empty → full scope).
187
336
  const token = getStoredToken();
188
- if (!token) throw new WebSocketError("Not authenticated", "NOT_CONNECTED");
189
337
  const requestId = ++this.requestId;
190
338
  const body = { ...(message as Record<string, unknown>), requestId };
191
- const timer = setTimeout(() => {
192
- /* handled below */
193
- }, this.config.requestTimeout);
194
339
  try {
340
+ const headers: Record<string, string> = {
341
+ "Content-Type": "application/json",
342
+ };
343
+ if (token) headers.Authorization = `Bearer ${token}`;
195
344
  const res = await fetch(resolveApiUrl("/api/console/request"), {
196
345
  method: "POST",
197
- headers: {
198
- Authorization: `Bearer ${token}`,
199
- "Content-Type": "application/json",
200
- },
346
+ headers,
201
347
  body: JSON.stringify(body),
202
348
  signal: AbortSignal.timeout(this.config.requestTimeout),
203
349
  });
204
- clearTimeout(timer);
205
350
  const json = (await res.json()) as {
206
351
  success?: boolean;
207
352
  data?: T;
@@ -213,7 +358,6 @@ export class WebSocketManager {
213
358
  }
214
359
  return json.data as T;
215
360
  } catch (error) {
216
- clearTimeout(timer);
217
361
  if (error instanceof WebSocketError) throw error;
218
362
  throw new MessageError("REST request failed", error as Error);
219
363
  }
@@ -335,7 +479,9 @@ export class WebSocketManager {
335
479
 
336
480
  private handleMessage(event: MessageEvent): void {
337
481
  try {
338
- const message = JSON.parse(event.data) as WebSocketMessage;
482
+ const message = normalizeConsolePushMessage(
483
+ JSON.parse(event.data) as WebSocketMessage,
484
+ ) as WebSocketMessage;
339
485
  if (message.requestId && this.pendingRequests.has(message.requestId)) {
340
486
  this.handleRequestResponse(message);
341
487
  return;
@@ -350,18 +496,22 @@ export class WebSocketManager {
350
496
 
351
497
  private handleBroadcast(message: WebSocketMessage): void {
352
498
  const t = message.type;
353
- if (t === "bot:request" || t === "bot:notice" || t === "bot:message") {
499
+ if (
500
+ t === SIDE_EVENT_PUSH.REQUEST_RECEIVE
501
+ || t === SIDE_EVENT_PUSH.NOTICE_RECEIVE
502
+ || t === SIDE_EVENT_PUSH.MESSAGE_RECEIVE
503
+ ) {
354
504
  if (typeof window !== "undefined") {
355
505
  window.dispatchEvent(new CustomEvent("zhin-console-bot-push", { detail: message }));
356
506
  }
357
507
  return;
358
508
  }
359
- if (t === "hmr:reload") {
509
+ if (t === "hmr:reload" && message.delivery !== 'history') {
360
510
  console.info(`[HMR] File changed, reloading...`);
361
511
  window.location.reload();
362
512
  return;
363
513
  }
364
- if (t === "system:restarting") {
514
+ if (t === "system:restarting" && message.delivery !== 'history') {
365
515
  console.info("[System] Server restarting, will reload shortly...");
366
516
  setTimeout(() => window.location.reload(), 3000);
367
517
  return;
@@ -391,8 +541,10 @@ export class WebSocketManager {
391
541
  }
392
542
 
393
543
  private handleConnectionError(error: Error): void {
394
- this.clearReconnectTimer();
395
- this.setState(ConnectionState.ERROR);
544
+ // Used by the legacy WS path. Do NOT clear the reconnect timer here —
545
+ // callers that still want to retry should set RECONNECTING then call
546
+ // scheduleReconnect(). Terminal failures go through scheduleReconnect's
547
+ // maxAttempts branch which sets ERROR.
396
548
  console.error("[Console transport] Connection error:", error);
397
549
  this.callbacks.onError?.(error as unknown as Event);
398
550
  }
@@ -400,12 +552,23 @@ export class WebSocketManager {
400
552
  private scheduleReconnect(): void {
401
553
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
402
554
  this.setState(ConnectionState.ERROR);
555
+ this.notifyConnection(false);
403
556
  return;
404
557
  }
405
558
  this.reconnectAttempts++;
559
+ // Keep / restore RECONNECTING so the timer callback's state guard passes.
560
+ if (this.state !== ConnectionState.RECONNECTING) {
561
+ this.setState(ConnectionState.RECONNECTING);
562
+ }
406
563
  const delay = this.config.reconnectInterval * this.reconnectAttempts;
564
+ this.clearReconnectTimer();
407
565
  this.reconnectTimer = setTimeout(() => {
408
- if (this.state === ConnectionState.RECONNECTING) this.connect();
566
+ if (
567
+ this.state === ConnectionState.RECONNECTING
568
+ || this.state === ConnectionState.DISCONNECTED
569
+ ) {
570
+ this.connect();
571
+ }
409
572
  }, delay);
410
573
  }
411
574
 
@@ -424,3 +587,31 @@ export class WebSocketManager {
424
587
  this.pendingRequests.clear();
425
588
  }
426
589
  }
590
+
591
+ function eventCursorStorageKey(): string {
592
+ return `zhin.console.event-cursor:${getApiBase()}`;
593
+ }
594
+
595
+ function readEventCursor(): ConsoleEventCursor | null {
596
+ if (typeof localStorage === 'undefined') return null;
597
+ try {
598
+ const parsed = JSON.parse(localStorage.getItem(eventCursorStorageKey()) ?? 'null') as Partial<ConsoleEventCursor> | null;
599
+ return parsed
600
+ && typeof parsed.runtimeId === 'string'
601
+ && Number.isSafeInteger(parsed.eventId)
602
+ && (parsed.eventId ?? -1) >= 0
603
+ ? { runtimeId: parsed.runtimeId, eventId: parsed.eventId as number }
604
+ : null;
605
+ } catch {
606
+ return null;
607
+ }
608
+ }
609
+
610
+ function writeEventCursor(cursor: ConsoleEventCursor): void {
611
+ if (typeof localStorage === 'undefined') return;
612
+ try {
613
+ localStorage.setItem(eventCursorStorageKey(), JSON.stringify(cursor));
614
+ } catch {
615
+ // HTTP history remains available when browser storage is unavailable.
616
+ }
617
+ }
@@ -3,7 +3,7 @@ const TOKEN_KEY = "zhin_api_token";
3
3
 
4
4
  export function getApiBase(): string {
5
5
  const stored = localStorage.getItem(API_BASE_KEY)?.trim();
6
- if (stored) return stored.replace(/\/$/, "");
6
+ if (stored) return trimTrailingSlash(stored);
7
7
  if (typeof window !== "undefined") {
8
8
  return window.location.origin;
9
9
  }
@@ -11,11 +11,23 @@ export function getApiBase(): string {
11
11
  }
12
12
 
13
13
  export function setApiBase(base: string): void {
14
- localStorage.setItem(API_BASE_KEY, base.replace(/\/$/, ""));
14
+ localStorage.setItem(API_BASE_KEY, trimTrailingSlash(base));
15
15
  }
16
16
 
17
17
  export function getStoredToken(): string | null {
18
- return localStorage.getItem(TOKEN_KEY);
18
+ const runtime = getRuntimeToken();
19
+ if (runtime) return runtime;
20
+ if (typeof localStorage !== "undefined") {
21
+ const local = localStorage.getItem(TOKEN_KEY)?.trim();
22
+ if (local) return local;
23
+ }
24
+ // Some login flows only persist to sessionStorage; keep transport in sync
25
+ // with sandbox page / agent-stream which already read both stores.
26
+ if (typeof sessionStorage !== "undefined") {
27
+ const session = sessionStorage.getItem(TOKEN_KEY)?.trim();
28
+ if (session) return session;
29
+ }
30
+ return null;
19
31
  }
20
32
 
21
33
  export function resolveApiUrl(path: string): string {
@@ -23,3 +35,13 @@ export function resolveApiUrl(path: string): string {
23
35
  const p = path.startsWith("/") ? path : `/${path}`;
24
36
  return `${base}${p}`;
25
37
  }
38
+
39
+ function trimTrailingSlash(value: string): string {
40
+ return value.endsWith("/") ? value.slice(0, -1) : value;
41
+ }
42
+
43
+ function getRuntimeToken(): string | null {
44
+ if (typeof window === "undefined") return null;
45
+ const token = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
46
+ return token?.trim() || null;
47
+ }
@@ -1,6 +1,11 @@
1
+ import type { ConsoleEventDelivery } from '@zhin.js/console-protocol';
2
+
1
3
  export interface BaseMessage {
2
4
  type: string;
3
5
  timestamp?: number;
6
+ runtimeId?: string;
7
+ eventId?: number;
8
+ delivery?: ConsoleEventDelivery;
4
9
  requestId?: number;
5
10
  error?: string;
6
11
  }