@hydra-acp/cli 0.1.0 → 0.1.2

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/dist/cli.js CHANGED
@@ -34,7 +34,12 @@ var init_paths = __esm({
34
34
  agentsDir: () => path.join(hydraHome(), "agents"),
35
35
  agentDir: (id) => path.join(hydraHome(), "agents", id),
36
36
  sessionsDir: () => path.join(hydraHome(), "sessions"),
37
- sessionFile: (id) => path.join(hydraHome(), "sessions", `${id}.json`),
37
+ // One directory per session id under sessions/. Co-locates the
38
+ // session record, its transcript, and any future per-session state
39
+ // (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
40
+ sessionDir: (id) => path.join(hydraHome(), "sessions", id),
41
+ sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
42
+ historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
38
43
  extensionsDir: () => path.join(hydraHome(), "extensions"),
39
44
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
40
45
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -215,6 +220,32 @@ function extractHydraMeta(meta) {
215
220
  out.resume = parsed.data;
216
221
  }
217
222
  }
223
+ if (typeof obj.currentModel === "string") {
224
+ out.currentModel = obj.currentModel;
225
+ }
226
+ if (typeof obj.currentMode === "string") {
227
+ out.currentMode = obj.currentMode;
228
+ }
229
+ if (Array.isArray(obj.availableCommands)) {
230
+ const cmds = [];
231
+ for (const raw of obj.availableCommands) {
232
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
233
+ continue;
234
+ }
235
+ const c = raw;
236
+ if (typeof c.name !== "string") {
237
+ continue;
238
+ }
239
+ const cmd = { name: c.name };
240
+ if (typeof c.description === "string") {
241
+ cmd.description = c.description;
242
+ }
243
+ cmds.push(cmd);
244
+ }
245
+ if (cmds.length > 0) {
246
+ out.availableCommands = cmds;
247
+ }
248
+ }
218
249
  return out;
219
250
  }
220
251
  function mergeMeta(passthrough, ours) {
@@ -502,6 +533,25 @@ function withCode(err, code) {
502
533
  err.code = code;
503
534
  return err;
504
535
  }
536
+ function isStateUpdate(method, params) {
537
+ if (method !== "session/update") {
538
+ return false;
539
+ }
540
+ const obj = params ?? {};
541
+ const kind = obj.update?.sessionUpdate;
542
+ return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
543
+ }
544
+ function sameAdvertisedCommands(a, b) {
545
+ if (a.length !== b.length) {
546
+ return false;
547
+ }
548
+ for (let i = 0; i < a.length; i++) {
549
+ if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
550
+ return false;
551
+ }
552
+ }
553
+ return true;
554
+ }
505
555
  function captureInternalChunk(capture, params) {
506
556
  const obj = params ?? {};
507
557
  const update = obj.update ?? {};
@@ -564,7 +614,7 @@ function firstLine(text, max) {
564
614
  }
565
615
  return void 0;
566
616
  }
567
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session;
617
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
568
618
  var init_session = __esm({
569
619
  "src/core/session.ts"() {
570
620
  "use strict";
@@ -586,14 +636,26 @@ var init_session = __esm({
586
636
  agentMeta;
587
637
  agentArgs;
588
638
  title;
639
+ // Snapshot state delivered to attaching clients via the attach
640
+ // response _meta rather than via history replay (which would be
641
+ // stale-prone for snapshot-shaped events).
642
+ currentModel;
643
+ currentMode;
589
644
  updatedAt;
590
645
  clients = /* @__PURE__ */ new Map();
591
646
  history = [];
647
+ historyStore;
592
648
  promptQueue = [];
593
649
  promptInFlight = false;
594
650
  closed = false;
595
651
  closeHandlers = [];
596
652
  titleHandlers = [];
653
+ // Subscribers notified after every entry that's actually persisted to
654
+ // history (skipping snapshot-shaped events filtered by
655
+ // recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
656
+ // endpoint uses this to tail a live session's conversation stream
657
+ // without participating in turns or prompts.
658
+ broadcastHandlers = [];
597
659
  // True once we've observed our first session/prompt; gates the
598
660
  // first-prompt-seeded title so subsequent prompts don't churn it.
599
661
  firstPromptSeeded = false;
@@ -613,12 +675,18 @@ var init_session = __esm({
613
675
  idleTimer;
614
676
  spawnReplacementAgent;
615
677
  agentChangeHandlers = [];
616
- // Last available_commands_update we observed from the agent. Stored so
617
- // we can re-broadcast a merged (hydra ∪ agent) list whenever either
618
- // half changes most importantly when a fresh client attaches and
619
- // replays history, since the in-cache update is the daemon's merged
620
- // form (the agent's raw form is never broadcast).
678
+ // Last available_commands_update we observed from the agent. Stored
679
+ // so we can re-broadcast a merged (hydra ∪ agent) list whenever
680
+ // either half changes, and persisted to meta.json so a fresh attach
681
+ // can deliver the merged list via _meta without depending on history
682
+ // replay.
621
683
  agentAdvertisedCommands = [];
684
+ // Persist hooks for snapshot-shaped state. SessionManager hooks these
685
+ // to mirror changes into meta.json so cold-resurrect attaches can
686
+ // surface the latest snapshot via the attach response _meta.
687
+ agentCommandsHandlers = [];
688
+ modelHandlers = [];
689
+ modeHandlers = [];
622
690
  constructor(init) {
623
691
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
624
692
  this.cwd = init.cwd;
@@ -628,11 +696,19 @@ var init_session = __esm({
628
696
  this.agentMeta = init.agentMeta;
629
697
  this.agentArgs = init.agentArgs;
630
698
  this.title = init.title;
699
+ this.currentModel = init.currentModel;
700
+ this.currentMode = init.currentMode;
701
+ if (init.agentCommands && init.agentCommands.length > 0) {
702
+ this.agentAdvertisedCommands = [...init.agentCommands];
703
+ }
631
704
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
632
705
  this.spawnReplacementAgent = init.spawnReplacementAgent;
706
+ this.historyStore = init.historyStore;
707
+ if (init.seedHistory && init.seedHistory.length > 0) {
708
+ this.history = [...init.seedHistory];
709
+ }
633
710
  this.updatedAt = Date.now();
634
711
  this.wireAgent(this.agent);
635
- this.broadcastMergedCommands();
636
712
  }
637
713
  broadcastMergedCommands() {
638
714
  const merged = [
@@ -661,8 +737,15 @@ var init_session = __esm({
661
737
  }
662
738
  const agentCmds = extractAdvertisedCommands(params);
663
739
  if (agentCmds !== null) {
664
- this.agentAdvertisedCommands = agentCmds;
665
- this.broadcastMergedCommands();
740
+ this.setAgentAdvertisedCommands(agentCmds);
741
+ return;
742
+ }
743
+ if (this.maybeApplyAgentModel(params)) {
744
+ this.recordAndBroadcast("session/update", params);
745
+ return;
746
+ }
747
+ if (this.maybeApplyAgentMode(params)) {
748
+ this.recordAndBroadcast("session/update", params);
666
749
  return;
667
750
  }
668
751
  this.maybeApplyAgentSessionInfo(params);
@@ -684,6 +767,27 @@ var init_session = __esm({
684
767
  get attachedCount() {
685
768
  return this.clients.size;
686
769
  }
770
+ // Snapshot of the current in-memory replay history. Used by the
771
+ // HTTP history endpoint to deliver the "what's accumulated so far"
772
+ // prefix before optionally tailing with onBroadcast. Returns a copy
773
+ // so callers can't mutate our cache.
774
+ getHistorySnapshot() {
775
+ return [...this.history];
776
+ }
777
+ // Subscribe to recordable broadcast entries — fires once per entry
778
+ // that lands in history (so snapshot-shaped session_info/model/mode/
779
+ // available_commands updates do NOT trigger this; they're broadcast
780
+ // live but not recorded). Returns an unsubscribe function the caller
781
+ // must invoke when done.
782
+ onBroadcast(handler) {
783
+ this.broadcastHandlers.push(handler);
784
+ return () => {
785
+ const i = this.broadcastHandlers.indexOf(handler);
786
+ if (i >= 0) {
787
+ this.broadcastHandlers.splice(i, 1);
788
+ }
789
+ };
790
+ }
687
791
  attach(client, historyPolicy) {
688
792
  if (this.closed) {
689
793
  throw withCode(
@@ -889,6 +993,91 @@ var init_session = __esm({
889
993
  this.firstPromptSeeded = true;
890
994
  this.setTitle(seed);
891
995
  }
996
+ // Apply an agent-emitted current_model_update. Returns true if the
997
+ // notification was a model update (caller still needs to broadcast
998
+ // it). Returns false otherwise so the caller can try the next kind.
999
+ maybeApplyAgentModel(params) {
1000
+ const obj = params ?? {};
1001
+ const update = obj.update ?? {};
1002
+ if (update.sessionUpdate !== "current_model_update") {
1003
+ return false;
1004
+ }
1005
+ const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
1006
+ if (raw === void 0) {
1007
+ return true;
1008
+ }
1009
+ const trimmed = raw.trim();
1010
+ if (!trimmed || trimmed === this.currentModel) {
1011
+ return true;
1012
+ }
1013
+ this.currentModel = trimmed;
1014
+ for (const handler of this.modelHandlers) {
1015
+ try {
1016
+ handler(trimmed);
1017
+ } catch {
1018
+ }
1019
+ }
1020
+ return true;
1021
+ }
1022
+ maybeApplyAgentMode(params) {
1023
+ const obj = params ?? {};
1024
+ const update = obj.update ?? {};
1025
+ if (update.sessionUpdate !== "current_mode_update") {
1026
+ return false;
1027
+ }
1028
+ const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
1029
+ if (raw === void 0) {
1030
+ return true;
1031
+ }
1032
+ const trimmed = raw.trim();
1033
+ if (!trimmed || trimmed === this.currentMode) {
1034
+ return true;
1035
+ }
1036
+ this.currentMode = trimmed;
1037
+ for (const handler of this.modeHandlers) {
1038
+ try {
1039
+ handler(trimmed);
1040
+ } catch {
1041
+ }
1042
+ }
1043
+ return true;
1044
+ }
1045
+ // Update the cached agent command list, fire persist handlers, and
1046
+ // broadcast the merged list to attached clients. Idempotent on a
1047
+ // structurally identical list so we don't churn meta.json on noisy
1048
+ // re-emissions.
1049
+ setAgentAdvertisedCommands(commands) {
1050
+ if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
1051
+ this.broadcastMergedCommands();
1052
+ return;
1053
+ }
1054
+ this.agentAdvertisedCommands = commands;
1055
+ for (const handler of this.agentCommandsHandlers) {
1056
+ try {
1057
+ handler(commands);
1058
+ } catch {
1059
+ }
1060
+ }
1061
+ this.broadcastMergedCommands();
1062
+ }
1063
+ // Subscribe to snapshot-state updates. SessionManager wires these to
1064
+ // persist the new value into meta.json so cold resurrect can restore
1065
+ // them via the attach response _meta.
1066
+ onAgentCommandsChange(handler) {
1067
+ this.agentCommandsHandlers.push(handler);
1068
+ }
1069
+ onModelChange(handler) {
1070
+ this.modelHandlers.push(handler);
1071
+ }
1072
+ onModeChange(handler) {
1073
+ this.modeHandlers.push(handler);
1074
+ }
1075
+ // Returns a freshly merged command list (hydra ∪ agent) for callers
1076
+ // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1077
+ // assembling the attach response.
1078
+ mergedAvailableCommands() {
1079
+ return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1080
+ }
892
1081
  // Pick up an agent-emitted session_info_update and store its title
893
1082
  // as our canonical record. The notification is also forwarded to
894
1083
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1199,9 +1388,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1199
1388
  }
1200
1389
  recordAndBroadcast(method, params, excludeClientId) {
1201
1390
  const rewritten = this.rewriteForClient(params);
1202
- this.history.push({ method, params: rewritten, recordedAt: Date.now() });
1203
- if (this.history.length > 1e3) {
1204
- this.history = this.history.slice(-500);
1391
+ const recordable = !isStateUpdate(method, rewritten);
1392
+ if (recordable) {
1393
+ const entry = {
1394
+ method,
1395
+ params: rewritten,
1396
+ recordedAt: Date.now()
1397
+ };
1398
+ this.history.push(entry);
1399
+ let trimmed = false;
1400
+ if (this.history.length > 1e3) {
1401
+ this.history = this.history.slice(-500);
1402
+ trimmed = true;
1403
+ }
1404
+ if (this.historyStore) {
1405
+ if (trimmed) {
1406
+ void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1407
+ } else {
1408
+ void this.historyStore.append(this.sessionId, entry).catch(
1409
+ () => void 0
1410
+ );
1411
+ }
1412
+ }
1413
+ for (const handler of this.broadcastHandlers) {
1414
+ try {
1415
+ handler(entry);
1416
+ } catch {
1417
+ }
1418
+ }
1205
1419
  }
1206
1420
  this.updatedAt = Date.now();
1207
1421
  for (const client of this.clients.values()) {
@@ -1297,6 +1511,12 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1297
1511
  }
1298
1512
  }
1299
1513
  };
1514
+ STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1515
+ "session_info_update",
1516
+ "current_model_update",
1517
+ "current_mode_update",
1518
+ "available_commands_update"
1519
+ ]);
1300
1520
  }
1301
1521
  });
1302
1522
 
@@ -1552,13 +1772,248 @@ var init_sessions = __esm({
1552
1772
  }
1553
1773
  });
1554
1774
 
1775
+ // src/shim/resilient-ws.ts
1776
+ import { setTimeout as sleep3 } from "timers/promises";
1777
+ import { WebSocket } from "ws";
1778
+ function isResponse(msg) {
1779
+ return !("method" in msg) && "id" in msg && msg.id !== void 0;
1780
+ }
1781
+ async function openWs(url, subprotocols) {
1782
+ return new Promise((resolve2, reject) => {
1783
+ const ws = new WebSocket(url, subprotocols);
1784
+ const onOpen = () => {
1785
+ ws.off("error", onError);
1786
+ resolve2(wsToMessageStream(ws));
1787
+ };
1788
+ const onError = (err) => {
1789
+ ws.off("open", onOpen);
1790
+ reject(err);
1791
+ };
1792
+ ws.once("open", onOpen);
1793
+ ws.once("error", onError);
1794
+ });
1795
+ }
1796
+ var BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_MULTIPLIER, MAX_RECONNECT_ATTEMPTS, ResilientWsStream;
1797
+ var init_resilient_ws = __esm({
1798
+ "src/shim/resilient-ws.ts"() {
1799
+ "use strict";
1800
+ init_ws_stream();
1801
+ init_types();
1802
+ BACKOFF_INITIAL_MS = 200;
1803
+ BACKOFF_MAX_MS = 5e3;
1804
+ BACKOFF_MULTIPLIER = 2;
1805
+ MAX_RECONNECT_ATTEMPTS = 60;
1806
+ ResilientWsStream = class {
1807
+ constructor(opts) {
1808
+ this.opts = opts;
1809
+ }
1810
+ opts;
1811
+ current;
1812
+ outboundQueue = [];
1813
+ messageHandlers = [];
1814
+ closeHandlers = [];
1815
+ destroyed = false;
1816
+ firstConnect = true;
1817
+ reconnectInFlight;
1818
+ connectGate;
1819
+ releaseConnectGate;
1820
+ pendingRequests = /* @__PURE__ */ new Map();
1821
+ async start() {
1822
+ await this.connectWithRetry();
1823
+ }
1824
+ onMessage(handler) {
1825
+ this.messageHandlers.push(handler);
1826
+ }
1827
+ onClose(handler) {
1828
+ this.closeHandlers.push(handler);
1829
+ }
1830
+ async send(message) {
1831
+ if (this.destroyed) {
1832
+ throw new Error("resilient ws stream is destroyed");
1833
+ }
1834
+ if (this.connectGate || !this.current) {
1835
+ this.outboundQueue.push(message);
1836
+ return;
1837
+ }
1838
+ try {
1839
+ await this.current.send(message);
1840
+ } catch (err) {
1841
+ this.outboundQueue.push(message);
1842
+ this.scheduleReconnect(err);
1843
+ }
1844
+ }
1845
+ // Send a request directly and resolve when the matching response arrives
1846
+ // on the same connection. Used by onConnect handlers to await replay-attach
1847
+ // responses before letting the outbound queue drain. Bypasses the
1848
+ // connectGate intentionally.
1849
+ async request(message) {
1850
+ if (this.destroyed) {
1851
+ throw new Error("resilient ws stream is destroyed");
1852
+ }
1853
+ if (!this.current) {
1854
+ throw new Error("resilient ws stream not connected");
1855
+ }
1856
+ const id = message.id;
1857
+ const promise = new Promise((resolve2, reject) => {
1858
+ this.pendingRequests.set(id, { resolve: resolve2, reject });
1859
+ });
1860
+ try {
1861
+ await this.current.send(message);
1862
+ } catch (err) {
1863
+ this.pendingRequests.delete(id);
1864
+ throw err;
1865
+ }
1866
+ return promise;
1867
+ }
1868
+ async close() {
1869
+ this.destroyed = true;
1870
+ if (this.current) {
1871
+ await this.current.close().catch(() => void 0);
1872
+ }
1873
+ for (const handler of this.closeHandlers) {
1874
+ handler();
1875
+ }
1876
+ }
1877
+ async connectWithRetry() {
1878
+ let attempt = 0;
1879
+ let backoff = BACKOFF_INITIAL_MS;
1880
+ while (!this.destroyed) {
1881
+ try {
1882
+ const stream = await openWs(this.opts.url, this.opts.subprotocols);
1883
+ this.bindStream(stream);
1884
+ const wasFirst = this.firstConnect;
1885
+ this.firstConnect = false;
1886
+ this.connectGate = new Promise((resolve2) => {
1887
+ this.releaseConnectGate = resolve2;
1888
+ });
1889
+ try {
1890
+ if (this.opts.onConnect) {
1891
+ try {
1892
+ await this.opts.onConnect(wasFirst);
1893
+ } catch (err) {
1894
+ this.log(
1895
+ `hydra-acp: post-connect handler failed: ${err.message}`
1896
+ );
1897
+ }
1898
+ }
1899
+ } finally {
1900
+ this.releaseConnectGate?.();
1901
+ this.releaseConnectGate = void 0;
1902
+ this.connectGate = void 0;
1903
+ }
1904
+ await this.flushQueue();
1905
+ return;
1906
+ } catch (err) {
1907
+ attempt += 1;
1908
+ if (this.opts.onConnectFailure) {
1909
+ this.opts.onConnectFailure(err);
1910
+ }
1911
+ if (attempt >= MAX_RECONNECT_ATTEMPTS) {
1912
+ throw new Error(
1913
+ `hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
1914
+ );
1915
+ }
1916
+ this.log(
1917
+ `hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
1918
+ );
1919
+ await sleep3(backoff);
1920
+ backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
1921
+ }
1922
+ }
1923
+ }
1924
+ bindStream(stream) {
1925
+ this.current = stream;
1926
+ stream.onMessage((msg) => {
1927
+ if (isResponse(msg)) {
1928
+ const pending = this.pendingRequests.get(msg.id);
1929
+ if (pending) {
1930
+ this.pendingRequests.delete(msg.id);
1931
+ pending.resolve(msg);
1932
+ }
1933
+ }
1934
+ for (const handler of this.messageHandlers) {
1935
+ handler(msg);
1936
+ }
1937
+ });
1938
+ stream.onClose((err) => {
1939
+ if (this.destroyed) {
1940
+ return;
1941
+ }
1942
+ this.current = void 0;
1943
+ if (this.pendingRequests.size > 0) {
1944
+ const reason = err ?? new Error("ws closed before response");
1945
+ for (const { reject } of this.pendingRequests.values()) {
1946
+ reject(reason);
1947
+ }
1948
+ this.pendingRequests.clear();
1949
+ }
1950
+ this.scheduleReconnect(err);
1951
+ });
1952
+ }
1953
+ async flushQueue() {
1954
+ if (!this.current) {
1955
+ return;
1956
+ }
1957
+ const queue = this.outboundQueue;
1958
+ this.outboundQueue = [];
1959
+ for (const msg of queue) {
1960
+ try {
1961
+ await this.current.send(msg);
1962
+ } catch (err) {
1963
+ this.outboundQueue.unshift(msg);
1964
+ this.scheduleReconnect(err);
1965
+ return;
1966
+ }
1967
+ }
1968
+ }
1969
+ scheduleReconnect(err) {
1970
+ if (this.destroyed || this.reconnectInFlight) {
1971
+ return;
1972
+ }
1973
+ this.log(
1974
+ `hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
1975
+ );
1976
+ if (this.opts.onDisconnect) {
1977
+ try {
1978
+ this.opts.onDisconnect(err);
1979
+ } catch (hookErr) {
1980
+ this.log(
1981
+ `hydra-acp: onDisconnect handler threw: ${hookErr.message}`
1982
+ );
1983
+ }
1984
+ }
1985
+ this.reconnectInFlight = (async () => {
1986
+ try {
1987
+ await this.connectWithRetry();
1988
+ } catch (final) {
1989
+ for (const handler of this.closeHandlers) {
1990
+ handler(final);
1991
+ }
1992
+ this.destroyed = true;
1993
+ } finally {
1994
+ this.reconnectInFlight = void 0;
1995
+ }
1996
+ })();
1997
+ }
1998
+ log(line) {
1999
+ if (this.opts.log) {
2000
+ this.opts.log(line);
2001
+ return;
2002
+ }
2003
+ process.stderr.write(`${line}
2004
+ `);
2005
+ }
2006
+ };
2007
+ }
2008
+ });
2009
+
1555
2010
  // src/tui/history.ts
1556
- import { promises as fs8 } from "fs";
2011
+ import { promises as fs9 } from "fs";
1557
2012
  import * as path4 from "path";
1558
2013
  async function loadHistory(file) {
1559
2014
  let text;
1560
2015
  try {
1561
- text = await fs8.readFile(file, "utf8");
2016
+ text = await fs9.readFile(file, "utf8");
1562
2017
  } catch (err) {
1563
2018
  if (err.code === "ENOENT") {
1564
2019
  return [];
@@ -1598,9 +2053,9 @@ function appendEntry(history, entry) {
1598
2053
  return out;
1599
2054
  }
1600
2055
  async function saveHistory(file, history) {
1601
- await fs8.mkdir(path4.dirname(file), { recursive: true });
2056
+ await fs9.mkdir(path4.dirname(file), { recursive: true });
1602
2057
  const lines = history.map((entry) => JSON.stringify(entry));
1603
- await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2058
+ await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1604
2059
  }
1605
2060
  var HISTORY_CAP;
1606
2061
  var init_history = __esm({
@@ -1807,6 +2262,8 @@ var init_picker = __esm({
1807
2262
  });
1808
2263
 
1809
2264
  // src/tui/screen.ts
2265
+ import stringWidth from "string-width";
2266
+ import wrapAnsi from "wrap-ansi";
1810
2267
  function computePromptVisualRows(buffer, room) {
1811
2268
  const rows = [];
1812
2269
  for (let i = 0; i < buffer.length; i++) {
@@ -1933,6 +2390,15 @@ function writeStyled(term, text, style) {
1933
2390
  term.noFormat(text);
1934
2391
  }
1935
2392
  }
2393
+ function wrapAnsiBody(text, width) {
2394
+ if (width <= 0) {
2395
+ return [text];
2396
+ }
2397
+ if (text.length === 0) {
2398
+ return [""];
2399
+ }
2400
+ return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
2401
+ }
1936
2402
  function wrap(text, width) {
1937
2403
  if (width <= 0) {
1938
2404
  return [text];
@@ -2611,8 +3077,24 @@ var init_screen = __esm({
2611
3077
  const w = this.term.width;
2612
3078
  this.term.moveTo(1, 1).eraseLineAfter();
2613
3079
  const usage = formatUsage(this.header.usage);
2614
- const cwdRoom = Math.max(8, w - 40 - (usage ? usage.length + 3 : 0));
2615
- this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(shortId(this.header.sessionId));
3080
+ const sid = shortId(this.header.sessionId);
3081
+ const title = this.header.title?.trim();
3082
+ const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3083
+ const variableRoom = Math.max(8, w - fixed);
3084
+ let cwdRoom;
3085
+ let titleRoom;
3086
+ if (title) {
3087
+ const cwdCap = Math.max(8, Math.floor(variableRoom / 2));
3088
+ cwdRoom = Math.min(this.header.cwd.length, cwdCap);
3089
+ titleRoom = Math.max(8, variableRoom - cwdRoom);
3090
+ } else {
3091
+ titleRoom = 0;
3092
+ cwdRoom = variableRoom;
3093
+ }
3094
+ this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
3095
+ if (title) {
3096
+ this.term(" \xB7 ").bold(truncate(title, titleRoom));
3097
+ }
2616
3098
  if (usage) {
2617
3099
  const col = Math.max(1, w - usage.length + 1);
2618
3100
  this.term.moveTo(col, 1);
@@ -2829,6 +3311,8 @@ var init_screen = __esm({
2829
3311
  if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
2830
3312
  this.term(" ").dim(formatElapsed(this.banner.elapsedMs));
2831
3313
  }
3314
+ } else if (this.banner.status === "disconnected") {
3315
+ this.term.brightRed(`${dot} ${this.banner.status}`);
2832
3316
  } else {
2833
3317
  this.term.brightGreen(`${dot} ${this.banner.status}`);
2834
3318
  }
@@ -2902,7 +3386,7 @@ var init_screen = __esm({
2902
3386
  for (const line of lines) {
2903
3387
  const prefix = line.prefix ?? "";
2904
3388
  const room = Math.max(1, width - prefix.length);
2905
- const chunks = wrap(line.body, room);
3389
+ const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
2906
3390
  for (let i = 0; i < chunks.length; i++) {
2907
3391
  const chunk = chunks[i] ?? "";
2908
3392
  const wrappedLine = {
@@ -2918,6 +3402,9 @@ var init_screen = __esm({
2918
3402
  if (line.fillRow) {
2919
3403
  wrappedLine.fillRow = true;
2920
3404
  }
3405
+ if (line.ansi) {
3406
+ wrappedLine.ansi = true;
3407
+ }
2921
3408
  out.push(wrappedLine);
2922
3409
  }
2923
3410
  }
@@ -2928,15 +3415,16 @@ var init_screen = __esm({
2928
3415
  writeStyled(this.term, line.prefix, line.prefixStyle ?? line.bodyStyle);
2929
3416
  }
2930
3417
  const remaining = Math.max(0, width - (line.prefix?.length ?? 0));
2931
- const bodyText = truncate(line.body, remaining);
3418
+ const bodyText = line.ansi ? line.body : truncate(line.body, remaining);
2932
3419
  writeStyled(this.term, bodyText, line.bodyStyle);
2933
3420
  if (line.fillRow) {
2934
- const pad = remaining - bodyText.length;
3421
+ const visible = line.ansi ? stringWidth(bodyText) : bodyText.length;
3422
+ const pad = remaining - visible;
2935
3423
  if (pad > 0) {
2936
3424
  writeStyled(this.term, " ".repeat(pad), line.bodyStyle);
2937
3425
  }
2938
3426
  }
2939
- if (line.body.includes("^")) {
3427
+ if (line.ansi || line.body.includes("^")) {
2940
3428
  this.term.styleReset();
2941
3429
  }
2942
3430
  }
@@ -3325,10 +3813,19 @@ function mapUpdate(update) {
3325
3813
  return mapUsage(u);
3326
3814
  case "available_commands_update":
3327
3815
  return mapAvailableCommands(u);
3816
+ case "session_info_update":
3817
+ return mapSessionInfo(u);
3328
3818
  default:
3329
3819
  return { kind: "unknown", sessionUpdate: tag, raw: update };
3330
3820
  }
3331
3821
  }
3822
+ function mapSessionInfo(u) {
3823
+ const title = readString(u, "title");
3824
+ if (title === void 0) {
3825
+ return null;
3826
+ }
3827
+ return { kind: "session-info", title };
3828
+ }
3332
3829
  function mapAvailableCommands(u) {
3333
3830
  const list = u.availableCommands ?? u.commands;
3334
3831
  if (!Array.isArray(list)) {
@@ -3530,6 +4027,8 @@ var init_render_update = __esm({
3530
4027
  });
3531
4028
 
3532
4029
  // src/tui/format.ts
4030
+ import chalk from "chalk";
4031
+ import { highlight, supportsLanguage } from "cli-highlight";
3533
4032
  function formatEvent(event) {
3534
4033
  switch (event.kind) {
3535
4034
  case "user-text":
@@ -3567,6 +4066,8 @@ function formatEvent(event) {
3567
4066
  return [];
3568
4067
  case "available-commands":
3569
4068
  return [];
4069
+ case "session-info":
4070
+ return [];
3570
4071
  case "unknown":
3571
4072
  return [];
3572
4073
  }
@@ -3581,19 +4082,42 @@ function parseAgentMarkdown(text) {
3581
4082
  const out = [];
3582
4083
  const lines = text.split("\n");
3583
4084
  let inCode = false;
4085
+ let codeLang = "";
4086
+ let codeBuffer = [];
4087
+ const flushCode = () => {
4088
+ if (codeBuffer.length === 0) {
4089
+ return;
4090
+ }
4091
+ const highlighted = highlightFencedBlock(codeLang, codeBuffer);
4092
+ for (const piece of highlighted) {
4093
+ const entry = {
4094
+ prefix: " ",
4095
+ body: piece.body,
4096
+ bodyStyle: "code",
4097
+ fillRow: true
4098
+ };
4099
+ if (piece.ansi) {
4100
+ entry.ansi = true;
4101
+ }
4102
+ out.push(entry);
4103
+ }
4104
+ codeBuffer = [];
4105
+ codeLang = "";
4106
+ };
3584
4107
  for (const line of lines) {
3585
- const fence = line.match(/^\s*```\s*\w*\s*$/);
4108
+ const fence = line.match(/^\s*```\s*(\w*)\s*$/);
3586
4109
  if (fence) {
3587
- inCode = !inCode;
4110
+ if (!inCode) {
4111
+ inCode = true;
4112
+ codeLang = fence[1] ?? "";
4113
+ } else {
4114
+ flushCode();
4115
+ inCode = false;
4116
+ }
3588
4117
  continue;
3589
4118
  }
3590
4119
  if (inCode) {
3591
- out.push({
3592
- prefix: " ",
3593
- body: line,
3594
- bodyStyle: "code",
3595
- fillRow: true
3596
- });
4120
+ codeBuffer.push(line);
3597
4121
  continue;
3598
4122
  }
3599
4123
  const heading = line.match(/^(#{1,6})\s+(.*)$/);
@@ -3637,8 +4161,34 @@ function parseAgentMarkdown(text) {
3637
4161
  bodyStyle: "agent"
3638
4162
  });
3639
4163
  }
4164
+ if (inCode) {
4165
+ flushCode();
4166
+ }
3640
4167
  return out;
3641
4168
  }
4169
+ function highlightFencedBlock(lang, lines) {
4170
+ if (lang.length === 0 || !supportsLanguage(lang)) {
4171
+ return lines.map((body) => ({ body, ansi: false }));
4172
+ }
4173
+ let highlighted;
4174
+ try {
4175
+ highlighted = highlight(lines.join("\n"), {
4176
+ language: lang,
4177
+ theme: HIGHLIGHT_THEME,
4178
+ ignoreIllegals: true
4179
+ });
4180
+ } catch {
4181
+ return lines.map((body) => ({ body, ansi: false }));
4182
+ }
4183
+ const out = highlighted.split("\n");
4184
+ if (out.length !== lines.length) {
4185
+ return lines.map((body) => ({ body, ansi: false }));
4186
+ }
4187
+ return out.map((body, i) => ({
4188
+ body,
4189
+ ansi: body !== lines[i]
4190
+ }));
4191
+ }
3642
4192
  function formatBlock(text, prefix, bodyStyle, prefixStyle, sentBy, fillRow) {
3643
4193
  const lines = text.split("\n");
3644
4194
  const out = [];
@@ -3750,15 +4300,40 @@ function toolStatusStyle(status) {
3750
4300
  return "tool-status-pending";
3751
4301
  }
3752
4302
  }
4303
+ var highlightChalk, HIGHLIGHT_THEME;
3753
4304
  var init_format = __esm({
3754
4305
  "src/tui/format.ts"() {
3755
4306
  "use strict";
4307
+ highlightChalk = new chalk.Instance({ level: 3 });
4308
+ HIGHLIGHT_THEME = {
4309
+ keyword: highlightChalk.blueBright,
4310
+ built_in: highlightChalk.cyan,
4311
+ type: highlightChalk.cyanBright,
4312
+ literal: highlightChalk.blue,
4313
+ number: highlightChalk.greenBright,
4314
+ string: highlightChalk.yellow,
4315
+ regexp: highlightChalk.red,
4316
+ comment: highlightChalk.gray,
4317
+ function: highlightChalk.yellow,
4318
+ title: highlightChalk.yellow,
4319
+ class: highlightChalk.yellowBright,
4320
+ attr: highlightChalk.cyan,
4321
+ attribute: highlightChalk.cyan,
4322
+ variable: highlightChalk.white,
4323
+ params: highlightChalk.white,
4324
+ meta: highlightChalk.magenta,
4325
+ symbol: highlightChalk.magenta,
4326
+ addition: highlightChalk.greenBright,
4327
+ deletion: highlightChalk.redBright,
4328
+ section: highlightChalk.cyan,
4329
+ tag: highlightChalk.cyan,
4330
+ name: highlightChalk.cyanBright
4331
+ };
3756
4332
  }
3757
4333
  });
3758
4334
 
3759
4335
  // src/tui/app.ts
3760
- import WebSocket2 from "ws";
3761
- import { once } from "events";
4336
+ import { nanoid as nanoid3 } from "nanoid";
3762
4337
  import termkit from "terminal-kit";
3763
4338
  async function runTuiApp(opts) {
3764
4339
  const config = await ensureConfig();
@@ -3775,9 +4350,31 @@ async function runSession(term, config, opts) {
3775
4350
  term.grabInput(false);
3776
4351
  process.exit(0);
3777
4352
  }
3778
- const ws = await openWs2(config);
3779
- const stream = wsToMessageStream(ws);
4353
+ const protocol = config.daemon.tls ? "wss" : "ws";
4354
+ const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4355
+ const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
4356
+ let onReconnect = null;
4357
+ let onDisconnectHook = null;
4358
+ const stream = new ResilientWsStream({
4359
+ url: wsUrl,
4360
+ subprotocols,
4361
+ onConnect: async (firstConnect) => {
4362
+ if (firstConnect) {
4363
+ return;
4364
+ }
4365
+ if (onReconnect) {
4366
+ await onReconnect();
4367
+ }
4368
+ },
4369
+ onDisconnect: (err) => {
4370
+ if (onDisconnectHook) {
4371
+ onDisconnectHook(err);
4372
+ }
4373
+ },
4374
+ log: () => void 0
4375
+ });
3780
4376
  const conn = new JsonRpcConnection(stream);
4377
+ await stream.start();
3781
4378
  let bufferedEvents = [];
3782
4379
  let applyRenderEvent = null;
3783
4380
  const appendRender = (event) => {
@@ -3941,6 +4538,10 @@ async function runSession(term, config, opts) {
3941
4538
  let resolvedSessionId = ctx.sessionId;
3942
4539
  let resolvedAgentId = ctx.agentId;
3943
4540
  let resolvedCwd = ctx.cwd;
4541
+ let resolvedTitle;
4542
+ let initialModel;
4543
+ let initialMode;
4544
+ let initialCommands;
3944
4545
  if (ctx.sessionId === "__new__") {
3945
4546
  const created = await conn.request("session/new", {
3946
4547
  cwd: ctx.cwd,
@@ -3956,6 +4557,16 @@ async function runSession(term, config, opts) {
3956
4557
  if (hydraMeta.cwd) {
3957
4558
  resolvedCwd = hydraMeta.cwd;
3958
4559
  }
4560
+ if (hydraMeta.name) {
4561
+ resolvedTitle = hydraMeta.name;
4562
+ }
4563
+ initialModel = hydraMeta.currentModel;
4564
+ initialMode = hydraMeta.currentMode;
4565
+ if (hydraMeta.availableCommands) {
4566
+ initialCommands = hydraMeta.availableCommands.map(
4567
+ (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
4568
+ );
4569
+ }
3959
4570
  } else {
3960
4571
  const attached = await conn.request("session/attach", {
3961
4572
  sessionId: ctx.sessionId,
@@ -3971,8 +4582,17 @@ async function runSession(term, config, opts) {
3971
4582
  if (hydraMeta.cwd) {
3972
4583
  resolvedCwd = hydraMeta.cwd;
3973
4584
  }
4585
+ if (hydraMeta.name) {
4586
+ resolvedTitle = hydraMeta.name;
4587
+ }
4588
+ initialModel = hydraMeta.currentModel;
4589
+ initialMode = hydraMeta.currentMode;
4590
+ if (hydraMeta.availableCommands) {
4591
+ initialCommands = hydraMeta.availableCommands.map(
4592
+ (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
4593
+ );
4594
+ }
3974
4595
  }
3975
- void upstreamSessionId;
3976
4596
  const historyFile = paths.tuiHistoryFile();
3977
4597
  let history = await loadHistory(historyFile).catch(() => []);
3978
4598
  const dispatcher = new InputDispatcher({ history });
@@ -4010,7 +4630,7 @@ async function runSession(term, config, opts) {
4010
4630
  { name: "/demo-plan", description: "Inject synthetic plan events (UI test)" },
4011
4631
  { name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
4012
4632
  ];
4013
- let agentCommands = [];
4633
+ let agentCommands = initialCommands ?? [];
4014
4634
  const allCommands = () => {
4015
4635
  const seen = /* @__PURE__ */ new Set();
4016
4636
  const out = [];
@@ -4134,8 +4754,15 @@ async function runSession(term, config, opts) {
4134
4754
  screen.setHeader({
4135
4755
  agent: headerName,
4136
4756
  cwd: resolvedCwd,
4137
- sessionId: resolvedSessionId
4757
+ sessionId: resolvedSessionId,
4758
+ title: resolvedTitle
4138
4759
  });
4760
+ if (initialMode) {
4761
+ screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
4762
+ }
4763
+ if (initialModel) {
4764
+ screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
4765
+ }
4139
4766
  let finishSession = null;
4140
4767
  const sessionDone = new Promise((resolve2) => {
4141
4768
  finishSession = resolve2;
@@ -4221,10 +4848,7 @@ async function runSession(term, config, opts) {
4221
4848
  process.off("SIGINT", sigintHandler);
4222
4849
  screen.stop();
4223
4850
  saveHistory(historyFile, history).catch(() => void 0);
4224
- try {
4225
- ws.close();
4226
- } catch {
4227
- }
4851
+ void stream.close().catch(() => void 0);
4228
4852
  };
4229
4853
  const stop = (code = 0) => {
4230
4854
  teardown();
@@ -4617,6 +5241,12 @@ async function runSession(term, config, opts) {
4617
5241
  refreshCompletions();
4618
5242
  return;
4619
5243
  }
5244
+ if (event.kind === "session-info") {
5245
+ if (event.title !== void 0) {
5246
+ screen.setHeader({ title: event.title });
5247
+ }
5248
+ return;
5249
+ }
4620
5250
  if (event.kind === "usage-update") {
4621
5251
  let changed = false;
4622
5252
  if (event.used !== void 0 && usage.used !== event.used) {
@@ -4712,6 +5342,95 @@ async function runSession(term, config, opts) {
4712
5342
  } finally {
4713
5343
  screen.resumeRepaint();
4714
5344
  }
5345
+ const resetInFlightUiState = () => {
5346
+ if (pendingPermission) {
5347
+ const resolve2 = pendingPermission.resolve;
5348
+ pendingPermission = null;
5349
+ screen.setPermissionPrompt(null);
5350
+ resolve2({ outcome: { outcome: "cancelled" } });
5351
+ }
5352
+ closeAgentText();
5353
+ if (toolsBlockStartedAt !== null) {
5354
+ if (toolCallOrder.length > 0) {
5355
+ toolsBlockEndedAt = Date.now();
5356
+ renderToolsBlock();
5357
+ screen.clearKey("tools");
5358
+ } else {
5359
+ screen.removeBlock("tools");
5360
+ }
5361
+ toolStates.clear();
5362
+ toolCallOrder.length = 0;
5363
+ toolsBlockStartedAt = null;
5364
+ toolsBlockEndedAt = null;
5365
+ toolsExpanded = false;
5366
+ }
5367
+ screen.clearKey("plan");
5368
+ if (pendingTurns > 0) {
5369
+ adjustPendingTurns(-pendingTurns);
5370
+ }
5371
+ };
5372
+ onDisconnectHook = () => {
5373
+ screen.setBanner({ status: "disconnected", elapsedMs: void 0 });
5374
+ };
5375
+ onReconnect = async () => {
5376
+ resetInFlightUiState();
5377
+ const initReq = {
5378
+ jsonrpc: "2.0",
5379
+ id: `tui-reinit-${nanoid3()}`,
5380
+ method: "initialize",
5381
+ params: {
5382
+ protocolVersion: 1,
5383
+ clientCapabilities: {
5384
+ fs: { readTextFile: false, writeTextFile: false },
5385
+ terminal: false
5386
+ },
5387
+ clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
5388
+ }
5389
+ };
5390
+ try {
5391
+ await stream.request(initReq);
5392
+ } catch {
5393
+ }
5394
+ const attachReq = {
5395
+ jsonrpc: "2.0",
5396
+ id: `tui-reattach-${nanoid3()}`,
5397
+ method: "session/attach",
5398
+ params: {
5399
+ sessionId: resolvedSessionId,
5400
+ historyPolicy: "none",
5401
+ clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
5402
+ ...upstreamSessionId !== void 0 ? {
5403
+ _meta: {
5404
+ [HYDRA_META_KEY]: {
5405
+ resume: {
5406
+ upstreamSessionId,
5407
+ agentId: resolvedAgentId,
5408
+ cwd: resolvedCwd
5409
+ }
5410
+ }
5411
+ }
5412
+ } : {}
5413
+ }
5414
+ };
5415
+ try {
5416
+ const resp = await stream.request(attachReq);
5417
+ if (resp.error) {
5418
+ throw new Error(resp.error.message);
5419
+ }
5420
+ } catch (err) {
5421
+ screen.appendLines([
5422
+ {
5423
+ prefix: " ",
5424
+ body: `reattach failed: ${err.message}`,
5425
+ bodyStyle: "tool-status-fail"
5426
+ }
5427
+ ]);
5428
+ }
5429
+ screen.setBanner({
5430
+ status: pendingTurns > 0 ? "running" : "ready",
5431
+ elapsedMs: pendingTurns > 0 ? 0 : void 0
5432
+ });
5433
+ };
4715
5434
  conn.onClose((err) => {
4716
5435
  if (err) {
4717
5436
  term.red(`
@@ -4777,23 +5496,13 @@ function newCtx(opts, cwd, config) {
4777
5496
  cwd
4778
5497
  };
4779
5498
  }
4780
- async function openWs2(config) {
4781
- const protocol = config.daemon.tls ? "wss" : "ws";
4782
- const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4783
- const ws = new WebSocket2(url, [
4784
- "acp.v1",
4785
- `hydra-acp-token.${config.daemon.authToken}`
4786
- ]);
4787
- await once(ws, "open");
4788
- return ws;
4789
- }
4790
5499
  var PLAN_PREFIX_TEXT;
4791
5500
  var init_app = __esm({
4792
5501
  "src/tui/app.ts"() {
4793
5502
  "use strict";
4794
5503
  init_connection();
4795
- init_ws_stream();
4796
5504
  init_types();
5505
+ init_resilient_ws();
4797
5506
  init_config();
4798
5507
  init_daemon_bootstrap();
4799
5508
  init_paths();
@@ -4921,7 +5630,7 @@ import { setTimeout as sleep2 } from "timers/promises";
4921
5630
 
4922
5631
  // src/daemon/server.ts
4923
5632
  init_config();
4924
- import * as fs6 from "fs";
5633
+ import * as fs7 from "fs";
4925
5634
  import * as fsp2 from "fs/promises";
4926
5635
  import Fastify from "fastify";
4927
5636
  import websocketPlugin from "@fastify/websocket";
@@ -5238,6 +5947,10 @@ init_paths();
5238
5947
  import * as fs4 from "fs/promises";
5239
5948
  import * as path2 from "path";
5240
5949
  import { z as z4 } from "zod";
5950
+ var PersistedAgentCommand = z4.object({
5951
+ name: z4.string(),
5952
+ description: z4.string().optional()
5953
+ });
5241
5954
  var SessionRecord = z4.object({
5242
5955
  version: z4.literal(1),
5243
5956
  sessionId: z4.string(),
@@ -5246,6 +5959,13 @@ var SessionRecord = z4.object({
5246
5959
  cwd: z4.string(),
5247
5960
  title: z4.string().optional(),
5248
5961
  agentArgs: z4.array(z4.string()).optional(),
5962
+ // Snapshot of "what is currently true about this session" carried in
5963
+ // meta.json so a late-attaching or cold-resurrected client can be
5964
+ // told via the attach response _meta without depending on history
5965
+ // replay of a snapshot-shaped notification.
5966
+ currentModel: z4.string().optional(),
5967
+ currentMode: z4.string().optional(),
5968
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
5249
5969
  createdAt: z4.string(),
5250
5970
  updatedAt: z4.string()
5251
5971
  });
@@ -5258,7 +5978,7 @@ function assertSafeId(id) {
5258
5978
  var SessionStore = class {
5259
5979
  async write(record) {
5260
5980
  assertSafeId(record.sessionId);
5261
- await fs4.mkdir(paths.sessionsDir(), { recursive: true });
5981
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
5262
5982
  const full = { version: 1, ...record };
5263
5983
  await fs4.writeFile(
5264
5984
  paths.sessionFile(record.sessionId),
@@ -5298,6 +6018,14 @@ var SessionStore = class {
5298
6018
  throw err;
5299
6019
  }
5300
6020
  }
6021
+ try {
6022
+ await fs4.rmdir(paths.sessionDir(sessionId));
6023
+ } catch (err) {
6024
+ const e = err;
6025
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
6026
+ throw err;
6027
+ }
6028
+ }
5301
6029
  }
5302
6030
  async list() {
5303
6031
  let entries;
@@ -5312,11 +6040,7 @@ var SessionStore = class {
5312
6040
  }
5313
6041
  const records = [];
5314
6042
  for (const entry of entries) {
5315
- if (!entry.endsWith(".json")) {
5316
- continue;
5317
- }
5318
- const id = entry.slice(0, -".json".length);
5319
- const record = await this.read(id);
6043
+ const record = await this.read(entry);
5320
6044
  if (record) {
5321
6045
  records.push(record);
5322
6046
  }
@@ -5333,11 +6057,137 @@ function recordFromMemorySession(args) {
5333
6057
  cwd: args.cwd,
5334
6058
  title: args.title,
5335
6059
  agentArgs: args.agentArgs,
6060
+ currentModel: args.currentModel,
6061
+ currentMode: args.currentMode,
6062
+ agentCommands: args.agentCommands,
5336
6063
  createdAt: args.createdAt ?? now,
5337
6064
  updatedAt: args.updatedAt ?? now
5338
6065
  };
5339
6066
  }
5340
6067
 
6068
+ // src/core/history-store.ts
6069
+ init_paths();
6070
+ import * as fs5 from "fs/promises";
6071
+ var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
6072
+ var MAX_ENTRIES = 1e3;
6073
+ var HistoryStore = class {
6074
+ // Serialize writes per session id so appends and rewrites don't
6075
+ // interleave JSONL lines on disk. The chain swallows errors so one
6076
+ // failed append doesn't poison every subsequent write.
6077
+ writeQueues = /* @__PURE__ */ new Map();
6078
+ async append(sessionId, entry) {
6079
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6080
+ return;
6081
+ }
6082
+ return this.enqueue(sessionId, async () => {
6083
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
6084
+ const line = JSON.stringify(entry) + "\n";
6085
+ await fs5.appendFile(paths.historyFile(sessionId), line, {
6086
+ encoding: "utf8",
6087
+ mode: 384
6088
+ });
6089
+ });
6090
+ }
6091
+ async rewrite(sessionId, entries) {
6092
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6093
+ return;
6094
+ }
6095
+ return this.enqueue(sessionId, async () => {
6096
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
6097
+ const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
6098
+ await fs5.writeFile(paths.historyFile(sessionId), body, {
6099
+ encoding: "utf8",
6100
+ mode: 384
6101
+ });
6102
+ });
6103
+ }
6104
+ async load(sessionId) {
6105
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6106
+ return [];
6107
+ }
6108
+ const pending = this.writeQueues.get(sessionId);
6109
+ if (pending) {
6110
+ await pending;
6111
+ }
6112
+ let raw;
6113
+ try {
6114
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
6115
+ } catch (err) {
6116
+ const e = err;
6117
+ if (e.code === "ENOENT") {
6118
+ return [];
6119
+ }
6120
+ throw err;
6121
+ }
6122
+ const out = [];
6123
+ for (const line of raw.split("\n")) {
6124
+ if (line.length === 0) {
6125
+ continue;
6126
+ }
6127
+ let parsed;
6128
+ try {
6129
+ parsed = JSON.parse(line);
6130
+ } catch {
6131
+ continue;
6132
+ }
6133
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
6134
+ continue;
6135
+ }
6136
+ const obj = parsed;
6137
+ if (typeof obj.method !== "string") {
6138
+ continue;
6139
+ }
6140
+ if (typeof obj.recordedAt !== "number") {
6141
+ continue;
6142
+ }
6143
+ out.push({
6144
+ method: obj.method,
6145
+ params: obj.params,
6146
+ recordedAt: obj.recordedAt
6147
+ });
6148
+ }
6149
+ if (out.length > MAX_ENTRIES) {
6150
+ return out.slice(-MAX_ENTRIES);
6151
+ }
6152
+ return out;
6153
+ }
6154
+ async delete(sessionId) {
6155
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6156
+ return;
6157
+ }
6158
+ return this.enqueue(sessionId, async () => {
6159
+ try {
6160
+ await fs5.unlink(paths.historyFile(sessionId));
6161
+ } catch (err) {
6162
+ const e = err;
6163
+ if (e.code !== "ENOENT") {
6164
+ throw err;
6165
+ }
6166
+ }
6167
+ try {
6168
+ await fs5.rmdir(paths.sessionDir(sessionId));
6169
+ } catch (err) {
6170
+ const e = err;
6171
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
6172
+ throw err;
6173
+ }
6174
+ }
6175
+ });
6176
+ }
6177
+ enqueue(sessionId, task) {
6178
+ const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
6179
+ const task$ = prev.then(task, task);
6180
+ const settled = task$.catch(() => void 0);
6181
+ this.writeQueues.set(sessionId, settled);
6182
+ void settled.finally(() => {
6183
+ if (this.writeQueues.get(sessionId) === settled) {
6184
+ this.writeQueues.delete(sessionId);
6185
+ }
6186
+ });
6187
+ return task$;
6188
+ }
6189
+ };
6190
+
5341
6191
  // src/core/session-manager.ts
5342
6192
  init_types();
5343
6193
  var SessionManager = class {
@@ -5345,6 +6195,7 @@ var SessionManager = class {
5345
6195
  this.registry = registry;
5346
6196
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
5347
6197
  this.store = store ?? new SessionStore();
6198
+ this.histories = new HistoryStore();
5348
6199
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
5349
6200
  }
5350
6201
  registry;
@@ -5352,7 +6203,12 @@ var SessionManager = class {
5352
6203
  resurrectionInflight = /* @__PURE__ */ new Map();
5353
6204
  spawner;
5354
6205
  store;
6206
+ histories;
5355
6207
  idleTimeoutMs;
6208
+ // Serialize meta.json read-modify-write operations per session id so
6209
+ // concurrent snapshot updates (e.g. an agent emitting model + mode
6210
+ // back-to-back) don't lose writes via interleaved reads.
6211
+ metaWriteQueues = /* @__PURE__ */ new Map();
5356
6212
  async create(params) {
5357
6213
  const fresh = await this.bootstrapAgent({
5358
6214
  agentId: params.agentId,
@@ -5369,7 +6225,8 @@ var SessionManager = class {
5369
6225
  title: params.title,
5370
6226
  agentArgs: params.agentArgs,
5371
6227
  idleTimeoutMs: this.idleTimeoutMs,
5372
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
6228
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6229
+ historyStore: this.histories
5373
6230
  });
5374
6231
  await this.attachManagerHooks(session);
5375
6232
  return session;
@@ -5445,7 +6302,12 @@ var SessionManager = class {
5445
6302
  title: params.title,
5446
6303
  agentArgs: params.agentArgs,
5447
6304
  idleTimeoutMs: this.idleTimeoutMs,
5448
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
6305
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6306
+ historyStore: this.histories,
6307
+ seedHistory: params.seedHistory,
6308
+ currentModel: params.currentModel,
6309
+ currentMode: params.currentMode,
6310
+ agentCommands: params.agentCommands
5449
6311
  });
5450
6312
  await this.attachManagerHooks(session);
5451
6313
  return session;
@@ -5499,6 +6361,7 @@ var SessionManager = class {
5499
6361
  this.sessions.delete(session.sessionId);
5500
6362
  if (deleteRecord) {
5501
6363
  void this.store.delete(session.sessionId).catch(() => void 0);
6364
+ void this.histories.delete(session.sessionId).catch(() => void 0);
5502
6365
  }
5503
6366
  });
5504
6367
  session.onTitleChange((title) => {
@@ -5509,6 +6372,24 @@ var SessionManager = class {
5509
6372
  () => void 0
5510
6373
  );
5511
6374
  });
6375
+ session.onModelChange((model) => {
6376
+ void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
6377
+ () => void 0
6378
+ );
6379
+ });
6380
+ session.onModeChange((mode) => {
6381
+ void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
6382
+ () => void 0
6383
+ );
6384
+ });
6385
+ session.onAgentCommandsChange((commands) => {
6386
+ void this.persistSnapshot(session.sessionId, {
6387
+ agentCommands: commands.map((c) => ({
6388
+ name: c.name,
6389
+ ...c.description !== void 0 ? { description: c.description } : {}
6390
+ }))
6391
+ }).catch(() => void 0);
6392
+ });
5512
6393
  this.sessions.set(session.sessionId, session);
5513
6394
  await this.store.write(
5514
6395
  recordFromMemorySession({
@@ -5517,22 +6398,45 @@ var SessionManager = class {
5517
6398
  agentId: session.agentId,
5518
6399
  cwd: session.cwd,
5519
6400
  title: session.title,
5520
- agentArgs: session.agentArgs
6401
+ agentArgs: session.agentArgs,
6402
+ currentModel: session.currentModel,
6403
+ currentMode: session.currentMode
5521
6404
  })
5522
6405
  ).catch(() => void 0);
5523
6406
  }
6407
+ // Resolve a session's recorded history without forcing a resurrect.
6408
+ // Returns the in-memory snapshot if the session is hot, falls back
6409
+ // to the on-disk history file otherwise. Returns undefined if the
6410
+ // session id is unknown to both the live map and disk store, so the
6411
+ // caller can distinguish "no history yet" (empty array) from "404".
6412
+ async getHistory(sessionId) {
6413
+ const live = this.sessions.get(sessionId);
6414
+ if (live) {
6415
+ return live.getHistorySnapshot();
6416
+ }
6417
+ const record = await this.store.read(sessionId);
6418
+ if (!record) {
6419
+ return void 0;
6420
+ }
6421
+ return this.histories.load(sessionId).catch(() => []);
6422
+ }
5524
6423
  async loadFromDisk(sessionId) {
5525
6424
  const record = await this.store.read(sessionId);
5526
6425
  if (!record) {
5527
6426
  return void 0;
5528
6427
  }
6428
+ const seedHistory = await this.histories.load(sessionId).catch(() => []);
5529
6429
  return {
5530
6430
  hydraSessionId: record.sessionId,
5531
6431
  upstreamSessionId: record.upstreamSessionId,
5532
6432
  agentId: record.agentId,
5533
6433
  cwd: record.cwd,
5534
6434
  title: record.title,
5535
- agentArgs: record.agentArgs
6435
+ agentArgs: record.agentArgs,
6436
+ seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
6437
+ currentModel: record.currentModel,
6438
+ currentMode: record.currentMode,
6439
+ agentCommands: record.agentCommands
5536
6440
  };
5537
6441
  }
5538
6442
  get(sessionId) {
@@ -5620,14 +6524,16 @@ var SessionManager = class {
5620
6524
  // record's title in sync with what was broadcast to clients so a
5621
6525
  // daemon restart (and later resurrect) restores the same title.
5622
6526
  async persistTitle(sessionId, title) {
5623
- const record = await this.store.read(sessionId);
5624
- if (!record) {
5625
- return;
5626
- }
5627
- await this.store.write({
5628
- ...record,
5629
- title,
5630
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6527
+ await this.enqueueMetaWrite(sessionId, async () => {
6528
+ const record = await this.store.read(sessionId);
6529
+ if (!record) {
6530
+ return;
6531
+ }
6532
+ await this.store.write({
6533
+ ...record,
6534
+ title,
6535
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6536
+ });
5631
6537
  });
5632
6538
  }
5633
6539
  // Persist an agent swap from /hydra switch. The on-disk record's
@@ -5635,17 +6541,52 @@ var SessionManager = class {
5635
6541
  // later resurrect) brings the session back up on the agent the user
5636
6542
  // most recently switched to, not the one it was originally created on.
5637
6543
  async persistAgentChange(sessionId, agentId, upstreamSessionId) {
5638
- const record = await this.store.read(sessionId);
5639
- if (!record) {
5640
- return;
5641
- }
5642
- await this.store.write({
5643
- ...record,
5644
- agentId,
5645
- upstreamSessionId,
5646
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6544
+ await this.enqueueMetaWrite(sessionId, async () => {
6545
+ const record = await this.store.read(sessionId);
6546
+ if (!record) {
6547
+ return;
6548
+ }
6549
+ await this.store.write({
6550
+ ...record,
6551
+ agentId,
6552
+ upstreamSessionId,
6553
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6554
+ });
6555
+ });
6556
+ }
6557
+ // Update one or more snapshot fields (model, mode, commands) in
6558
+ // meta.json. Used so cold-resurrect can deliver the latest snapshot
6559
+ // to attaching clients via the attach response _meta. No-op if the
6560
+ // session record has gone away (race with deleteRecord).
6561
+ async persistSnapshot(sessionId, update) {
6562
+ await this.enqueueMetaWrite(sessionId, async () => {
6563
+ const record = await this.store.read(sessionId);
6564
+ if (!record) {
6565
+ return;
6566
+ }
6567
+ await this.store.write({
6568
+ ...record,
6569
+ ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
6570
+ ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
6571
+ ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
6572
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
6573
+ });
5647
6574
  });
5648
6575
  }
6576
+ // Serialize meta.json writes per session id so concurrent
6577
+ // read-modify-write operations don't interleave reads.
6578
+ enqueueMetaWrite(sessionId, task) {
6579
+ const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
6580
+ const next = prev.then(task, task);
6581
+ const settled = next.catch(() => void 0);
6582
+ this.metaWriteQueues.set(sessionId, settled);
6583
+ void settled.finally(() => {
6584
+ if (this.metaWriteQueues.get(sessionId) === settled) {
6585
+ this.metaWriteQueues.delete(sessionId);
6586
+ }
6587
+ });
6588
+ return next;
6589
+ }
5649
6590
  async closeAll() {
5650
6591
  const sessions = [...this.sessions.values()];
5651
6592
  await Promise.allSettled(sessions.map((s) => s.close()));
@@ -5656,7 +6597,7 @@ var SessionManager = class {
5656
6597
  // src/core/extensions.ts
5657
6598
  init_paths();
5658
6599
  import { spawn as spawn2 } from "child_process";
5659
- import * as fs5 from "fs";
6600
+ import * as fs6 from "fs";
5660
6601
  import * as fsp from "fs/promises";
5661
6602
  import * as path3 from "path";
5662
6603
  var RESTART_BASE_MS = 1e3;
@@ -5939,7 +6880,7 @@ var ExtensionManager = class {
5939
6880
  }
5940
6881
  const ext = entry.config;
5941
6882
  const command = ext.command.length > 0 ? ext.command : [ext.name];
5942
- const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
6883
+ const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
5943
6884
  flags: "a"
5944
6885
  });
5945
6886
  logStream.write(
@@ -5989,7 +6930,7 @@ var ExtensionManager = class {
5989
6930
  }
5990
6931
  if (typeof child.pid === "number") {
5991
6932
  try {
5992
- fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6933
+ fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
5993
6934
  `, {
5994
6935
  encoding: "utf8",
5995
6936
  mode: 384
@@ -6014,7 +6955,7 @@ var ExtensionManager = class {
6014
6955
  });
6015
6956
  child.on("exit", (code, signal) => {
6016
6957
  try {
6017
- fs5.unlinkSync(paths.extensionPidFile(ext.name));
6958
+ fs6.unlinkSync(paths.extensionPidFile(ext.name));
6018
6959
  } catch {
6019
6960
  }
6020
6961
  logStream.write(
@@ -6130,8 +7071,7 @@ init_config();
6130
7071
  function registerSessionRoutes(app, manager, defaults) {
6131
7072
  app.get("/v1/sessions", async (request) => {
6132
7073
  const query = request.query;
6133
- const all = query?.all === "true" || query?.all === "1";
6134
- const sessions = await manager.list({ cwd: query?.cwd, all });
7074
+ const sessions = await manager.list({ cwd: query?.cwd });
6135
7075
  return { sessions };
6136
7076
  });
6137
7077
  app.post("/v1/sessions", async (request, reply) => {
@@ -6169,6 +7109,50 @@ function registerSessionRoutes(app, manager, defaults) {
6169
7109
  }
6170
7110
  reply.code(204).send();
6171
7111
  });
7112
+ app.get("/v1/sessions/:id/history", async (request, reply) => {
7113
+ const raw = request.params.id;
7114
+ const query = request.query;
7115
+ const follow = query?.follow === "1" || query?.follow === "true";
7116
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
7117
+ const live = manager.get(id);
7118
+ let snapshot;
7119
+ let unsubscribe;
7120
+ if (live) {
7121
+ snapshot = live.getHistorySnapshot();
7122
+ if (follow) {
7123
+ unsubscribe = live.onBroadcast((entry) => {
7124
+ if (reply.raw.writableEnded) {
7125
+ return;
7126
+ }
7127
+ reply.raw.write(JSON.stringify(entry) + "\n");
7128
+ });
7129
+ }
7130
+ } else {
7131
+ const cold = await manager.getHistory(id);
7132
+ if (cold === void 0) {
7133
+ reply.code(404).send({ error: "session not found" });
7134
+ return reply;
7135
+ }
7136
+ snapshot = cold;
7137
+ }
7138
+ reply.raw.setHeader("Content-Type", "application/x-ndjson");
7139
+ reply.raw.setHeader("Cache-Control", "no-cache");
7140
+ reply.raw.statusCode = 200;
7141
+ for (const entry of snapshot ?? []) {
7142
+ reply.raw.write(JSON.stringify(entry) + "\n");
7143
+ }
7144
+ if (!unsubscribe) {
7145
+ reply.raw.end();
7146
+ return reply;
7147
+ }
7148
+ request.raw.on("close", () => {
7149
+ unsubscribe?.();
7150
+ if (!reply.raw.writableEnded) {
7151
+ reply.raw.end();
7152
+ }
7153
+ });
7154
+ return reply;
7155
+ });
6172
7156
  }
6173
7157
 
6174
7158
  // src/daemon/routes/agents.ts
@@ -6559,6 +7543,16 @@ function buildResponseMeta(session) {
6559
7543
  if (session.agentArgs && session.agentArgs.length > 0) {
6560
7544
  ours.agentArgs = session.agentArgs;
6561
7545
  }
7546
+ if (session.currentModel !== void 0) {
7547
+ ours.currentModel = session.currentModel;
7548
+ }
7549
+ if (session.currentMode !== void 0) {
7550
+ ours.currentMode = session.currentMode;
7551
+ }
7552
+ const commands = session.mergedAvailableCommands();
7553
+ if (commands.length > 0) {
7554
+ ours.availableCommands = commands;
7555
+ }
6562
7556
  return mergeMeta(session.agentMeta, ours);
6563
7557
  }
6564
7558
  function buildInitializeResult() {
@@ -6679,7 +7673,7 @@ async function startDaemon(config) {
6679
7673
  await manager.closeAll();
6680
7674
  await app.close();
6681
7675
  try {
6682
- fs6.unlinkSync(paths.pidFile());
7676
+ fs7.unlinkSync(paths.pidFile());
6683
7677
  } catch {
6684
7678
  }
6685
7679
  try {
@@ -6718,7 +7712,7 @@ function ensureLoopbackOrTls(config) {
6718
7712
  init_daemon_bootstrap();
6719
7713
 
6720
7714
  // src/cli/commands/log-tail.ts
6721
- import * as fs7 from "fs";
7715
+ import * as fs8 from "fs";
6722
7716
  import * as fsp3 from "fs/promises";
6723
7717
  async function runLogTail(logPath, argv, notFoundMessage) {
6724
7718
  const opts = parseLogTailFlags(argv);
@@ -6742,7 +7736,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
6742
7736
  process.stdout.write(`-- following ${logPath} --
6743
7737
  `);
6744
7738
  let pending = false;
6745
- const watcher = fs7.watch(logPath, () => {
7739
+ const watcher = fs8.watch(logPath, () => {
6746
7740
  if (pending) {
6747
7741
  return;
6748
7742
  }
@@ -7500,226 +8494,7 @@ function maxLen3(headerCell, values) {
7500
8494
  // src/shim/proxy.ts
7501
8495
  init_config();
7502
8496
  init_daemon_bootstrap();
7503
-
7504
- // src/shim/resilient-ws.ts
7505
- init_ws_stream();
7506
- init_types();
7507
- import { setTimeout as sleep3 } from "timers/promises";
7508
- import { WebSocket } from "ws";
7509
- var BACKOFF_INITIAL_MS = 200;
7510
- var BACKOFF_MAX_MS = 5e3;
7511
- var BACKOFF_MULTIPLIER = 2;
7512
- var MAX_RECONNECT_ATTEMPTS = 60;
7513
- var ResilientWsStream = class {
7514
- constructor(opts) {
7515
- this.opts = opts;
7516
- }
7517
- opts;
7518
- current;
7519
- outboundQueue = [];
7520
- messageHandlers = [];
7521
- closeHandlers = [];
7522
- destroyed = false;
7523
- firstConnect = true;
7524
- reconnectInFlight;
7525
- connectGate;
7526
- releaseConnectGate;
7527
- pendingRequests = /* @__PURE__ */ new Map();
7528
- async start() {
7529
- await this.connectWithRetry();
7530
- }
7531
- onMessage(handler) {
7532
- this.messageHandlers.push(handler);
7533
- }
7534
- onClose(handler) {
7535
- this.closeHandlers.push(handler);
7536
- }
7537
- async send(message) {
7538
- if (this.destroyed) {
7539
- throw new Error("resilient ws stream is destroyed");
7540
- }
7541
- if (this.connectGate || !this.current) {
7542
- this.outboundQueue.push(message);
7543
- return;
7544
- }
7545
- try {
7546
- await this.current.send(message);
7547
- } catch (err) {
7548
- this.outboundQueue.push(message);
7549
- this.scheduleReconnect(err);
7550
- }
7551
- }
7552
- // Send a request directly and resolve when the matching response arrives
7553
- // on the same connection. Used by onConnect handlers to await replay-attach
7554
- // responses before letting the outbound queue drain. Bypasses the
7555
- // connectGate intentionally.
7556
- async request(message) {
7557
- if (this.destroyed) {
7558
- throw new Error("resilient ws stream is destroyed");
7559
- }
7560
- if (!this.current) {
7561
- throw new Error("resilient ws stream not connected");
7562
- }
7563
- const id = message.id;
7564
- const promise = new Promise((resolve2, reject) => {
7565
- this.pendingRequests.set(id, { resolve: resolve2, reject });
7566
- });
7567
- try {
7568
- await this.current.send(message);
7569
- } catch (err) {
7570
- this.pendingRequests.delete(id);
7571
- throw err;
7572
- }
7573
- return promise;
7574
- }
7575
- async close() {
7576
- this.destroyed = true;
7577
- if (this.current) {
7578
- await this.current.close().catch(() => void 0);
7579
- }
7580
- for (const handler of this.closeHandlers) {
7581
- handler();
7582
- }
7583
- }
7584
- async connectWithRetry() {
7585
- let attempt = 0;
7586
- let backoff = BACKOFF_INITIAL_MS;
7587
- while (!this.destroyed) {
7588
- try {
7589
- const stream = await openWs(this.opts.url, this.opts.subprotocols);
7590
- this.bindStream(stream);
7591
- const wasFirst = this.firstConnect;
7592
- this.firstConnect = false;
7593
- this.connectGate = new Promise((resolve2) => {
7594
- this.releaseConnectGate = resolve2;
7595
- });
7596
- try {
7597
- if (this.opts.onConnect) {
7598
- try {
7599
- await this.opts.onConnect(wasFirst);
7600
- } catch (err) {
7601
- this.log(
7602
- `hydra-acp: post-connect handler failed: ${err.message}`
7603
- );
7604
- }
7605
- }
7606
- } finally {
7607
- this.releaseConnectGate?.();
7608
- this.releaseConnectGate = void 0;
7609
- this.connectGate = void 0;
7610
- }
7611
- await this.flushQueue();
7612
- return;
7613
- } catch (err) {
7614
- attempt += 1;
7615
- if (this.opts.onConnectFailure) {
7616
- this.opts.onConnectFailure(err);
7617
- }
7618
- if (attempt >= MAX_RECONNECT_ATTEMPTS) {
7619
- throw new Error(
7620
- `hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
7621
- );
7622
- }
7623
- this.log(
7624
- `hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
7625
- );
7626
- await sleep3(backoff);
7627
- backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
7628
- }
7629
- }
7630
- }
7631
- bindStream(stream) {
7632
- this.current = stream;
7633
- stream.onMessage((msg) => {
7634
- if (isResponse(msg)) {
7635
- const pending = this.pendingRequests.get(msg.id);
7636
- if (pending) {
7637
- this.pendingRequests.delete(msg.id);
7638
- pending.resolve(msg);
7639
- }
7640
- }
7641
- for (const handler of this.messageHandlers) {
7642
- handler(msg);
7643
- }
7644
- });
7645
- stream.onClose((err) => {
7646
- if (this.destroyed) {
7647
- return;
7648
- }
7649
- this.current = void 0;
7650
- if (this.pendingRequests.size > 0) {
7651
- const reason = err ?? new Error("ws closed before response");
7652
- for (const { reject } of this.pendingRequests.values()) {
7653
- reject(reason);
7654
- }
7655
- this.pendingRequests.clear();
7656
- }
7657
- this.scheduleReconnect(err);
7658
- });
7659
- }
7660
- async flushQueue() {
7661
- if (!this.current) {
7662
- return;
7663
- }
7664
- const queue = this.outboundQueue;
7665
- this.outboundQueue = [];
7666
- for (const msg of queue) {
7667
- try {
7668
- await this.current.send(msg);
7669
- } catch (err) {
7670
- this.outboundQueue.unshift(msg);
7671
- this.scheduleReconnect(err);
7672
- return;
7673
- }
7674
- }
7675
- }
7676
- scheduleReconnect(err) {
7677
- if (this.destroyed || this.reconnectInFlight) {
7678
- return;
7679
- }
7680
- this.log(
7681
- `hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
7682
- );
7683
- this.reconnectInFlight = (async () => {
7684
- try {
7685
- await this.connectWithRetry();
7686
- } catch (final) {
7687
- for (const handler of this.closeHandlers) {
7688
- handler(final);
7689
- }
7690
- this.destroyed = true;
7691
- } finally {
7692
- this.reconnectInFlight = void 0;
7693
- }
7694
- })();
7695
- }
7696
- log(line) {
7697
- if (this.opts.log) {
7698
- this.opts.log(line);
7699
- return;
7700
- }
7701
- process.stderr.write(`${line}
7702
- `);
7703
- }
7704
- };
7705
- function isResponse(msg) {
7706
- return !("method" in msg) && "id" in msg && msg.id !== void 0;
7707
- }
7708
- async function openWs(url, subprotocols) {
7709
- return new Promise((resolve2, reject) => {
7710
- const ws = new WebSocket(url, subprotocols);
7711
- const onOpen = () => {
7712
- ws.off("error", onError);
7713
- resolve2(wsToMessageStream(ws));
7714
- };
7715
- const onError = (err) => {
7716
- ws.off("open", onOpen);
7717
- reject(err);
7718
- };
7719
- ws.once("open", onOpen);
7720
- ws.once("error", onError);
7721
- });
7722
- }
8497
+ init_resilient_ws();
7723
8498
 
7724
8499
  // src/shim/session-tracker.ts
7725
8500
  init_types();