@mitralab.io/platform-sdk 1.1.0 → 1.1.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.
package/README.md CHANGED
@@ -246,7 +246,9 @@ unsubscribe()
246
246
  session.close()
247
247
  ```
248
248
 
249
- Open an existing task with `session({ taskId })`. The default `auto` transport refreshes before connecting, opens `/copilot/ws/tasks/{taskId}`, and performs at most one safe recovery through persisted history plus the HTTP/SSE channel. Set `transport: "http"` when WebSockets are unavailable. Messages sent during a turn enter a FIFO queue with a maximum of 10 items; the session also exposes edit, remove, clear, approval, cancel, history, close, and typed events.
249
+ Open an existing task with `session({ taskId })`. The default `auto` transport refreshes before connecting, asks the Copilot where the chat is served, and opens whichever channel it names: the box that runs the agent when one is offered, `/copilot/ws/tasks/{taskId}` otherwise. The choice belongs to the server, never to application configuration, so a Copilot that stops offering the box leaves every application on the Copilot socket without a republish. Opening the box never asks it to replay, whether the conversation is new to the session or is being opened again after an idle close: what an idle conversation missed is history, which the application loads over REST. A box socket that drops in the middle of a turn is redialed by the SDK itself, with a bounded backoff (1, 2, 4, 8 and 16 seconds) and the replay from the last position seen on that socket, so the answer keeps streaming; a position taken on one box is forgotten when the Copilot points the conversation to another box; replayed `textChunk` frames reach the `delta` event like live text. While that happens the session emits `raw` events of type `channelReconnecting` (payload `attempt`, `maxAttempts`, `reason`) and `channelConnected` (payload `attempt`); the session `status` stays `streaming`, because Core has no reconnecting status. The disconnect reaches Core, and with it the `error` event, only when the attempts run out or the Copilot no longer offers the box. A drop with no turn in flight, a channel taken over by another tab (close code 4409), and any close of the Copilot socket the session did not ask for are reported at once, whatever the code, so the next send reopens the channel instead of waiting on a socket that is gone. Recovery through persisted history plus the HTTP/SSE channel is unchanged. Set `transport: "http"` when WebSockets are unavailable. Messages sent during a turn enter a FIFO queue with a maximum of 10 items; the session also exposes edit, remove, clear, approval, cancel, history, close, and typed events.
250
+
251
+ A prompt sent while the browser says it is offline (`navigator.onLine === false`) does not enter Core at all, because Core opens the channel and reads the turn baseline before the prompt goes out and every one of those requests would fail first: the session keeps the prompt in an outbox, emits a `raw` event of type `inputUnsent` (payload `attempt` 0, `reason`, `waitingForOnline` true) and hands it to Core, in order, when the browser fires `online`; the turn starts then. A prompt whose `POST /inputs` went out but got no response at all is kept the same way: the session sends the same request again when the browser fires `online` or on a bounded backoff (2, 5, 10, 20 and 40 seconds), while the turn stays `streaming`, with `inputUnsent` events (payload `attempt`, `reason`, `waitingForOnline`, `retryInMs` when a timer is armed) and, once it goes through, `inputSent` (payload `attempts`). A response from the server, an error included, is never retried: the prompt fails through the `error` event as before. A session closed with a prompt still waiting emits `error` with code `INPUT_UNSENT` before it goes quiet, and `sendAndWait` rejects. The retry cannot tell a request the server never received from one whose response was lost on the way back, so a prompt may reach the server twice in that case; the Copilot's `/inputs` accepts no client message id yet. The public `agentTasks.sendInput` primitive is not held back: it fails immediately, as it always did.
250
252
 
251
253
  API keys and removal accept `ANTHROPIC` or `OPENAI`. OAuth accepts only `ANTHROPIC`; device authorization accepts only `OPENAI`. The facade enforces those producer-supported pairs in TypeScript and at runtime.
252
254
 
package/dist/index.cjs CHANGED
@@ -1460,8 +1460,230 @@ var QueriesModule = class {
1460
1460
  // src/modules/agent-tasks.ts
1461
1461
  var import_sdk_core8 = require("@mitralab.io/sdk-core");
1462
1462
 
1463
+ // src/modules/agent-outbox.ts
1464
+ var OUTBOX_DELAYS_MS = [2e3, 5e3, 1e4, 2e4, 4e4];
1465
+ var browserNetwork = {
1466
+ isOffline: () => globalThis.navigator?.onLine === false,
1467
+ onOnline: (listener) => {
1468
+ const target = globalThis;
1469
+ if (typeof target.addEventListener !== "function") return () => void 0;
1470
+ target.addEventListener("online", listener);
1471
+ return () => target.removeEventListener?.("online", listener);
1472
+ }
1473
+ };
1474
+ function sendGotNoResponse(error) {
1475
+ return error instanceof TypeError;
1476
+ }
1477
+ function reasonOf(error) {
1478
+ return error instanceof Error ? error.message : String(error);
1479
+ }
1480
+ function neverSent(pending) {
1481
+ return new Error(
1482
+ `Agent prompt was never sent: the session closed while it waited for the network (${pending} pending).`
1483
+ );
1484
+ }
1485
+ var AgentInputOutbox = class {
1486
+ constructor(tasks, announce, network = browserNetwork) {
1487
+ this.tasks = tasks;
1488
+ this.announce = announce;
1489
+ this.network = network;
1490
+ }
1491
+ held = /* @__PURE__ */ new Map();
1492
+ gated = /* @__PURE__ */ new Map();
1493
+ async sendInput(taskId, input) {
1494
+ try {
1495
+ await this.tasks.sendInput(taskId, input);
1496
+ } catch (error) {
1497
+ if (input.type !== "message" || !sendGotNoResponse(error)) throw error;
1498
+ await this.hold(taskId, input, error);
1499
+ }
1500
+ }
1501
+ /** The session is closing: a prompt still waiting is reported, never dropped in silence. */
1502
+ abandon(taskId) {
1503
+ const pending = this.held.get(taskId);
1504
+ if (!pending?.length) return;
1505
+ this.held.delete(taskId);
1506
+ this.drop(taskId, pending);
1507
+ }
1508
+ /** True when the browser says there is no network at all. */
1509
+ get offline() {
1510
+ return this.network.isOffline();
1511
+ }
1512
+ /**
1513
+ * Keeps a prompt out of the core until the browser reports the network is back, then hands it
1514
+ * over through `deliver`. Prompts held for one session leave in the order they arrived. The
1515
+ * promise settles when the prompt is handed over, or rejects if the session closes first.
1516
+ */
1517
+ holdUntilOnline(session, taskId, deliver) {
1518
+ return new Promise((resolve, reject) => {
1519
+ const prompt = { resolve, reject, disarm: () => void 0 };
1520
+ this.gated.set(session, [...this.gated.get(session) ?? [], prompt]);
1521
+ if (taskId) {
1522
+ this.announce(taskId, {
1523
+ type: "inputUnsent",
1524
+ payload: { attempt: 0, reason: "The browser is offline.", waitingForOnline: true },
1525
+ timestamp: Date.now()
1526
+ });
1527
+ }
1528
+ const offOnline = this.network.onOnline(() => {
1529
+ prompt.disarm();
1530
+ const rest = (this.gated.get(session) ?? []).filter((candidate) => candidate !== prompt);
1531
+ if (rest.length) this.gated.set(session, rest);
1532
+ else this.gated.delete(session);
1533
+ deliver();
1534
+ resolve();
1535
+ });
1536
+ prompt.disarm = () => {
1537
+ offOnline();
1538
+ prompt.disarm = () => void 0;
1539
+ };
1540
+ });
1541
+ }
1542
+ /** The session is closing with prompts that never entered the core: same report as `abandon`. */
1543
+ abandonGated(session, taskId) {
1544
+ const pending = this.gated.get(session);
1545
+ if (!pending?.length) return;
1546
+ this.gated.delete(session);
1547
+ this.drop(taskId, pending);
1548
+ }
1549
+ drop(taskId, pending) {
1550
+ const error = neverSent(pending.length);
1551
+ if (taskId) {
1552
+ this.announce(taskId, {
1553
+ type: "error",
1554
+ payload: { code: "INPUT_UNSENT", message: error.message },
1555
+ timestamp: Date.now()
1556
+ });
1557
+ }
1558
+ for (const prompt of pending) {
1559
+ prompt.disarm();
1560
+ prompt.reject(error);
1561
+ }
1562
+ }
1563
+ hold(taskId, input, cause) {
1564
+ return new Promise((resolve, reject) => {
1565
+ const prompt = { input, attempts: 1, resolve, reject, disarm: () => void 0 };
1566
+ this.held.set(taskId, [...this.held.get(taskId) ?? [], prompt]);
1567
+ this.schedule(taskId, prompt, cause);
1568
+ });
1569
+ }
1570
+ schedule(taskId, prompt, cause) {
1571
+ const waitingForOnline = this.network.isOffline();
1572
+ const retryInMs = waitingForOnline ? void 0 : OUTBOX_DELAYS_MS[prompt.attempts - 1];
1573
+ if (!waitingForOnline && retryInMs === void 0) {
1574
+ this.release(taskId, prompt);
1575
+ prompt.reject(new Error(
1576
+ `Agent prompt was not sent after ${prompt.attempts} attempts: ${reasonOf(cause)}`
1577
+ ));
1578
+ return;
1579
+ }
1580
+ this.announce(taskId, {
1581
+ type: "inputUnsent",
1582
+ payload: {
1583
+ attempt: prompt.attempts,
1584
+ reason: reasonOf(cause),
1585
+ waitingForOnline,
1586
+ ...retryInMs === void 0 ? {} : { retryInMs }
1587
+ },
1588
+ timestamp: Date.now()
1589
+ });
1590
+ const retry = () => {
1591
+ prompt.disarm();
1592
+ void this.retry(taskId, prompt);
1593
+ };
1594
+ const timer = retryInMs === void 0 ? null : globalThis.setTimeout(retry, retryInMs);
1595
+ const offOnline = this.network.onOnline(retry);
1596
+ prompt.disarm = () => {
1597
+ if (timer !== null) globalThis.clearTimeout(timer);
1598
+ offOnline();
1599
+ prompt.disarm = () => void 0;
1600
+ };
1601
+ }
1602
+ async retry(taskId, prompt) {
1603
+ prompt.attempts += 1;
1604
+ try {
1605
+ await this.tasks.sendInput(taskId, prompt.input);
1606
+ } catch (error) {
1607
+ if (!this.isHeld(taskId, prompt)) return;
1608
+ if (!sendGotNoResponse(error)) {
1609
+ this.release(taskId, prompt);
1610
+ prompt.reject(error instanceof Error ? error : new Error(String(error)));
1611
+ return;
1612
+ }
1613
+ this.schedule(taskId, prompt, error);
1614
+ return;
1615
+ }
1616
+ if (!this.isHeld(taskId, prompt)) return;
1617
+ this.release(taskId, prompt);
1618
+ this.announce(taskId, {
1619
+ type: "inputSent",
1620
+ payload: { attempts: prompt.attempts },
1621
+ timestamp: Date.now()
1622
+ });
1623
+ prompt.resolve();
1624
+ }
1625
+ isHeld(taskId, prompt) {
1626
+ return this.held.get(taskId)?.includes(prompt) ?? false;
1627
+ }
1628
+ release(taskId, prompt) {
1629
+ const rest = (this.held.get(taskId) ?? []).filter((candidate) => candidate !== prompt);
1630
+ if (rest.length) this.held.set(taskId, rest);
1631
+ else this.held.delete(taskId);
1632
+ }
1633
+ };
1634
+ function holdSendsWhileOffline(session, outbox) {
1635
+ const passThrough = (prompt) => !outbox.offline || !prompt.trim() || session.status === "closed";
1636
+ const gated = {
1637
+ send: (prompt, options) => {
1638
+ if (passThrough(prompt)) {
1639
+ session.send(prompt, options);
1640
+ return;
1641
+ }
1642
+ void outbox.holdUntilOnline(session, session.taskId, () => session.send(prompt, options)).catch(() => void 0);
1643
+ },
1644
+ sendAndWait: (prompt, options) => {
1645
+ if (passThrough(prompt)) return session.sendAndWait(prompt, options);
1646
+ let turn;
1647
+ return outbox.holdUntilOnline(session, session.taskId, () => {
1648
+ turn = session.sendAndWait(prompt, options);
1649
+ }).then(() => turn);
1650
+ },
1651
+ close: () => {
1652
+ outbox.abandonGated(session, session.taskId);
1653
+ session.close();
1654
+ }
1655
+ };
1656
+ return new Proxy(session, {
1657
+ get: (target, key) => key in gated ? gated[key] : Reflect.get(target, key, target)
1658
+ });
1659
+ }
1660
+
1463
1661
  // src/modules/agent-session.ts
1464
1662
  var CONNECT_TIMEOUT_MS = 15e3;
1663
+ var CHANNEL_BOOT_TIMEOUT_MS = 9e4;
1664
+ var CHANNEL_BOOT_RETRY_MS = 2e3;
1665
+ var SILENCE_TIMEOUT_MS = 6e4;
1666
+ var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 16e3];
1667
+ var SUPERSEDED_CLOSE_CODE = 4409;
1668
+ var SilenceWatchdog = class {
1669
+ constructor(onSilence) {
1670
+ this.onSilence = onSilence;
1671
+ }
1672
+ timer = null;
1673
+ touch() {
1674
+ this.clear();
1675
+ this.timer = globalThis.setTimeout(this.onSilence, SILENCE_TIMEOUT_MS);
1676
+ }
1677
+ clear() {
1678
+ if (this.timer !== null) {
1679
+ globalThis.clearTimeout(this.timer);
1680
+ this.timer = null;
1681
+ }
1682
+ }
1683
+ };
1684
+ function silenceError(transport) {
1685
+ return new Error(`Agent ${transport} went silent for ${SILENCE_TIMEOUT_MS / 1e3}s.`);
1686
+ }
1465
1687
  function stripBearer2(token) {
1466
1688
  return token.replace(/^Bearer\s+/i, "");
1467
1689
  }
@@ -1480,6 +1702,37 @@ function expectEvent(value) {
1480
1702
  ...typeof event.sequence === "number" ? { sequence: event.sequence } : {}
1481
1703
  };
1482
1704
  }
1705
+ function isSameGateway(candidate, apiUrl) {
1706
+ try {
1707
+ const target = new URL(candidate);
1708
+ if (target.protocol !== "ws:" && target.protocol !== "wss:") return false;
1709
+ return target.host === new URL(apiUrl).host;
1710
+ } catch {
1711
+ return false;
1712
+ }
1713
+ }
1714
+ function boxAddress(wsUrl) {
1715
+ const url = new URL(wsUrl);
1716
+ return `${url.origin}${url.pathname}`;
1717
+ }
1718
+ function toDirectChannel(body, apiUrl) {
1719
+ if (typeof body !== "object" || body === null) return null;
1720
+ const channel = body;
1721
+ if (typeof channel.wsUrl !== "string" || !isSameGateway(channel.wsUrl, apiUrl)) return null;
1722
+ return {
1723
+ wsUrl: channel.wsUrl,
1724
+ lastSequence: typeof channel.lastSequence === "number" && channel.lastSequence > 0 ? channel.lastSequence : 0
1725
+ };
1726
+ }
1727
+ function sleep(ms, signal) {
1728
+ return new Promise((resolve) => {
1729
+ const timer = globalThis.setTimeout(resolve, ms);
1730
+ signal?.addEventListener("abort", () => {
1731
+ globalThis.clearTimeout(timer);
1732
+ resolve();
1733
+ }, { once: true });
1734
+ });
1735
+ }
1483
1736
  function parseEvent(raw) {
1484
1737
  if (typeof raw !== "string") return expectEvent(raw);
1485
1738
  try {
@@ -1488,8 +1741,25 @@ function parseEvent(raw) {
1488
1741
  return null;
1489
1742
  }
1490
1743
  }
1491
- function isNormalClose(event) {
1492
- return event.code === 1e3 || event.code === 4409;
1744
+ function asDelta(event) {
1745
+ if (event.type !== "textChunk") return event;
1746
+ const kind = asObject(event.payload)?.kind;
1747
+ return { ...event, type: kind === "thinking" ? "thinking" : "textDelta" };
1748
+ }
1749
+ var TURN_FRAME_TYPES = /* @__PURE__ */ new Set(["textDelta", "thinking", "toolCall", "toolResult"]);
1750
+ var TURN_END_REASONS = /* @__PURE__ */ new Set(["stop", "endTurn", "interrupted"]);
1751
+ function turnAfter(event, inTurn) {
1752
+ if (TURN_FRAME_TYPES.has(event.type)) return true;
1753
+ if (event.type === "error") return false;
1754
+ if (event.type === "stepFinish") {
1755
+ const payload = asObject(event.payload);
1756
+ if (TURN_END_REASONS.has(payload?.reason)) return false;
1757
+ return asObject(payload?.lifecycle)?.interruptTerminal !== true;
1758
+ }
1759
+ return inTurn;
1760
+ }
1761
+ function channelEvent(type, payload) {
1762
+ return { type, payload, timestamp: Date.now() };
1493
1763
  }
1494
1764
  var BrowserAgentTaskEventSource = class {
1495
1765
  constructor(auth, apiUrl) {
@@ -1498,6 +1768,18 @@ var BrowserAgentTaskEventSource = class {
1498
1768
  }
1499
1769
  apiUrl;
1500
1770
  sseFallbackTasks = /* @__PURE__ */ new Set();
1771
+ // Tasks whose last WebSocket was the box itself. A box socket that closes says nothing about
1772
+ // WebSockets: the box went idle or the channel was superseded, and the answer is to ask the
1773
+ // copilot for the channel again. Only the copilot's own socket failing sends a task to SSE.
1774
+ directTasks = /* @__PURE__ */ new Set();
1775
+ /**
1776
+ * Ate onde esta sessao ja viu o log da caixa, por conversa, e de qual caixa esse log e. So o
1777
+ * redial no meio de um turno pede repeticao a partir daqui; abrir a conversa de novo nunca
1778
+ * pede. O cursor vale para uma caixa: outra caixa tem outro log, e um cursor da anterior
1779
+ * repetiria turnos antigos como se fossem texto ao vivo.
1780
+ */
1781
+ boxCursors = /* @__PURE__ */ new Map();
1782
+ boxAddresses = /* @__PURE__ */ new Map();
1501
1783
  async open(taskId, observer, signal, transport = "auto") {
1502
1784
  if (transport === "http") return this.openSse(taskId, observer, signal);
1503
1785
  if (transport === "websocket") return this.openWebSocket(taskId, observer, signal);
@@ -1508,13 +1790,13 @@ var BrowserAgentTaskEventSource = class {
1508
1790
  const connection = await this.openWebSocket(taskId, {
1509
1791
  ...observer,
1510
1792
  onDisconnect: (error) => {
1511
- this.sseFallbackTasks.add(taskId);
1793
+ if (!this.directTasks.has(taskId)) this.sseFallbackTasks.add(taskId);
1512
1794
  observer.onDisconnect(error);
1513
1795
  }
1514
1796
  }, signal);
1515
1797
  return this.wrapAutoConnection(taskId, connection);
1516
1798
  } catch {
1517
- this.sseFallbackTasks.add(taskId);
1799
+ if (!this.directTasks.has(taskId)) this.sseFallbackTasks.add(taskId);
1518
1800
  return this.wrapAutoConnection(taskId, await this.openSse(taskId, observer, signal));
1519
1801
  }
1520
1802
  }
@@ -1522,6 +1804,9 @@ var BrowserAgentTaskEventSource = class {
1522
1804
  return {
1523
1805
  close: () => {
1524
1806
  this.sseFallbackTasks.delete(taskId);
1807
+ this.directTasks.delete(taskId);
1808
+ this.boxCursors.delete(taskId);
1809
+ this.boxAddresses.delete(taskId);
1525
1810
  connection.close();
1526
1811
  }
1527
1812
  };
@@ -1534,13 +1819,161 @@ var BrowserAgentTaskEventSource = class {
1534
1819
  }
1535
1820
  return token;
1536
1821
  }
1822
+ /**
1823
+ * Pergunta ao copilot onde esta conversa e servida. Nada aqui e fatal: uma recusa, uma
1824
+ * resposta que nao entendemos ou uma rede que falhou significam apenas que a conversa segue
1825
+ * pelo socket do copilot, que e como toda conversa era servida antes da caixa existir.
1826
+ */
1827
+ requestDirectChannel(taskId, token, signal) {
1828
+ return this.askDirectChannel(taskId, token, signal).catch(() => null);
1829
+ }
1830
+ /** Like `requestDirectChannel`, but a request the network lost is thrown, not a refusal. */
1831
+ async askDirectChannel(taskId, token, signal) {
1832
+ const url = `${this.apiUrl}/copilot/api/v1/tasks/${encodeURIComponent(taskId)}/channel`;
1833
+ const deadline = Date.now() + CHANNEL_BOOT_TIMEOUT_MS;
1834
+ for (; ; ) {
1835
+ if (signal?.aborted) return null;
1836
+ const response = await globalThis.fetch(url, {
1837
+ method: "POST",
1838
+ headers: { Authorization: `Bearer ${stripBearer2(token)}` },
1839
+ ...signal ? { signal } : {}
1840
+ });
1841
+ if (response.status === 202) {
1842
+ if (Date.now() >= deadline) return null;
1843
+ await sleep(CHANNEL_BOOT_RETRY_MS, signal);
1844
+ continue;
1845
+ }
1846
+ if (!response.ok) return null;
1847
+ try {
1848
+ return toDirectChannel(await response.json(), this.apiUrl);
1849
+ } catch {
1850
+ return null;
1851
+ }
1852
+ }
1853
+ }
1537
1854
  async openWebSocket(taskId, observer, signal) {
1538
1855
  if (typeof globalThis.WebSocket !== "function") {
1539
1856
  throw new TypeError("WebSocket is not available.");
1540
1857
  }
1541
1858
  if (signal?.aborted) throw signal.reason ?? new Error("Agent WebSocket connection aborted.");
1542
1859
  const token = await this.requireFreshToken();
1860
+ const direct = await this.requestDirectChannel(taskId, token, signal);
1861
+ if (direct) this.directTasks.add(taskId);
1862
+ else this.directTasks.delete(taskId);
1863
+ if (direct) return this.openDirect(taskId, direct, observer, signal);
1543
1864
  const url = `${this.apiUrl.replace(/^http/i, "ws")}/copilot/ws/tasks/${encodeURIComponent(taskId)}?token=${encodeURIComponent(stripBearer2(token))}`;
1865
+ const socket = await this.dial(url, {
1866
+ signal,
1867
+ onFrame: (event) => observer.onEvent(event),
1868
+ onLost: (error) => observer.onDisconnect(error)
1869
+ });
1870
+ return { close: () => socket.close() };
1871
+ }
1872
+ /** Forgets the cursor when the copilot points the conversation to a box other than the last dialed. */
1873
+ adoptBox(taskId, channel) {
1874
+ const box = boxAddress(channel.wsUrl);
1875
+ if (this.boxAddresses.get(taskId) === box) return;
1876
+ this.boxAddresses.set(taskId, box);
1877
+ this.boxCursors.delete(taskId);
1878
+ }
1879
+ /**
1880
+ * The box path. A drop in the middle of a turn is redialed from here, with the replay the
1881
+ * box offers, so the core keeps one connection and one stream: reporting the drop instead
1882
+ * would make it reconcile the turn from persisted history, which is the screen freezing and
1883
+ * the rest of the answer landing at once. The core hears a disconnect only when the redial
1884
+ * gives up, when the copilot no longer offers the box, or when there is no turn to resume:
1885
+ * the sandbox pauses an idle box, and dialing it again would wake it for nobody.
1886
+ *
1887
+ * Opening never asks for a replay, whether the conversation is new to this session or was
1888
+ * open before. What an idle conversation missed is history, which the app loads by REST. Beta
1889
+ * 2026-09-14: a tab open overnight had its box replaced, and the open that followed replayed
1890
+ * the old box's cursor against the new log, which put every old turn on screen as live text.
1891
+ */
1892
+ async openDirect(taskId, channel, observer, signal) {
1893
+ this.adoptBox(taskId, channel);
1894
+ this.boxCursors.set(taskId, channel.lastSequence);
1895
+ const link = new AbortController();
1896
+ const onAbort = () => link.abort(signal?.reason);
1897
+ signal?.addEventListener("abort", onAbort, { once: true });
1898
+ let inTurn = false;
1899
+ let current = null;
1900
+ const onFrame = (event) => {
1901
+ if (typeof event.sequence === "number") {
1902
+ const seen = this.boxCursors.get(taskId) ?? 0;
1903
+ if (event.sequence > seen) this.boxCursors.set(taskId, event.sequence);
1904
+ }
1905
+ inTurn = turnAfter(event, inTurn);
1906
+ observer.onEvent(event);
1907
+ };
1908
+ const onLost = (error, code) => {
1909
+ current = null;
1910
+ if (link.signal.aborted) return;
1911
+ if (!inTurn || code === SUPERSEDED_CLOSE_CODE) {
1912
+ observer.onDisconnect(error);
1913
+ return;
1914
+ }
1915
+ void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then((socket) => {
1916
+ current = socket;
1917
+ });
1918
+ };
1919
+ current = await this.dial(channel.wsUrl, { signal: link.signal, onFrame, onLost });
1920
+ return {
1921
+ close: () => {
1922
+ signal?.removeEventListener("abort", onAbort);
1923
+ link.abort();
1924
+ current?.close();
1925
+ }
1926
+ };
1927
+ }
1928
+ async redial(taskId, cause, signal, observer, handlers) {
1929
+ let lastError = cause;
1930
+ for (const [index, delayMs] of RECONNECT_DELAYS_MS.entries()) {
1931
+ const attempt = index + 1;
1932
+ observer.onEvent(channelEvent("channelReconnecting", {
1933
+ attempt,
1934
+ maxAttempts: RECONNECT_DELAYS_MS.length,
1935
+ reason: lastError.message
1936
+ }));
1937
+ await sleep(delayMs, signal);
1938
+ if (signal.aborted) return null;
1939
+ const outcome = await this.reopenOnce(taskId, signal, handlers);
1940
+ if (signal.aborted) return null;
1941
+ if (outcome.kind === "socket") {
1942
+ observer.onEvent(channelEvent("channelConnected", { attempt }));
1943
+ return outcome.socket;
1944
+ }
1945
+ if (outcome.kind === "refused") {
1946
+ observer.onDisconnect(new Error(
1947
+ `The copilot no longer offers the box channel (after: ${cause.message})`
1948
+ ));
1949
+ return null;
1950
+ }
1951
+ lastError = outcome.error;
1952
+ }
1953
+ observer.onDisconnect(new Error(
1954
+ `Agent box channel could not be reopened after ${RECONNECT_DELAYS_MS.length} attempts: ${lastError.message}`
1955
+ ));
1956
+ return null;
1957
+ }
1958
+ // One attempt to get the box back: a fresh token, the channel request, the dial. A failure is
1959
+ // returned rather than thrown, so the caller decides between another attempt and giving up.
1960
+ async reopenOnce(taskId, signal, handlers) {
1961
+ try {
1962
+ const token = await this.requireFreshToken();
1963
+ const channel = await this.askDirectChannel(taskId, token, signal);
1964
+ if (signal.aborted) return { kind: "failed", error: new Error("Agent box redial aborted.") };
1965
+ if (!channel) return { kind: "refused" };
1966
+ this.adoptBox(taskId, channel);
1967
+ const replayFrom = this.boxCursors.get(taskId);
1968
+ if (replayFrom === void 0) this.boxCursors.set(taskId, channel.lastSequence);
1969
+ const socket = await this.dial(channel.wsUrl, { ...handlers, signal, replayFrom });
1970
+ return { kind: "socket", socket };
1971
+ } catch (error) {
1972
+ return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) };
1973
+ }
1974
+ }
1975
+ dial(url, options) {
1976
+ const { signal, replayFrom } = options;
1544
1977
  return new Promise((resolve, reject) => {
1545
1978
  const socket = new globalThis.WebSocket(url);
1546
1979
  let opened = false;
@@ -1559,9 +1992,17 @@ var BrowserAgentTaskEventSource = class {
1559
1992
  socket.close();
1560
1993
  reject(error);
1561
1994
  };
1995
+ const watchdog = new SilenceWatchdog(() => {
1996
+ if (intentionalClose) return;
1997
+ intentionalClose = true;
1998
+ removeAbortListener();
1999
+ options.onLost(silenceError("WebSocket"));
2000
+ socket.close(1e3, "Client closed");
2001
+ });
1562
2002
  const close = () => {
1563
2003
  if (intentionalClose) return;
1564
2004
  intentionalClose = true;
2005
+ watchdog.clear();
1565
2006
  removeAbortListener();
1566
2007
  socket.close(1e3, "Client closed");
1567
2008
  };
@@ -1581,24 +2022,33 @@ var BrowserAgentTaskEventSource = class {
1581
2022
  opened = true;
1582
2023
  settled = true;
1583
2024
  globalThis.clearTimeout(timer);
2025
+ watchdog.touch();
2026
+ if (replayFrom !== void 0) {
2027
+ try {
2028
+ socket.send(JSON.stringify({ type: "replay", fromSequence: replayFrom }));
2029
+ } catch {
2030
+ }
2031
+ }
1584
2032
  resolve({ close });
1585
2033
  };
1586
2034
  socket.onerror = () => {
1587
2035
  if (!opened) rejectHandshake(new Error("Failed to connect to the Agent WebSocket."));
1588
2036
  };
1589
2037
  socket.onmessage = (message) => {
2038
+ watchdog.touch();
1590
2039
  const event = parseEvent(message.data);
1591
- if (event) observer.onEvent(event);
2040
+ if (event) options.onFrame(asDelta(event));
1592
2041
  };
1593
2042
  socket.onclose = (event) => {
1594
2043
  globalThis.clearTimeout(timer);
2044
+ watchdog.clear();
1595
2045
  removeAbortListener();
1596
2046
  if (!opened) {
1597
2047
  rejectHandshake(new Error(`Agent WebSocket closed during handshake (${event.code}).`));
1598
2048
  return;
1599
2049
  }
1600
- if (!intentionalClose && !isNormalClose(event)) {
1601
- observer.onDisconnect(new Error(`Agent WebSocket disconnected (${event.code}).`));
2050
+ if (!intentionalClose) {
2051
+ options.onLost(new Error(`Agent WebSocket closed (${event.code}).`), event.code);
1602
2052
  }
1603
2053
  };
1604
2054
  });
@@ -1634,7 +2084,7 @@ var BrowserAgentTaskEventSource = class {
1634
2084
  disconnected = true;
1635
2085
  observer.onDisconnect(error);
1636
2086
  };
1637
- void this.readSse(response.body, observer, abort.signal).then(() => disconnect()).catch((error) => disconnect(error)).finally(() => signal?.removeEventListener("abort", onAbort));
2087
+ void this.readSse(response.body, observer, abort).then(() => disconnect()).catch((error) => disconnect(error)).finally(() => signal?.removeEventListener("abort", onAbort));
1638
2088
  return {
1639
2089
  close: () => {
1640
2090
  if (intentionalClose) return;
@@ -1644,13 +2094,28 @@ var BrowserAgentTaskEventSource = class {
1644
2094
  }
1645
2095
  };
1646
2096
  }
1647
- async readSse(body, observer, signal) {
2097
+ async readSse(body, observer, abort) {
1648
2098
  const reader = body.getReader();
1649
2099
  const decoder = new TextDecoder();
1650
2100
  let buffer = "";
2101
+ let breakSilence;
2102
+ const silence = new Promise((_, reject) => {
2103
+ breakSilence = reject;
2104
+ });
2105
+ const watchdog = new SilenceWatchdog(() => breakSilence(silenceError("SSE stream")));
1651
2106
  try {
1652
- while (!signal.aborted) {
1653
- const { done, value } = await reader.read();
2107
+ watchdog.touch();
2108
+ while (!abort.signal.aborted) {
2109
+ let chunk;
2110
+ try {
2111
+ chunk = await Promise.race([reader.read(), silence]);
2112
+ } catch (error) {
2113
+ abort.abort();
2114
+ await reader.cancel().catch(() => void 0);
2115
+ throw error;
2116
+ }
2117
+ watchdog.touch();
2118
+ const { done, value } = chunk;
1654
2119
  if (done) break;
1655
2120
  buffer += decoder.decode(value, { stream: true });
1656
2121
  let separator = /\r?\n\r?\n/.exec(buffer);
@@ -1664,6 +2129,7 @@ var BrowserAgentTaskEventSource = class {
1664
2129
  }
1665
2130
  }
1666
2131
  } finally {
2132
+ watchdog.clear();
1667
2133
  reader.releaseLock();
1668
2134
  }
1669
2135
  }
@@ -1672,11 +2138,35 @@ var BrowserAgentTaskEventSource = class {
1672
2138
  // src/modules/agent-tasks.ts
1673
2139
  function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
1674
2140
  const tasks = (0, import_sdk_core8.createAgentTasksModule)(httpClient, coreErrors);
2141
+ const source = new BrowserAgentTaskEventSource(auth, apiUrl);
2142
+ const observers = /* @__PURE__ */ new Map();
2143
+ const outbox = new AgentInputOutbox(tasks, (taskId, event) => observers.get(taskId)?.onEvent(event));
2144
+ const eventSource = {
2145
+ async open(taskId, observer, signal, transport) {
2146
+ observers.set(taskId, observer);
2147
+ let connection;
2148
+ try {
2149
+ connection = await source.open(taskId, observer, signal, transport);
2150
+ } catch (error) {
2151
+ observers.delete(taskId);
2152
+ throw error;
2153
+ }
2154
+ return {
2155
+ close: () => {
2156
+ outbox.abandon(taskId);
2157
+ observers.delete(taskId);
2158
+ connection.close();
2159
+ }
2160
+ };
2161
+ }
2162
+ };
1675
2163
  const manager = (0, import_sdk_core8.createAgentTaskSessionManager)({
1676
- tasks,
1677
- eventSource: new BrowserAgentTaskEventSource(auth, apiUrl)
2164
+ tasks: { ...tasks, sendInput: (taskId, input) => outbox.sendInput(taskId, input) },
2165
+ eventSource
2166
+ });
2167
+ return (0, import_sdk_core8.withAgentTaskSessions)(tasks, {
2168
+ session: (options) => holdSendsWhileOffline(manager.session(options), outbox)
1678
2169
  });
1679
- return (0, import_sdk_core8.withAgentTaskSessions)(tasks, manager);
1680
2170
  }
1681
2171
 
1682
2172
  // src/modules/agent-credentials.ts
package/dist/index.js CHANGED
@@ -1423,8 +1423,230 @@ import {
1423
1423
  withAgentTaskSessions
1424
1424
  } from "@mitralab.io/sdk-core";
1425
1425
 
1426
+ // src/modules/agent-outbox.ts
1427
+ var OUTBOX_DELAYS_MS = [2e3, 5e3, 1e4, 2e4, 4e4];
1428
+ var browserNetwork = {
1429
+ isOffline: () => globalThis.navigator?.onLine === false,
1430
+ onOnline: (listener) => {
1431
+ const target = globalThis;
1432
+ if (typeof target.addEventListener !== "function") return () => void 0;
1433
+ target.addEventListener("online", listener);
1434
+ return () => target.removeEventListener?.("online", listener);
1435
+ }
1436
+ };
1437
+ function sendGotNoResponse(error) {
1438
+ return error instanceof TypeError;
1439
+ }
1440
+ function reasonOf(error) {
1441
+ return error instanceof Error ? error.message : String(error);
1442
+ }
1443
+ function neverSent(pending) {
1444
+ return new Error(
1445
+ `Agent prompt was never sent: the session closed while it waited for the network (${pending} pending).`
1446
+ );
1447
+ }
1448
+ var AgentInputOutbox = class {
1449
+ constructor(tasks, announce, network = browserNetwork) {
1450
+ this.tasks = tasks;
1451
+ this.announce = announce;
1452
+ this.network = network;
1453
+ }
1454
+ held = /* @__PURE__ */ new Map();
1455
+ gated = /* @__PURE__ */ new Map();
1456
+ async sendInput(taskId, input) {
1457
+ try {
1458
+ await this.tasks.sendInput(taskId, input);
1459
+ } catch (error) {
1460
+ if (input.type !== "message" || !sendGotNoResponse(error)) throw error;
1461
+ await this.hold(taskId, input, error);
1462
+ }
1463
+ }
1464
+ /** The session is closing: a prompt still waiting is reported, never dropped in silence. */
1465
+ abandon(taskId) {
1466
+ const pending = this.held.get(taskId);
1467
+ if (!pending?.length) return;
1468
+ this.held.delete(taskId);
1469
+ this.drop(taskId, pending);
1470
+ }
1471
+ /** True when the browser says there is no network at all. */
1472
+ get offline() {
1473
+ return this.network.isOffline();
1474
+ }
1475
+ /**
1476
+ * Keeps a prompt out of the core until the browser reports the network is back, then hands it
1477
+ * over through `deliver`. Prompts held for one session leave in the order they arrived. The
1478
+ * promise settles when the prompt is handed over, or rejects if the session closes first.
1479
+ */
1480
+ holdUntilOnline(session, taskId, deliver) {
1481
+ return new Promise((resolve, reject) => {
1482
+ const prompt = { resolve, reject, disarm: () => void 0 };
1483
+ this.gated.set(session, [...this.gated.get(session) ?? [], prompt]);
1484
+ if (taskId) {
1485
+ this.announce(taskId, {
1486
+ type: "inputUnsent",
1487
+ payload: { attempt: 0, reason: "The browser is offline.", waitingForOnline: true },
1488
+ timestamp: Date.now()
1489
+ });
1490
+ }
1491
+ const offOnline = this.network.onOnline(() => {
1492
+ prompt.disarm();
1493
+ const rest = (this.gated.get(session) ?? []).filter((candidate) => candidate !== prompt);
1494
+ if (rest.length) this.gated.set(session, rest);
1495
+ else this.gated.delete(session);
1496
+ deliver();
1497
+ resolve();
1498
+ });
1499
+ prompt.disarm = () => {
1500
+ offOnline();
1501
+ prompt.disarm = () => void 0;
1502
+ };
1503
+ });
1504
+ }
1505
+ /** The session is closing with prompts that never entered the core: same report as `abandon`. */
1506
+ abandonGated(session, taskId) {
1507
+ const pending = this.gated.get(session);
1508
+ if (!pending?.length) return;
1509
+ this.gated.delete(session);
1510
+ this.drop(taskId, pending);
1511
+ }
1512
+ drop(taskId, pending) {
1513
+ const error = neverSent(pending.length);
1514
+ if (taskId) {
1515
+ this.announce(taskId, {
1516
+ type: "error",
1517
+ payload: { code: "INPUT_UNSENT", message: error.message },
1518
+ timestamp: Date.now()
1519
+ });
1520
+ }
1521
+ for (const prompt of pending) {
1522
+ prompt.disarm();
1523
+ prompt.reject(error);
1524
+ }
1525
+ }
1526
+ hold(taskId, input, cause) {
1527
+ return new Promise((resolve, reject) => {
1528
+ const prompt = { input, attempts: 1, resolve, reject, disarm: () => void 0 };
1529
+ this.held.set(taskId, [...this.held.get(taskId) ?? [], prompt]);
1530
+ this.schedule(taskId, prompt, cause);
1531
+ });
1532
+ }
1533
+ schedule(taskId, prompt, cause) {
1534
+ const waitingForOnline = this.network.isOffline();
1535
+ const retryInMs = waitingForOnline ? void 0 : OUTBOX_DELAYS_MS[prompt.attempts - 1];
1536
+ if (!waitingForOnline && retryInMs === void 0) {
1537
+ this.release(taskId, prompt);
1538
+ prompt.reject(new Error(
1539
+ `Agent prompt was not sent after ${prompt.attempts} attempts: ${reasonOf(cause)}`
1540
+ ));
1541
+ return;
1542
+ }
1543
+ this.announce(taskId, {
1544
+ type: "inputUnsent",
1545
+ payload: {
1546
+ attempt: prompt.attempts,
1547
+ reason: reasonOf(cause),
1548
+ waitingForOnline,
1549
+ ...retryInMs === void 0 ? {} : { retryInMs }
1550
+ },
1551
+ timestamp: Date.now()
1552
+ });
1553
+ const retry = () => {
1554
+ prompt.disarm();
1555
+ void this.retry(taskId, prompt);
1556
+ };
1557
+ const timer = retryInMs === void 0 ? null : globalThis.setTimeout(retry, retryInMs);
1558
+ const offOnline = this.network.onOnline(retry);
1559
+ prompt.disarm = () => {
1560
+ if (timer !== null) globalThis.clearTimeout(timer);
1561
+ offOnline();
1562
+ prompt.disarm = () => void 0;
1563
+ };
1564
+ }
1565
+ async retry(taskId, prompt) {
1566
+ prompt.attempts += 1;
1567
+ try {
1568
+ await this.tasks.sendInput(taskId, prompt.input);
1569
+ } catch (error) {
1570
+ if (!this.isHeld(taskId, prompt)) return;
1571
+ if (!sendGotNoResponse(error)) {
1572
+ this.release(taskId, prompt);
1573
+ prompt.reject(error instanceof Error ? error : new Error(String(error)));
1574
+ return;
1575
+ }
1576
+ this.schedule(taskId, prompt, error);
1577
+ return;
1578
+ }
1579
+ if (!this.isHeld(taskId, prompt)) return;
1580
+ this.release(taskId, prompt);
1581
+ this.announce(taskId, {
1582
+ type: "inputSent",
1583
+ payload: { attempts: prompt.attempts },
1584
+ timestamp: Date.now()
1585
+ });
1586
+ prompt.resolve();
1587
+ }
1588
+ isHeld(taskId, prompt) {
1589
+ return this.held.get(taskId)?.includes(prompt) ?? false;
1590
+ }
1591
+ release(taskId, prompt) {
1592
+ const rest = (this.held.get(taskId) ?? []).filter((candidate) => candidate !== prompt);
1593
+ if (rest.length) this.held.set(taskId, rest);
1594
+ else this.held.delete(taskId);
1595
+ }
1596
+ };
1597
+ function holdSendsWhileOffline(session, outbox) {
1598
+ const passThrough = (prompt) => !outbox.offline || !prompt.trim() || session.status === "closed";
1599
+ const gated = {
1600
+ send: (prompt, options) => {
1601
+ if (passThrough(prompt)) {
1602
+ session.send(prompt, options);
1603
+ return;
1604
+ }
1605
+ void outbox.holdUntilOnline(session, session.taskId, () => session.send(prompt, options)).catch(() => void 0);
1606
+ },
1607
+ sendAndWait: (prompt, options) => {
1608
+ if (passThrough(prompt)) return session.sendAndWait(prompt, options);
1609
+ let turn;
1610
+ return outbox.holdUntilOnline(session, session.taskId, () => {
1611
+ turn = session.sendAndWait(prompt, options);
1612
+ }).then(() => turn);
1613
+ },
1614
+ close: () => {
1615
+ outbox.abandonGated(session, session.taskId);
1616
+ session.close();
1617
+ }
1618
+ };
1619
+ return new Proxy(session, {
1620
+ get: (target, key) => key in gated ? gated[key] : Reflect.get(target, key, target)
1621
+ });
1622
+ }
1623
+
1426
1624
  // src/modules/agent-session.ts
1427
1625
  var CONNECT_TIMEOUT_MS = 15e3;
1626
+ var CHANNEL_BOOT_TIMEOUT_MS = 9e4;
1627
+ var CHANNEL_BOOT_RETRY_MS = 2e3;
1628
+ var SILENCE_TIMEOUT_MS = 6e4;
1629
+ var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 16e3];
1630
+ var SUPERSEDED_CLOSE_CODE = 4409;
1631
+ var SilenceWatchdog = class {
1632
+ constructor(onSilence) {
1633
+ this.onSilence = onSilence;
1634
+ }
1635
+ timer = null;
1636
+ touch() {
1637
+ this.clear();
1638
+ this.timer = globalThis.setTimeout(this.onSilence, SILENCE_TIMEOUT_MS);
1639
+ }
1640
+ clear() {
1641
+ if (this.timer !== null) {
1642
+ globalThis.clearTimeout(this.timer);
1643
+ this.timer = null;
1644
+ }
1645
+ }
1646
+ };
1647
+ function silenceError(transport) {
1648
+ return new Error(`Agent ${transport} went silent for ${SILENCE_TIMEOUT_MS / 1e3}s.`);
1649
+ }
1428
1650
  function stripBearer2(token) {
1429
1651
  return token.replace(/^Bearer\s+/i, "");
1430
1652
  }
@@ -1443,6 +1665,37 @@ function expectEvent(value) {
1443
1665
  ...typeof event.sequence === "number" ? { sequence: event.sequence } : {}
1444
1666
  };
1445
1667
  }
1668
+ function isSameGateway(candidate, apiUrl) {
1669
+ try {
1670
+ const target = new URL(candidate);
1671
+ if (target.protocol !== "ws:" && target.protocol !== "wss:") return false;
1672
+ return target.host === new URL(apiUrl).host;
1673
+ } catch {
1674
+ return false;
1675
+ }
1676
+ }
1677
+ function boxAddress(wsUrl) {
1678
+ const url = new URL(wsUrl);
1679
+ return `${url.origin}${url.pathname}`;
1680
+ }
1681
+ function toDirectChannel(body, apiUrl) {
1682
+ if (typeof body !== "object" || body === null) return null;
1683
+ const channel = body;
1684
+ if (typeof channel.wsUrl !== "string" || !isSameGateway(channel.wsUrl, apiUrl)) return null;
1685
+ return {
1686
+ wsUrl: channel.wsUrl,
1687
+ lastSequence: typeof channel.lastSequence === "number" && channel.lastSequence > 0 ? channel.lastSequence : 0
1688
+ };
1689
+ }
1690
+ function sleep(ms, signal) {
1691
+ return new Promise((resolve) => {
1692
+ const timer = globalThis.setTimeout(resolve, ms);
1693
+ signal?.addEventListener("abort", () => {
1694
+ globalThis.clearTimeout(timer);
1695
+ resolve();
1696
+ }, { once: true });
1697
+ });
1698
+ }
1446
1699
  function parseEvent(raw) {
1447
1700
  if (typeof raw !== "string") return expectEvent(raw);
1448
1701
  try {
@@ -1451,8 +1704,25 @@ function parseEvent(raw) {
1451
1704
  return null;
1452
1705
  }
1453
1706
  }
1454
- function isNormalClose(event) {
1455
- return event.code === 1e3 || event.code === 4409;
1707
+ function asDelta(event) {
1708
+ if (event.type !== "textChunk") return event;
1709
+ const kind = asObject(event.payload)?.kind;
1710
+ return { ...event, type: kind === "thinking" ? "thinking" : "textDelta" };
1711
+ }
1712
+ var TURN_FRAME_TYPES = /* @__PURE__ */ new Set(["textDelta", "thinking", "toolCall", "toolResult"]);
1713
+ var TURN_END_REASONS = /* @__PURE__ */ new Set(["stop", "endTurn", "interrupted"]);
1714
+ function turnAfter(event, inTurn) {
1715
+ if (TURN_FRAME_TYPES.has(event.type)) return true;
1716
+ if (event.type === "error") return false;
1717
+ if (event.type === "stepFinish") {
1718
+ const payload = asObject(event.payload);
1719
+ if (TURN_END_REASONS.has(payload?.reason)) return false;
1720
+ return asObject(payload?.lifecycle)?.interruptTerminal !== true;
1721
+ }
1722
+ return inTurn;
1723
+ }
1724
+ function channelEvent(type, payload) {
1725
+ return { type, payload, timestamp: Date.now() };
1456
1726
  }
1457
1727
  var BrowserAgentTaskEventSource = class {
1458
1728
  constructor(auth, apiUrl) {
@@ -1461,6 +1731,18 @@ var BrowserAgentTaskEventSource = class {
1461
1731
  }
1462
1732
  apiUrl;
1463
1733
  sseFallbackTasks = /* @__PURE__ */ new Set();
1734
+ // Tasks whose last WebSocket was the box itself. A box socket that closes says nothing about
1735
+ // WebSockets: the box went idle or the channel was superseded, and the answer is to ask the
1736
+ // copilot for the channel again. Only the copilot's own socket failing sends a task to SSE.
1737
+ directTasks = /* @__PURE__ */ new Set();
1738
+ /**
1739
+ * Ate onde esta sessao ja viu o log da caixa, por conversa, e de qual caixa esse log e. So o
1740
+ * redial no meio de um turno pede repeticao a partir daqui; abrir a conversa de novo nunca
1741
+ * pede. O cursor vale para uma caixa: outra caixa tem outro log, e um cursor da anterior
1742
+ * repetiria turnos antigos como se fossem texto ao vivo.
1743
+ */
1744
+ boxCursors = /* @__PURE__ */ new Map();
1745
+ boxAddresses = /* @__PURE__ */ new Map();
1464
1746
  async open(taskId, observer, signal, transport = "auto") {
1465
1747
  if (transport === "http") return this.openSse(taskId, observer, signal);
1466
1748
  if (transport === "websocket") return this.openWebSocket(taskId, observer, signal);
@@ -1471,13 +1753,13 @@ var BrowserAgentTaskEventSource = class {
1471
1753
  const connection = await this.openWebSocket(taskId, {
1472
1754
  ...observer,
1473
1755
  onDisconnect: (error) => {
1474
- this.sseFallbackTasks.add(taskId);
1756
+ if (!this.directTasks.has(taskId)) this.sseFallbackTasks.add(taskId);
1475
1757
  observer.onDisconnect(error);
1476
1758
  }
1477
1759
  }, signal);
1478
1760
  return this.wrapAutoConnection(taskId, connection);
1479
1761
  } catch {
1480
- this.sseFallbackTasks.add(taskId);
1762
+ if (!this.directTasks.has(taskId)) this.sseFallbackTasks.add(taskId);
1481
1763
  return this.wrapAutoConnection(taskId, await this.openSse(taskId, observer, signal));
1482
1764
  }
1483
1765
  }
@@ -1485,6 +1767,9 @@ var BrowserAgentTaskEventSource = class {
1485
1767
  return {
1486
1768
  close: () => {
1487
1769
  this.sseFallbackTasks.delete(taskId);
1770
+ this.directTasks.delete(taskId);
1771
+ this.boxCursors.delete(taskId);
1772
+ this.boxAddresses.delete(taskId);
1488
1773
  connection.close();
1489
1774
  }
1490
1775
  };
@@ -1497,13 +1782,161 @@ var BrowserAgentTaskEventSource = class {
1497
1782
  }
1498
1783
  return token;
1499
1784
  }
1785
+ /**
1786
+ * Pergunta ao copilot onde esta conversa e servida. Nada aqui e fatal: uma recusa, uma
1787
+ * resposta que nao entendemos ou uma rede que falhou significam apenas que a conversa segue
1788
+ * pelo socket do copilot, que e como toda conversa era servida antes da caixa existir.
1789
+ */
1790
+ requestDirectChannel(taskId, token, signal) {
1791
+ return this.askDirectChannel(taskId, token, signal).catch(() => null);
1792
+ }
1793
+ /** Like `requestDirectChannel`, but a request the network lost is thrown, not a refusal. */
1794
+ async askDirectChannel(taskId, token, signal) {
1795
+ const url = `${this.apiUrl}/copilot/api/v1/tasks/${encodeURIComponent(taskId)}/channel`;
1796
+ const deadline = Date.now() + CHANNEL_BOOT_TIMEOUT_MS;
1797
+ for (; ; ) {
1798
+ if (signal?.aborted) return null;
1799
+ const response = await globalThis.fetch(url, {
1800
+ method: "POST",
1801
+ headers: { Authorization: `Bearer ${stripBearer2(token)}` },
1802
+ ...signal ? { signal } : {}
1803
+ });
1804
+ if (response.status === 202) {
1805
+ if (Date.now() >= deadline) return null;
1806
+ await sleep(CHANNEL_BOOT_RETRY_MS, signal);
1807
+ continue;
1808
+ }
1809
+ if (!response.ok) return null;
1810
+ try {
1811
+ return toDirectChannel(await response.json(), this.apiUrl);
1812
+ } catch {
1813
+ return null;
1814
+ }
1815
+ }
1816
+ }
1500
1817
  async openWebSocket(taskId, observer, signal) {
1501
1818
  if (typeof globalThis.WebSocket !== "function") {
1502
1819
  throw new TypeError("WebSocket is not available.");
1503
1820
  }
1504
1821
  if (signal?.aborted) throw signal.reason ?? new Error("Agent WebSocket connection aborted.");
1505
1822
  const token = await this.requireFreshToken();
1823
+ const direct = await this.requestDirectChannel(taskId, token, signal);
1824
+ if (direct) this.directTasks.add(taskId);
1825
+ else this.directTasks.delete(taskId);
1826
+ if (direct) return this.openDirect(taskId, direct, observer, signal);
1506
1827
  const url = `${this.apiUrl.replace(/^http/i, "ws")}/copilot/ws/tasks/${encodeURIComponent(taskId)}?token=${encodeURIComponent(stripBearer2(token))}`;
1828
+ const socket = await this.dial(url, {
1829
+ signal,
1830
+ onFrame: (event) => observer.onEvent(event),
1831
+ onLost: (error) => observer.onDisconnect(error)
1832
+ });
1833
+ return { close: () => socket.close() };
1834
+ }
1835
+ /** Forgets the cursor when the copilot points the conversation to a box other than the last dialed. */
1836
+ adoptBox(taskId, channel) {
1837
+ const box = boxAddress(channel.wsUrl);
1838
+ if (this.boxAddresses.get(taskId) === box) return;
1839
+ this.boxAddresses.set(taskId, box);
1840
+ this.boxCursors.delete(taskId);
1841
+ }
1842
+ /**
1843
+ * The box path. A drop in the middle of a turn is redialed from here, with the replay the
1844
+ * box offers, so the core keeps one connection and one stream: reporting the drop instead
1845
+ * would make it reconcile the turn from persisted history, which is the screen freezing and
1846
+ * the rest of the answer landing at once. The core hears a disconnect only when the redial
1847
+ * gives up, when the copilot no longer offers the box, or when there is no turn to resume:
1848
+ * the sandbox pauses an idle box, and dialing it again would wake it for nobody.
1849
+ *
1850
+ * Opening never asks for a replay, whether the conversation is new to this session or was
1851
+ * open before. What an idle conversation missed is history, which the app loads by REST. Beta
1852
+ * 2026-09-14: a tab open overnight had its box replaced, and the open that followed replayed
1853
+ * the old box's cursor against the new log, which put every old turn on screen as live text.
1854
+ */
1855
+ async openDirect(taskId, channel, observer, signal) {
1856
+ this.adoptBox(taskId, channel);
1857
+ this.boxCursors.set(taskId, channel.lastSequence);
1858
+ const link = new AbortController();
1859
+ const onAbort = () => link.abort(signal?.reason);
1860
+ signal?.addEventListener("abort", onAbort, { once: true });
1861
+ let inTurn = false;
1862
+ let current = null;
1863
+ const onFrame = (event) => {
1864
+ if (typeof event.sequence === "number") {
1865
+ const seen = this.boxCursors.get(taskId) ?? 0;
1866
+ if (event.sequence > seen) this.boxCursors.set(taskId, event.sequence);
1867
+ }
1868
+ inTurn = turnAfter(event, inTurn);
1869
+ observer.onEvent(event);
1870
+ };
1871
+ const onLost = (error, code) => {
1872
+ current = null;
1873
+ if (link.signal.aborted) return;
1874
+ if (!inTurn || code === SUPERSEDED_CLOSE_CODE) {
1875
+ observer.onDisconnect(error);
1876
+ return;
1877
+ }
1878
+ void this.redial(taskId, error, link.signal, observer, { onFrame, onLost }).then((socket) => {
1879
+ current = socket;
1880
+ });
1881
+ };
1882
+ current = await this.dial(channel.wsUrl, { signal: link.signal, onFrame, onLost });
1883
+ return {
1884
+ close: () => {
1885
+ signal?.removeEventListener("abort", onAbort);
1886
+ link.abort();
1887
+ current?.close();
1888
+ }
1889
+ };
1890
+ }
1891
+ async redial(taskId, cause, signal, observer, handlers) {
1892
+ let lastError = cause;
1893
+ for (const [index, delayMs] of RECONNECT_DELAYS_MS.entries()) {
1894
+ const attempt = index + 1;
1895
+ observer.onEvent(channelEvent("channelReconnecting", {
1896
+ attempt,
1897
+ maxAttempts: RECONNECT_DELAYS_MS.length,
1898
+ reason: lastError.message
1899
+ }));
1900
+ await sleep(delayMs, signal);
1901
+ if (signal.aborted) return null;
1902
+ const outcome = await this.reopenOnce(taskId, signal, handlers);
1903
+ if (signal.aborted) return null;
1904
+ if (outcome.kind === "socket") {
1905
+ observer.onEvent(channelEvent("channelConnected", { attempt }));
1906
+ return outcome.socket;
1907
+ }
1908
+ if (outcome.kind === "refused") {
1909
+ observer.onDisconnect(new Error(
1910
+ `The copilot no longer offers the box channel (after: ${cause.message})`
1911
+ ));
1912
+ return null;
1913
+ }
1914
+ lastError = outcome.error;
1915
+ }
1916
+ observer.onDisconnect(new Error(
1917
+ `Agent box channel could not be reopened after ${RECONNECT_DELAYS_MS.length} attempts: ${lastError.message}`
1918
+ ));
1919
+ return null;
1920
+ }
1921
+ // One attempt to get the box back: a fresh token, the channel request, the dial. A failure is
1922
+ // returned rather than thrown, so the caller decides between another attempt and giving up.
1923
+ async reopenOnce(taskId, signal, handlers) {
1924
+ try {
1925
+ const token = await this.requireFreshToken();
1926
+ const channel = await this.askDirectChannel(taskId, token, signal);
1927
+ if (signal.aborted) return { kind: "failed", error: new Error("Agent box redial aborted.") };
1928
+ if (!channel) return { kind: "refused" };
1929
+ this.adoptBox(taskId, channel);
1930
+ const replayFrom = this.boxCursors.get(taskId);
1931
+ if (replayFrom === void 0) this.boxCursors.set(taskId, channel.lastSequence);
1932
+ const socket = await this.dial(channel.wsUrl, { ...handlers, signal, replayFrom });
1933
+ return { kind: "socket", socket };
1934
+ } catch (error) {
1935
+ return { kind: "failed", error: error instanceof Error ? error : new Error(String(error)) };
1936
+ }
1937
+ }
1938
+ dial(url, options) {
1939
+ const { signal, replayFrom } = options;
1507
1940
  return new Promise((resolve, reject) => {
1508
1941
  const socket = new globalThis.WebSocket(url);
1509
1942
  let opened = false;
@@ -1522,9 +1955,17 @@ var BrowserAgentTaskEventSource = class {
1522
1955
  socket.close();
1523
1956
  reject(error);
1524
1957
  };
1958
+ const watchdog = new SilenceWatchdog(() => {
1959
+ if (intentionalClose) return;
1960
+ intentionalClose = true;
1961
+ removeAbortListener();
1962
+ options.onLost(silenceError("WebSocket"));
1963
+ socket.close(1e3, "Client closed");
1964
+ });
1525
1965
  const close = () => {
1526
1966
  if (intentionalClose) return;
1527
1967
  intentionalClose = true;
1968
+ watchdog.clear();
1528
1969
  removeAbortListener();
1529
1970
  socket.close(1e3, "Client closed");
1530
1971
  };
@@ -1544,24 +1985,33 @@ var BrowserAgentTaskEventSource = class {
1544
1985
  opened = true;
1545
1986
  settled = true;
1546
1987
  globalThis.clearTimeout(timer);
1988
+ watchdog.touch();
1989
+ if (replayFrom !== void 0) {
1990
+ try {
1991
+ socket.send(JSON.stringify({ type: "replay", fromSequence: replayFrom }));
1992
+ } catch {
1993
+ }
1994
+ }
1547
1995
  resolve({ close });
1548
1996
  };
1549
1997
  socket.onerror = () => {
1550
1998
  if (!opened) rejectHandshake(new Error("Failed to connect to the Agent WebSocket."));
1551
1999
  };
1552
2000
  socket.onmessage = (message) => {
2001
+ watchdog.touch();
1553
2002
  const event = parseEvent(message.data);
1554
- if (event) observer.onEvent(event);
2003
+ if (event) options.onFrame(asDelta(event));
1555
2004
  };
1556
2005
  socket.onclose = (event) => {
1557
2006
  globalThis.clearTimeout(timer);
2007
+ watchdog.clear();
1558
2008
  removeAbortListener();
1559
2009
  if (!opened) {
1560
2010
  rejectHandshake(new Error(`Agent WebSocket closed during handshake (${event.code}).`));
1561
2011
  return;
1562
2012
  }
1563
- if (!intentionalClose && !isNormalClose(event)) {
1564
- observer.onDisconnect(new Error(`Agent WebSocket disconnected (${event.code}).`));
2013
+ if (!intentionalClose) {
2014
+ options.onLost(new Error(`Agent WebSocket closed (${event.code}).`), event.code);
1565
2015
  }
1566
2016
  };
1567
2017
  });
@@ -1597,7 +2047,7 @@ var BrowserAgentTaskEventSource = class {
1597
2047
  disconnected = true;
1598
2048
  observer.onDisconnect(error);
1599
2049
  };
1600
- void this.readSse(response.body, observer, abort.signal).then(() => disconnect()).catch((error) => disconnect(error)).finally(() => signal?.removeEventListener("abort", onAbort));
2050
+ void this.readSse(response.body, observer, abort).then(() => disconnect()).catch((error) => disconnect(error)).finally(() => signal?.removeEventListener("abort", onAbort));
1601
2051
  return {
1602
2052
  close: () => {
1603
2053
  if (intentionalClose) return;
@@ -1607,13 +2057,28 @@ var BrowserAgentTaskEventSource = class {
1607
2057
  }
1608
2058
  };
1609
2059
  }
1610
- async readSse(body, observer, signal) {
2060
+ async readSse(body, observer, abort) {
1611
2061
  const reader = body.getReader();
1612
2062
  const decoder = new TextDecoder();
1613
2063
  let buffer = "";
2064
+ let breakSilence;
2065
+ const silence = new Promise((_, reject) => {
2066
+ breakSilence = reject;
2067
+ });
2068
+ const watchdog = new SilenceWatchdog(() => breakSilence(silenceError("SSE stream")));
1614
2069
  try {
1615
- while (!signal.aborted) {
1616
- const { done, value } = await reader.read();
2070
+ watchdog.touch();
2071
+ while (!abort.signal.aborted) {
2072
+ let chunk;
2073
+ try {
2074
+ chunk = await Promise.race([reader.read(), silence]);
2075
+ } catch (error) {
2076
+ abort.abort();
2077
+ await reader.cancel().catch(() => void 0);
2078
+ throw error;
2079
+ }
2080
+ watchdog.touch();
2081
+ const { done, value } = chunk;
1617
2082
  if (done) break;
1618
2083
  buffer += decoder.decode(value, { stream: true });
1619
2084
  let separator = /\r?\n\r?\n/.exec(buffer);
@@ -1627,6 +2092,7 @@ var BrowserAgentTaskEventSource = class {
1627
2092
  }
1628
2093
  }
1629
2094
  } finally {
2095
+ watchdog.clear();
1630
2096
  reader.releaseLock();
1631
2097
  }
1632
2098
  }
@@ -1635,11 +2101,35 @@ var BrowserAgentTaskEventSource = class {
1635
2101
  // src/modules/agent-tasks.ts
1636
2102
  function createBrowserAgentTasksModule(httpClient, auth, apiUrl) {
1637
2103
  const tasks = createAgentTasksModule(httpClient, coreErrors);
2104
+ const source = new BrowserAgentTaskEventSource(auth, apiUrl);
2105
+ const observers = /* @__PURE__ */ new Map();
2106
+ const outbox = new AgentInputOutbox(tasks, (taskId, event) => observers.get(taskId)?.onEvent(event));
2107
+ const eventSource = {
2108
+ async open(taskId, observer, signal, transport) {
2109
+ observers.set(taskId, observer);
2110
+ let connection;
2111
+ try {
2112
+ connection = await source.open(taskId, observer, signal, transport);
2113
+ } catch (error) {
2114
+ observers.delete(taskId);
2115
+ throw error;
2116
+ }
2117
+ return {
2118
+ close: () => {
2119
+ outbox.abandon(taskId);
2120
+ observers.delete(taskId);
2121
+ connection.close();
2122
+ }
2123
+ };
2124
+ }
2125
+ };
1638
2126
  const manager = createAgentTaskSessionManager({
1639
- tasks,
1640
- eventSource: new BrowserAgentTaskEventSource(auth, apiUrl)
2127
+ tasks: { ...tasks, sendInput: (taskId, input) => outbox.sendInput(taskId, input) },
2128
+ eventSource
2129
+ });
2130
+ return withAgentTaskSessions(tasks, {
2131
+ session: (options) => holdSendsWhileOffline(manager.session(options), outbox)
1641
2132
  });
1642
- return withAgentTaskSessions(tasks, manager);
1643
2133
  }
1644
2134
 
1645
2135
  // src/modules/agent-credentials.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mitralab.io/platform-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "JavaScript/TypeScript SDK for building apps on the Mitra Platform",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -62,7 +62,7 @@
62
62
  "url": "https://github.com/mitralab-dev/mitra-platform-sdk/issues"
63
63
  },
64
64
  "dependencies": {
65
- "@mitralab.io/sdk-core": "0.2.0",
65
+ "@mitralab.io/sdk-core": "0.2.1",
66
66
  "mitra-interactions-sdk": "1.0.60-beta.48"
67
67
  },
68
68
  "devDependencies": {