@antelopejs/dms-ai 0.1.1 → 0.1.3

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 (149) hide show
  1. package/README.md +49 -0
  2. package/dist/channels/bridge.d.ts +22 -0
  3. package/dist/channels/bridge.js +87 -0
  4. package/dist/channels/bridge.js.map +1 -0
  5. package/dist/channels/channel-service.d.ts +5 -0
  6. package/dist/channels/channel-service.js +68 -0
  7. package/dist/channels/channel-service.js.map +1 -0
  8. package/dist/channels/message-budget.d.ts +3 -0
  9. package/dist/channels/message-budget.js +29 -0
  10. package/dist/channels/message-budget.js.map +1 -0
  11. package/dist/channels/registry.d.ts +7 -0
  12. package/dist/channels/registry.js +47 -0
  13. package/dist/channels/registry.js.map +1 -0
  14. package/dist/channels/sse-stream.d.ts +19 -0
  15. package/dist/channels/sse-stream.js +59 -0
  16. package/dist/channels/sse-stream.js.map +1 -0
  17. package/dist/chatbox/passthrough.d.ts +3 -0
  18. package/dist/chatbox/passthrough.js +53 -0
  19. package/dist/chatbox/passthrough.js.map +1 -0
  20. package/dist/constants/channels.d.ts +30 -0
  21. package/dist/constants/channels.js +38 -0
  22. package/dist/constants/channels.js.map +1 -0
  23. package/dist/constants/chatbox.d.ts +10 -0
  24. package/dist/constants/chatbox.js +35 -0
  25. package/dist/constants/chatbox.js.map +1 -0
  26. package/dist/constants/sidecar.d.ts +4 -0
  27. package/dist/constants/sidecar.js +3 -1
  28. package/dist/constants/sidecar.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +5 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/lifecycle/sidecar-socket.d.ts +2 -0
  33. package/dist/lifecycle/sidecar-socket.js +37 -0
  34. package/dist/lifecycle/sidecar-socket.js.map +1 -0
  35. package/dist/lifecycle/sidecar-unavailable-error.d.ts +3 -0
  36. package/dist/lifecycle/sidecar-unavailable-error.js +10 -0
  37. package/dist/lifecycle/sidecar-unavailable-error.js.map +1 -0
  38. package/dist/pages/activity/page.d.ts +1 -1
  39. package/dist/pages/overview/page.d.ts +3 -3
  40. package/dist/routes/channels.d.ts +8 -0
  41. package/dist/routes/channels.js +52 -0
  42. package/dist/routes/channels.js.map +1 -0
  43. package/dist/routes/chatbox.d.ts +7 -0
  44. package/dist/routes/chatbox.js +47 -0
  45. package/dist/routes/chatbox.js.map +1 -0
  46. package/dist/routes/index.d.ts +2 -0
  47. package/dist/routes/index.js +2 -0
  48. package/dist/routes/index.js.map +1 -1
  49. package/dist/routes/sidecar-info.js +0 -2
  50. package/dist/routes/sidecar-info.js.map +1 -1
  51. package/frontend-vue/app/components/SettingsView.vue +8 -0
  52. package/frontend-vue/app/plugins/ai.client.ts +137 -58
  53. package/frontend-vue/app/runtime/channel-client.ts +265 -0
  54. package/frontend-vue/app/runtime/chat-transport.ts +82 -0
  55. package/frontend-vue/app/runtime/constants.ts +30 -14
  56. package/frontend-vue/app/runtime/current-page.ts +9 -5
  57. package/frontend-vue/app/runtime/event-stream.ts +75 -0
  58. package/frontend-vue/app/runtime/overlay.ts +23 -6
  59. package/frontend-vue/app/runtime/sidecar-status.ts +10 -25
  60. package/frontend-vue/app/runtime/theme-bridge.ts +16 -4
  61. package/frontend-vue/package.json +3 -0
  62. package/frontend-vue/tests/backoff.test.ts +15 -15
  63. package/frontend-vue/tests/channel-client.test.ts +247 -0
  64. package/frontend-vue/tests/chat-transport.test.ts +67 -0
  65. package/frontend-vue/tests/sidecar-status.test.ts +39 -0
  66. package/package.json +11 -9
  67. package/sidecar/chatbox/dist/assets/index-C8u7mkHn.css +1 -0
  68. package/sidecar/chatbox/dist/assets/index-tdZvY7ee.js +52 -0
  69. package/sidecar/chatbox/dist/index.html +2 -2
  70. package/sidecar/dist/agent/disposals.d.ts +5 -0
  71. package/sidecar/dist/agent/disposals.js +17 -0
  72. package/sidecar/dist/agent/disposals.js.map +1 -0
  73. package/sidecar/dist/agent/effective-mode.d.ts +2 -0
  74. package/sidecar/dist/agent/effective-mode.js +20 -0
  75. package/sidecar/dist/agent/effective-mode.js.map +1 -0
  76. package/sidecar/dist/agent/provider.d.ts +2 -2
  77. package/sidecar/dist/agent/runner-events.d.ts +9 -1
  78. package/sidecar/dist/agent/runner.d.ts +2 -2
  79. package/sidecar/dist/agent/runner.js +11 -5
  80. package/sidecar/dist/agent/runner.js.map +1 -1
  81. package/sidecar/dist/agent/session.d.ts +3 -3
  82. package/sidecar/dist/agent/session.js +54 -34
  83. package/sidecar/dist/agent/session.js.map +1 -1
  84. package/sidecar/dist/agent/switching-runner.js +16 -14
  85. package/sidecar/dist/agent/switching-runner.js.map +1 -1
  86. package/sidecar/dist/agent/turn-end-reasons.d.ts +3 -0
  87. package/sidecar/dist/agent/turn-end-reasons.js +24 -0
  88. package/sidecar/dist/agent/turn-end-reasons.js.map +1 -0
  89. package/sidecar/dist/constants/agent.d.ts +6 -1
  90. package/sidecar/dist/constants/agent.js +13 -1
  91. package/sidecar/dist/constants/agent.js.map +1 -1
  92. package/sidecar/dist/constants/claude.d.ts +27 -1
  93. package/sidecar/dist/constants/claude.js +49 -0
  94. package/sidecar/dist/constants/claude.js.map +1 -1
  95. package/sidecar/dist/constants/codex.d.ts +6 -0
  96. package/sidecar/dist/constants/codex.js +13 -0
  97. package/sidecar/dist/constants/codex.js.map +1 -1
  98. package/sidecar/dist/constants/project.d.ts +1 -1
  99. package/sidecar/dist/constants/project.js +1 -4
  100. package/sidecar/dist/constants/project.js.map +1 -1
  101. package/sidecar/dist/constants/settings.js.map +1 -1
  102. package/sidecar/dist/constants/timing.d.ts +1 -0
  103. package/sidecar/dist/constants/timing.js +1 -0
  104. package/sidecar/dist/constants/timing.js.map +1 -1
  105. package/sidecar/dist/protocol/events.d.ts +36 -0
  106. package/sidecar/dist/protocol/events.js +13 -0
  107. package/sidecar/dist/protocol/events.js.map +1 -1
  108. package/sidecar/dist/providers/claude/activity.d.ts +5 -0
  109. package/sidecar/dist/providers/claude/activity.js +63 -0
  110. package/sidecar/dist/providers/claude/activity.js.map +1 -0
  111. package/sidecar/dist/providers/claude/adapter.js +21 -13
  112. package/sidecar/dist/providers/claude/adapter.js.map +1 -1
  113. package/sidecar/dist/providers/claude/config.d.ts +2 -1
  114. package/sidecar/dist/providers/claude/config.js +4 -0
  115. package/sidecar/dist/providers/claude/config.js.map +1 -1
  116. package/sidecar/dist/providers/claude/provider.js +18 -25
  117. package/sidecar/dist/providers/claude/provider.js.map +1 -1
  118. package/sidecar/dist/providers/claude/result.d.ts +3 -0
  119. package/sidecar/dist/providers/claude/result.js +61 -0
  120. package/sidecar/dist/providers/claude/result.js.map +1 -0
  121. package/sidecar/dist/providers/claude/safe-mode.d.ts +4 -0
  122. package/sidecar/dist/providers/claude/safe-mode.js +24 -0
  123. package/sidecar/dist/providers/claude/safe-mode.js.map +1 -0
  124. package/sidecar/dist/providers/codex/adapter.js +8 -2
  125. package/sidecar/dist/providers/codex/adapter.js.map +1 -1
  126. package/sidecar/dist/providers/codex/client.d.ts +1 -0
  127. package/sidecar/dist/providers/codex/client.js +1 -0
  128. package/sidecar/dist/providers/codex/client.js.map +1 -1
  129. package/sidecar/dist/providers/codex/config.js +2 -1
  130. package/sidecar/dist/providers/codex/config.js.map +1 -1
  131. package/sidecar/dist/providers/codex/process.js +113 -33
  132. package/sidecar/dist/providers/codex/process.js.map +1 -1
  133. package/sidecar/dist/providers/codex/provider.js +6 -4
  134. package/sidecar/dist/providers/codex/provider.js.map +1 -1
  135. package/sidecar/dist/server/routing.js +39 -21
  136. package/sidecar/dist/server/routing.js.map +1 -1
  137. package/sidecar/dist/server/turn-progress.d.ts +13 -0
  138. package/sidecar/dist/server/turn-progress.js +72 -0
  139. package/sidecar/dist/server/turn-progress.js.map +1 -0
  140. package/sidecar/dist/server/ws.js +4 -2
  141. package/sidecar/dist/server/ws.js.map +1 -1
  142. package/sidecar/dist/state/types.d.ts +2 -1
  143. package/sidecar/dist/state/types.js +1 -0
  144. package/sidecar/dist/state/types.js.map +1 -1
  145. package/sidecar/package.json +1 -1
  146. package/frontend-vue/app/runtime/ws-client.ts +0 -196
  147. package/frontend-vue/tests/sidecar-auth.test.ts +0 -87
  148. package/sidecar/chatbox/dist/assets/index-CEZ1NLp9.js +0 -52
  149. package/sidecar/chatbox/dist/assets/index-D_51Vqqk.css +0 -1
@@ -0,0 +1,265 @@
1
+ import {
2
+ type BackoffState,
3
+ createBackoff,
4
+ nextDelay,
5
+ resetBackoff,
6
+ } from './backoff'
7
+ import {
8
+ CHANNEL_EVENT_READY,
9
+ CHANNEL_EVENTS_SEGMENT,
10
+ CHANNEL_LIST_SEPARATOR,
11
+ CHANNEL_MESSAGES_SEGMENT,
12
+ CHANNEL_RECONNECT_DELAYS_MS,
13
+ CHANNEL_STATUS_CONNECTED,
14
+ CHANNEL_STATUS_CONNECTING,
15
+ CHANNEL_STATUS_DISCONNECTED,
16
+ CHANNEL_STATUS_RECONNECTING,
17
+ CHANNELS_PATH,
18
+ EVENT_STREAM_TYPE,
19
+ } from './constants'
20
+ import { readEventStream, type StreamEvent } from './event-stream'
21
+
22
+ export type ChannelStatus =
23
+ | typeof CHANNEL_STATUS_CONNECTING
24
+ | typeof CHANNEL_STATUS_CONNECTED
25
+ | typeof CHANNEL_STATUS_DISCONNECTED
26
+ | typeof CHANNEL_STATUS_RECONNECTING
27
+
28
+ /** Posts one message as JSON; `$authFetch` in the dashboard refreshes a stale session. */
29
+ export type PostMessage = (path: string, body: unknown) => Promise<unknown>
30
+
31
+ /** Opens the event stream of a set of channels. */
32
+ export type OpenStream = (path: string, signal: AbortSignal) => Promise<Response>
33
+
34
+ export interface ChannelClientOptions {
35
+ channels: readonly string[]
36
+ post: PostMessage
37
+ openStream?: OpenStream
38
+ /** Every connection is a new sidecar socket per channel: identify on it here. */
39
+ onReady?: () => void
40
+ /** Raw sidecar frame, as the socket of that channel delivered it. */
41
+ onFrame?: (channel: string, raw: string) => void
42
+ onStatusChange?: (status: ChannelStatus) => void
43
+ }
44
+
45
+ export interface ChannelClient {
46
+ /**
47
+ * Queued and posted one at a time, in order. False when it was dropped
48
+ * because the stream is not connected, so the caller knows nothing left.
49
+ */
50
+ send: (channel: string, msg: unknown) => boolean
51
+ isConnected: () => boolean
52
+ getStatus: () => ChannelStatus
53
+ /** Opens the stream if it is not open yet. */
54
+ start: () => void
55
+ /** Closes the stream until the next `start`. */
56
+ stop: () => void
57
+ /**
58
+ * Drops any pending backoff and replaces the stream at once, if started.
59
+ * The status says connecting until the new stream is ready, since nothing
60
+ * can be sent in between.
61
+ */
62
+ reconnectNow: () => void
63
+ }
64
+
65
+ interface ReadyPayload {
66
+ connections?: Record<string, unknown>
67
+ }
68
+
69
+ interface QueuedMessage {
70
+ channel: string
71
+ msg: unknown
72
+ }
73
+
74
+ interface ClientState {
75
+ options: ChannelClientOptions
76
+ status: ChannelStatus
77
+ connections: Record<string, string> | null
78
+ stream: AbortController | null
79
+ retryTimer: ReturnType<typeof setTimeout> | null
80
+ queue: QueuedMessage[]
81
+ isDraining: boolean
82
+ isStarted: boolean
83
+ backoff: BackoffState
84
+ }
85
+
86
+ export function channelEventsPath(channels: readonly string[]): string {
87
+ return `${CHANNELS_PATH}/${channels.join(CHANNEL_LIST_SEPARATOR)}/${CHANNEL_EVENTS_SEGMENT}`
88
+ }
89
+
90
+ export function channelMessagesPath(connectionId: string): string {
91
+ return `${CHANNELS_PATH}/${encodeURIComponent(connectionId)}/${CHANNEL_MESSAGES_SEGMENT}`
92
+ }
93
+
94
+ function openEventStream(path: string, signal: AbortSignal): Promise<Response> {
95
+ return fetch(path, {
96
+ headers: { accept: EVENT_STREAM_TYPE },
97
+ credentials: 'same-origin',
98
+ cache: 'no-store',
99
+ signal,
100
+ })
101
+ }
102
+
103
+ function setStatus(state: ClientState, next: ChannelStatus): void {
104
+ if (state.status === next) return
105
+ state.status = next
106
+ state.options.onStatusChange?.(next)
107
+ }
108
+
109
+ function readConnections(data: string): Record<string, string> | null {
110
+ try {
111
+ const { connections } = JSON.parse(data) as ReadyPayload
112
+ if (connections === undefined || connections === null) return null
113
+ const entries = Object.entries(connections).filter(
114
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
115
+ )
116
+ return Object.fromEntries(entries)
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ function handleReady(state: ClientState, event: StreamEvent): void {
123
+ state.connections = readConnections(event.data)
124
+ resetBackoff(state.backoff)
125
+ setStatus(state, CHANNEL_STATUS_CONNECTED)
126
+ state.options.onReady?.()
127
+ }
128
+
129
+ function handleEvent(state: ClientState, event: StreamEvent): void {
130
+ if (event.name === CHANNEL_EVENT_READY) return handleReady(state, event)
131
+ if (!state.options.channels.includes(event.name)) return
132
+ state.options.onFrame?.(event.name, event.data)
133
+ }
134
+
135
+ function scheduleRetry(state: ClientState): void {
136
+ if (state.retryTimer !== null) return
137
+ const delay = nextDelay(state.backoff, CHANNEL_RECONNECT_DELAYS_MS)
138
+ state.retryTimer = setTimeout(() => {
139
+ state.retryTimer = null
140
+ connect(state)
141
+ }, delay)
142
+ }
143
+
144
+ function lose(state: ClientState): void {
145
+ state.connections = null
146
+ state.queue = []
147
+ if (!state.isStarted) return
148
+ setStatus(state, CHANNEL_STATUS_RECONNECTING)
149
+ scheduleRetry(state)
150
+ }
151
+
152
+ async function run(state: ClientState, controller: AbortController): Promise<void> {
153
+ const open = state.options.openStream ?? openEventStream
154
+ try {
155
+ const response = await open(
156
+ channelEventsPath(state.options.channels),
157
+ controller.signal,
158
+ )
159
+ if (!response.ok || response.body === null) return
160
+ await readEventStream(response.body, (event) => handleEvent(state, event))
161
+ } catch {
162
+ return
163
+ } finally {
164
+ if (!controller.signal.aborted) lose(state)
165
+ }
166
+ }
167
+
168
+ function connect(state: ClientState): void {
169
+ if (!state.isStarted) return
170
+ state.stream?.abort()
171
+ state.connections = null
172
+ const controller = new AbortController()
173
+ state.stream = controller
174
+ void run(state, controller)
175
+ }
176
+
177
+ function restart(state: ClientState): void {
178
+ state.stream?.abort()
179
+ lose(state)
180
+ }
181
+
182
+ async function drain(state: ClientState): Promise<void> {
183
+ if (state.isDraining) return
184
+ state.isDraining = true
185
+ while (state.queue.length > 0 && state.connections !== null) {
186
+ const next = state.queue.shift() as QueuedMessage
187
+ const connectionId = state.connections[next.channel]
188
+ if (connectionId === undefined) continue
189
+ try {
190
+ await state.options.post(channelMessagesPath(connectionId), next.msg)
191
+ } catch {
192
+ restart(state)
193
+ }
194
+ }
195
+ state.isDraining = false
196
+ }
197
+
198
+ function send(state: ClientState, channel: string, msg: unknown): boolean {
199
+ if (state.connections?.[channel] === undefined) return false
200
+ state.queue.push({ channel, msg })
201
+ void drain(state)
202
+ return true
203
+ }
204
+
205
+ function clearRetry(state: ClientState): void {
206
+ if (state.retryTimer !== null) clearTimeout(state.retryTimer)
207
+ state.retryTimer = null
208
+ }
209
+
210
+ function start(state: ClientState): void {
211
+ if (state.isStarted) return
212
+ state.isStarted = true
213
+ resetBackoff(state.backoff)
214
+ setStatus(state, CHANNEL_STATUS_CONNECTING)
215
+ connect(state)
216
+ }
217
+
218
+ function stop(state: ClientState): void {
219
+ if (!state.isStarted) return
220
+ state.isStarted = false
221
+ clearRetry(state)
222
+ state.stream?.abort()
223
+ state.connections = null
224
+ state.queue = []
225
+ setStatus(state, CHANNEL_STATUS_DISCONNECTED)
226
+ }
227
+
228
+ function reconnectNow(state: ClientState): void {
229
+ if (!state.isStarted) return
230
+ clearRetry(state)
231
+ resetBackoff(state.backoff)
232
+ setStatus(state, CHANNEL_STATUS_CONNECTING)
233
+ connect(state)
234
+ }
235
+
236
+ /**
237
+ * One stream to the sidecar through the DMS carrying several channels, and
238
+ * sequential POSTs to send on each. Browsers keep at most six HTTP/1.1
239
+ * connections per origin, so a tab holds one such stream and only while it
240
+ * needs it: the caller starts and stops it. While started, any end of the
241
+ * stream — a sidecar that went down, a hot reload of dms-ai, a network drop —
242
+ * reconnects with backoff, and each new connection is announced through
243
+ * `onReady` so the caller identifies itself again.
244
+ */
245
+ export function createChannelClient(options: ChannelClientOptions): ChannelClient {
246
+ const state: ClientState = {
247
+ options,
248
+ status: CHANNEL_STATUS_DISCONNECTED,
249
+ connections: null,
250
+ stream: null,
251
+ retryTimer: null,
252
+ queue: [],
253
+ isDraining: false,
254
+ isStarted: false,
255
+ backoff: createBackoff(),
256
+ }
257
+ return {
258
+ send: (channel, msg) => send(state, channel, msg),
259
+ isConnected: () => state.connections !== null,
260
+ getStatus: () => state.status,
261
+ start: () => start(state),
262
+ stop: () => stop(state),
263
+ reconnectNow: () => reconnectNow(state),
264
+ }
265
+ }
@@ -0,0 +1,82 @@
1
+ import type { ChannelStatus } from './channel-client'
2
+ import { CHAT_TRANSPORT_KEY, LOG_PREFIX } from './constants'
3
+
4
+ /**
5
+ * What the chat document, same-origin with the dashboard, reads on its parent
6
+ * window to reach the sidecar: the dashboard owns the tab's only stream and
7
+ * lends the chat its channel of it.
8
+ */
9
+ export interface ChatTransport {
10
+ /** Raw sidecar frames of the chat channel, in order. */
11
+ onFrame: (listener: (raw: string) => void) => () => void
12
+ /** Every (re)connection is a new sidecar socket: the chat says hello again. */
13
+ onReady: (listener: () => void) => () => void
14
+ onStatus: (listener: (status: ChannelStatus) => void) => () => void
15
+ getStatus: () => ChannelStatus
16
+ /** False when the message was dropped because the channel is not connected. */
17
+ send: (msg: unknown) => boolean
18
+ reconnect: () => void
19
+ }
20
+
21
+ export interface ChatTransportActions {
22
+ send: (msg: unknown) => boolean
23
+ reconnect: () => void
24
+ getStatus: () => ChannelStatus
25
+ }
26
+
27
+ export interface ChatTransportHub {
28
+ transport: ChatTransport
29
+ deliverFrame: (raw: string) => void
30
+ announceReady: () => void
31
+ announceStatus: (status: ChannelStatus) => void
32
+ }
33
+
34
+ interface Listeners<T> {
35
+ add: (listener: (value: T) => void) => () => void
36
+ emit: (value: T) => void
37
+ }
38
+
39
+ function createListeners<T>(): Listeners<T> {
40
+ const listeners = new Set<(value: T) => void>()
41
+ return {
42
+ add: (listener) => {
43
+ listeners.add(listener)
44
+ return () => listeners.delete(listener)
45
+ },
46
+ emit: (value) => {
47
+ for (const listener of listeners) {
48
+ try {
49
+ listener(value)
50
+ } catch (error: unknown) {
51
+ console.error(`${LOG_PREFIX} chat listener failed`, error)
52
+ }
53
+ }
54
+ },
55
+ }
56
+ }
57
+
58
+ export function createChatTransport(
59
+ actions: ChatTransportActions,
60
+ ): ChatTransportHub {
61
+ const frames = createListeners<string>()
62
+ const ready = createListeners<undefined>()
63
+ const statuses = createListeners<ChannelStatus>()
64
+ return {
65
+ transport: {
66
+ onFrame: frames.add,
67
+ onReady: (listener) => ready.add(() => listener()),
68
+ onStatus: statuses.add,
69
+ getStatus: actions.getStatus,
70
+ send: actions.send,
71
+ reconnect: actions.reconnect,
72
+ },
73
+ deliverFrame: frames.emit,
74
+ announceReady: () => ready.emit(undefined),
75
+ announceStatus: statuses.emit,
76
+ }
77
+ }
78
+
79
+ /** Publishes the transport where the chat document looks for it. */
80
+ export function exposeChatTransport(transport: ChatTransport): void {
81
+ Reflect.set(globalThis, CHAT_TRANSPORT_KEY, transport)
82
+ }
@@ -3,17 +3,36 @@ export const OVERLAY_Z_INDEX = 999_999
3
3
  export const OVERLAY_DOM_ID = 'dms-ai-overlay-root'
4
4
  export const OVERLAY_IFRAME_ID = 'dms-ai-overlay-iframe'
5
5
  export const SIDECAR_INFO_PATH = '/ai/sidecar-info'
6
- export const SIDECAR_PROBE_HOST = 'http://localhost'
6
+ /** The chat document, served through the DMS from the dashboard's own origin. */
7
+ export const CHATBOX_PATH = '/api/ai/chatbox/'
8
+ /** Event stream to receive, POST to send: HTTP the DMS frontend server relays. */
9
+ export const CHANNELS_PATH = '/ai/channels'
10
+ export const CHANNEL_EVENTS_SEGMENT = 'events'
11
+ export const CHANNEL_MESSAGES_SEGMENT = 'messages'
12
+ export const CHANNEL_LIST_SEPARATOR = ','
13
+ export const HOST_CHANNEL = 'host'
14
+ export const CHAT_CHANNEL = 'chat'
15
+ export const CHANNEL_EVENT_READY = 'ready'
16
+ /**
17
+ * How long the stream outlives a closed panel or a hidden tab, so a quick
18
+ * close and reopen does not reconnect, while a tab left in the background soon
19
+ * gives back its one of the browser's six HTTP/1.1 connections to the dashboard.
20
+ */
21
+ export const CHANNEL_IDLE_STOP_MS = 2_000
22
+ export const VISIBILITY_CHANGE_EVENT = 'visibilitychange'
23
+ /** Where the chat document, same-origin, finds the dashboard's chat transport. */
24
+ export const CHAT_TRANSPORT_KEY = 'dmsAiChatTransport'
25
+ export const OVERLAY_OPEN_CHANGE_EVENT = 'dms-ai:open-change'
26
+ export const EVENT_STREAM_TYPE = 'text/event-stream'
27
+ export const JSON_CONTENT_TYPE = 'application/json'
7
28
  export const OVERLAY_BOX_SHADOW = '0 8px 24px rgba(0, 0, 0, 0.18)'
8
- export const SIDECAR_HOST_NAME = 'localhost'
9
- export const WS_HOST_PATH = '/ws/host'
10
- export const WS_RECONNECT_DELAYS_MS = [
29
+ export const CHANNEL_RECONNECT_DELAYS_MS = [
11
30
  1000, 2000, 4000, 8000, 15000, 30000,
12
31
  ] as const
13
32
  // Sidecar reachability, as seen by the host. `reviving` is the self-healing
14
- // state (idle-exited / crashed-respawning): the probe loop is relearning the new
15
- // random port and will repoint the frame. `unavailable` is terminal (the crash
16
- // budget is spent) and needs a reload.
33
+ // state (idle-exited / crashed-respawning): the probe loop revives the sidecar
34
+ // and the channels reconnect once it answers. `unavailable` is terminal (the
35
+ // crash budget is spent) and needs a reload.
17
36
  export const SIDECAR_STATUS_CONNECTING = 'connecting'
18
37
  export const SIDECAR_STATUS_CONNECTED = 'connected'
19
38
  export const SIDECAR_STATUS_REVIVING = 'reviving'
@@ -30,13 +49,10 @@ export const PLACEHOLDER_REVIVING_TEXT = 'Reconnecting the assistant…'
30
49
  export const PLACEHOLDER_UNAVAILABLE_TITLE = 'Assistant unavailable'
31
50
  export const PLACEHOLDER_UNAVAILABLE_TEXT = 'Reload the page to try again.'
32
51
 
33
- export const WS_STATUS_CONNECTING = 'connecting'
34
- export const WS_STATUS_CONNECTED = 'connected'
35
- export const WS_STATUS_DISCONNECTED = 'disconnected'
36
- export const WS_STATUS_RECONNECTING = 'reconnecting'
37
- export const WS_PROTOCOL_SECURE = 'wss:'
38
- export const WS_PROTOCOL_INSECURE = 'ws:'
39
- export const PAGE_PROTOCOL_SECURE = 'https:'
52
+ export const CHANNEL_STATUS_CONNECTING = 'connecting'
53
+ export const CHANNEL_STATUS_CONNECTED = 'connected'
54
+ export const CHANNEL_STATUS_DISCONNECTED = 'disconnected'
55
+ export const CHANNEL_STATUS_RECONNECTING = 'reconnecting'
40
56
  export const HOST_ROLE = 'host'
41
57
  export const HELLO_MESSAGE_TYPE = 'hello'
42
58
  export const LOG_PREFIX = '[dms-ai]'
@@ -14,14 +14,18 @@ interface InstallCurrentPageTrackerOptions {
14
14
  send: (msg: HostStateUpdateMessage) => void
15
15
  }
16
16
 
17
+ /** The state update announcing the page at `url`. */
18
+ export function buildCurrentPageUpdate(url: string): HostStateUpdateMessage {
19
+ return {
20
+ type: HOST_STATE_UPDATE_TYPE,
21
+ currentPage: { path: new URL(url, window.location.origin).pathname },
22
+ }
23
+ }
24
+
17
25
  export function installCurrentPageTracker(
18
26
  options: InstallCurrentPageTrackerOptions,
19
27
  ): () => void {
20
- const pushFor = (url: string): void =>
21
- options.send({
22
- type: HOST_STATE_UPDATE_TYPE,
23
- currentPage: { path: new URL(url, window.location.origin).pathname },
24
- })
28
+ const pushFor = (url: string): void => options.send(buildCurrentPageUpdate(url))
25
29
  const stop = router.on('navigate', (event) => pushFor(event.detail.page.url))
26
30
  pushFor(window.location.href)
27
31
  return stop
@@ -0,0 +1,75 @@
1
+ export interface StreamEvent {
2
+ name: string
3
+ data: string
4
+ }
5
+
6
+ const DEFAULT_EVENT_NAME = 'message'
7
+ const FRAME_SEPARATOR = /\r?\n\r?\n/
8
+ const LINE_SEPARATOR = /\r?\n/
9
+ const FIELD_SEPARATOR = ':'
10
+ const EVENT_FIELD = 'event'
11
+ const DATA_FIELD = 'data'
12
+
13
+ interface Field {
14
+ name: string
15
+ value: string
16
+ }
17
+
18
+ function parseField(line: string): Field {
19
+ const boundary = line.indexOf(FIELD_SEPARATOR)
20
+ if (boundary === -1) return { name: line, value: '' }
21
+ const raw = line.slice(boundary + FIELD_SEPARATOR.length)
22
+ return {
23
+ name: line.slice(0, boundary),
24
+ value: raw.startsWith(' ') ? raw.slice(1) : raw,
25
+ }
26
+ }
27
+
28
+ function parseFrame(frame: string): StreamEvent | null {
29
+ let name = DEFAULT_EVENT_NAME
30
+ const data: string[] = []
31
+ for (const line of frame.split(LINE_SEPARATOR)) {
32
+ const field = parseField(line)
33
+ if (field.name === EVENT_FIELD) name = field.value
34
+ if (field.name === DATA_FIELD) data.push(field.value)
35
+ }
36
+ if (data.length === 0) return null
37
+ return { name, data: data.join('\n') }
38
+ }
39
+
40
+ function dispatchFrames(
41
+ buffer: string,
42
+ onEvent: (event: StreamEvent) => void,
43
+ ): string {
44
+ const frames = buffer.split(FRAME_SEPARATOR)
45
+ const rest = frames.pop() ?? ''
46
+ for (const frame of frames) {
47
+ const event = parseFrame(frame)
48
+ if (event !== null) onEvent(event)
49
+ }
50
+ return rest
51
+ }
52
+
53
+ /**
54
+ * Delivers every event of a text/event-stream body until it ends. Read over
55
+ * fetch rather than EventSource, like the DMS realtime stream, so a failed
56
+ * request surfaces its status and nothing reconnects behind the caller's back.
57
+ */
58
+ export async function readEventStream(
59
+ body: ReadableStream<Uint8Array>,
60
+ onEvent: (event: StreamEvent) => void,
61
+ ): Promise<void> {
62
+ const reader = body.getReader()
63
+ const decoder = new TextDecoder()
64
+ let buffer = ''
65
+ try {
66
+ while (true) {
67
+ const chunk = await reader.read()
68
+ if (chunk.done) return
69
+ buffer += decoder.decode(chunk.value, { stream: true })
70
+ buffer = dispatchFrames(buffer, onEvent)
71
+ }
72
+ } finally {
73
+ reader.releaseLock()
74
+ }
75
+ }
@@ -4,6 +4,7 @@ import {
4
4
  OVERLAY_DOM_ID,
5
5
  OVERLAY_IFRAME_ID,
6
6
  OVERLAY_MIN_WIDTH_PX,
7
+ OVERLAY_OPEN_CHANGE_EVENT,
7
8
  OVERLAY_OUTSIDE_TOGGLE_SUPPRESS_MS,
8
9
  OVERLAY_PLACEHOLDER_ID,
9
10
  OVERLAY_RESIZE_HANDLE_SIZE_PX,
@@ -21,6 +22,7 @@ import {
21
22
  import {
22
23
  type HostTheme,
23
24
  installThemeBridge,
25
+ isFromFrame,
24
26
  readHostMode,
25
27
  } from './theme-bridge'
26
28
 
@@ -37,7 +39,9 @@ export interface OverlayHandle {
37
39
  // use `toggleOpen` so it is never suppressed.
38
40
  toggleFromLauncher: () => void
39
41
  isOpen: () => boolean
40
- // Point the frame at a (possibly new) sidecar URL. A no-op when the URL is
42
+ /** Called on every open or close, however the panel was toggled. */
43
+ onOpenChange: (listener: (isOpen: boolean) => void) => () => void
44
+ // Point the frame at the chat document. A no-op when the URL is
41
45
  // unchanged and already loaded, so a redundant `connected` notify won't reload
42
46
  // the chatbox; otherwise it reloads and shows a connecting placeholder until
43
47
  // the frame paints.
@@ -106,9 +110,9 @@ function buildContainer(): HTMLDivElement {
106
110
  return container
107
111
  }
108
112
 
109
- // Built with no `src`: the frame is only pointed at the sidecar via `repoint`
110
- // once the status controller confirms a reachable port, so the browser never
111
- // renders its native "can't reach" page for a dead port.
113
+ // Built with no `src`: the frame is only pointed at the chat via `repoint` once
114
+ // the status controller confirms the sidecar runs, so it never renders the
115
+ // error page of a sidecar that is down.
112
116
  function buildIframe(): HTMLIFrameElement {
113
117
  const iframe = document.createElement('iframe')
114
118
  iframe.id = OVERLAY_IFRAME_ID
@@ -291,8 +295,8 @@ function createPlaceholderController(
291
295
  }
292
296
 
293
297
  // Repoint the frame at `url`, deferring the placeholder-hide until it paints so a
294
- // port change reloads the chatbox without flashing the previous dead frame. A
295
- // no-op reload when the URL is already loaded.
298
+ // reload of the chatbox never flashes the previous frame. A no-op reload when
299
+ // the URL is already loaded.
296
300
  function createRepointer(
297
301
  iframe: HTMLIFrameElement,
298
302
  placeholder: PlaceholderController,
@@ -321,9 +325,14 @@ function setOpen(
321
325
  stateRef: OverlayPrefsRef,
322
326
  isOpen: boolean,
323
327
  ): void {
328
+ const hasChanged = stateRef.value.isOpen !== isOpen
324
329
  stateRef.value = { ...stateRef.value, isOpen }
325
330
  applyPrefs(elements, stateRef.value)
326
331
  writePrefsDebounced(stateRef.value)
332
+ if (!hasChanged) return
333
+ elements.container.dispatchEvent(
334
+ new CustomEvent(OVERLAY_OPEN_CHANGE_EVENT, { detail: isOpen }),
335
+ )
327
336
  }
328
337
 
329
338
  export function injectOverlay(): OverlayHandle | null {
@@ -360,6 +369,7 @@ export function injectOverlay(): OverlayHandle | null {
360
369
  // The chatbox header's close button lives inside the iframe; it asks the
361
370
  // host to slide the panel away via postMessage.
362
371
  globalThis.addEventListener('message', (event: MessageEvent) => {
372
+ if (!isFromFrame(event, elements.iframe)) return
363
373
  const type = (event.data as MessageData)?.type
364
374
  if (typeof type === 'string' && CLOSE_MESSAGE_TYPES.has(type)) {
365
375
  setOpen(elements, stateRef, false)
@@ -377,6 +387,13 @@ export function injectOverlay(): OverlayHandle | null {
377
387
  toggle()
378
388
  },
379
389
  isOpen: (): boolean => stateRef.value.isOpen,
390
+ onOpenChange: (listener) => {
391
+ const onChange = (event: Event): void =>
392
+ listener((event as CustomEvent<boolean>).detail)
393
+ elements.container.addEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
394
+ return () =>
395
+ elements.container.removeEventListener(OVERLAY_OPEN_CHANGE_EVENT, onChange)
396
+ },
380
397
  repoint,
381
398
  showPlaceholder: (kind) => placeholder.show(kind),
382
399
  }
@@ -14,8 +14,6 @@ export type SidecarStatus =
14
14
  | typeof SIDECAR_STATUS_UNAVAILABLE
15
15
 
16
16
  interface SidecarInfo {
17
- port: number | null
18
- clientToken: string | null
19
17
  isRunning: boolean
20
18
  hasGivenUp: boolean
21
19
  disabled: boolean
@@ -27,14 +25,14 @@ export interface SidecarStatusController {
27
25
  // is disabled for this process — so the caller skips injection entirely.
28
26
  init: () => Promise<boolean>
29
27
  getStatus: () => SidecarStatus
30
- getPort: () => number | null
31
- getClientToken: () => string | null
32
28
  // Subscribe to status changes; fires immediately with the current status so a
33
29
  // late subscriber (overlay/icon injected after init) reflects it right away.
34
30
  subscribe: (cb: (status: SidecarStatus) => void) => () => void
35
- // The host WebSocket dropped: re-probe now to relearn a possibly-new port
36
- // rather than let the socket keep retrying the dead one.
37
- reportWsDown: () => void
31
+ /**
32
+ * The host channel dropped: re-probe now, which also revives an idle-exited
33
+ * sidecar, and report `connected` again once it answers.
34
+ */
35
+ reportChannelDown: () => void
38
36
  dispose: () => void
39
37
  }
40
38
 
@@ -46,9 +44,6 @@ async function fetchInfo(
46
44
  try {
47
45
  const body = await fetchDetails()
48
46
  return {
49
- port: typeof body.port === 'number' ? body.port : null,
50
- clientToken:
51
- typeof body.clientToken === 'string' ? body.clientToken : null,
52
47
  isRunning: body.isRunning === true,
53
48
  hasGivenUp: body.hasGivenUp === true,
54
49
  disabled: body.disabled === true,
@@ -64,8 +59,6 @@ export function createSidecarStatusController(
64
59
  const backoff = createBackoff()
65
60
  const subscribers = new Set<(status: SidecarStatus) => void>()
66
61
  let status: SidecarStatus = SIDECAR_STATUS_CONNECTING
67
- let port: number | null = null
68
- let clientToken: string | null = null
69
62
  let pollTimer: ReturnType<typeof setTimeout> | null = null
70
63
  let disposed = false
71
64
 
@@ -105,19 +98,13 @@ export function createSidecarStatusController(
105
98
  setStatus(SIDECAR_STATUS_UNAVAILABLE)
106
99
  return
107
100
  }
108
- if (info.isRunning && info.port !== null && info.clientToken) {
109
- port = info.port
110
- clientToken = info.clientToken
101
+ if (info.isRunning) {
111
102
  resetBackoff(backoff)
112
- // A reviving→connected transition notifies via setStatus; a port change
113
- // while already connected leaves the status word unchanged, so force a
114
- // notify there too to drive the overlay/WS onto the new port.
115
- if (status === SIDECAR_STATUS_CONNECTED) notify()
116
- else setStatus(SIDECAR_STATUS_CONNECTED)
103
+ setStatus(SIDECAR_STATUS_CONNECTED)
117
104
  return
118
105
  }
119
106
  // Down but not terminal — the probe already asked the backend to respawn;
120
- // keep polling until it reports a port.
107
+ // keep polling until it reports the sidecar running.
121
108
  setStatus(SIDECAR_STATUS_REVIVING)
122
109
  schedulePoll()
123
110
  }
@@ -131,8 +118,6 @@ export function createSidecarStatusController(
131
118
  return true
132
119
  },
133
120
  getStatus: () => status,
134
- getPort: () => port,
135
- getClientToken: () => clientToken,
136
121
  subscribe: (cb) => {
137
122
  subscribers.add(cb)
138
123
  cb(status)
@@ -140,13 +125,13 @@ export function createSidecarStatusController(
140
125
  subscribers.delete(cb)
141
126
  }
142
127
  },
143
- reportWsDown: () => {
128
+ reportChannelDown: () => {
144
129
  if (disposed) return
145
130
  if (status !== SIDECAR_STATUS_CONNECTED) return
146
131
  // Surface the drop right away (amber dot + spinner) instead of waiting on
147
132
  // the re-probe: that probe awaits a respawn and can take a second or two,
148
133
  // during which the icon would otherwise look healthy. poll() flips back
149
- // to connected once the new port is live.
134
+ // to connected once the sidecar answers again.
150
135
  resetBackoff(backoff)
151
136
  setStatus(SIDECAR_STATUS_REVIVING)
152
137
  void poll()