@tetherto/mdk-ui-agent 0.0.0 → 0.12.1

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 (217) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +310 -0
  3. package/dist/branding.d.ts +36 -0
  4. package/dist/branding.d.ts.map +1 -0
  5. package/dist/branding.js +37 -0
  6. package/dist/branding.js.map +1 -0
  7. package/dist/components/approval-card.d.ts +46 -0
  8. package/dist/components/approval-card.d.ts.map +1 -0
  9. package/dist/components/approval-card.js +84 -0
  10. package/dist/components/approval-card.js.map +1 -0
  11. package/dist/components/assistant-message.d.ts +25 -0
  12. package/dist/components/assistant-message.d.ts.map +1 -0
  13. package/dist/components/assistant-message.js +49 -0
  14. package/dist/components/assistant-message.js.map +1 -0
  15. package/dist/components/co-pilot-panel.d.ts +17 -0
  16. package/dist/components/co-pilot-panel.d.ts.map +1 -0
  17. package/dist/components/co-pilot-panel.js +33 -0
  18. package/dist/components/co-pilot-panel.js.map +1 -0
  19. package/dist/components/code-block.d.ts +17 -0
  20. package/dist/components/code-block.d.ts.map +1 -0
  21. package/dist/components/code-block.js +40 -0
  22. package/dist/components/code-block.js.map +1 -0
  23. package/dist/components/collapsible-answer.d.ts +26 -0
  24. package/dist/components/collapsible-answer.d.ts.map +1 -0
  25. package/dist/components/collapsible-answer.js +30 -0
  26. package/dist/components/collapsible-answer.js.map +1 -0
  27. package/dist/components/composer.d.ts +31 -0
  28. package/dist/components/composer.d.ts.map +1 -0
  29. package/dist/components/composer.js +82 -0
  30. package/dist/components/composer.js.map +1 -0
  31. package/dist/components/conversation-list.d.ts +21 -0
  32. package/dist/components/conversation-list.d.ts.map +1 -0
  33. package/dist/components/conversation-list.js +42 -0
  34. package/dist/components/conversation-list.js.map +1 -0
  35. package/dist/components/icons.d.ts +88 -0
  36. package/dist/components/icons.d.ts.map +1 -0
  37. package/dist/components/icons.js +112 -0
  38. package/dist/components/icons.js.map +1 -0
  39. package/dist/components/launcher-fab.d.ts +8 -0
  40. package/dist/components/launcher-fab.d.ts.map +1 -0
  41. package/dist/components/launcher-fab.js +6 -0
  42. package/dist/components/launcher-fab.js.map +1 -0
  43. package/dist/components/leaked-tool-call-notice.d.ts +18 -0
  44. package/dist/components/leaked-tool-call-notice.d.ts.map +1 -0
  45. package/dist/components/leaked-tool-call-notice.js +14 -0
  46. package/dist/components/leaked-tool-call-notice.js.map +1 -0
  47. package/dist/components/markdown-link.d.ts +17 -0
  48. package/dist/components/markdown-link.d.ts.map +1 -0
  49. package/dist/components/markdown-link.js +18 -0
  50. package/dist/components/markdown-link.js.map +1 -0
  51. package/dist/components/markdown-text.d.ts +26 -0
  52. package/dist/components/markdown-text.d.ts.map +1 -0
  53. package/dist/components/markdown-text.js +32 -0
  54. package/dist/components/markdown-text.js.map +1 -0
  55. package/dist/components/message-list.d.ts +24 -0
  56. package/dist/components/message-list.d.ts.map +1 -0
  57. package/dist/components/message-list.js +92 -0
  58. package/dist/components/message-list.js.map +1 -0
  59. package/dist/components/no-tools-note.d.ts +26 -0
  60. package/dist/components/no-tools-note.d.ts.map +1 -0
  61. package/dist/components/no-tools-note.js +27 -0
  62. package/dist/components/no-tools-note.js.map +1 -0
  63. package/dist/components/panel-header.d.ts +26 -0
  64. package/dist/components/panel-header.d.ts.map +1 -0
  65. package/dist/components/panel-header.js +13 -0
  66. package/dist/components/panel-header.js.map +1 -0
  67. package/dist/components/rehype-highlight-subset.d.ts +17 -0
  68. package/dist/components/rehype-highlight-subset.d.ts.map +1 -0
  69. package/dist/components/rehype-highlight-subset.js +77 -0
  70. package/dist/components/rehype-highlight-subset.js.map +1 -0
  71. package/dist/components/system-message.d.ts +15 -0
  72. package/dist/components/system-message.d.ts.map +1 -0
  73. package/dist/components/system-message.js +12 -0
  74. package/dist/components/system-message.js.map +1 -0
  75. package/dist/components/tool-chip.d.ts +19 -0
  76. package/dist/components/tool-chip.d.ts.map +1 -0
  77. package/dist/components/tool-chip.js +32 -0
  78. package/dist/components/tool-chip.js.map +1 -0
  79. package/dist/components/user-message.d.ts +9 -0
  80. package/dist/components/user-message.d.ts.map +1 -0
  81. package/dist/components/user-message.js +7 -0
  82. package/dist/components/user-message.js.map +1 -0
  83. package/dist/core/conversation-store.d.ts +107 -0
  84. package/dist/core/conversation-store.d.ts.map +1 -0
  85. package/dist/core/conversation-store.js +254 -0
  86. package/dist/core/conversation-store.js.map +1 -0
  87. package/dist/core/error-text.d.ts +19 -0
  88. package/dist/core/error-text.d.ts.map +1 -0
  89. package/dist/core/error-text.js +35 -0
  90. package/dist/core/error-text.js.map +1 -0
  91. package/dist/core/events.d.ts +98 -0
  92. package/dist/core/events.d.ts.map +1 -0
  93. package/dist/core/events.js +50 -0
  94. package/dist/core/events.js.map +1 -0
  95. package/dist/core/format.d.ts +23 -0
  96. package/dist/core/format.d.ts.map +1 -0
  97. package/dist/core/format.js +49 -0
  98. package/dist/core/format.js.map +1 -0
  99. package/dist/core/index.d.ts +27 -0
  100. package/dist/core/index.d.ts.map +1 -0
  101. package/dist/core/index.js +20 -0
  102. package/dist/core/index.js.map +1 -0
  103. package/dist/core/markdown.d.ts +26 -0
  104. package/dist/core/markdown.d.ts.map +1 -0
  105. package/dist/core/markdown.js +49 -0
  106. package/dist/core/markdown.js.map +1 -0
  107. package/dist/core/prose.d.ts +27 -0
  108. package/dist/core/prose.d.ts.map +1 -0
  109. package/dist/core/prose.js +79 -0
  110. package/dist/core/prose.js.map +1 -0
  111. package/dist/core/small-talk.d.ts +15 -0
  112. package/dist/core/small-talk.d.ts.map +1 -0
  113. package/dist/core/small-talk.js +42 -0
  114. package/dist/core/small-talk.js.map +1 -0
  115. package/dist/core/sse.d.ts +108 -0
  116. package/dist/core/sse.d.ts.map +1 -0
  117. package/dist/core/sse.js +236 -0
  118. package/dist/core/sse.js.map +1 -0
  119. package/dist/core/tool-label.d.ts +16 -0
  120. package/dist/core/tool-label.d.ts.map +1 -0
  121. package/dist/core/tool-label.js +31 -0
  122. package/dist/core/tool-label.js.map +1 -0
  123. package/dist/core/transport.d.ts +71 -0
  124. package/dist/core/transport.d.ts.map +1 -0
  125. package/dist/core/transport.js +147 -0
  126. package/dist/core/transport.js.map +1 -0
  127. package/dist/core/turn.d.ts +87 -0
  128. package/dist/core/turn.d.ts.map +1 -0
  129. package/dist/core/turn.js +232 -0
  130. package/dist/core/turn.js.map +1 -0
  131. package/dist/core/uuid.d.ts +21 -0
  132. package/dist/core/uuid.d.ts.map +1 -0
  133. package/dist/core/uuid.js +54 -0
  134. package/dist/core/uuid.js.map +1 -0
  135. package/dist/entries/chat-page.d.ts +28 -0
  136. package/dist/entries/chat-page.d.ts.map +1 -0
  137. package/dist/entries/chat-page.js +25 -0
  138. package/dist/entries/chat-page.js.map +1 -0
  139. package/dist/entries/co-pilot.d.ts +41 -0
  140. package/dist/entries/co-pilot.d.ts.map +1 -0
  141. package/dist/entries/co-pilot.js +39 -0
  142. package/dist/entries/co-pilot.js.map +1 -0
  143. package/dist/entries/lazy-panel.d.ts +15 -0
  144. package/dist/entries/lazy-panel.d.ts.map +1 -0
  145. package/dist/entries/lazy-panel.js +19 -0
  146. package/dist/entries/lazy-panel.js.map +1 -0
  147. package/dist/hooks/index.d.ts +7 -0
  148. package/dist/hooks/index.d.ts.map +1 -0
  149. package/dist/hooks/index.js +4 -0
  150. package/dist/hooks/index.js.map +1 -0
  151. package/dist/hooks/use-agent-chat.d.ts +46 -0
  152. package/dist/hooks/use-agent-chat.d.ts.map +1 -0
  153. package/dist/hooks/use-agent-chat.js +288 -0
  154. package/dist/hooks/use-agent-chat.js.map +1 -0
  155. package/dist/hooks/use-agent-config.d.ts +29 -0
  156. package/dist/hooks/use-agent-config.d.ts.map +1 -0
  157. package/dist/hooks/use-agent-config.js +45 -0
  158. package/dist/hooks/use-agent-config.js.map +1 -0
  159. package/dist/hooks/use-conversations.d.ts +19 -0
  160. package/dist/hooks/use-conversations.d.ts.map +1 -0
  161. package/dist/hooks/use-conversations.js +19 -0
  162. package/dist/hooks/use-conversations.js.map +1 -0
  163. package/dist/index.d.ts +28 -0
  164. package/dist/index.d.ts.map +1 -0
  165. package/dist/index.js +26 -0
  166. package/dist/index.js.map +1 -0
  167. package/dist/styles.css +1 -0
  168. package/package.json +105 -1
  169. package/src/branding.ts +40 -0
  170. package/src/components/approval-card.tsx +141 -0
  171. package/src/components/assistant-message.tsx +140 -0
  172. package/src/components/co-pilot-panel.tsx +109 -0
  173. package/src/components/code-block.tsx +63 -0
  174. package/src/components/collapsible-answer.tsx +55 -0
  175. package/src/components/composer.tsx +147 -0
  176. package/src/components/conversation-list.tsx +141 -0
  177. package/src/components/icons.tsx +189 -0
  178. package/src/components/launcher-fab.tsx +16 -0
  179. package/src/components/leaked-tool-call-notice.tsx +29 -0
  180. package/src/components/markdown-link.tsx +30 -0
  181. package/src/components/markdown-text.tsx +58 -0
  182. package/src/components/message-list.tsx +164 -0
  183. package/src/components/no-tools-note.tsx +33 -0
  184. package/src/components/panel-header.tsx +86 -0
  185. package/src/components/rehype-highlight-subset.ts +84 -0
  186. package/src/components/system-message.tsx +19 -0
  187. package/src/components/tool-chip.tsx +58 -0
  188. package/src/components/user-message.tsx +18 -0
  189. package/src/core/conversation-store.ts +342 -0
  190. package/src/core/error-text.ts +36 -0
  191. package/src/core/events.ts +127 -0
  192. package/src/core/format.ts +59 -0
  193. package/src/core/index.ts +115 -0
  194. package/src/core/markdown.ts +52 -0
  195. package/src/core/prose.ts +78 -0
  196. package/src/core/small-talk.ts +45 -0
  197. package/src/core/sse.ts +269 -0
  198. package/src/core/tool-label.ts +33 -0
  199. package/src/core/transport.ts +208 -0
  200. package/src/core/turn.ts +299 -0
  201. package/src/core/uuid.ts +59 -0
  202. package/src/entries/chat-page.tsx +56 -0
  203. package/src/entries/co-pilot.tsx +84 -0
  204. package/src/entries/lazy-panel.tsx +19 -0
  205. package/src/hooks/index.ts +8 -0
  206. package/src/hooks/use-agent-chat.ts +387 -0
  207. package/src/hooks/use-agent-config.ts +70 -0
  208. package/src/hooks/use-conversations.ts +39 -0
  209. package/src/index.ts +30 -0
  210. package/src/styles/_approval.scss +68 -0
  211. package/src/styles/_composer.scss +45 -0
  212. package/src/styles/_conversations.scss +160 -0
  213. package/src/styles/_messages.scss +444 -0
  214. package/src/styles/_mixins.scss +27 -0
  215. package/src/styles/_panel.scss +147 -0
  216. package/src/styles/_tokens.scss +30 -0
  217. package/src/styles.scss +16 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Two text repairs applied before an answer is rendered as markdown.
3
+ *
4
+ * Both exist because the model decides its own output format and nothing in the
5
+ * charter constrains it: the panel has to read whatever comes back.
6
+ */
7
+
8
+ /** Opening fence of a whole-answer wrapper: ```markdown / ```md, nothing else on the line. */
9
+ const MARKDOWN_FENCE_OPEN = /^`{3,}\s*(?:markdown|md)\s*$/i
10
+ /** Any closing fence line. */
11
+ const FENCE_CLOSE = /^`{3,}\s*$/
12
+
13
+ /**
14
+ * Unwraps an answer the model wrapped entirely in a ```markdown fence.
15
+ *
16
+ * A common quirk: asked for a formatted answer, the model returns the formatted
17
+ * answer inside a fence, and the panel renders the whole reply as one code block
18
+ * — table syntax, bullets and all. Only the whole-answer case is unwrapped, and
19
+ * only when the fence actually closes at the end: an ordinary code fence, an
20
+ * unclosed fence mid-stream, and a fence with prose after it are all left alone.
21
+ */
22
+ export function unwrapWholeAnswerFence(text: string): string {
23
+ const lines = text.trim().split('\n')
24
+ const first = lines[0]
25
+ if (lines.length < 2 || first === undefined || !MARKDOWN_FENCE_OPEN.test(first)) return text
26
+
27
+ // Paired with the last fence line, which is a whole-answer wrapper only if it is
28
+ // also the final line: an unclosed fence, or one with prose after it, is not.
29
+ const last = lines[lines.length - 1]
30
+ if (last === undefined || !FENCE_CLOSE.test(last)) return text
31
+
32
+ return lines.slice(1, -1).join('\n')
33
+ }
34
+
35
+ /**
36
+ * Cuts `text` to roughly `max` characters, on the last paragraph or line break
37
+ * before the limit.
38
+ *
39
+ * Slicing markdown at an arbitrary character can split a fence or a table row and
40
+ * make the preview render as something the answer never said, so the cut lands on
41
+ * a block boundary where there is one within reach.
42
+ */
43
+ export function clipToBoundary(text: string, max: number): string {
44
+ if (text.length <= max) return text
45
+
46
+ const head = text.slice(0, max)
47
+ const paragraph = head.lastIndexOf('\n\n')
48
+ const line = head.lastIndexOf('\n')
49
+ const cut = paragraph > max / 2 ? paragraph : line > max / 2 ? line : max
50
+
51
+ return head.slice(0, cut).trimEnd()
52
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Detects a tool call that leaked into the answer.
3
+ *
4
+ * The agent loop is prompt-based, not native function calling: the model is told
5
+ * to reply with only `{"tool": "<name>", "args": { ... }}`, and
6
+ * `backend/core/agent/src/loop.js` decides prose-vs-tool-call from the first
7
+ * non-whitespace character being `{`. A model that wraps its JSON in a ```json
8
+ * fence therefore leads with a backtick, is classified as prose, and the raw
9
+ * tool call is streamed to the operator as if it were the answer. Measured at
10
+ * roughly one read in eight against `gemma3:4b` — common enough to handle.
11
+ *
12
+ * Rendering that verbatim is the single most confusing failure mode in the
13
+ * stack, so it is detected and labelled instead.
14
+ */
15
+
16
+ const FENCE = /^```[a-z]*\s*|\n?```$/gi
17
+
18
+ /**
19
+ * The first balanced JSON object in `text`, string-aware so a brace inside a
20
+ * string literal does not close it. Mirrors `extractJsonObject` in the producer.
21
+ */
22
+ function extractJsonObject(text: string): string | null {
23
+ const start = text.indexOf('{')
24
+ if (start === -1) return null
25
+
26
+ let depth = 0
27
+ let inString = false
28
+ let escaped = false
29
+
30
+ for (let index = start; index < text.length; index += 1) {
31
+ const char = text[index]
32
+ if (inString) {
33
+ if (escaped) escaped = false
34
+ else if (char === '\\') escaped = true
35
+ else if (char === '"') inString = false
36
+ continue
37
+ }
38
+ if (char === '"') {
39
+ inString = true
40
+ continue
41
+ }
42
+ if (char === '{') depth += 1
43
+ else if (char === '}') {
44
+ depth -= 1
45
+ if (depth === 0) return text.slice(start, index + 1)
46
+ }
47
+ }
48
+ return null
49
+ }
50
+
51
+ export type LeakedToolCall = { tool: string, args: Record<string, unknown> }
52
+
53
+ /**
54
+ * Returns the leaked call when `text` is nothing but a tool call, else null.
55
+ *
56
+ * Deliberately strict: the JSON must be the whole message once code fences are
57
+ * stripped. A genuine answer that merely quotes some JSON keeps rendering as
58
+ * prose.
59
+ */
60
+ export function parseLeakedToolCall(text: string): LeakedToolCall | null {
61
+ const stripped = text.trim().replace(FENCE, '').trim()
62
+ if (!stripped.startsWith('{')) return null
63
+
64
+ const raw = extractJsonObject(stripped)
65
+ if (raw === null || raw !== stripped) return null
66
+
67
+ try {
68
+ const parsed = JSON.parse(raw) as { tool?: unknown, args?: unknown }
69
+ if (typeof parsed.tool !== 'string') return null
70
+ const args
71
+ = typeof parsed.args === 'object' && parsed.args !== null
72
+ ? (parsed.args as Record<string, unknown>)
73
+ : {}
74
+ return { tool: parsed.tool, args }
75
+ } catch {
76
+ return null
77
+ }
78
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Was an operator message conversation rather than a question about the fleet?
3
+ *
4
+ * This exists for one caller: whether an answer is worth marking as having used no tools. The
5
+ * marker is there to catch an answer that should have consulted the fleet and did not — and
6
+ * against a greeting, a thank-you, or "what can you do", calling nothing was the right move, so
7
+ * every one of those was carrying a warning about a failure that had not happened.
8
+ *
9
+ * Deliberately asymmetric. All three gates below must agree before a message counts as small
10
+ * talk, and any fleet word vetoes it outright, because the two mistakes are not equal: missing a
11
+ * greeting costs one redundant marker, while reading a fleet question as small talk hides exactly
12
+ * the warning the marker exists to give.
13
+ */
14
+
15
+ /** Openers where the greeting *is* the message — nothing is being asked of the fleet. */
16
+ const CONVERSATIONAL = [
17
+ /^(?:hi|hey|hello|yo|hiya|howdy|greetings|good (?:morning|afternoon|evening|day))\b/,
18
+ /^(?:how are you|how's it going|what's up|sup)\b/,
19
+ /^(?:thanks|thank you|thx|ty|cheers|nice|great|cool|awesome|perfect|got it|ok|okay|sure|sorry|never mind|nvm)\b/,
20
+ /^(?:bye|goodbye|see you|see ya|later)\b/,
21
+ /^(?:who are you|what are you|what can you do|what do you do|can you help|help)\b/,
22
+ ]
23
+
24
+ /**
25
+ * Words that make a message about the fleet however it opens, so "hey, list the miners" is not
26
+ * small talk. Kept to unambiguous terms: "up" and "down" are fleet state but also "what's up".
27
+ */
28
+ const FLEET_TERMS
29
+ = /\b(?:device|devices|miner|miners|asic|asics|rig|rigs|worker|workers|site|container|containers|pool|pools|sensor|sensors|powermeter|powermeters|power meter|hashrate|hash rate|temperature|telemetry|fleet|rank|reboot|restart|offline|online|status|list|show|count)\b/
30
+
31
+ /** Past this, a message is doing more than saying hello, whatever it opened with. */
32
+ const MAX_WORDS = 6
33
+
34
+ export function isSmallTalk(text: string): boolean {
35
+ const normalized = text
36
+ .toLowerCase()
37
+ .replaceAll('’', '\'') // a typographic apostrophe still spells "what's"
38
+ .trim()
39
+
40
+ if (normalized.length === 0) return false
41
+ if (FLEET_TERMS.test(normalized)) return false
42
+ if (normalized.split(/\s+/).length > MAX_WORDS) return false
43
+
44
+ return CONVERSATIONAL.some((pattern) => pattern.test(normalized))
45
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * SSE reading for the agent message route.
3
+ *
4
+ * `EventSource` is unusable here: the message is a POST body and `EventSource`
5
+ * is GET-only. So this is `fetch` + a body-stream reader, which is also what the
6
+ * producer's own docs prescribe.
7
+ *
8
+ * The gateway writes `event: <type>\ndata: <json>\n\n` and nothing else — no
9
+ * `id:`, no `retry:`, and no heartbeat comment frames. The `event:` line is
10
+ * redundant (`data.type` carries the same value) so only `data:` is parsed. The
11
+ * rest of the parser is nonetheless written against the spec rather than against
12
+ * that one producer: CRLF line endings and multi-line `data:` are both legal, and
13
+ * a proxy that rewrites line endings would otherwise silently end every turn as a
14
+ * truncation.
15
+ */
16
+
17
+ import type { WireEvent } from './events'
18
+ import { EVENT, isTerminal, isWireEvent } from './events'
19
+
20
+ /** A blank line ends a frame; either line ending is legal (spec §7). */
21
+ const FRAME_SEPARATOR = /\r?\n\r?\n/
22
+ const LINE_SEPARATOR = /\r?\n/
23
+ const DATA_PREFIX = 'data:'
24
+
25
+ /** Synthesized locally, never sent by the gateway. See `readAgentStream`. */
26
+ export const ERR_STREAM_TRUNCATED = 'ERR_AGENT_STREAM_TRUNCATED'
27
+ /** Synthesized locally: the socket stayed open but nothing arrived. See `readAgentStream`. */
28
+ export const ERR_STREAM_IDLE = 'ERR_AGENT_STREAM_IDLE'
29
+
30
+ /**
31
+ * How long a turn may say nothing between events before the connection is
32
+ * assumed lost.
33
+ *
34
+ * The gateway sends no heartbeats, so silence is the only symptom of a wedged
35
+ * agent, a half-closed proxy or a laptop resumed from sleep. Once events are
36
+ * flowing, 30s of nothing is already far outside what a working turn does: the
37
+ * producer emits a frame per token.
38
+ */
39
+ export const STREAM_IDLE_TIMEOUT_MS = 30_000
40
+
41
+ /**
42
+ * The same limit before the first event, which is a different wait entirely.
43
+ *
44
+ * Nothing is emitted until the producer's first model call returns a routing
45
+ * decision, and a local model may think for a long time before it answers a
46
+ * single character: measured against a 4B thinking model on this hardware, the
47
+ * first event arrived at 61s. Held to the producer's own per-call budget
48
+ * (`REQUEST_TIMEOUT_MS`, 120s in backend/core/agent/src/constants.js) so a turn
49
+ * the server is still legitimately working on is never abandoned here first —
50
+ * whatever the model does, the server gives up before this does and says why.
51
+ */
52
+ export const FIRST_EVENT_IDLE_TIMEOUT_MS = 120_000
53
+
54
+ /**
55
+ * The same limit while an approval is outstanding, where silence is legitimate:
56
+ * the producer is waiting on a human, and only auto-rejects after its own
57
+ * `agent.approvalTimeoutMs`. Five minutes is that default plus room to spare, so
58
+ * the stream is never given up on before the server itself would have resumed it.
59
+ */
60
+ export const APPROVAL_IDLE_TIMEOUT_MS = 300_000
61
+
62
+ /** Thrown out of `readFrames` when the deadline expires; converted to an event above. */
63
+ export class StreamIdleError extends Error {
64
+ constructor() {
65
+ super(ERR_STREAM_IDLE)
66
+ this.name = 'StreamIdleError'
67
+ }
68
+ }
69
+
70
+ export type ReadFramesOptions = {
71
+ /**
72
+ * Milliseconds of silence tolerated before the next read is given up on, read
73
+ * fresh before every read so the caller can widen it mid-stream. `0` disables
74
+ * the deadline.
75
+ */
76
+ idleTimeoutMs?: () => number
77
+ }
78
+
79
+ /**
80
+ * Pulls the `data:` payload out of one frame.
81
+ *
82
+ * A frame may carry several `data:` lines, which the spec joins with newlines;
83
+ * anything else (`event:`, `id:`, a `:` comment) is ignored. Returns null for a
84
+ * frame with no data at all.
85
+ */
86
+ export function parseFrame(frame: string): unknown {
87
+ const data = frame
88
+ .split(LINE_SEPARATOR)
89
+ .filter((line) => line.startsWith(DATA_PREFIX))
90
+ .map((line) => line.slice(DATA_PREFIX.length).trim())
91
+
92
+ if (data.length === 0) return null
93
+
94
+ try {
95
+ return JSON.parse(data.join('\n'))
96
+ } catch {
97
+ // A frame we cannot parse is indistinguishable from an event type we do not
98
+ // know about, and invariant 5 says ignore those rather than throw.
99
+ return null
100
+ }
101
+ }
102
+
103
+ const IDLE = Symbol('idle')
104
+
105
+ /**
106
+ * One `read()`, given up on after `timeoutMs` of silence.
107
+ *
108
+ * The abandoned read is left attached to a rejection handler on purpose: the
109
+ * caller's `finally` cancels the reader straight after, and an unobserved
110
+ * rejection from a read parked on a socket we just cancelled would surface as a
111
+ * global unhandled rejection.
112
+ */
113
+ async function readWithDeadline(
114
+ reader: ReadableStreamDefaultReader<Uint8Array>,
115
+ timeoutMs: number,
116
+ ): Promise<ReadableStreamReadResult<Uint8Array> | typeof IDLE> {
117
+ const read = reader.read()
118
+ if (timeoutMs <= 0) return read
119
+ read.catch(() => {})
120
+
121
+ let timer: ReturnType<typeof setTimeout> | undefined
122
+ try {
123
+ return await Promise.race([
124
+ read,
125
+ new Promise<typeof IDLE>((resolve) => {
126
+ timer = setTimeout(() => resolve(IDLE), timeoutMs)
127
+ }),
128
+ ])
129
+ } finally {
130
+ clearTimeout(timer)
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Yields each decoded `data:` payload, in order, as frames arrive.
136
+ *
137
+ * The abort signal is handled here rather than left to `fetch`. Aborting a fetch
138
+ * whose headers have already arrived is only specified to error the body stream
139
+ * — cancelling the reader is what actually unblocks a `read()` that is parked
140
+ * waiting for the next token, which is where an abort almost always lands.
141
+ */
142
+ export async function* readFrames(
143
+ stream: ReadableStream<Uint8Array>,
144
+ signal?: AbortSignal,
145
+ options: ReadFramesOptions = {},
146
+ ): AsyncGenerator<unknown> {
147
+ const reader = stream.getReader()
148
+ const decoder = new TextDecoder()
149
+ let buffer = ''
150
+
151
+ const cancel = () => {
152
+ reader.cancel().catch(() => {
153
+ // Already closed or errored; there is nothing left to release.
154
+ })
155
+ }
156
+
157
+ if (signal?.aborted) cancel()
158
+ signal?.addEventListener('abort', cancel, { once: true })
159
+
160
+ const drain = function* (): Generator<unknown> {
161
+ for (;;) {
162
+ const match = FRAME_SEPARATOR.exec(buffer)
163
+ if (!match) return
164
+ const frame = buffer.slice(0, match.index)
165
+ buffer = buffer.slice(match.index + match[0].length)
166
+ const payload = parseFrame(frame)
167
+ if (payload !== null) yield payload
168
+ }
169
+ }
170
+
171
+ try {
172
+ for (;;) {
173
+ const outcome = await readWithDeadline(reader, options.idleTimeoutMs?.() ?? 0)
174
+ if (outcome === IDLE) throw new StreamIdleError()
175
+
176
+ const { value, done } = outcome
177
+ if (done) break
178
+ buffer += decoder.decode(value, { stream: true })
179
+ yield* drain()
180
+ }
181
+
182
+ // Flush whatever the decoder was holding from an incomplete multibyte
183
+ // sequence, then the final frame if it arrived without its blank line.
184
+ buffer += decoder.decode()
185
+ yield* drain()
186
+ const tail = parseFrame(buffer)
187
+ if (tail !== null) yield tail
188
+ } finally {
189
+ signal?.removeEventListener('abort', cancel)
190
+ cancel()
191
+ }
192
+ }
193
+
194
+ export type ReadAgentStreamOptions = {
195
+ /** Silence tolerated between events. Defaults to {@link STREAM_IDLE_TIMEOUT_MS}; `0` disables it. */
196
+ idleTimeoutMs?: number
197
+ /** Silence tolerated while an approval is outstanding. Defaults to {@link APPROVAL_IDLE_TIMEOUT_MS}. */
198
+ approvalIdleTimeoutMs?: number
199
+ /** Silence tolerated before the first event. Defaults to {@link FIRST_EVENT_IDLE_TIMEOUT_MS}. */
200
+ firstEventIdleTimeoutMs?: number
201
+ }
202
+
203
+ /**
204
+ * Turns a live response into the guaranteed-well-formed event sequence the rest
205
+ * of the package is written against. Four contract obligations live here:
206
+ *
207
+ * 1. Unknown or malformed events are dropped, not thrown on (invariant 5).
208
+ * 2. Nothing is emitted after the terminal event (invariant 1), so a
209
+ * misbehaving producer cannot reopen a finished turn.
210
+ * 3. Every stream ends with exactly one terminal event. The gateway's stream
211
+ * route calls `rep.hijack()`, so a failure after the headers are sent just
212
+ * closes the socket — no `error` frame, no `done`. Left alone that reads as a
213
+ * turn that never finishes, so an EOF with no terminal event is converted
214
+ * into one here. An abort is excluded: the operator stopping the turn is not
215
+ * a failure, and nothing should be reported back to them as one.
216
+ * 4. A socket that stays open but stops delivering ends the same way. Without
217
+ * that, a consumer awaiting the next event parks forever and the panel stays
218
+ * busy until the page is reloaded. The deadline is per phase, because how much
219
+ * silence is normal is not constant across a turn: wide before the first event
220
+ * (the model is still thinking), wide again while an approval is outstanding
221
+ * (the producer is waiting on a human), narrow in between.
222
+ */
223
+ export async function* readAgentStream(
224
+ stream: ReadableStream<Uint8Array>,
225
+ signal?: AbortSignal,
226
+ options: ReadAgentStreamOptions = {},
227
+ ): AsyncGenerator<WireEvent> {
228
+ const streamingTimeout = options.idleTimeoutMs ?? STREAM_IDLE_TIMEOUT_MS
229
+ const approvalTimeout = options.approvalIdleTimeoutMs ?? APPROVAL_IDLE_TIMEOUT_MS
230
+ const firstEventTimeout = options.firstEventIdleTimeoutMs ?? FIRST_EVENT_IDLE_TIMEOUT_MS
231
+
232
+ let terminalSeen = false
233
+ let awaitingApproval = false
234
+ let seenAnyEvent = false
235
+ let idle = false
236
+
237
+ const idleTimeoutMs = () => {
238
+ if (streamingTimeout <= 0) return 0
239
+ if (awaitingApproval) return approvalTimeout
240
+ if (!seenAnyEvent) return firstEventTimeout
241
+ return streamingTimeout
242
+ }
243
+
244
+ try {
245
+ for await (const payload of readFrames(stream, signal, { idleTimeoutMs })) {
246
+ if (!isWireEvent(payload)) continue
247
+ if (terminalSeen) continue
248
+ seenAnyEvent = true
249
+ // An approval pauses the producer; its result is what releases it.
250
+ if (payload.type === EVENT.PENDING_APPROVAL) awaitingApproval = true
251
+ if (payload.type === EVENT.TOOL_RESULT) awaitingApproval = false
252
+ if (isTerminal(payload)) terminalSeen = true
253
+ yield payload
254
+ }
255
+ } catch (error) {
256
+ if (!(error instanceof StreamIdleError)) throw error
257
+ idle = true
258
+ }
259
+
260
+ if (!terminalSeen && !signal?.aborted) {
261
+ yield {
262
+ type: EVENT.ERROR,
263
+ error: idle ? ERR_STREAM_IDLE : ERR_STREAM_TRUNCATED,
264
+ turnId: '',
265
+ seq: Number.MAX_SAFE_INTEGER,
266
+ ts: 0,
267
+ }
268
+ }
269
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Human labels for tool chips.
3
+ *
4
+ * Tools are supplied by whichever MCP server the operator points the agent at,
5
+ * so no name can be hardcoded here — `summarize_site` and `get_device` happen to
6
+ * come from the reference site tools, but another deployment ships another set
7
+ * entirely. Callers pass a `toolLabels` map for the names they know; everything
8
+ * else falls back to a readable form of the identifier.
9
+ */
10
+
11
+ export type ToolLabels = Record<string, string>
12
+
13
+ /** `summarize_site` -> `Summarize site`. */
14
+ export function humanizeToolName(name: string): string {
15
+ const words = name
16
+ .replaceAll(/[_-]+/g, ' ')
17
+ .replaceAll(/([a-z\d])([A-Z])/g, '$1 $2')
18
+ .trim()
19
+ if (words.length === 0) return name
20
+ return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase()
21
+ }
22
+
23
+ export function toolLabel(name: string, labels?: ToolLabels): string {
24
+ return labels?.[name] ?? humanizeToolName(name)
25
+ }
26
+
27
+ /** `420` -> `0.4s`, so a chip reads `Site status · 0.4s`. */
28
+ export function formatDuration(durationMs: number | undefined): string | null {
29
+ if (durationMs === undefined || !Number.isFinite(durationMs)) return null
30
+ const seconds = durationMs / 1000
31
+ // Tenths stay readable up to a minute; past that the decimal is noise.
32
+ return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.round(seconds)}s`
33
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The four agent routes, as declared in `backend/apps/agent/mdk-plugin.json`.
3
+ *
4
+ * POST /agent/sessions -> { sessionId }
5
+ * POST /agent/sessions/:id/messages -> text/event-stream
6
+ * POST /agent/sessions/:id/approvals/:approvalId -> { approvalId, approved }
7
+ * DELETE /agent/sessions/:id -> { sessionId, deleted }
8
+ *
9
+ * Note the approval route is session-scoped. `backend/core/agent/docs/CONTRACT.md`
10
+ * documents it as `/agent/turns/{turnId}/approvals/{approvalId}`; that is stale —
11
+ * `turnId` appears in no URL.
12
+ *
13
+ * The gateway sends no CORS headers, and the stream route calls `rep.hijack()` so
14
+ * no CORS hook could decorate it even if one existed. Callers must be same-origin
15
+ * (served from `staticRootPath`) or proxied (`/agent` in the dev server).
16
+ */
17
+
18
+ import type { WireEvent } from './events'
19
+ import type { ReadAgentStreamOptions } from './sse'
20
+ import { readAgentStream } from './sse'
21
+
22
+ export const AGENT_ROUTE_PREFIX = '/agent'
23
+
24
+ /** The `ERR_*` codes these routes can return. */
25
+ export const AGENT_ERROR = {
26
+ SESSION_NOT_FOUND: 'ERR_AGENT_SESSION_NOT_FOUND',
27
+ TURN_ACTIVE: 'ERR_AGENT_TURN_ACTIVE',
28
+ TEXT_REQUIRED: 'ERR_AGENT_MESSAGE_TEXT_REQUIRED',
29
+ UNAVAILABLE: 'ERR_AGENT_UNAVAILABLE',
30
+ APPROVAL_NOT_FOUND: 'ERR_AGENT_APPROVAL_NOT_FOUND',
31
+ } as const
32
+
33
+ export type AgentErrorCode = (typeof AGENT_ERROR)[keyof typeof AGENT_ERROR]
34
+
35
+ export type TokenSource = () => string | null | undefined | Promise<string | null | undefined>
36
+
37
+ export type AgentTransportOptions = {
38
+ /** Gateway origin. Empty string means relative URLs — i.e. same-origin or proxied. */
39
+ baseUrl?: string
40
+ getToken?: TokenSource
41
+ /** Injected in tests; defaults to the global `fetch`. */
42
+ fetchImpl?: typeof fetch
43
+ }
44
+
45
+ /** A gateway error carrying the `ERR_*` code the UI branches on. */
46
+ export class AgentApiError extends Error {
47
+ readonly status: number
48
+ readonly code: string
49
+
50
+ constructor(status: number, code: string, message?: string) {
51
+ super(message ?? code)
52
+ this.name = 'AgentApiError'
53
+ this.status = status
54
+ this.code = code
55
+ }
56
+
57
+ is(code: AgentErrorCode): boolean {
58
+ return this.code === code
59
+ }
60
+ }
61
+
62
+ // Trimmed with a scan rather than /\/+$/: the regex backtracks quadratically on a long run
63
+ // of trailing slashes, which CodeQL flags as a polynomial ReDoS (js/polynomial-redos).
64
+ function stripTrailingSlashes(value: string): string {
65
+ let end = value.length
66
+ while (end > 0 && value[end - 1] === '/') end--
67
+ return value.slice(0, end)
68
+ }
69
+
70
+ function joinUrl(baseUrl: string | undefined, path: string): string {
71
+ if (!baseUrl) return path
72
+ return `${stripTrailingSlashes(baseUrl)}${path}`
73
+ }
74
+
75
+ async function buildHeaders(
76
+ options: AgentTransportOptions,
77
+ extra?: Record<string, string>,
78
+ ): Promise<Headers> {
79
+ const headers = new Headers(extra)
80
+ const token = await options.getToken?.()
81
+ if (token) headers.set('authorization', `Bearer ${token}`)
82
+ return headers
83
+ }
84
+
85
+ /**
86
+ * Both error body shapes, reduced to a code.
87
+ *
88
+ * Normal routes answer `{ statusCode, error, message }`; the streaming route
89
+ * answers `{ statusCode, message }` with no `error` key at all. Either way the
90
+ * `ERR_*` code is in `message`. `ERR_AGENT_UNAVAILABLE` additionally has its
91
+ * reason concatenated in (`"ERR_AGENT_UNAVAILABLE: config.agent missing"`), so
92
+ * the code is only the part before the colon.
93
+ */
94
+ async function toApiError(response: Response): Promise<AgentApiError> {
95
+ let message = response.statusText
96
+ try {
97
+ const body = (await response.json()) as { message?: string }
98
+ if (typeof body?.message === 'string') message = body.message
99
+ } catch {
100
+ // Non-JSON body (or none). The status alone still identifies the failure.
101
+ }
102
+ const code = message.split(':')[0]?.trim() || `HTTP_${response.status}`
103
+ return new AgentApiError(response.status, code, message)
104
+ }
105
+
106
+ async function requestJson<T>(
107
+ path: string,
108
+ init: RequestInit,
109
+ options: AgentTransportOptions,
110
+ ): Promise<T> {
111
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
112
+ const response = await fetchImpl(joinUrl(options.baseUrl, path), init)
113
+ if (!response.ok) throw await toApiError(response)
114
+ return (await response.json()) as T
115
+ }
116
+
117
+ /** Identity comes from the token, so the request body is ignored by the gateway. */
118
+ export async function createSession(options: AgentTransportOptions = {}): Promise<string> {
119
+ const headers = await buildHeaders(options, { 'content-type': 'application/json' })
120
+ const body = await requestJson<{ sessionId: string }>(
121
+ `${AGENT_ROUTE_PREFIX}/sessions`,
122
+ { method: 'POST', headers, body: '{}' },
123
+ options,
124
+ )
125
+ return body.sessionId
126
+ }
127
+
128
+ export async function deleteSession(
129
+ sessionId: string,
130
+ options: AgentTransportOptions = {},
131
+ ): Promise<void> {
132
+ const headers = await buildHeaders(options)
133
+ await requestJson(
134
+ `${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}`,
135
+ { method: 'DELETE', headers },
136
+ options,
137
+ )
138
+ }
139
+
140
+ /**
141
+ * Answers a paused approval. The decision travels on its own request while the
142
+ * message stream stays open — the stream resumes on its own once this lands.
143
+ *
144
+ * The gateway tests `approved === true`, so anything else is a rejection. It is
145
+ * passed through as a real boolean rather than coerced, to keep that explicit.
146
+ *
147
+ * A decision that arrives after the approval timed out, or a second decision for
148
+ * the same approval, is a 404 `ERR_AGENT_APPROVAL_NOT_FOUND` — the two are
149
+ * deliberately indistinguishable. Callers should treat it as "already settled",
150
+ * not as a failure.
151
+ */
152
+ export async function decideApproval(
153
+ sessionId: string,
154
+ approvalId: string,
155
+ approved: boolean,
156
+ options: AgentTransportOptions = {},
157
+ ): Promise<void> {
158
+ const headers = await buildHeaders(options, { 'content-type': 'application/json' })
159
+ await requestJson(
160
+ `${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}`
161
+ + `/approvals/${encodeURIComponent(approvalId)}`,
162
+ { method: 'POST', headers, body: JSON.stringify({ approved }) },
163
+ options,
164
+ )
165
+ }
166
+
167
+ export type StreamTurnOptions = AgentTransportOptions & ReadAgentStreamOptions & {
168
+ signal?: AbortSignal
169
+ }
170
+
171
+ /**
172
+ * Sends one message and yields its events until the turn ends.
173
+ *
174
+ * Only one turn may be in flight per session; a second send while this is
175
+ * running — including while it is paused on an approval, since a paused turn
176
+ * still holds the session busy — throws `ERR_AGENT_TURN_ACTIVE`.
177
+ */
178
+ export async function* streamTurn(
179
+ sessionId: string,
180
+ text: string,
181
+ options: StreamTurnOptions = {},
182
+ ): AsyncGenerator<WireEvent> {
183
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
184
+ const headers = await buildHeaders(options, {
185
+ 'content-type': 'application/json',
186
+ 'accept': 'text/event-stream',
187
+ })
188
+
189
+ const response = await fetchImpl(
190
+ joinUrl(options.baseUrl, `${AGENT_ROUTE_PREFIX}/sessions/${encodeURIComponent(sessionId)}/messages`),
191
+ { method: 'POST', headers, body: JSON.stringify({ text }), signal: options.signal },
192
+ )
193
+
194
+ // 404 / 409 / 400 are all raised before the SSE headers go out, so they arrive
195
+ // as an ordinary JSON body rather than as an event on the stream.
196
+ if (!response.ok) throw await toApiError(response)
197
+ if (!response.body) throw new AgentApiError(response.status, 'ERR_AGENT_STREAM_EMPTY')
198
+
199
+ yield* readAgentStream(response.body, options.signal, {
200
+ ...(options.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: options.idleTimeoutMs }),
201
+ ...(options.approvalIdleTimeoutMs === undefined
202
+ ? {}
203
+ : { approvalIdleTimeoutMs: options.approvalIdleTimeoutMs }),
204
+ ...(options.firstEventIdleTimeoutMs === undefined
205
+ ? {}
206
+ : { firstEventIdleTimeoutMs: options.firstEventIdleTimeoutMs }),
207
+ })
208
+ }