@hydra-acp/cli 0.1.1 → 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({
@@ -2622,8 +3077,24 @@ var init_screen = __esm({
2622
3077
  const w = this.term.width;
2623
3078
  this.term.moveTo(1, 1).eraseLineAfter();
2624
3079
  const usage = formatUsage(this.header.usage);
2625
- const cwdRoom = Math.max(8, w - 40 - (usage ? usage.length + 3 : 0));
2626
- 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
+ }
2627
3098
  if (usage) {
2628
3099
  const col = Math.max(1, w - usage.length + 1);
2629
3100
  this.term.moveTo(col, 1);
@@ -2840,6 +3311,8 @@ var init_screen = __esm({
2840
3311
  if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
2841
3312
  this.term(" ").dim(formatElapsed(this.banner.elapsedMs));
2842
3313
  }
3314
+ } else if (this.banner.status === "disconnected") {
3315
+ this.term.brightRed(`${dot} ${this.banner.status}`);
2843
3316
  } else {
2844
3317
  this.term.brightGreen(`${dot} ${this.banner.status}`);
2845
3318
  }
@@ -3340,10 +3813,19 @@ function mapUpdate(update) {
3340
3813
  return mapUsage(u);
3341
3814
  case "available_commands_update":
3342
3815
  return mapAvailableCommands(u);
3816
+ case "session_info_update":
3817
+ return mapSessionInfo(u);
3343
3818
  default:
3344
3819
  return { kind: "unknown", sessionUpdate: tag, raw: update };
3345
3820
  }
3346
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
+ }
3347
3829
  function mapAvailableCommands(u) {
3348
3830
  const list = u.availableCommands ?? u.commands;
3349
3831
  if (!Array.isArray(list)) {
@@ -3584,6 +4066,8 @@ function formatEvent(event) {
3584
4066
  return [];
3585
4067
  case "available-commands":
3586
4068
  return [];
4069
+ case "session-info":
4070
+ return [];
3587
4071
  case "unknown":
3588
4072
  return [];
3589
4073
  }
@@ -3849,8 +4333,7 @@ var init_format = __esm({
3849
4333
  });
3850
4334
 
3851
4335
  // src/tui/app.ts
3852
- import WebSocket2 from "ws";
3853
- import { once } from "events";
4336
+ import { nanoid as nanoid3 } from "nanoid";
3854
4337
  import termkit from "terminal-kit";
3855
4338
  async function runTuiApp(opts) {
3856
4339
  const config = await ensureConfig();
@@ -3867,9 +4350,31 @@ async function runSession(term, config, opts) {
3867
4350
  term.grabInput(false);
3868
4351
  process.exit(0);
3869
4352
  }
3870
- const ws = await openWs2(config);
3871
- 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
+ });
3872
4376
  const conn = new JsonRpcConnection(stream);
4377
+ await stream.start();
3873
4378
  let bufferedEvents = [];
3874
4379
  let applyRenderEvent = null;
3875
4380
  const appendRender = (event) => {
@@ -4033,6 +4538,10 @@ async function runSession(term, config, opts) {
4033
4538
  let resolvedSessionId = ctx.sessionId;
4034
4539
  let resolvedAgentId = ctx.agentId;
4035
4540
  let resolvedCwd = ctx.cwd;
4541
+ let resolvedTitle;
4542
+ let initialModel;
4543
+ let initialMode;
4544
+ let initialCommands;
4036
4545
  if (ctx.sessionId === "__new__") {
4037
4546
  const created = await conn.request("session/new", {
4038
4547
  cwd: ctx.cwd,
@@ -4048,6 +4557,16 @@ async function runSession(term, config, opts) {
4048
4557
  if (hydraMeta.cwd) {
4049
4558
  resolvedCwd = hydraMeta.cwd;
4050
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
+ }
4051
4570
  } else {
4052
4571
  const attached = await conn.request("session/attach", {
4053
4572
  sessionId: ctx.sessionId,
@@ -4063,8 +4582,17 @@ async function runSession(term, config, opts) {
4063
4582
  if (hydraMeta.cwd) {
4064
4583
  resolvedCwd = hydraMeta.cwd;
4065
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
+ }
4066
4595
  }
4067
- void upstreamSessionId;
4068
4596
  const historyFile = paths.tuiHistoryFile();
4069
4597
  let history = await loadHistory(historyFile).catch(() => []);
4070
4598
  const dispatcher = new InputDispatcher({ history });
@@ -4102,7 +4630,7 @@ async function runSession(term, config, opts) {
4102
4630
  { name: "/demo-plan", description: "Inject synthetic plan events (UI test)" },
4103
4631
  { name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
4104
4632
  ];
4105
- let agentCommands = [];
4633
+ let agentCommands = initialCommands ?? [];
4106
4634
  const allCommands = () => {
4107
4635
  const seen = /* @__PURE__ */ new Set();
4108
4636
  const out = [];
@@ -4226,8 +4754,15 @@ async function runSession(term, config, opts) {
4226
4754
  screen.setHeader({
4227
4755
  agent: headerName,
4228
4756
  cwd: resolvedCwd,
4229
- sessionId: resolvedSessionId
4757
+ sessionId: resolvedSessionId,
4758
+ title: resolvedTitle
4230
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
+ }
4231
4766
  let finishSession = null;
4232
4767
  const sessionDone = new Promise((resolve2) => {
4233
4768
  finishSession = resolve2;
@@ -4313,10 +4848,7 @@ async function runSession(term, config, opts) {
4313
4848
  process.off("SIGINT", sigintHandler);
4314
4849
  screen.stop();
4315
4850
  saveHistory(historyFile, history).catch(() => void 0);
4316
- try {
4317
- ws.close();
4318
- } catch {
4319
- }
4851
+ void stream.close().catch(() => void 0);
4320
4852
  };
4321
4853
  const stop = (code = 0) => {
4322
4854
  teardown();
@@ -4709,6 +5241,12 @@ async function runSession(term, config, opts) {
4709
5241
  refreshCompletions();
4710
5242
  return;
4711
5243
  }
5244
+ if (event.kind === "session-info") {
5245
+ if (event.title !== void 0) {
5246
+ screen.setHeader({ title: event.title });
5247
+ }
5248
+ return;
5249
+ }
4712
5250
  if (event.kind === "usage-update") {
4713
5251
  let changed = false;
4714
5252
  if (event.used !== void 0 && usage.used !== event.used) {
@@ -4804,6 +5342,95 @@ async function runSession(term, config, opts) {
4804
5342
  } finally {
4805
5343
  screen.resumeRepaint();
4806
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
+ };
4807
5434
  conn.onClose((err) => {
4808
5435
  if (err) {
4809
5436
  term.red(`
@@ -4869,23 +5496,13 @@ function newCtx(opts, cwd, config) {
4869
5496
  cwd
4870
5497
  };
4871
5498
  }
4872
- async function openWs2(config) {
4873
- const protocol = config.daemon.tls ? "wss" : "ws";
4874
- const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4875
- const ws = new WebSocket2(url, [
4876
- "acp.v1",
4877
- `hydra-acp-token.${config.daemon.authToken}`
4878
- ]);
4879
- await once(ws, "open");
4880
- return ws;
4881
- }
4882
5499
  var PLAN_PREFIX_TEXT;
4883
5500
  var init_app = __esm({
4884
5501
  "src/tui/app.ts"() {
4885
5502
  "use strict";
4886
5503
  init_connection();
4887
- init_ws_stream();
4888
5504
  init_types();
5505
+ init_resilient_ws();
4889
5506
  init_config();
4890
5507
  init_daemon_bootstrap();
4891
5508
  init_paths();
@@ -5013,7 +5630,7 @@ import { setTimeout as sleep2 } from "timers/promises";
5013
5630
 
5014
5631
  // src/daemon/server.ts
5015
5632
  init_config();
5016
- import * as fs6 from "fs";
5633
+ import * as fs7 from "fs";
5017
5634
  import * as fsp2 from "fs/promises";
5018
5635
  import Fastify from "fastify";
5019
5636
  import websocketPlugin from "@fastify/websocket";
@@ -5330,6 +5947,10 @@ init_paths();
5330
5947
  import * as fs4 from "fs/promises";
5331
5948
  import * as path2 from "path";
5332
5949
  import { z as z4 } from "zod";
5950
+ var PersistedAgentCommand = z4.object({
5951
+ name: z4.string(),
5952
+ description: z4.string().optional()
5953
+ });
5333
5954
  var SessionRecord = z4.object({
5334
5955
  version: z4.literal(1),
5335
5956
  sessionId: z4.string(),
@@ -5338,6 +5959,13 @@ var SessionRecord = z4.object({
5338
5959
  cwd: z4.string(),
5339
5960
  title: z4.string().optional(),
5340
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(),
5341
5969
  createdAt: z4.string(),
5342
5970
  updatedAt: z4.string()
5343
5971
  });
@@ -5350,7 +5978,7 @@ function assertSafeId(id) {
5350
5978
  var SessionStore = class {
5351
5979
  async write(record) {
5352
5980
  assertSafeId(record.sessionId);
5353
- await fs4.mkdir(paths.sessionsDir(), { recursive: true });
5981
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
5354
5982
  const full = { version: 1, ...record };
5355
5983
  await fs4.writeFile(
5356
5984
  paths.sessionFile(record.sessionId),
@@ -5390,6 +6018,14 @@ var SessionStore = class {
5390
6018
  throw err;
5391
6019
  }
5392
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
+ }
5393
6029
  }
5394
6030
  async list() {
5395
6031
  let entries;
@@ -5404,11 +6040,7 @@ var SessionStore = class {
5404
6040
  }
5405
6041
  const records = [];
5406
6042
  for (const entry of entries) {
5407
- if (!entry.endsWith(".json")) {
5408
- continue;
5409
- }
5410
- const id = entry.slice(0, -".json".length);
5411
- const record = await this.read(id);
6043
+ const record = await this.read(entry);
5412
6044
  if (record) {
5413
6045
  records.push(record);
5414
6046
  }
@@ -5425,11 +6057,137 @@ function recordFromMemorySession(args) {
5425
6057
  cwd: args.cwd,
5426
6058
  title: args.title,
5427
6059
  agentArgs: args.agentArgs,
6060
+ currentModel: args.currentModel,
6061
+ currentMode: args.currentMode,
6062
+ agentCommands: args.agentCommands,
5428
6063
  createdAt: args.createdAt ?? now,
5429
6064
  updatedAt: args.updatedAt ?? now
5430
6065
  };
5431
6066
  }
5432
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
+
5433
6191
  // src/core/session-manager.ts
5434
6192
  init_types();
5435
6193
  var SessionManager = class {
@@ -5437,6 +6195,7 @@ var SessionManager = class {
5437
6195
  this.registry = registry;
5438
6196
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
5439
6197
  this.store = store ?? new SessionStore();
6198
+ this.histories = new HistoryStore();
5440
6199
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
5441
6200
  }
5442
6201
  registry;
@@ -5444,7 +6203,12 @@ var SessionManager = class {
5444
6203
  resurrectionInflight = /* @__PURE__ */ new Map();
5445
6204
  spawner;
5446
6205
  store;
6206
+ histories;
5447
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();
5448
6212
  async create(params) {
5449
6213
  const fresh = await this.bootstrapAgent({
5450
6214
  agentId: params.agentId,
@@ -5461,7 +6225,8 @@ var SessionManager = class {
5461
6225
  title: params.title,
5462
6226
  agentArgs: params.agentArgs,
5463
6227
  idleTimeoutMs: this.idleTimeoutMs,
5464
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
6228
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6229
+ historyStore: this.histories
5465
6230
  });
5466
6231
  await this.attachManagerHooks(session);
5467
6232
  return session;
@@ -5537,7 +6302,12 @@ var SessionManager = class {
5537
6302
  title: params.title,
5538
6303
  agentArgs: params.agentArgs,
5539
6304
  idleTimeoutMs: this.idleTimeoutMs,
5540
- 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
5541
6311
  });
5542
6312
  await this.attachManagerHooks(session);
5543
6313
  return session;
@@ -5591,6 +6361,7 @@ var SessionManager = class {
5591
6361
  this.sessions.delete(session.sessionId);
5592
6362
  if (deleteRecord) {
5593
6363
  void this.store.delete(session.sessionId).catch(() => void 0);
6364
+ void this.histories.delete(session.sessionId).catch(() => void 0);
5594
6365
  }
5595
6366
  });
5596
6367
  session.onTitleChange((title) => {
@@ -5601,6 +6372,24 @@ var SessionManager = class {
5601
6372
  () => void 0
5602
6373
  );
5603
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
+ });
5604
6393
  this.sessions.set(session.sessionId, session);
5605
6394
  await this.store.write(
5606
6395
  recordFromMemorySession({
@@ -5609,22 +6398,45 @@ var SessionManager = class {
5609
6398
  agentId: session.agentId,
5610
6399
  cwd: session.cwd,
5611
6400
  title: session.title,
5612
- agentArgs: session.agentArgs
6401
+ agentArgs: session.agentArgs,
6402
+ currentModel: session.currentModel,
6403
+ currentMode: session.currentMode
5613
6404
  })
5614
6405
  ).catch(() => void 0);
5615
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
+ }
5616
6423
  async loadFromDisk(sessionId) {
5617
6424
  const record = await this.store.read(sessionId);
5618
6425
  if (!record) {
5619
6426
  return void 0;
5620
6427
  }
6428
+ const seedHistory = await this.histories.load(sessionId).catch(() => []);
5621
6429
  return {
5622
6430
  hydraSessionId: record.sessionId,
5623
6431
  upstreamSessionId: record.upstreamSessionId,
5624
6432
  agentId: record.agentId,
5625
6433
  cwd: record.cwd,
5626
6434
  title: record.title,
5627
- 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
5628
6440
  };
5629
6441
  }
5630
6442
  get(sessionId) {
@@ -5712,14 +6524,16 @@ var SessionManager = class {
5712
6524
  // record's title in sync with what was broadcast to clients so a
5713
6525
  // daemon restart (and later resurrect) restores the same title.
5714
6526
  async persistTitle(sessionId, title) {
5715
- const record = await this.store.read(sessionId);
5716
- if (!record) {
5717
- return;
5718
- }
5719
- await this.store.write({
5720
- ...record,
5721
- title,
5722
- 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
+ });
5723
6537
  });
5724
6538
  }
5725
6539
  // Persist an agent swap from /hydra switch. The on-disk record's
@@ -5727,17 +6541,52 @@ var SessionManager = class {
5727
6541
  // later resurrect) brings the session back up on the agent the user
5728
6542
  // most recently switched to, not the one it was originally created on.
5729
6543
  async persistAgentChange(sessionId, agentId, upstreamSessionId) {
5730
- const record = await this.store.read(sessionId);
5731
- if (!record) {
5732
- return;
5733
- }
5734
- await this.store.write({
5735
- ...record,
5736
- agentId,
5737
- upstreamSessionId,
5738
- 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
+ });
5739
6555
  });
5740
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
+ });
6574
+ });
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
+ }
5741
6590
  async closeAll() {
5742
6591
  const sessions = [...this.sessions.values()];
5743
6592
  await Promise.allSettled(sessions.map((s) => s.close()));
@@ -5748,7 +6597,7 @@ var SessionManager = class {
5748
6597
  // src/core/extensions.ts
5749
6598
  init_paths();
5750
6599
  import { spawn as spawn2 } from "child_process";
5751
- import * as fs5 from "fs";
6600
+ import * as fs6 from "fs";
5752
6601
  import * as fsp from "fs/promises";
5753
6602
  import * as path3 from "path";
5754
6603
  var RESTART_BASE_MS = 1e3;
@@ -6031,7 +6880,7 @@ var ExtensionManager = class {
6031
6880
  }
6032
6881
  const ext = entry.config;
6033
6882
  const command = ext.command.length > 0 ? ext.command : [ext.name];
6034
- const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
6883
+ const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
6035
6884
  flags: "a"
6036
6885
  });
6037
6886
  logStream.write(
@@ -6081,7 +6930,7 @@ var ExtensionManager = class {
6081
6930
  }
6082
6931
  if (typeof child.pid === "number") {
6083
6932
  try {
6084
- fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6933
+ fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6085
6934
  `, {
6086
6935
  encoding: "utf8",
6087
6936
  mode: 384
@@ -6106,7 +6955,7 @@ var ExtensionManager = class {
6106
6955
  });
6107
6956
  child.on("exit", (code, signal) => {
6108
6957
  try {
6109
- fs5.unlinkSync(paths.extensionPidFile(ext.name));
6958
+ fs6.unlinkSync(paths.extensionPidFile(ext.name));
6110
6959
  } catch {
6111
6960
  }
6112
6961
  logStream.write(
@@ -6222,8 +7071,7 @@ init_config();
6222
7071
  function registerSessionRoutes(app, manager, defaults) {
6223
7072
  app.get("/v1/sessions", async (request) => {
6224
7073
  const query = request.query;
6225
- const all = query?.all === "true" || query?.all === "1";
6226
- const sessions = await manager.list({ cwd: query?.cwd, all });
7074
+ const sessions = await manager.list({ cwd: query?.cwd });
6227
7075
  return { sessions };
6228
7076
  });
6229
7077
  app.post("/v1/sessions", async (request, reply) => {
@@ -6261,6 +7109,50 @@ function registerSessionRoutes(app, manager, defaults) {
6261
7109
  }
6262
7110
  reply.code(204).send();
6263
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
+ });
6264
7156
  }
6265
7157
 
6266
7158
  // src/daemon/routes/agents.ts
@@ -6651,6 +7543,16 @@ function buildResponseMeta(session) {
6651
7543
  if (session.agentArgs && session.agentArgs.length > 0) {
6652
7544
  ours.agentArgs = session.agentArgs;
6653
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
+ }
6654
7556
  return mergeMeta(session.agentMeta, ours);
6655
7557
  }
6656
7558
  function buildInitializeResult() {
@@ -6771,7 +7673,7 @@ async function startDaemon(config) {
6771
7673
  await manager.closeAll();
6772
7674
  await app.close();
6773
7675
  try {
6774
- fs6.unlinkSync(paths.pidFile());
7676
+ fs7.unlinkSync(paths.pidFile());
6775
7677
  } catch {
6776
7678
  }
6777
7679
  try {
@@ -6810,7 +7712,7 @@ function ensureLoopbackOrTls(config) {
6810
7712
  init_daemon_bootstrap();
6811
7713
 
6812
7714
  // src/cli/commands/log-tail.ts
6813
- import * as fs7 from "fs";
7715
+ import * as fs8 from "fs";
6814
7716
  import * as fsp3 from "fs/promises";
6815
7717
  async function runLogTail(logPath, argv, notFoundMessage) {
6816
7718
  const opts = parseLogTailFlags(argv);
@@ -6834,7 +7736,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
6834
7736
  process.stdout.write(`-- following ${logPath} --
6835
7737
  `);
6836
7738
  let pending = false;
6837
- const watcher = fs7.watch(logPath, () => {
7739
+ const watcher = fs8.watch(logPath, () => {
6838
7740
  if (pending) {
6839
7741
  return;
6840
7742
  }
@@ -7592,226 +8494,7 @@ function maxLen3(headerCell, values) {
7592
8494
  // src/shim/proxy.ts
7593
8495
  init_config();
7594
8496
  init_daemon_bootstrap();
7595
-
7596
- // src/shim/resilient-ws.ts
7597
- init_ws_stream();
7598
- init_types();
7599
- import { setTimeout as sleep3 } from "timers/promises";
7600
- import { WebSocket } from "ws";
7601
- var BACKOFF_INITIAL_MS = 200;
7602
- var BACKOFF_MAX_MS = 5e3;
7603
- var BACKOFF_MULTIPLIER = 2;
7604
- var MAX_RECONNECT_ATTEMPTS = 60;
7605
- var ResilientWsStream = class {
7606
- constructor(opts) {
7607
- this.opts = opts;
7608
- }
7609
- opts;
7610
- current;
7611
- outboundQueue = [];
7612
- messageHandlers = [];
7613
- closeHandlers = [];
7614
- destroyed = false;
7615
- firstConnect = true;
7616
- reconnectInFlight;
7617
- connectGate;
7618
- releaseConnectGate;
7619
- pendingRequests = /* @__PURE__ */ new Map();
7620
- async start() {
7621
- await this.connectWithRetry();
7622
- }
7623
- onMessage(handler) {
7624
- this.messageHandlers.push(handler);
7625
- }
7626
- onClose(handler) {
7627
- this.closeHandlers.push(handler);
7628
- }
7629
- async send(message) {
7630
- if (this.destroyed) {
7631
- throw new Error("resilient ws stream is destroyed");
7632
- }
7633
- if (this.connectGate || !this.current) {
7634
- this.outboundQueue.push(message);
7635
- return;
7636
- }
7637
- try {
7638
- await this.current.send(message);
7639
- } catch (err) {
7640
- this.outboundQueue.push(message);
7641
- this.scheduleReconnect(err);
7642
- }
7643
- }
7644
- // Send a request directly and resolve when the matching response arrives
7645
- // on the same connection. Used by onConnect handlers to await replay-attach
7646
- // responses before letting the outbound queue drain. Bypasses the
7647
- // connectGate intentionally.
7648
- async request(message) {
7649
- if (this.destroyed) {
7650
- throw new Error("resilient ws stream is destroyed");
7651
- }
7652
- if (!this.current) {
7653
- throw new Error("resilient ws stream not connected");
7654
- }
7655
- const id = message.id;
7656
- const promise = new Promise((resolve2, reject) => {
7657
- this.pendingRequests.set(id, { resolve: resolve2, reject });
7658
- });
7659
- try {
7660
- await this.current.send(message);
7661
- } catch (err) {
7662
- this.pendingRequests.delete(id);
7663
- throw err;
7664
- }
7665
- return promise;
7666
- }
7667
- async close() {
7668
- this.destroyed = true;
7669
- if (this.current) {
7670
- await this.current.close().catch(() => void 0);
7671
- }
7672
- for (const handler of this.closeHandlers) {
7673
- handler();
7674
- }
7675
- }
7676
- async connectWithRetry() {
7677
- let attempt = 0;
7678
- let backoff = BACKOFF_INITIAL_MS;
7679
- while (!this.destroyed) {
7680
- try {
7681
- const stream = await openWs(this.opts.url, this.opts.subprotocols);
7682
- this.bindStream(stream);
7683
- const wasFirst = this.firstConnect;
7684
- this.firstConnect = false;
7685
- this.connectGate = new Promise((resolve2) => {
7686
- this.releaseConnectGate = resolve2;
7687
- });
7688
- try {
7689
- if (this.opts.onConnect) {
7690
- try {
7691
- await this.opts.onConnect(wasFirst);
7692
- } catch (err) {
7693
- this.log(
7694
- `hydra-acp: post-connect handler failed: ${err.message}`
7695
- );
7696
- }
7697
- }
7698
- } finally {
7699
- this.releaseConnectGate?.();
7700
- this.releaseConnectGate = void 0;
7701
- this.connectGate = void 0;
7702
- }
7703
- await this.flushQueue();
7704
- return;
7705
- } catch (err) {
7706
- attempt += 1;
7707
- if (this.opts.onConnectFailure) {
7708
- this.opts.onConnectFailure(err);
7709
- }
7710
- if (attempt >= MAX_RECONNECT_ATTEMPTS) {
7711
- throw new Error(
7712
- `hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
7713
- );
7714
- }
7715
- this.log(
7716
- `hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
7717
- );
7718
- await sleep3(backoff);
7719
- backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
7720
- }
7721
- }
7722
- }
7723
- bindStream(stream) {
7724
- this.current = stream;
7725
- stream.onMessage((msg) => {
7726
- if (isResponse(msg)) {
7727
- const pending = this.pendingRequests.get(msg.id);
7728
- if (pending) {
7729
- this.pendingRequests.delete(msg.id);
7730
- pending.resolve(msg);
7731
- }
7732
- }
7733
- for (const handler of this.messageHandlers) {
7734
- handler(msg);
7735
- }
7736
- });
7737
- stream.onClose((err) => {
7738
- if (this.destroyed) {
7739
- return;
7740
- }
7741
- this.current = void 0;
7742
- if (this.pendingRequests.size > 0) {
7743
- const reason = err ?? new Error("ws closed before response");
7744
- for (const { reject } of this.pendingRequests.values()) {
7745
- reject(reason);
7746
- }
7747
- this.pendingRequests.clear();
7748
- }
7749
- this.scheduleReconnect(err);
7750
- });
7751
- }
7752
- async flushQueue() {
7753
- if (!this.current) {
7754
- return;
7755
- }
7756
- const queue = this.outboundQueue;
7757
- this.outboundQueue = [];
7758
- for (const msg of queue) {
7759
- try {
7760
- await this.current.send(msg);
7761
- } catch (err) {
7762
- this.outboundQueue.unshift(msg);
7763
- this.scheduleReconnect(err);
7764
- return;
7765
- }
7766
- }
7767
- }
7768
- scheduleReconnect(err) {
7769
- if (this.destroyed || this.reconnectInFlight) {
7770
- return;
7771
- }
7772
- this.log(
7773
- `hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
7774
- );
7775
- this.reconnectInFlight = (async () => {
7776
- try {
7777
- await this.connectWithRetry();
7778
- } catch (final) {
7779
- for (const handler of this.closeHandlers) {
7780
- handler(final);
7781
- }
7782
- this.destroyed = true;
7783
- } finally {
7784
- this.reconnectInFlight = void 0;
7785
- }
7786
- })();
7787
- }
7788
- log(line) {
7789
- if (this.opts.log) {
7790
- this.opts.log(line);
7791
- return;
7792
- }
7793
- process.stderr.write(`${line}
7794
- `);
7795
- }
7796
- };
7797
- function isResponse(msg) {
7798
- return !("method" in msg) && "id" in msg && msg.id !== void 0;
7799
- }
7800
- async function openWs(url, subprotocols) {
7801
- return new Promise((resolve2, reject) => {
7802
- const ws = new WebSocket(url, subprotocols);
7803
- const onOpen = () => {
7804
- ws.off("error", onError);
7805
- resolve2(wsToMessageStream(ws));
7806
- };
7807
- const onError = (err) => {
7808
- ws.off("open", onOpen);
7809
- reject(err);
7810
- };
7811
- ws.once("open", onOpen);
7812
- ws.once("error", onError);
7813
- });
7814
- }
8497
+ init_resilient_ws();
7815
8498
 
7816
8499
  // src/shim/session-tracker.ts
7817
8500
  init_types();