@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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/daemon/server.ts
2
- import * as fs5 from "fs";
2
+ import * as fs6 from "fs";
3
3
  import * as fsp2 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -32,7 +32,12 @@ var paths = {
32
32
  agentsDir: () => path.join(hydraHome(), "agents"),
33
33
  agentDir: (id) => path.join(hydraHome(), "agents", id),
34
34
  sessionsDir: () => path.join(hydraHome(), "sessions"),
35
- sessionFile: (id) => path.join(hydraHome(), "sessions", `${id}.json`),
35
+ // One directory per session id under sessions/. Co-locates the
36
+ // session record, its transcript, and any future per-session state
37
+ // (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
38
+ sessionDir: (id) => path.join(hydraHome(), "sessions", id),
39
+ sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
40
+ historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
36
41
  extensionsDir: () => path.join(hydraHome(), "extensions"),
37
42
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
38
43
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -410,6 +415,32 @@ function extractHydraMeta(meta) {
410
415
  out.resume = parsed.data;
411
416
  }
412
417
  }
418
+ if (typeof obj.currentModel === "string") {
419
+ out.currentModel = obj.currentModel;
420
+ }
421
+ if (typeof obj.currentMode === "string") {
422
+ out.currentMode = obj.currentMode;
423
+ }
424
+ if (Array.isArray(obj.availableCommands)) {
425
+ const cmds = [];
426
+ for (const raw of obj.availableCommands) {
427
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
428
+ continue;
429
+ }
430
+ const c = raw;
431
+ if (typeof c.name !== "string") {
432
+ continue;
433
+ }
434
+ const cmd = { name: c.name };
435
+ if (typeof c.description === "string") {
436
+ cmd.description = c.description;
437
+ }
438
+ cmds.push(cmd);
439
+ }
440
+ if (cmds.length > 0) {
441
+ out.availableCommands = cmds;
442
+ }
443
+ }
413
444
  return out;
414
445
  }
415
446
  function mergeMeta(passthrough, ours) {
@@ -779,14 +810,26 @@ var Session = class {
779
810
  agentMeta;
780
811
  agentArgs;
781
812
  title;
813
+ // Snapshot state delivered to attaching clients via the attach
814
+ // response _meta rather than via history replay (which would be
815
+ // stale-prone for snapshot-shaped events).
816
+ currentModel;
817
+ currentMode;
782
818
  updatedAt;
783
819
  clients = /* @__PURE__ */ new Map();
784
820
  history = [];
821
+ historyStore;
785
822
  promptQueue = [];
786
823
  promptInFlight = false;
787
824
  closed = false;
788
825
  closeHandlers = [];
789
826
  titleHandlers = [];
827
+ // Subscribers notified after every entry that's actually persisted to
828
+ // history (skipping snapshot-shaped events filtered by
829
+ // recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
830
+ // endpoint uses this to tail a live session's conversation stream
831
+ // without participating in turns or prompts.
832
+ broadcastHandlers = [];
790
833
  // True once we've observed our first session/prompt; gates the
791
834
  // first-prompt-seeded title so subsequent prompts don't churn it.
792
835
  firstPromptSeeded = false;
@@ -806,12 +849,18 @@ var Session = class {
806
849
  idleTimer;
807
850
  spawnReplacementAgent;
808
851
  agentChangeHandlers = [];
809
- // Last available_commands_update we observed from the agent. Stored so
810
- // we can re-broadcast a merged (hydra ∪ agent) list whenever either
811
- // half changes most importantly when a fresh client attaches and
812
- // replays history, since the in-cache update is the daemon's merged
813
- // form (the agent's raw form is never broadcast).
852
+ // Last available_commands_update we observed from the agent. Stored
853
+ // so we can re-broadcast a merged (hydra ∪ agent) list whenever
854
+ // either half changes, and persisted to meta.json so a fresh attach
855
+ // can deliver the merged list via _meta without depending on history
856
+ // replay.
814
857
  agentAdvertisedCommands = [];
858
+ // Persist hooks for snapshot-shaped state. SessionManager hooks these
859
+ // to mirror changes into meta.json so cold-resurrect attaches can
860
+ // surface the latest snapshot via the attach response _meta.
861
+ agentCommandsHandlers = [];
862
+ modelHandlers = [];
863
+ modeHandlers = [];
815
864
  constructor(init) {
816
865
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
817
866
  this.cwd = init.cwd;
@@ -821,11 +870,19 @@ var Session = class {
821
870
  this.agentMeta = init.agentMeta;
822
871
  this.agentArgs = init.agentArgs;
823
872
  this.title = init.title;
873
+ this.currentModel = init.currentModel;
874
+ this.currentMode = init.currentMode;
875
+ if (init.agentCommands && init.agentCommands.length > 0) {
876
+ this.agentAdvertisedCommands = [...init.agentCommands];
877
+ }
824
878
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
825
879
  this.spawnReplacementAgent = init.spawnReplacementAgent;
880
+ this.historyStore = init.historyStore;
881
+ if (init.seedHistory && init.seedHistory.length > 0) {
882
+ this.history = [...init.seedHistory];
883
+ }
826
884
  this.updatedAt = Date.now();
827
885
  this.wireAgent(this.agent);
828
- this.broadcastMergedCommands();
829
886
  }
830
887
  broadcastMergedCommands() {
831
888
  const merged = [
@@ -854,8 +911,15 @@ var Session = class {
854
911
  }
855
912
  const agentCmds = extractAdvertisedCommands(params);
856
913
  if (agentCmds !== null) {
857
- this.agentAdvertisedCommands = agentCmds;
858
- this.broadcastMergedCommands();
914
+ this.setAgentAdvertisedCommands(agentCmds);
915
+ return;
916
+ }
917
+ if (this.maybeApplyAgentModel(params)) {
918
+ this.recordAndBroadcast("session/update", params);
919
+ return;
920
+ }
921
+ if (this.maybeApplyAgentMode(params)) {
922
+ this.recordAndBroadcast("session/update", params);
859
923
  return;
860
924
  }
861
925
  this.maybeApplyAgentSessionInfo(params);
@@ -877,6 +941,27 @@ var Session = class {
877
941
  get attachedCount() {
878
942
  return this.clients.size;
879
943
  }
944
+ // Snapshot of the current in-memory replay history. Used by the
945
+ // HTTP history endpoint to deliver the "what's accumulated so far"
946
+ // prefix before optionally tailing with onBroadcast. Returns a copy
947
+ // so callers can't mutate our cache.
948
+ getHistorySnapshot() {
949
+ return [...this.history];
950
+ }
951
+ // Subscribe to recordable broadcast entries — fires once per entry
952
+ // that lands in history (so snapshot-shaped session_info/model/mode/
953
+ // available_commands updates do NOT trigger this; they're broadcast
954
+ // live but not recorded). Returns an unsubscribe function the caller
955
+ // must invoke when done.
956
+ onBroadcast(handler) {
957
+ this.broadcastHandlers.push(handler);
958
+ return () => {
959
+ const i = this.broadcastHandlers.indexOf(handler);
960
+ if (i >= 0) {
961
+ this.broadcastHandlers.splice(i, 1);
962
+ }
963
+ };
964
+ }
880
965
  attach(client, historyPolicy) {
881
966
  if (this.closed) {
882
967
  throw withCode(
@@ -1082,6 +1167,91 @@ var Session = class {
1082
1167
  this.firstPromptSeeded = true;
1083
1168
  this.setTitle(seed);
1084
1169
  }
1170
+ // Apply an agent-emitted current_model_update. Returns true if the
1171
+ // notification was a model update (caller still needs to broadcast
1172
+ // it). Returns false otherwise so the caller can try the next kind.
1173
+ maybeApplyAgentModel(params) {
1174
+ const obj = params ?? {};
1175
+ const update = obj.update ?? {};
1176
+ if (update.sessionUpdate !== "current_model_update") {
1177
+ return false;
1178
+ }
1179
+ const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
1180
+ if (raw === void 0) {
1181
+ return true;
1182
+ }
1183
+ const trimmed = raw.trim();
1184
+ if (!trimmed || trimmed === this.currentModel) {
1185
+ return true;
1186
+ }
1187
+ this.currentModel = trimmed;
1188
+ for (const handler of this.modelHandlers) {
1189
+ try {
1190
+ handler(trimmed);
1191
+ } catch {
1192
+ }
1193
+ }
1194
+ return true;
1195
+ }
1196
+ maybeApplyAgentMode(params) {
1197
+ const obj = params ?? {};
1198
+ const update = obj.update ?? {};
1199
+ if (update.sessionUpdate !== "current_mode_update") {
1200
+ return false;
1201
+ }
1202
+ const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
1203
+ if (raw === void 0) {
1204
+ return true;
1205
+ }
1206
+ const trimmed = raw.trim();
1207
+ if (!trimmed || trimmed === this.currentMode) {
1208
+ return true;
1209
+ }
1210
+ this.currentMode = trimmed;
1211
+ for (const handler of this.modeHandlers) {
1212
+ try {
1213
+ handler(trimmed);
1214
+ } catch {
1215
+ }
1216
+ }
1217
+ return true;
1218
+ }
1219
+ // Update the cached agent command list, fire persist handlers, and
1220
+ // broadcast the merged list to attached clients. Idempotent on a
1221
+ // structurally identical list so we don't churn meta.json on noisy
1222
+ // re-emissions.
1223
+ setAgentAdvertisedCommands(commands) {
1224
+ if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
1225
+ this.broadcastMergedCommands();
1226
+ return;
1227
+ }
1228
+ this.agentAdvertisedCommands = commands;
1229
+ for (const handler of this.agentCommandsHandlers) {
1230
+ try {
1231
+ handler(commands);
1232
+ } catch {
1233
+ }
1234
+ }
1235
+ this.broadcastMergedCommands();
1236
+ }
1237
+ // Subscribe to snapshot-state updates. SessionManager wires these to
1238
+ // persist the new value into meta.json so cold resurrect can restore
1239
+ // them via the attach response _meta.
1240
+ onAgentCommandsChange(handler) {
1241
+ this.agentCommandsHandlers.push(handler);
1242
+ }
1243
+ onModelChange(handler) {
1244
+ this.modelHandlers.push(handler);
1245
+ }
1246
+ onModeChange(handler) {
1247
+ this.modeHandlers.push(handler);
1248
+ }
1249
+ // Returns a freshly merged command list (hydra ∪ agent) for callers
1250
+ // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1251
+ // assembling the attach response.
1252
+ mergedAvailableCommands() {
1253
+ return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1254
+ }
1085
1255
  // Pick up an agent-emitted session_info_update and store its title
1086
1256
  // as our canonical record. The notification is also forwarded to
1087
1257
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1392,9 +1562,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1392
1562
  }
1393
1563
  recordAndBroadcast(method, params, excludeClientId) {
1394
1564
  const rewritten = this.rewriteForClient(params);
1395
- this.history.push({ method, params: rewritten, recordedAt: Date.now() });
1396
- if (this.history.length > 1e3) {
1397
- this.history = this.history.slice(-500);
1565
+ const recordable = !isStateUpdate(method, rewritten);
1566
+ if (recordable) {
1567
+ const entry = {
1568
+ method,
1569
+ params: rewritten,
1570
+ recordedAt: Date.now()
1571
+ };
1572
+ this.history.push(entry);
1573
+ let trimmed = false;
1574
+ if (this.history.length > 1e3) {
1575
+ this.history = this.history.slice(-500);
1576
+ trimmed = true;
1577
+ }
1578
+ if (this.historyStore) {
1579
+ if (trimmed) {
1580
+ void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1581
+ } else {
1582
+ void this.historyStore.append(this.sessionId, entry).catch(
1583
+ () => void 0
1584
+ );
1585
+ }
1586
+ }
1587
+ for (const handler of this.broadcastHandlers) {
1588
+ try {
1589
+ handler(entry);
1590
+ } catch {
1591
+ }
1592
+ }
1398
1593
  }
1399
1594
  this.updatedAt = Date.now();
1400
1595
  for (const client of this.clients.values()) {
@@ -1494,6 +1689,31 @@ function withCode(err, code) {
1494
1689
  err.code = code;
1495
1690
  return err;
1496
1691
  }
1692
+ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1693
+ "session_info_update",
1694
+ "current_model_update",
1695
+ "current_mode_update",
1696
+ "available_commands_update"
1697
+ ]);
1698
+ function isStateUpdate(method, params) {
1699
+ if (method !== "session/update") {
1700
+ return false;
1701
+ }
1702
+ const obj = params ?? {};
1703
+ const kind = obj.update?.sessionUpdate;
1704
+ return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
1705
+ }
1706
+ function sameAdvertisedCommands(a, b) {
1707
+ if (a.length !== b.length) {
1708
+ return false;
1709
+ }
1710
+ for (let i = 0; i < a.length; i++) {
1711
+ if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
1712
+ return false;
1713
+ }
1714
+ }
1715
+ return true;
1716
+ }
1497
1717
  function captureInternalChunk(capture, params) {
1498
1718
  const obj = params ?? {};
1499
1719
  const update = obj.update ?? {};
@@ -1561,6 +1781,10 @@ function firstLine(text, max) {
1561
1781
  import * as fs3 from "fs/promises";
1562
1782
  import * as path2 from "path";
1563
1783
  import { z as z4 } from "zod";
1784
+ var PersistedAgentCommand = z4.object({
1785
+ name: z4.string(),
1786
+ description: z4.string().optional()
1787
+ });
1564
1788
  var SessionRecord = z4.object({
1565
1789
  version: z4.literal(1),
1566
1790
  sessionId: z4.string(),
@@ -1569,6 +1793,13 @@ var SessionRecord = z4.object({
1569
1793
  cwd: z4.string(),
1570
1794
  title: z4.string().optional(),
1571
1795
  agentArgs: z4.array(z4.string()).optional(),
1796
+ // Snapshot of "what is currently true about this session" carried in
1797
+ // meta.json so a late-attaching or cold-resurrected client can be
1798
+ // told via the attach response _meta without depending on history
1799
+ // replay of a snapshot-shaped notification.
1800
+ currentModel: z4.string().optional(),
1801
+ currentMode: z4.string().optional(),
1802
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
1572
1803
  createdAt: z4.string(),
1573
1804
  updatedAt: z4.string()
1574
1805
  });
@@ -1581,7 +1812,7 @@ function assertSafeId(id) {
1581
1812
  var SessionStore = class {
1582
1813
  async write(record) {
1583
1814
  assertSafeId(record.sessionId);
1584
- await fs3.mkdir(paths.sessionsDir(), { recursive: true });
1815
+ await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1585
1816
  const full = { version: 1, ...record };
1586
1817
  await fs3.writeFile(
1587
1818
  paths.sessionFile(record.sessionId),
@@ -1621,6 +1852,14 @@ var SessionStore = class {
1621
1852
  throw err;
1622
1853
  }
1623
1854
  }
1855
+ try {
1856
+ await fs3.rmdir(paths.sessionDir(sessionId));
1857
+ } catch (err) {
1858
+ const e = err;
1859
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
1860
+ throw err;
1861
+ }
1862
+ }
1624
1863
  }
1625
1864
  async list() {
1626
1865
  let entries;
@@ -1635,11 +1874,7 @@ var SessionStore = class {
1635
1874
  }
1636
1875
  const records = [];
1637
1876
  for (const entry of entries) {
1638
- if (!entry.endsWith(".json")) {
1639
- continue;
1640
- }
1641
- const id = entry.slice(0, -".json".length);
1642
- const record = await this.read(id);
1877
+ const record = await this.read(entry);
1643
1878
  if (record) {
1644
1879
  records.push(record);
1645
1880
  }
@@ -1656,17 +1891,143 @@ function recordFromMemorySession(args) {
1656
1891
  cwd: args.cwd,
1657
1892
  title: args.title,
1658
1893
  agentArgs: args.agentArgs,
1894
+ currentModel: args.currentModel,
1895
+ currentMode: args.currentMode,
1896
+ agentCommands: args.agentCommands,
1659
1897
  createdAt: args.createdAt ?? now,
1660
1898
  updatedAt: args.updatedAt ?? now
1661
1899
  };
1662
1900
  }
1663
1901
 
1902
+ // src/core/history-store.ts
1903
+ import * as fs4 from "fs/promises";
1904
+ var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
1905
+ var MAX_ENTRIES = 1e3;
1906
+ var HistoryStore = class {
1907
+ // Serialize writes per session id so appends and rewrites don't
1908
+ // interleave JSONL lines on disk. The chain swallows errors so one
1909
+ // failed append doesn't poison every subsequent write.
1910
+ writeQueues = /* @__PURE__ */ new Map();
1911
+ async append(sessionId, entry) {
1912
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1913
+ return;
1914
+ }
1915
+ return this.enqueue(sessionId, async () => {
1916
+ await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
1917
+ const line = JSON.stringify(entry) + "\n";
1918
+ await fs4.appendFile(paths.historyFile(sessionId), line, {
1919
+ encoding: "utf8",
1920
+ mode: 384
1921
+ });
1922
+ });
1923
+ }
1924
+ async rewrite(sessionId, entries) {
1925
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1926
+ return;
1927
+ }
1928
+ return this.enqueue(sessionId, async () => {
1929
+ await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
1930
+ const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1931
+ await fs4.writeFile(paths.historyFile(sessionId), body, {
1932
+ encoding: "utf8",
1933
+ mode: 384
1934
+ });
1935
+ });
1936
+ }
1937
+ async load(sessionId) {
1938
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1939
+ return [];
1940
+ }
1941
+ const pending = this.writeQueues.get(sessionId);
1942
+ if (pending) {
1943
+ await pending;
1944
+ }
1945
+ let raw;
1946
+ try {
1947
+ raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
1948
+ } catch (err) {
1949
+ const e = err;
1950
+ if (e.code === "ENOENT") {
1951
+ return [];
1952
+ }
1953
+ throw err;
1954
+ }
1955
+ const out = [];
1956
+ for (const line of raw.split("\n")) {
1957
+ if (line.length === 0) {
1958
+ continue;
1959
+ }
1960
+ let parsed;
1961
+ try {
1962
+ parsed = JSON.parse(line);
1963
+ } catch {
1964
+ continue;
1965
+ }
1966
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1967
+ continue;
1968
+ }
1969
+ const obj = parsed;
1970
+ if (typeof obj.method !== "string") {
1971
+ continue;
1972
+ }
1973
+ if (typeof obj.recordedAt !== "number") {
1974
+ continue;
1975
+ }
1976
+ out.push({
1977
+ method: obj.method,
1978
+ params: obj.params,
1979
+ recordedAt: obj.recordedAt
1980
+ });
1981
+ }
1982
+ if (out.length > MAX_ENTRIES) {
1983
+ return out.slice(-MAX_ENTRIES);
1984
+ }
1985
+ return out;
1986
+ }
1987
+ async delete(sessionId) {
1988
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1989
+ return;
1990
+ }
1991
+ return this.enqueue(sessionId, async () => {
1992
+ try {
1993
+ await fs4.unlink(paths.historyFile(sessionId));
1994
+ } catch (err) {
1995
+ const e = err;
1996
+ if (e.code !== "ENOENT") {
1997
+ throw err;
1998
+ }
1999
+ }
2000
+ try {
2001
+ await fs4.rmdir(paths.sessionDir(sessionId));
2002
+ } catch (err) {
2003
+ const e = err;
2004
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
2005
+ throw err;
2006
+ }
2007
+ }
2008
+ });
2009
+ }
2010
+ enqueue(sessionId, task) {
2011
+ const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
2012
+ const task$ = prev.then(task, task);
2013
+ const settled = task$.catch(() => void 0);
2014
+ this.writeQueues.set(sessionId, settled);
2015
+ void settled.finally(() => {
2016
+ if (this.writeQueues.get(sessionId) === settled) {
2017
+ this.writeQueues.delete(sessionId);
2018
+ }
2019
+ });
2020
+ return task$;
2021
+ }
2022
+ };
2023
+
1664
2024
  // src/core/session-manager.ts
1665
2025
  var SessionManager = class {
1666
2026
  constructor(registry, spawner, store, options = {}) {
1667
2027
  this.registry = registry;
1668
2028
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
1669
2029
  this.store = store ?? new SessionStore();
2030
+ this.histories = new HistoryStore();
1670
2031
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
1671
2032
  }
1672
2033
  registry;
@@ -1674,7 +2035,12 @@ var SessionManager = class {
1674
2035
  resurrectionInflight = /* @__PURE__ */ new Map();
1675
2036
  spawner;
1676
2037
  store;
2038
+ histories;
1677
2039
  idleTimeoutMs;
2040
+ // Serialize meta.json read-modify-write operations per session id so
2041
+ // concurrent snapshot updates (e.g. an agent emitting model + mode
2042
+ // back-to-back) don't lose writes via interleaved reads.
2043
+ metaWriteQueues = /* @__PURE__ */ new Map();
1678
2044
  async create(params) {
1679
2045
  const fresh = await this.bootstrapAgent({
1680
2046
  agentId: params.agentId,
@@ -1691,7 +2057,8 @@ var SessionManager = class {
1691
2057
  title: params.title,
1692
2058
  agentArgs: params.agentArgs,
1693
2059
  idleTimeoutMs: this.idleTimeoutMs,
1694
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
2060
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2061
+ historyStore: this.histories
1695
2062
  });
1696
2063
  await this.attachManagerHooks(session);
1697
2064
  return session;
@@ -1767,7 +2134,12 @@ var SessionManager = class {
1767
2134
  title: params.title,
1768
2135
  agentArgs: params.agentArgs,
1769
2136
  idleTimeoutMs: this.idleTimeoutMs,
1770
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
2137
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2138
+ historyStore: this.histories,
2139
+ seedHistory: params.seedHistory,
2140
+ currentModel: params.currentModel,
2141
+ currentMode: params.currentMode,
2142
+ agentCommands: params.agentCommands
1771
2143
  });
1772
2144
  await this.attachManagerHooks(session);
1773
2145
  return session;
@@ -1821,6 +2193,7 @@ var SessionManager = class {
1821
2193
  this.sessions.delete(session.sessionId);
1822
2194
  if (deleteRecord) {
1823
2195
  void this.store.delete(session.sessionId).catch(() => void 0);
2196
+ void this.histories.delete(session.sessionId).catch(() => void 0);
1824
2197
  }
1825
2198
  });
1826
2199
  session.onTitleChange((title) => {
@@ -1831,6 +2204,24 @@ var SessionManager = class {
1831
2204
  () => void 0
1832
2205
  );
1833
2206
  });
2207
+ session.onModelChange((model) => {
2208
+ void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
2209
+ () => void 0
2210
+ );
2211
+ });
2212
+ session.onModeChange((mode) => {
2213
+ void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
2214
+ () => void 0
2215
+ );
2216
+ });
2217
+ session.onAgentCommandsChange((commands) => {
2218
+ void this.persistSnapshot(session.sessionId, {
2219
+ agentCommands: commands.map((c) => ({
2220
+ name: c.name,
2221
+ ...c.description !== void 0 ? { description: c.description } : {}
2222
+ }))
2223
+ }).catch(() => void 0);
2224
+ });
1834
2225
  this.sessions.set(session.sessionId, session);
1835
2226
  await this.store.write(
1836
2227
  recordFromMemorySession({
@@ -1839,22 +2230,45 @@ var SessionManager = class {
1839
2230
  agentId: session.agentId,
1840
2231
  cwd: session.cwd,
1841
2232
  title: session.title,
1842
- agentArgs: session.agentArgs
2233
+ agentArgs: session.agentArgs,
2234
+ currentModel: session.currentModel,
2235
+ currentMode: session.currentMode
1843
2236
  })
1844
2237
  ).catch(() => void 0);
1845
2238
  }
2239
+ // Resolve a session's recorded history without forcing a resurrect.
2240
+ // Returns the in-memory snapshot if the session is hot, falls back
2241
+ // to the on-disk history file otherwise. Returns undefined if the
2242
+ // session id is unknown to both the live map and disk store, so the
2243
+ // caller can distinguish "no history yet" (empty array) from "404".
2244
+ async getHistory(sessionId) {
2245
+ const live = this.sessions.get(sessionId);
2246
+ if (live) {
2247
+ return live.getHistorySnapshot();
2248
+ }
2249
+ const record = await this.store.read(sessionId);
2250
+ if (!record) {
2251
+ return void 0;
2252
+ }
2253
+ return this.histories.load(sessionId).catch(() => []);
2254
+ }
1846
2255
  async loadFromDisk(sessionId) {
1847
2256
  const record = await this.store.read(sessionId);
1848
2257
  if (!record) {
1849
2258
  return void 0;
1850
2259
  }
2260
+ const seedHistory = await this.histories.load(sessionId).catch(() => []);
1851
2261
  return {
1852
2262
  hydraSessionId: record.sessionId,
1853
2263
  upstreamSessionId: record.upstreamSessionId,
1854
2264
  agentId: record.agentId,
1855
2265
  cwd: record.cwd,
1856
2266
  title: record.title,
1857
- agentArgs: record.agentArgs
2267
+ agentArgs: record.agentArgs,
2268
+ seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
2269
+ currentModel: record.currentModel,
2270
+ currentMode: record.currentMode,
2271
+ agentCommands: record.agentCommands
1858
2272
  };
1859
2273
  }
1860
2274
  get(sessionId) {
@@ -1942,14 +2356,16 @@ var SessionManager = class {
1942
2356
  // record's title in sync with what was broadcast to clients so a
1943
2357
  // daemon restart (and later resurrect) restores the same title.
1944
2358
  async persistTitle(sessionId, title) {
1945
- const record = await this.store.read(sessionId);
1946
- if (!record) {
1947
- return;
1948
- }
1949
- await this.store.write({
1950
- ...record,
1951
- title,
1952
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2359
+ await this.enqueueMetaWrite(sessionId, async () => {
2360
+ const record = await this.store.read(sessionId);
2361
+ if (!record) {
2362
+ return;
2363
+ }
2364
+ await this.store.write({
2365
+ ...record,
2366
+ title,
2367
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2368
+ });
1953
2369
  });
1954
2370
  }
1955
2371
  // Persist an agent swap from /hydra switch. The on-disk record's
@@ -1957,17 +2373,52 @@ var SessionManager = class {
1957
2373
  // later resurrect) brings the session back up on the agent the user
1958
2374
  // most recently switched to, not the one it was originally created on.
1959
2375
  async persistAgentChange(sessionId, agentId, upstreamSessionId) {
1960
- const record = await this.store.read(sessionId);
1961
- if (!record) {
1962
- return;
1963
- }
1964
- await this.store.write({
1965
- ...record,
1966
- agentId,
1967
- upstreamSessionId,
1968
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2376
+ await this.enqueueMetaWrite(sessionId, async () => {
2377
+ const record = await this.store.read(sessionId);
2378
+ if (!record) {
2379
+ return;
2380
+ }
2381
+ await this.store.write({
2382
+ ...record,
2383
+ agentId,
2384
+ upstreamSessionId,
2385
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2386
+ });
1969
2387
  });
1970
2388
  }
2389
+ // Update one or more snapshot fields (model, mode, commands) in
2390
+ // meta.json. Used so cold-resurrect can deliver the latest snapshot
2391
+ // to attaching clients via the attach response _meta. No-op if the
2392
+ // session record has gone away (race with deleteRecord).
2393
+ async persistSnapshot(sessionId, update) {
2394
+ await this.enqueueMetaWrite(sessionId, async () => {
2395
+ const record = await this.store.read(sessionId);
2396
+ if (!record) {
2397
+ return;
2398
+ }
2399
+ await this.store.write({
2400
+ ...record,
2401
+ ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
2402
+ ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
2403
+ ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
2404
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2405
+ });
2406
+ });
2407
+ }
2408
+ // Serialize meta.json writes per session id so concurrent
2409
+ // read-modify-write operations don't interleave reads.
2410
+ enqueueMetaWrite(sessionId, task) {
2411
+ const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
2412
+ const next = prev.then(task, task);
2413
+ const settled = next.catch(() => void 0);
2414
+ this.metaWriteQueues.set(sessionId, settled);
2415
+ void settled.finally(() => {
2416
+ if (this.metaWriteQueues.get(sessionId) === settled) {
2417
+ this.metaWriteQueues.delete(sessionId);
2418
+ }
2419
+ });
2420
+ return next;
2421
+ }
1971
2422
  async closeAll() {
1972
2423
  const sessions = [...this.sessions.values()];
1973
2424
  await Promise.allSettled(sessions.map((s) => s.close()));
@@ -1977,7 +2428,7 @@ var SessionManager = class {
1977
2428
 
1978
2429
  // src/core/extensions.ts
1979
2430
  import { spawn as spawn2 } from "child_process";
1980
- import * as fs4 from "fs";
2431
+ import * as fs5 from "fs";
1981
2432
  import * as fsp from "fs/promises";
1982
2433
  import * as path3 from "path";
1983
2434
  var RESTART_BASE_MS = 1e3;
@@ -2260,7 +2711,7 @@ var ExtensionManager = class {
2260
2711
  }
2261
2712
  const ext = entry.config;
2262
2713
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2263
- const logStream = fs4.createWriteStream(paths.extensionLogFile(ext.name), {
2714
+ const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
2264
2715
  flags: "a"
2265
2716
  });
2266
2717
  logStream.write(
@@ -2310,7 +2761,7 @@ var ExtensionManager = class {
2310
2761
  }
2311
2762
  if (typeof child.pid === "number") {
2312
2763
  try {
2313
- fs4.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2764
+ fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2314
2765
  `, {
2315
2766
  encoding: "utf8",
2316
2767
  mode: 384
@@ -2335,7 +2786,7 @@ var ExtensionManager = class {
2335
2786
  });
2336
2787
  child.on("exit", (code, signal) => {
2337
2788
  try {
2338
- fs4.unlinkSync(paths.extensionPidFile(ext.name));
2789
+ fs5.unlinkSync(paths.extensionPidFile(ext.name));
2339
2790
  } catch {
2340
2791
  }
2341
2792
  logStream.write(
@@ -2447,8 +2898,7 @@ function constantTimeEqual(a, b) {
2447
2898
  function registerSessionRoutes(app, manager, defaults) {
2448
2899
  app.get("/v1/sessions", async (request) => {
2449
2900
  const query = request.query;
2450
- const all = query?.all === "true" || query?.all === "1";
2451
- const sessions = await manager.list({ cwd: query?.cwd, all });
2901
+ const sessions = await manager.list({ cwd: query?.cwd });
2452
2902
  return { sessions };
2453
2903
  });
2454
2904
  app.post("/v1/sessions", async (request, reply) => {
@@ -2486,6 +2936,50 @@ function registerSessionRoutes(app, manager, defaults) {
2486
2936
  }
2487
2937
  reply.code(204).send();
2488
2938
  });
2939
+ app.get("/v1/sessions/:id/history", async (request, reply) => {
2940
+ const raw = request.params.id;
2941
+ const query = request.query;
2942
+ const follow = query?.follow === "1" || query?.follow === "true";
2943
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
2944
+ const live = manager.get(id);
2945
+ let snapshot;
2946
+ let unsubscribe;
2947
+ if (live) {
2948
+ snapshot = live.getHistorySnapshot();
2949
+ if (follow) {
2950
+ unsubscribe = live.onBroadcast((entry) => {
2951
+ if (reply.raw.writableEnded) {
2952
+ return;
2953
+ }
2954
+ reply.raw.write(JSON.stringify(entry) + "\n");
2955
+ });
2956
+ }
2957
+ } else {
2958
+ const cold = await manager.getHistory(id);
2959
+ if (cold === void 0) {
2960
+ reply.code(404).send({ error: "session not found" });
2961
+ return reply;
2962
+ }
2963
+ snapshot = cold;
2964
+ }
2965
+ reply.raw.setHeader("Content-Type", "application/x-ndjson");
2966
+ reply.raw.setHeader("Cache-Control", "no-cache");
2967
+ reply.raw.statusCode = 200;
2968
+ for (const entry of snapshot ?? []) {
2969
+ reply.raw.write(JSON.stringify(entry) + "\n");
2970
+ }
2971
+ if (!unsubscribe) {
2972
+ reply.raw.end();
2973
+ return reply;
2974
+ }
2975
+ request.raw.on("close", () => {
2976
+ unsubscribe?.();
2977
+ if (!reply.raw.writableEnded) {
2978
+ reply.raw.end();
2979
+ }
2980
+ });
2981
+ return reply;
2982
+ });
2489
2983
  }
2490
2984
 
2491
2985
  // src/daemon/routes/agents.ts
@@ -2946,6 +3440,16 @@ function buildResponseMeta(session) {
2946
3440
  if (session.agentArgs && session.agentArgs.length > 0) {
2947
3441
  ours.agentArgs = session.agentArgs;
2948
3442
  }
3443
+ if (session.currentModel !== void 0) {
3444
+ ours.currentModel = session.currentModel;
3445
+ }
3446
+ if (session.currentMode !== void 0) {
3447
+ ours.currentMode = session.currentMode;
3448
+ }
3449
+ const commands = session.mergedAvailableCommands();
3450
+ if (commands.length > 0) {
3451
+ ours.availableCommands = commands;
3452
+ }
2949
3453
  return mergeMeta(session.agentMeta, ours);
2950
3454
  }
2951
3455
  function buildInitializeResult() {
@@ -3066,7 +3570,7 @@ async function startDaemon(config) {
3066
3570
  await manager.closeAll();
3067
3571
  await app.close();
3068
3572
  try {
3069
- fs5.unlinkSync(paths.pidFile());
3573
+ fs6.unlinkSync(paths.pidFile());
3070
3574
  } catch {
3071
3575
  }
3072
3576
  try {