agent-relay-server 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/index.html CHANGED
@@ -10136,6 +10136,37 @@ var persistImpl = (config, baseOptions) => (set, get, api) => {
10136
10136
  };
10137
10137
  var persist = persistImpl;
10138
10138
  //#endregion
10139
+ //#region ../sdk/src/sse.ts
10140
+ /**
10141
+ * Parse a single SSE frame — the text between blank-line (`\n\n`) separators —
10142
+ * into its event name, data lines, and optional retry hint. Spec-compliant
10143
+ * field parsing: normalises CRLF, skips comment (`:`-prefixed) lines, splits each
10144
+ * line at the first colon, and strips one leading space from the value.
10145
+ */
10146
+ function parseSseFrame(frame) {
10147
+ let event = "message";
10148
+ const data = [];
10149
+ let retry;
10150
+ for (const rawLine of frame.replace(/\r\n/g, "\n").split("\n")) {
10151
+ if (!rawLine || rawLine.startsWith(":")) continue;
10152
+ const sep = rawLine.indexOf(":");
10153
+ const field = sep >= 0 ? rawLine.slice(0, sep) : rawLine;
10154
+ let value = sep >= 0 ? rawLine.slice(sep + 1) : "";
10155
+ if (value.startsWith(" ")) value = value.slice(1);
10156
+ if (field === "event") event = value;
10157
+ else if (field === "data") data.push(value);
10158
+ else if (field === "retry") {
10159
+ const next = Number(value);
10160
+ if (Number.isFinite(next) && next >= 1e3) retry = next;
10161
+ }
10162
+ }
10163
+ return {
10164
+ event,
10165
+ data,
10166
+ retry
10167
+ };
10168
+ }
10169
+ //#endregion
10139
10170
  //#region src/lib/api.ts
10140
10171
  var authToken = "";
10141
10172
  function setAuthToken(token) {
@@ -10190,21 +10221,8 @@ function openRelayEventStream(token, handlers) {
10190
10221
  reconnectTimer = setTimeout(connect, retryMs);
10191
10222
  };
10192
10223
  const dispatchFrame = (frame) => {
10193
- let event = "message";
10194
- const data = [];
10195
- for (const rawLine of frame.replace(/\r\n/g, "\n").split("\n")) {
10196
- if (!rawLine || rawLine.startsWith(":")) continue;
10197
- const separator = rawLine.indexOf(":");
10198
- const field = separator >= 0 ? rawLine.slice(0, separator) : rawLine;
10199
- let value = separator >= 0 ? rawLine.slice(separator + 1) : "";
10200
- if (value.startsWith(" ")) value = value.slice(1);
10201
- if (field === "event") event = value;
10202
- else if (field === "data") data.push(value);
10203
- else if (field === "retry") {
10204
- const nextRetry = Number(value);
10205
- if (Number.isFinite(nextRetry) && nextRetry >= 1e3) retryMs = nextRetry;
10206
- }
10207
- }
10224
+ const { event, data, retry } = parseSseFrame(frame);
10225
+ if (retry) retryMs = retry;
10208
10226
  if (data.length > 0) handlers.message(event, data.join("\n"));
10209
10227
  };
10210
10228
  const connect = async () => {
@@ -10330,25 +10348,27 @@ async function apiBlob(path) {
10330
10348
  return response.blob();
10331
10349
  }
10332
10350
  //#endregion
10333
- //#region src/lib/voice.ts
10351
+ //#region ../sdk/src/speech-text.ts
10334
10352
  /**
10335
- * Browser voice I/O for chat.
10353
+ * Shared text speech preparation, imported by both the dashboard
10354
+ * (`dashboard/src/lib/voice.ts`) and the voice connector
10355
+ * (`connectors/voice/src/text.ts`). Single source of truth — these rules used to
10356
+ * live duplicated in both places and drifted.
10336
10357
  *
10337
- * TTS (output) is 100% browser-native via the Web Speech API — the store
10338
- * already receives every agent response turn, so there is no backend round-trip.
10339
- * STT (input) records the mic and posts the clip to the voice connector's
10340
- * whisper endpoint through the relay proxy (single-origin, auth-gated).
10358
+ * Two stages, applied in order by {@link prepareForSpeech}:
10359
+ * 1. {@link speechify} collapse markdown/code structure into prose worth hearing.
10360
+ * 2. {@link normalizeForSpeech} rule-based normalization of inline tokens
10361
+ * (numbers, units, symbols, code identifiers, paths) so the speech engine
10362
+ * reads them naturally.
10341
10363
  *
10342
- * Speaker policy ("active chat owns the speaker"):
10343
- * - One utterance at a time. Disabled => silent.
10344
- * - Only the active chat speaks, and only responses that arrive while it is
10345
- * active (no backlog replay).
10346
- * - Switching away lets the current utterance finish but drops the now-background
10347
- * chat's queued + future responses.
10348
- * - The active chat preempts: if it speaks while a previous chat's audio lingers,
10349
- * that audio is cancelled.
10364
+ * Why both, and why here: the server-side Kokoro engine (agent-speech) already
10365
+ * normalizes some of this internally (number ranges, decimals, currency) via its
10366
+ * phonemizer but that normalization does NOT run for the browser Web Speech
10367
+ * fallback, and the dashboard's own sentence splitter trips on decimals before
10368
+ * the engine ever sees them. Normalizing here fixes the browser path and protects
10369
+ * the splitter; feeding already-normalized text to Kokoro is harmless (idempotent).
10350
10370
  */
10351
- /** Collapse markdown/code into something worth hearing (mirrors the connector's text.ts). */
10371
+ /** Collapse markdown/code into something worth hearing. */
10352
10372
  function speechify(markdown) {
10353
10373
  if (!markdown) return "";
10354
10374
  let text = markdown.replace(/\r\n/g, "\n");
@@ -10357,6 +10377,7 @@ function speechify(markdown) {
10357
10377
  return ` code block, ${lines} ${lines === 1 ? "line" : "lines"}. `;
10358
10378
  });
10359
10379
  text = text.replace(/```[^\n]*/g, " code block. ");
10380
+ text = collapseTables(text);
10360
10381
  text = text.replace(/^#{1,6}\s+/gm, "");
10361
10382
  text = text.replace(/^\s*[-*+]\s+/gm, ". ");
10362
10383
  text = text.replace(/^\s*\d+\.\s+/gm, ". ");
@@ -10368,6 +10389,127 @@ function speechify(markdown) {
10368
10389
  text = text.replace(/\s*\.\s*\.\s*(\.\s*)+/g, ". ");
10369
10390
  return text.trim();
10370
10391
  }
10392
+ /** A markdown table row: `| a | b |` or `a | b`. The separator row is `|---|:-:|`. */
10393
+ function isTableRow(line) {
10394
+ return /\|/.test(line) && line.trim().length > 0;
10395
+ }
10396
+ function isTableSeparator(line) {
10397
+ return /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line);
10398
+ }
10399
+ function tableCells(line) {
10400
+ return line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()).filter((c) => c.length > 0);
10401
+ }
10402
+ /**
10403
+ * Replace contiguous markdown tables with a spoken summary
10404
+ * ("table with columns A, B, C; N rows.") rather than reading pipes and cells.
10405
+ */
10406
+ function collapseTables(text) {
10407
+ const lines = text.split("\n");
10408
+ const out = [];
10409
+ let i = 0;
10410
+ while (i < lines.length) {
10411
+ if (isTableRow(lines[i]) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
10412
+ const headers = tableCells(lines[i]);
10413
+ let j = i + 2;
10414
+ let rows = 0;
10415
+ while (j < lines.length && isTableRow(lines[j]) && !isTableSeparator(lines[j])) {
10416
+ rows++;
10417
+ j++;
10418
+ }
10419
+ const cols = headers.length ? `with columns ${headers.join(", ")}` : "";
10420
+ const rowText = `${rows} ${rows === 1 ? "row" : "rows"}`;
10421
+ out.push(`table ${cols ? `${cols}, ` : ""}${rowText}.`.replace(/\s+/g, " ").trim());
10422
+ i = j;
10423
+ continue;
10424
+ }
10425
+ out.push(lines[i]);
10426
+ i++;
10427
+ }
10428
+ return out.join("\n");
10429
+ }
10430
+ var UNIT_WORDS = {
10431
+ ms: "milliseconds",
10432
+ s: "seconds",
10433
+ kb: "kilobytes",
10434
+ mb: "megabytes",
10435
+ gb: "gigabytes",
10436
+ tb: "terabytes"
10437
+ };
10438
+ /** Spell out a fractional part digit-by-digit: "14" → "1 4" (so "3.14" → "3 point 1 4"). */
10439
+ function spaceDigits(frac) {
10440
+ return frac.split("").join(" ");
10441
+ }
10442
+ /**
10443
+ * Ordered normalization rules. ORDER IS LOAD-BEARING:
10444
+ * - URLs/paths first, before anything mangles their slashes/dots.
10445
+ * - `~` → "approximately" before number rules consume the digits after it.
10446
+ * - number ranges ("5-7") before unit expansion, so the unit attaches once: "5 to 7 seconds".
10447
+ * - decimals before unit expansion and before the sentence splitter sees the dot.
10448
+ * - unit expansion last among the number rules.
10449
+ */
10450
+ var SPEECH_RULES = [
10451
+ {
10452
+ name: "url",
10453
+ pattern: /\bhttps?:\/\/\S+/gi,
10454
+ replace: " link "
10455
+ },
10456
+ {
10457
+ name: "path",
10458
+ pattern: /(?:\/[A-Za-z0-9._-]+){2,}\/?/g,
10459
+ replace: (m) => " " + m.replace(/[/._-]+/g, " ").trim() + " "
10460
+ },
10461
+ {
10462
+ name: "func-call",
10463
+ pattern: /\b([A-Za-z_$][\w$]*)\(\)/g,
10464
+ replace: "$1"
10465
+ },
10466
+ {
10467
+ name: "approx",
10468
+ pattern: /~(?=\s*[\d.])/g,
10469
+ replace: "approximately "
10470
+ },
10471
+ {
10472
+ name: "range",
10473
+ pattern: /(\d)\s*[-–—]\s*(?=\d)/g,
10474
+ replace: "$1 to "
10475
+ },
10476
+ {
10477
+ name: "decimal",
10478
+ pattern: /\b(\d+)\.(\d+)\b/g,
10479
+ replace: (_m, i, f) => `${i} point ${spaceDigits(f)}`
10480
+ },
10481
+ {
10482
+ name: "byte-unit",
10483
+ pattern: /\b(\d+)\s?(kb|mb|gb|tb)\b/gi,
10484
+ replace: (_m, n, u) => `${n} ${UNIT_WORDS[u.toLowerCase()]}`
10485
+ },
10486
+ {
10487
+ name: "ms-unit",
10488
+ pattern: /\b(\d{1,5})\s?ms\b/g,
10489
+ replace: (_m, n) => `${n} ${UNIT_WORDS.ms}`
10490
+ },
10491
+ {
10492
+ name: "s-unit",
10493
+ pattern: /\b(\d{1,3})\s?s\b/g,
10494
+ replace: (_m, n) => `${n} ${UNIT_WORDS.s}`
10495
+ }
10496
+ ];
10497
+ /**
10498
+ * Apply the inline normalization rules in order. Pure and idempotent enough to
10499
+ * feed straight into either the Kokoro engine or the browser Web Speech API.
10500
+ */
10501
+ function normalizeForSpeech(text) {
10502
+ if (!text) return "";
10503
+ let out = text;
10504
+ for (const rule of SPEECH_RULES) out = out.replace(rule.pattern, rule.replace);
10505
+ return out.replace(/[ \t]{2,}/g, " ").trim();
10506
+ }
10507
+ /** Full pipeline: markdown → prose → normalized speech text. */
10508
+ function prepareForSpeech(markdown) {
10509
+ return normalizeForSpeech(speechify(markdown));
10510
+ }
10511
+ //#endregion
10512
+ //#region src/lib/voice.ts
10371
10513
  var MAX_CHUNK = 220;
10372
10514
  /** Split into utterance-sized chunks (sentence boundaries; hard-split very long runs). */
10373
10515
  function chunkForSpeech(text) {
@@ -10402,12 +10544,31 @@ var VoiceTts = class {
10402
10544
  gen = 0;
10403
10545
  audioEl = null;
10404
10546
  audioUrl = null;
10547
+ playingKey = null;
10548
+ manual = false;
10549
+ listeners = /* @__PURE__ */ new Set();
10405
10550
  get available() {
10406
10551
  return synthAvailable || typeof Audio !== "undefined";
10407
10552
  }
10408
10553
  isEnabled() {
10409
10554
  return this.enabled;
10410
10555
  }
10556
+ /** Subscribe to manual-playback changes (useSyncExternalStore). */
10557
+ subscribe(fn) {
10558
+ this.listeners.add(fn);
10559
+ return () => {
10560
+ this.listeners.delete(fn);
10561
+ };
10562
+ }
10563
+ /** Id of the bubble currently played on demand, or null. */
10564
+ getPlayingKey() {
10565
+ return this.playingKey;
10566
+ }
10567
+ setPlayingKey(key) {
10568
+ if (key === this.playingKey) return;
10569
+ this.playingKey = key;
10570
+ for (const fn of this.listeners) fn();
10571
+ }
10411
10572
  setEnabled(on) {
10412
10573
  if (on === this.enabled) return;
10413
10574
  this.enabled = on;
@@ -10433,9 +10594,9 @@ var VoiceTts = class {
10433
10594
  /** A captured agent response turn arrived for `chatId`. */
10434
10595
  onResponse(chatId, rawText) {
10435
10596
  if (!this.enabled || !this.available || !chatId || chatId !== this.active) return;
10436
- const text = speechify(rawText);
10597
+ const text = prepareForSpeech(rawText);
10437
10598
  if (!text) return;
10438
- if (this.speaking && this.currentChat && this.currentChat !== chatId) {
10599
+ if (this.speaking && (this.manual || this.currentChat && this.currentChat !== chatId)) {
10439
10600
  this.queue = [];
10440
10601
  this.cancel();
10441
10602
  }
@@ -10445,6 +10606,30 @@ var VoiceTts = class {
10445
10606
  });
10446
10607
  this.pump();
10447
10608
  }
10609
+ /**
10610
+ * Play an arbitrary response on demand (chat-bubble play button), independent
10611
+ * of which chat is active. Preempts whatever is currently playing.
10612
+ */
10613
+ speak(rawText, key) {
10614
+ if (!this.available) return;
10615
+ const text = prepareForSpeech(rawText);
10616
+ if (!text) return;
10617
+ this.reset();
10618
+ this.manual = true;
10619
+ this.speaking = true;
10620
+ this.currentChat = null;
10621
+ this.setPlayingKey(key ?? null);
10622
+ const gen = ++this.gen;
10623
+ const chunks = chunkForSpeech(text);
10624
+ const done = () => {
10625
+ if (gen !== this.gen) return;
10626
+ this.speaking = false;
10627
+ this.manual = false;
10628
+ this.setPlayingKey(null);
10629
+ };
10630
+ if (this.mode === "kokoro") this.speakKokoro(chunks, 0, gen, done);
10631
+ else this.speakBrowser(chunks, 0, gen, done);
10632
+ }
10448
10633
  /** Cut all speech immediately (e.g. when the user starts talking). */
10449
10634
  bargeIn() {
10450
10635
  this.reset();
@@ -10457,6 +10642,8 @@ var VoiceTts = class {
10457
10642
  this.gen++;
10458
10643
  this.currentChat = null;
10459
10644
  this.speaking = false;
10645
+ this.manual = false;
10646
+ this.setPlayingKey(null);
10460
10647
  try {
10461
10648
  window.speechSynthesis.cancel();
10462
10649
  } catch {}
@@ -10479,6 +10666,7 @@ var VoiceTts = class {
10479
10666
  const item = this.queue.shift();
10480
10667
  if (!item) return;
10481
10668
  this.speaking = true;
10669
+ this.manual = false;
10482
10670
  this.currentChat = item.chatId;
10483
10671
  const gen = ++this.gen;
10484
10672
  const chunks = chunkForSpeech(item.text);
@@ -11285,6 +11473,12 @@ function emptyAttention() {
11285
11473
  score: 0
11286
11474
  };
11287
11475
  }
11476
+ function channelIsReady(channel) {
11477
+ if (!channel || channel.status === "offline") return false;
11478
+ if (channel.targetHealth?.status === "ok") return true;
11479
+ if (channel.targetHealth?.status === "error" || channel.targetHealth?.status === "warning") return false;
11480
+ return channel.ready;
11481
+ }
11288
11482
  function channelPresence(channel) {
11289
11483
  if (!channel) return {
11290
11484
  label: "unknown",
@@ -11310,18 +11504,18 @@ function channelPresence(channel) {
11310
11504
  icon: "PlugZap",
11311
11505
  badges: []
11312
11506
  };
11313
- if (!channel.ready) return {
11314
- label: "not ready",
11315
- tone: "warning",
11316
- icon: "Loader",
11317
- badges: []
11318
- };
11319
11507
  if (channel.status === "busy") return {
11320
11508
  label: "busy",
11321
11509
  tone: "warning",
11322
11510
  icon: "Activity",
11323
11511
  badges: []
11324
11512
  };
11513
+ if (!channelIsReady(channel)) return {
11514
+ label: "not ready",
11515
+ tone: "warning",
11516
+ icon: "Loader",
11517
+ badges: []
11518
+ };
11325
11519
  return {
11326
11520
  label: "ready",
11327
11521
  tone: "success",
@@ -12732,15 +12926,15 @@ var useRelayStore = create$1()(persist((set, get) => ({
12732
12926
  if (event === "connected") return;
12733
12927
  if (event === "message.new") {
12734
12928
  const msg = JSON.parse(data);
12929
+ if (msg.kind === "session" && msg.from !== "user") {
12930
+ const sess = msg.payload?.session;
12931
+ if (sess?.type === "response" && sess?.origin === "provider") voiceTts.onResponse(inboxPeer(msg), msg.body);
12932
+ }
12735
12933
  const s = get();
12736
12934
  if (s.messages.some((m) => m.id === msg.id)) return;
12737
12935
  const msgs = [...s.messages, msg];
12738
12936
  if (msgs.length > 500) msgs.splice(0, msgs.length - 500);
12739
12937
  set({ messages: msgs });
12740
- if (msg.kind === "session" && msg.from !== "user") {
12741
- const sess = msg.payload?.session;
12742
- if (sess?.type === "response" && sess?.origin === "provider") voiceTts.onResponse(inboxPeer(msg), msg.body);
12743
- }
12744
12938
  const peer = inboxPeer(msg);
12745
12939
  if (isHumanInboundMessage(msg) && peer && s.view === "chat" && s.selectedInboxThread === peer && !isDashboardHidden()) get().markInboxThreadReadTo(peer, msg.id);
12746
12940
  return;
@@ -126475,9 +126669,14 @@ function AddReaction({ open, onToggle, onReact }) {
126475
126669
  })]
126476
126670
  });
126477
126671
  }
126672
+ function useTtsPlayingKey() {
126673
+ return (0, import_react.useSyncExternalStore)((cb) => voiceTts.subscribe(cb), () => voiceTts.getPlayingKey(), () => null);
126674
+ }
126478
126675
  var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, onOpenReferencedPath, onPreviewReferencedPath, onPreviewReferencedPathEnd }) {
126479
126676
  const isOutbound = msg.from === HUMAN_AGENT_ID;
126480
126677
  const reactToMessage = useRelayStore((s) => s.reactToMessage);
126678
+ const voiceTtsEnabled = useRelayStore((s) => s.voiceTtsEnabled);
126679
+ const ttsPlayingKey = useTtsPlayingKey();
126481
126680
  const peerCwd = useRelayStore((s) => {
126482
126681
  const cwd = s.agentsById[peer]?.meta?.cwd;
126483
126682
  return typeof cwd === "string" ? cwd : "";
@@ -126492,6 +126691,13 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
126492
126691
  const reactions = groupedReactions(msg);
126493
126692
  const receipt = isOutbound ? outboundReceipt(msg, peer) : null;
126494
126693
  const ReceiptIcon = receipt?.icon;
126694
+ const isTtsPlaying = ttsPlayingKey === String(msg.id);
126695
+ const showPlayButton = voiceTtsEnabled && !isOutbound && !isReactionEvent(msg) && body.trim().length > 0;
126696
+ function togglePlay(e) {
126697
+ e.stopPropagation();
126698
+ if (isTtsPlaying) voiceTts.bargeIn();
126699
+ else voiceTts.speak(msg.body, String(msg.id));
126700
+ }
126495
126701
  (0, import_react.useEffect)(() => {
126496
126702
  if (!showQuickReact) return;
126497
126703
  function dismiss(e) {
@@ -126543,11 +126749,19 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
126543
126749
  setShowQuickReact((v) => !v);
126544
126750
  }
126545
126751
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
126752
+ "data-msg-id": msg.id,
126546
126753
  className: cn$2("group/msg flex mb-3", isOutbound ? "justify-end" : "justify-start"),
126547
126754
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
126548
126755
  ref: bubbleRef,
126549
126756
  className: "relative max-w-[85%] md:max-w-[75%]",
126550
126757
  children: [
126758
+ showPlayButton && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
126759
+ type: "button",
126760
+ title: isTtsPlaying ? "Stop playback" : "Play aloud",
126761
+ onClick: togglePlay,
126762
+ className: cn$2("absolute -top-2 -right-2 z-20 inline-flex h-6 w-6 items-center justify-center rounded-full border bg-popover shadow-sm transition", isTtsPlaying ? "border-primary/50 text-primary opacity-100" : "border-border text-muted-foreground opacity-70 hover:bg-muted hover:text-foreground hover:opacity-100 md:opacity-0 md:group-hover/msg:opacity-100"),
126763
+ children: isTtsPlaying ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "h-3.5 w-3.5 animate-pulse" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Volume2, { className: "h-3.5 w-3.5" })
126764
+ }),
126551
126765
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
126552
126766
  className: cn$2("rounded-2xl px-3.5 py-2 text-sm select-text", !isOutbound && !isReactionEvent(msg) && "cursor-pointer", isOutbound ? "bg-primary text-primary-foreground rounded-br-sm" : "bg-card ring-1 ring-foreground/10 rounded-bl-sm"),
126553
126767
  onPointerDown: handleBubblePointerDown,
@@ -126778,6 +126992,109 @@ function sameActivityTurn(a, b) {
126778
126992
  if (at && bt) return at === bt;
126779
126993
  return !at && !bt;
126780
126994
  }
126995
+ function StickyPromptBanner({ scrollRef, timeline }) {
126996
+ const [hidden, setHidden] = (0, import_react.useState)(true);
126997
+ const [expanded, setExpanded] = (0, import_react.useState)(false);
126998
+ const [collapsed, setCollapsed] = (0, import_react.useState)(false);
126999
+ const [promptAbove, setPromptAbove] = (0, import_react.useState)(true);
127000
+ const lastOutbound = (0, import_react.useMemo)(() => {
127001
+ for (let i = timeline.length - 1; i >= 0; i--) {
127002
+ const entry = timeline[i];
127003
+ if (!entry || entry.type !== "message") continue;
127004
+ if (entry.msg.from === "user") return {
127005
+ id: entry.msg.id,
127006
+ body: messageBody(entry.msg)
127007
+ };
127008
+ }
127009
+ return null;
127010
+ }, [timeline]);
127011
+ (0, import_react.useEffect)(() => {
127012
+ const scrollEl = scrollRef.current;
127013
+ if (!lastOutbound || !scrollEl) {
127014
+ setHidden(true);
127015
+ return;
127016
+ }
127017
+ const target = scrollEl.querySelector(`[data-msg-id="${lastOutbound.id}"]`);
127018
+ if (!target) {
127019
+ setHidden(true);
127020
+ return;
127021
+ }
127022
+ const observer = new IntersectionObserver(([e]) => {
127023
+ if (!e) return;
127024
+ setHidden(e.isIntersecting);
127025
+ if (!e.isIntersecting) {
127026
+ const rootTop = e.rootBounds?.top ?? scrollEl.getBoundingClientRect().top;
127027
+ setPromptAbove(e.boundingClientRect.top < rootTop);
127028
+ }
127029
+ }, {
127030
+ root: scrollEl,
127031
+ threshold: 0
127032
+ });
127033
+ observer.observe(target);
127034
+ const onScroll = () => {
127035
+ const r = target.getBoundingClientRect();
127036
+ const sr = scrollEl.getBoundingClientRect();
127037
+ setPromptAbove(r.top < sr.top);
127038
+ };
127039
+ scrollEl.addEventListener("scroll", onScroll, { passive: true });
127040
+ return () => {
127041
+ observer.disconnect();
127042
+ scrollEl.removeEventListener("scroll", onScroll);
127043
+ };
127044
+ }, [lastOutbound?.id, scrollRef]);
127045
+ (0, import_react.useEffect)(() => {
127046
+ setExpanded(false);
127047
+ setCollapsed(false);
127048
+ }, [lastOutbound?.id]);
127049
+ if (hidden || !lastOutbound || !lastOutbound.body.trim()) return null;
127050
+ function scrollToPrompt() {
127051
+ const scrollEl = scrollRef.current;
127052
+ if (!scrollEl || !lastOutbound) return;
127053
+ scrollEl.querySelector(`[data-msg-id="${lastOutbound.id}"]`)?.scrollIntoView({
127054
+ behavior: "smooth",
127055
+ block: "center"
127056
+ });
127057
+ }
127058
+ const arrow = promptAbove ? "↑" : "↓";
127059
+ if (collapsed) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
127060
+ className: "sticky top-0 z-10 -mx-3 md:-mx-4 -mt-3 md:-mt-4 bg-background",
127061
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
127062
+ type: "button",
127063
+ className: "w-full flex items-center justify-center gap-1.5 py-1 text-[11px] text-primary/60 hover:text-primary border-b border-primary/10 transition-colors",
127064
+ onClick: () => setCollapsed(false),
127065
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: "Your prompt" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChevronDown, { className: "w-3 h-3" })]
127066
+ })
127067
+ });
127068
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
127069
+ className: "sticky top-0 z-10 -mx-3 md:-mx-4 -mt-3 md:-mt-4 px-2 md:px-3 pt-2 pb-1.5 bg-background border-b border-primary/10",
127070
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
127071
+ className: "flex items-center gap-2 text-[11px] text-muted-foreground mb-0.5",
127072
+ children: [
127073
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
127074
+ className: "font-medium text-primary/70",
127075
+ children: "Your prompt"
127076
+ }),
127077
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("button", {
127078
+ type: "button",
127079
+ className: "ml-auto hover:text-foreground transition-colors",
127080
+ onClick: scrollToPrompt,
127081
+ children: [arrow, " scroll to original"]
127082
+ }),
127083
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
127084
+ type: "button",
127085
+ className: "hover:text-foreground transition-colors",
127086
+ onClick: () => setCollapsed(true),
127087
+ title: "Minimize",
127088
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ChevronUp, { className: "w-3 h-3" })
127089
+ })
127090
+ ]
127091
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
127092
+ className: cn$2("text-sm text-foreground leading-snug cursor-pointer", !expanded && "line-clamp-2"),
127093
+ onClick: () => setExpanded((e) => !e),
127094
+ children: lastOutbound.body
127095
+ })]
127096
+ });
127097
+ }
126781
127098
  function ChatPanel({ threads, onBack, showBackButton }) {
126782
127099
  const selectedInboxThread = useRelayStore((s) => s.selectedInboxThread);
126783
127100
  const agentsById = useRelayStore((s) => s.agentsById);
@@ -127438,6 +127755,10 @@ function ChatPanel({ threads, onBack, showBackButton }) {
127438
127755
  children: "No messages yet"
127439
127756
  })]
127440
127757
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
127758
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StickyPromptBanner, {
127759
+ scrollRef: pinnedScroll.ref,
127760
+ timeline
127761
+ }),
127441
127762
  timeline.map((entry) => {
127442
127763
  if (entry.type === "message") return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MessageBubble, {
127443
127764
  msg: entry.msg,
@@ -129872,7 +130193,7 @@ function ChannelCard({ channel }) {
129872
130193
  }
129873
130194
  function ChannelsView() {
129874
130195
  const channels = useRelayStore((s) => s.channels);
129875
- const readyCount = channels.filter((c) => c.status !== "offline" && c.ready).length;
130196
+ const readyCount = channels.filter(channelIsReady).length;
129876
130197
  const errorCount = channels.filter((c) => c.targetHealth?.status === "error").length;
129877
130198
  const warningCount = channels.filter((c) => c.targetHealth?.status === "warning").length;
129878
130199
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -157589,6 +157910,29 @@ function AgentProfileModal() {
157589
157910
  ...partial
157590
157911
  } });
157591
157912
  }
157913
+ function updateCodexToolOutputTokenLimit(value) {
157914
+ const providerOptions = profile.providerOptions || {};
157915
+ const codex = { ...providerOptions.codex && typeof providerOptions.codex === "object" && !Array.isArray(providerOptions.codex) ? providerOptions.codex : {} };
157916
+ if (!value.trim()) delete codex.toolOutputTokenLimit;
157917
+ else {
157918
+ const limit = Number(value);
157919
+ if (!Number.isSafeInteger(limit)) return;
157920
+ codex.toolOutputTokenLimit = limit;
157921
+ }
157922
+ updateProfileModal({ providerOptions: {
157923
+ ...providerOptions,
157924
+ codex
157925
+ } });
157926
+ }
157927
+ function updateMaxSpawnedAgents(value) {
157928
+ if (!value.trim()) {
157929
+ updateProfileModal({ maxSpawnedAgents: void 0 });
157930
+ return;
157931
+ }
157932
+ const n = Number(value);
157933
+ if (!Number.isInteger(n) || n < 0) return;
157934
+ updateProfileModal({ maxSpawnedAgents: Math.min(n, 100) });
157935
+ }
157592
157936
  function selectBase(base) {
157593
157937
  const isHost = base === "host";
157594
157938
  updateProfileModal({
@@ -157615,6 +157959,8 @@ function AgentProfileModal() {
157615
157959
  }
157616
157960
  });
157617
157961
  }
157962
+ const codexOptions = profile.providerOptions?.codex && typeof profile.providerOptions.codex === "object" && !Array.isArray(profile.providerOptions.codex) ? profile.providerOptions.codex : {};
157963
+ const codexToolOutputTokenLimit = Number.isSafeInteger(codexOptions.toolOutputTokenLimit) ? String(codexOptions.toolOutputTokenLimit) : "";
157618
157964
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Dialog, {
157619
157965
  open,
157620
157966
  onOpenChange: (o) => !o && closeProfileModal(),
@@ -157752,6 +158098,27 @@ function AgentProfileModal() {
157752
158098
  })] })]
157753
158099
  })
157754
158100
  }),
158101
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, {
158102
+ title: "Spawning",
158103
+ defaultOpen: false,
158104
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
158105
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Spawn Quota" }),
158106
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
158107
+ type: "number",
158108
+ min: 0,
158109
+ max: 100,
158110
+ step: 1,
158111
+ value: profile.maxSpawnedAgents === void 0 ? "" : String(profile.maxSpawnedAgents),
158112
+ onChange: (e) => updateMaxSpawnedAgents(e.target.value),
158113
+ placeholder: "0",
158114
+ disabled: readOnly
158115
+ }),
158116
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
158117
+ className: "mt-1 text-xs text-muted-foreground",
158118
+ children: "Max concurrent live child agents this profile may spawn. Empty or 0 = cannot spawn (default). Children never inherit it (no grandchildren)."
158119
+ })
158120
+ ] })
158121
+ }),
157755
158122
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, {
157756
158123
  title: "Environment Variables",
157757
158124
  defaultOpen: false,
@@ -157761,10 +158128,18 @@ function AgentProfileModal() {
157761
158128
  disabled: readOnly
157762
158129
  })
157763
158130
  }),
157764
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Section, {
158131
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Section, {
157765
158132
  title: "Provider Options",
157766
158133
  defaultOpen: false,
157767
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Textarea, {
158134
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Codex Tool Output Limit" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
158135
+ type: "number",
158136
+ min: 1e3,
158137
+ max: 2e5,
158138
+ value: codexToolOutputTokenLimit,
158139
+ onChange: (e) => updateCodexToolOutputTokenLimit(e.target.value),
158140
+ placeholder: "12000",
158141
+ disabled: readOnly
158142
+ })] }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Textarea, {
157768
158143
  value: JSON.stringify(profile.providerOptions || {}, null, 2),
157769
158144
  onChange: (e) => {
157770
158145
  try {
@@ -157775,7 +158150,7 @@ function AgentProfileModal() {
157775
158150
  disabled: readOnly,
157776
158151
  className: "font-mono text-xs",
157777
158152
  placeholder: "{}"
157778
- })
158153
+ })]
157779
158154
  })
157780
158155
  ]
157781
158156
  }),
@@ -158607,6 +158982,10 @@ if ("serviceWorker" in navigator) {
158607
158982
  inset-block: calc(var(--spacing) * 0);
158608
158983
  }
158609
158984
 
158985
+ .-top-2 {
158986
+ top: calc(var(--spacing) * -2);
158987
+ }
158988
+
158610
158989
  .top-0 {
158611
158990
  top: calc(var(--spacing) * 0);
158612
158991
  }
@@ -158635,6 +159014,10 @@ if ("serviceWorker" in navigator) {
158635
159014
  right: calc(var(--spacing) * -.5);
158636
159015
  }
158637
159016
 
159017
+ .-right-2 {
159018
+ right: calc(var(--spacing) * -2);
159019
+ }
159020
+
158638
159021
  .right-2 {
158639
159022
  right: calc(var(--spacing) * 2);
158640
159023
  }
@@ -158769,6 +159152,10 @@ if ("serviceWorker" in navigator) {
158769
159152
  margin-inline: calc(var(--spacing) * -1);
158770
159153
  }
158771
159154
 
159155
+ .-mx-3 {
159156
+ margin-inline: calc(var(--spacing) * -3);
159157
+ }
159158
+
158772
159159
  .-mx-4 {
158773
159160
  margin-inline: calc(var(--spacing) * -4);
158774
159161
  }
@@ -158793,6 +159180,10 @@ if ("serviceWorker" in navigator) {
158793
159180
  margin-top: calc(var(--spacing) * -1);
158794
159181
  }
158795
159182
 
159183
+ .-mt-3 {
159184
+ margin-top: calc(var(--spacing) * -3);
159185
+ }
159186
+
158796
159187
  .mt-0\.5 {
158797
159188
  margin-top: calc(var(--spacing) * .5);
158798
159189
  }
@@ -160183,6 +160574,16 @@ if ("serviceWorker" in navigator) {
160183
160574
  }
160184
160575
  }
160185
160576
 
160577
+ .border-primary\/10 {
160578
+ border-color: var(--primary);
160579
+ }
160580
+
160581
+ @supports (color: color-mix(in lab, red, red)) {
160582
+ .border-primary\/10 {
160583
+ border-color: color-mix(in oklab, var(--primary) 10%, transparent);
160584
+ }
160585
+ }
160586
+
160186
160587
  .border-primary\/25 {
160187
160588
  border-color: var(--primary);
160188
160589
  }
@@ -161223,6 +161624,10 @@ if ("serviceWorker" in navigator) {
161223
161624
  padding-bottom: calc(var(--spacing) * 1);
161224
161625
  }
161225
161626
 
161627
+ .pb-1\.5 {
161628
+ padding-bottom: calc(var(--spacing) * 1.5);
161629
+ }
161630
+
161226
161631
  .pb-2 {
161227
161632
  padding-bottom: calc(var(--spacing) * 2);
161228
161633
  }
@@ -162440,6 +162845,10 @@ if ("serviceWorker" in navigator) {
162440
162845
  .hover\:opacity-80:hover {
162441
162846
  opacity: .8;
162442
162847
  }
162848
+
162849
+ .hover\:opacity-100:hover {
162850
+ opacity: 1;
162851
+ }
162443
162852
  }
162444
162853
 
162445
162854
  .focus\:bg-accent:focus {
@@ -163007,6 +163416,14 @@ if ("serviceWorker" in navigator) {
163007
163416
  margin: calc(var(--spacing) * -6);
163008
163417
  }
163009
163418
 
163419
+ .md\:-mx-4 {
163420
+ margin-inline: calc(var(--spacing) * -4);
163421
+ }
163422
+
163423
+ .md\:-mt-4 {
163424
+ margin-top: calc(var(--spacing) * -4);
163425
+ }
163426
+
163010
163427
  .md\:block {
163011
163428
  display: block;
163012
163429
  }
@@ -163094,6 +163511,10 @@ if ("serviceWorker" in navigator) {
163094
163511
  padding: calc(var(--spacing) * 6);
163095
163512
  }
163096
163513
 
163514
+ .md\:px-3 {
163515
+ padding-inline: calc(var(--spacing) * 3);
163516
+ }
163517
+
163097
163518
  .md\:px-4 {
163098
163519
  padding-inline: calc(var(--spacing) * 4);
163099
163520
  }
@@ -163115,6 +163536,10 @@ if ("serviceWorker" in navigator) {
163115
163536
  text-wrap: pretty;
163116
163537
  }
163117
163538
 
163539
+ .md\:opacity-0 {
163540
+ opacity: 0;
163541
+ }
163542
+
163118
163543
  @media (hover: hover) {
163119
163544
  .md\:group-hover\/msg\:pointer-events-auto:is(:where(.group\/msg):hover *) {
163120
163545
  pointer-events: auto;