@hydra-acp/cli 0.1.3 → 0.1.4

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 fs7 from "fs";
2
+ import * as fs8 from "fs";
3
3
  import * as fsp2 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -62,7 +62,7 @@ var DaemonConfig = z.object({
62
62
  authToken: z.string().min(16),
63
63
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
64
64
  tls: TlsConfig.optional(),
65
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
65
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
66
66
  });
67
67
  var RegistryConfig = z.object({
68
68
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -137,8 +137,7 @@ async function ensureConfig() {
137
137
  if (e.code !== "ENOENT") {
138
138
  throw err;
139
139
  }
140
- const config = defaultConfig();
141
- await writeConfig(config);
140
+ const config = await writeMinimalInitConfig();
142
141
  process.stderr.write(
143
142
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
144
143
  `
@@ -154,6 +153,16 @@ async function writeConfig(config) {
154
153
  mode: 384
155
154
  });
156
155
  }
156
+ async function writeMinimalInitConfig(authToken) {
157
+ const token = authToken ?? generateAuthToken();
158
+ const minimal = { daemon: { authToken: token } };
159
+ await fs.mkdir(paths.home(), { recursive: true });
160
+ await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
161
+ encoding: "utf8",
162
+ mode: 384
163
+ });
164
+ return HydraConfig.parse(minimal);
165
+ }
157
166
  function generateAuthToken() {
158
167
  const bytes = new Uint8Array(32);
159
168
  crypto.getRandomValues(bytes);
@@ -348,7 +357,8 @@ function planSpawn(agent, extraArgs = []) {
348
357
  }
349
358
 
350
359
  // src/core/session-manager.ts
351
- import * as fs5 from "fs/promises";
360
+ import * as fs6 from "fs/promises";
361
+ import { customAlphabet as customAlphabet3 } from "nanoid";
352
362
 
353
363
  // src/core/agent-instance.ts
354
364
  import { spawn } from "child_process";
@@ -364,7 +374,8 @@ var JsonRpcErrorCodes = {
364
374
  SessionNotFound: -32001,
365
375
  PermissionDenied: -32002,
366
376
  AlreadyAttached: -32003,
367
- AgentNotInstalled: -32005
377
+ AgentNotInstalled: -32005,
378
+ BundleAlreadyImported: -32010
368
379
  };
369
380
  var InitializeParams = z3.object({
370
381
  protocolVersion: z3.number().optional(),
@@ -813,6 +824,8 @@ function hydraCommandsAsAdvertised() {
813
824
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
814
825
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
815
826
  var HYDRA_SESSION_PREFIX = "hydra_session_";
827
+ var MAX_HISTORY_ENTRIES = 1e3;
828
+ var COMPACT_EVERY = 200;
816
829
  var Session = class {
817
830
  sessionId;
818
831
  cwd;
@@ -832,8 +845,8 @@ var Session = class {
832
845
  currentModel;
833
846
  currentMode;
834
847
  updatedAt;
848
+ createdAt;
835
849
  clients = /* @__PURE__ */ new Map();
836
- history = [];
837
850
  historyStore;
838
851
  promptQueue = [];
839
852
  promptInFlight = false;
@@ -849,6 +862,15 @@ var Session = class {
849
862
  // True once we've observed our first session/prompt; gates the
850
863
  // first-prompt-seeded title so subsequent prompts don't churn it.
851
864
  firstPromptSeeded = false;
865
+ // Wall-clock when the active prompt started, undefined when idle.
866
+ // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
867
+ // Drives the mid-turn elapsed counter delivered to fresh attachers.
868
+ promptStartedAt;
869
+ // Counts appends since the last compaction. When it hits COMPACT_EVERY
870
+ // we ask the history store to trim the file to the most recent
871
+ // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
872
+ // file-size checks.
873
+ appendCount = 0;
852
874
  // Permission requests that have been broadcast to one or more
853
875
  // clients but have not yet resolved. Replayed to clients that
854
876
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -863,6 +885,12 @@ var Session = class {
863
885
  internalPromptCapture;
864
886
  idleTimeoutMs;
865
887
  idleTimer;
888
+ // Time of the last recordable broadcast (or session creation, if
889
+ // none yet). Drives the inactivity-based idle close; deliberately
890
+ // does NOT include snapshot state pings (model/mode/title/commands)
891
+ // or attach/detach, which would otherwise let passive observers
892
+ // and noisy state churn keep a quiet session alive forever.
893
+ lastRecordedAt;
866
894
  spawnReplacementAgent;
867
895
  agentChangeHandlers = [];
868
896
  // Last available_commands_update we observed from the agent. Stored
@@ -897,11 +925,11 @@ var Session = class {
897
925
  this.firstPromptSeeded = true;
898
926
  }
899
927
  this.historyStore = init.historyStore;
900
- if (init.seedHistory && init.seedHistory.length > 0) {
901
- this.history = [...init.seedHistory];
902
- }
903
928
  this.updatedAt = Date.now();
929
+ this.createdAt = init.createdAt ?? this.updatedAt;
930
+ this.lastRecordedAt = this.updatedAt;
904
931
  this.wireAgent(this.agent);
932
+ this.scheduleIdleCheck();
905
933
  }
906
934
  broadcastMergedCommands() {
907
935
  const merged = [
@@ -961,34 +989,20 @@ var Session = class {
961
989
  return this.clients.size;
962
990
  }
963
991
  // Wall-clock when the in-flight agent turn began, or undefined when
964
- // idle. Derived from history: the most recent prompt_received without
965
- // a later turn_complete is the outstanding turn, and its recordedAt
966
- // is when the prompt was first broadcast. Used by buildResponseMeta
967
- // so a fresh client reattaching mid-turn boots up with the busy
968
- // banner showing real elapsed time.
992
+ // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
993
+ // so the daemon can hand a fresh attacher mid-turn the right elapsed
994
+ // time without scanning history.
969
995
  get turnStartedAt() {
970
- for (let i = this.history.length - 1; i >= 0; i--) {
971
- const entry = this.history[i];
972
- if (!entry) {
973
- continue;
974
- }
975
- const params = entry.params;
976
- const kind = params?.update?.sessionUpdate;
977
- if (kind === "turn_complete") {
978
- return void 0;
979
- }
980
- if (kind === "prompt_received") {
981
- return entry.recordedAt;
982
- }
983
- }
984
- return void 0;
996
+ return this.promptStartedAt;
985
997
  }
986
- // Snapshot of the current in-memory replay history. Used by the
987
- // HTTP history endpoint to deliver the "what's accumulated so far"
988
- // prefix before optionally tailing with onBroadcast. Returns a copy
989
- // so callers can't mutate our cache.
990
- getHistorySnapshot() {
991
- return [...this.history];
998
+ // Read the persisted history from disk. Returns [] if no history
999
+ // file exists (fresh session, never prompted). Used by attach() and
1000
+ // the HTTP /history endpoint.
1001
+ async getHistorySnapshot() {
1002
+ if (!this.historyStore) {
1003
+ return [];
1004
+ }
1005
+ return this.historyStore.load(this.sessionId).catch(() => []);
992
1006
  }
993
1007
  // Subscribe to recordable broadcast entries — fires once per entry
994
1008
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -1004,6 +1018,10 @@ var Session = class {
1004
1018
  }
1005
1019
  };
1006
1020
  }
1021
+ // Register a client and (asynchronously) load the replay slice it
1022
+ // should receive. Validation errors throw synchronously so callers
1023
+ // can rely on either the registration being in effect or having
1024
+ // thrown; the disk-load is the only async work.
1007
1025
  attach(client, historyPolicy) {
1008
1026
  if (this.closed) {
1009
1027
  throw withCode(
@@ -1019,14 +1037,10 @@ var Session = class {
1019
1037
  }
1020
1038
  this.clients.set(client.clientId, client);
1021
1039
  this.updatedAt = Date.now();
1022
- this.cancelIdleTimer();
1023
- if (historyPolicy === "none") {
1024
- return [];
1040
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
1041
+ return Promise.resolve([]);
1025
1042
  }
1026
- if (historyPolicy === "pending_only") {
1027
- return [];
1028
- }
1029
- return [...this.history];
1043
+ return this.getHistorySnapshot();
1030
1044
  }
1031
1045
  // Dispatch in-flight permission requests to a freshly-attached
1032
1046
  // client. Called by the daemon's WS handler *after* it finishes
@@ -1040,7 +1054,6 @@ var Session = class {
1040
1054
  detach(clientId) {
1041
1055
  if (this.clients.delete(clientId)) {
1042
1056
  this.updatedAt = Date.now();
1043
- this.maybeStartIdleTimer();
1044
1057
  }
1045
1058
  }
1046
1059
  async prompt(clientId, params) {
@@ -1086,6 +1099,7 @@ var Session = class {
1086
1099
  if (client.clientInfo?.version) {
1087
1100
  sentBy.version = client.clientInfo.version;
1088
1101
  }
1102
+ this.promptStartedAt = Date.now();
1089
1103
  this.recordAndBroadcast(
1090
1104
  "session/update",
1091
1105
  {
@@ -1122,6 +1136,7 @@ var Session = class {
1122
1136
  if (stopReason !== void 0) {
1123
1137
  update.stopReason = stopReason;
1124
1138
  }
1139
+ this.promptStartedAt = void 0;
1125
1140
  this.recordAndBroadcast(
1126
1141
  "session/update",
1127
1142
  {
@@ -1307,6 +1322,12 @@ var Session = class {
1307
1322
  mergedAvailableCommands() {
1308
1323
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1309
1324
  }
1325
+ // The agent's own advertised commands (not merged with hydra verbs).
1326
+ // Used by SessionManager to persist into meta.json so cold resurrect
1327
+ // can re-deliver via the attach response _meta.
1328
+ agentOnlyAdvertisedCommands() {
1329
+ return [...this.agentAdvertisedCommands];
1330
+ }
1310
1331
  // Pick up an agent-emitted session_info_update and store its title
1311
1332
  // as our canonical record. The notification is also forwarded to
1312
1333
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1438,7 +1459,7 @@ var Session = class {
1438
1459
  const spawnAgent = this.spawnReplacementAgent;
1439
1460
  return this.enqueuePrompt(async () => {
1440
1461
  const oldAgentId = this.agentId;
1441
- const transcript = this.buildSwitchTranscript(oldAgentId);
1462
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1442
1463
  const fresh = await spawnAgent({
1443
1464
  agentId: newAgentId,
1444
1465
  cwd: this.cwd,
@@ -1470,15 +1491,20 @@ var Session = class {
1470
1491
  return { stopReason: "end_turn" };
1471
1492
  });
1472
1493
  }
1473
- // Walk this.history (rewritten-for-clients notification cache) and
1474
- // produce a labeled transcript suitable for handing to a fresh agent.
1475
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1476
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1477
- // banners) and other update kinds we don't think the next agent
1478
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1479
- buildSwitchTranscript(prevAgentId) {
1494
+ // Walk the persisted history and produce a labeled transcript suitable
1495
+ // for handing to a fresh agent. Includes user prompts, agent replies,
1496
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
1497
+ // switches don't accumulate banners) and other update kinds we don't
1498
+ // think the next agent benefits from re-seeing (plans, thoughts,
1499
+ // mode/model/usage).
1500
+ //
1501
+ // The header text defaults to the agent-swap framing; callers like
1502
+ // seedFromImport pass a custom header when the new agent is taking
1503
+ // over an imported session rather than swapping mid-conversation.
1504
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
1480
1505
  const lines = [];
1481
- for (const note of this.history) {
1506
+ const history = await this.getHistorySnapshot();
1507
+ for (const note of history) {
1482
1508
  if (note.method !== "session/update") {
1483
1509
  continue;
1484
1510
  }
@@ -1532,29 +1558,53 @@ var Session = class {
1532
1558
  if (current) {
1533
1559
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1534
1560
  }
1561
+ const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
1562
+ const followup = headerOverride?.followup ?? `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`;
1535
1563
  return [
1536
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1537
- `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1564
+ intro,
1565
+ followup,
1538
1566
  "",
1539
1567
  "--- begin transcript ---",
1540
1568
  ...coalesced,
1541
1569
  "--- end transcript ---"
1542
1570
  ].join("\n");
1543
1571
  }
1572
+ // Replay the persisted history into a freshly-spawned agent so an
1573
+ // imported session has context. Called by SessionManager.doResurrect
1574
+ // on the first wake-up of a session whose meta.json has an empty
1575
+ // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1576
+ // any user prompts arriving mid-seed queue behind it (mirrors the
1577
+ // /hydra switch path so the agent isn't asked to respond to a user
1578
+ // turn before it has absorbed the imported transcript). Best-effort:
1579
+ // if the agent fails to absorb the transcript we still leave the
1580
+ // session usable — the user just continues without context.
1581
+ async seedFromImport() {
1582
+ await this.enqueuePrompt(async () => {
1583
+ const transcript = await this.buildSwitchTranscript(this.agentId, {
1584
+ intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
1585
+ followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
1586
+ });
1587
+ if (!transcript) {
1588
+ return void 0;
1589
+ }
1590
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1591
+ return void 0;
1592
+ });
1593
+ }
1544
1594
  // Tell every attached client (a) the agent identity has changed
1545
- // (session_info_update with an agentId field clients that already
1546
- // listen for title updates pick this up; older clients ignore unknown
1547
- // fields harmlessly) and (b) drop a visible banner into the transcript
1548
- // so users see the switch rather than just suddenly getting answers
1549
- // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1595
+ // (session_info_update carrying agentId inside _meta["hydra-acp"]
1596
+ // the ACP schema for session_info_update is just title/updatedAt/_meta,
1597
+ // so non-hydra clients harmlessly ignore the extension; hydra-aware
1598
+ // ones read it and relabel) and (b) drop a visible banner into the
1599
+ // transcript so users see the switch rather than just suddenly getting
1600
+ // answers from a different agent. Both updates carry synthetic=true
1550
1601
  // so a future /hydra switch's transcript builder filters them out.
1551
1602
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1552
1603
  this.recordAndBroadcast("session/update", {
1553
1604
  sessionId: this.sessionId,
1554
1605
  update: {
1555
1606
  sessionUpdate: "session_info_update",
1556
- agentId: newAgentId,
1557
- _meta: { "hydra-acp": { synthetic: true } }
1607
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1558
1608
  }
1559
1609
  });
1560
1610
  this.recordAndBroadcast("session/update", {
@@ -1585,22 +1635,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1585
1635
  handler(opts);
1586
1636
  }
1587
1637
  }
1588
- maybeStartIdleTimer() {
1589
- if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1638
+ // Last meaningful activity timestamp. Bumped only by recordable
1639
+ // broadcasts in recordAndBroadcast the same signal historyMtimeIso
1640
+ // uses for the picker. Initialized at construction (and seeded from
1641
+ // the newest entry on resurrect) so the inactivity window starts
1642
+ // ticking from a sensible floor when there's no history yet.
1643
+ get lastActivityAt() {
1644
+ return this.lastRecordedAt;
1645
+ }
1646
+ // (Re-)arm the idle timer to fire when the inactivity window
1647
+ // elapses past lastActivityAt. Called once at construction and after
1648
+ // every recorded broadcast. The previous design gated on
1649
+ // clients.size === 0; we drop that gate because extensions
1650
+ // (slack/notifier/approver/browser) hold persistent attaches that
1651
+ // would otherwise keep a quiet session alive forever.
1652
+ scheduleIdleCheck() {
1653
+ if (this.closed || this.idleTimeoutMs <= 0) {
1590
1654
  return;
1591
1655
  }
1656
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1657
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1658
+ }
1659
+ armIdleTimer(delay) {
1592
1660
  if (this.idleTimer) {
1593
- return;
1661
+ clearTimeout(this.idleTimer);
1594
1662
  }
1595
1663
  this.idleTimer = setTimeout(() => {
1596
1664
  this.idleTimer = void 0;
1597
- const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1598
- void this.close(opts).catch(() => void 0);
1599
- }, this.idleTimeoutMs);
1665
+ this.checkIdle();
1666
+ }, delay);
1600
1667
  if (typeof this.idleTimer.unref === "function") {
1601
1668
  this.idleTimer.unref();
1602
1669
  }
1603
1670
  }
1671
+ checkIdle() {
1672
+ if (this.closed || this.idleTimeoutMs <= 0) {
1673
+ return;
1674
+ }
1675
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
1676
+ this.armIdleTimer(this.idleTimeoutMs);
1677
+ return;
1678
+ }
1679
+ const idle = Date.now() - this.lastActivityAt;
1680
+ if (idle < this.idleTimeoutMs) {
1681
+ this.armIdleTimer(this.idleTimeoutMs - idle);
1682
+ return;
1683
+ }
1684
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1685
+ void this.close(opts).catch(() => void 0);
1686
+ }
1604
1687
  cancelIdleTimer() {
1605
1688
  if (this.idleTimer) {
1606
1689
  clearTimeout(this.idleTimer);
@@ -1625,17 +1708,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1625
1708
  params: rewritten,
1626
1709
  recordedAt: Date.now()
1627
1710
  };
1628
- this.history.push(entry);
1629
- let trimmed = false;
1630
- if (this.history.length > 1e3) {
1631
- this.history = this.history.slice(-500);
1632
- trimmed = true;
1633
- }
1711
+ this.lastRecordedAt = entry.recordedAt;
1712
+ this.appendCount += 1;
1634
1713
  if (this.historyStore) {
1635
- if (trimmed) {
1636
- void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1637
- } else {
1638
- void this.historyStore.append(this.sessionId, entry).catch(
1714
+ const store = this.historyStore;
1715
+ void store.append(this.sessionId, entry).catch(() => void 0);
1716
+ if (this.appendCount >= COMPACT_EVERY) {
1717
+ this.appendCount = 0;
1718
+ void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1639
1719
  () => void 0
1640
1720
  );
1641
1721
  }
@@ -1646,6 +1726,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1646
1726
  } catch {
1647
1727
  }
1648
1728
  }
1729
+ this.scheduleIdleCheck();
1649
1730
  }
1650
1731
  this.updatedAt = Date.now();
1651
1732
  for (const client of this.clients.values()) {
@@ -1836,7 +1917,14 @@ function firstLine(text, max) {
1836
1917
  // src/core/session-store.ts
1837
1918
  import * as fs3 from "fs/promises";
1838
1919
  import * as path2 from "path";
1920
+ import { customAlphabet as customAlphabet2 } from "nanoid";
1839
1921
  import { z as z4 } from "zod";
1922
+ var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1923
+ var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
1924
+ var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
1925
+ function generateLineageId() {
1926
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
1927
+ }
1840
1928
  var PersistedAgentCommand = z4.object({
1841
1929
  name: z4.string(),
1842
1930
  description: z4.string().optional()
@@ -1844,7 +1932,20 @@ var PersistedAgentCommand = z4.object({
1844
1932
  var SessionRecord = z4.object({
1845
1933
  version: z4.literal(1),
1846
1934
  sessionId: z4.string(),
1935
+ // Optional for back-compat with records written before this field
1936
+ // existed; mergeForPersistence generates one on next write so any
1937
+ // touched session converges to having a lineageId. A record that
1938
+ // never gets written again (truly cold and untouched) just won't
1939
+ // participate in lineage-based dedup, which is correct — it was
1940
+ // never exported, so no incoming bundle can claim its lineage.
1941
+ lineageId: z4.string().optional(),
1847
1942
  upstreamSessionId: z4.string(),
1943
+ // When non-empty, marks a session that was created by import and is
1944
+ // waiting for its first attach to bootstrap a fresh upstream agent
1945
+ // and replay the imported history as a takeover transcript. The
1946
+ // origin's local id at export time, kept for debuggability and as a
1947
+ // breadcrumb in `sessions list` (informational, not used for routing).
1948
+ importedFromSessionId: z4.string().optional(),
1848
1949
  agentId: z4.string(),
1849
1950
  cwd: z4.string(),
1850
1951
  title: z4.string().optional(),
@@ -1917,6 +2018,25 @@ var SessionStore = class {
1917
2018
  }
1918
2019
  }
1919
2020
  }
2021
+ // Find a persisted session by lineageId. Used by SessionManager.import
2022
+ // to detect bundles that have already been imported (lineageId match)
2023
+ // so we can either error out or, with replace:true, overwrite.
2024
+ // Returns undefined if no record has that lineageId. Records that
2025
+ // pre-date the lineageId field simply don't match — which is
2026
+ // correct: they were never exported, so no incoming bundle can
2027
+ // legitimately claim their lineage.
2028
+ async findByLineageId(lineageId) {
2029
+ if (lineageId.length === 0) {
2030
+ return void 0;
2031
+ }
2032
+ const all = await this.list().catch(() => []);
2033
+ for (const record of all) {
2034
+ if (record.lineageId === lineageId) {
2035
+ return record;
2036
+ }
2037
+ }
2038
+ return void 0;
2039
+ }
1920
2040
  async list() {
1921
2041
  let entries;
1922
2042
  try {
@@ -1942,7 +2062,9 @@ function recordFromMemorySession(args) {
1942
2062
  const now = (/* @__PURE__ */ new Date()).toISOString();
1943
2063
  return {
1944
2064
  sessionId: args.sessionId,
2065
+ lineageId: args.lineageId,
1945
2066
  upstreamSessionId: args.upstreamSessionId,
2067
+ importedFromSessionId: args.importedFromSessionId,
1946
2068
  agentId: args.agentId,
1947
2069
  cwd: args.cwd,
1948
2070
  title: args.title,
@@ -1990,6 +2112,36 @@ var HistoryStore = class {
1990
2112
  });
1991
2113
  });
1992
2114
  }
2115
+ // Trim the on-disk history file to the most recent maxEntries lines.
2116
+ // Runs through the same per-session write queue as append/rewrite so
2117
+ // it's safe to invoke alongside ongoing writes; a no-op if the file is
2118
+ // already at or below the cap.
2119
+ async compact(sessionId, maxEntries) {
2120
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
2121
+ return;
2122
+ }
2123
+ return this.enqueue(sessionId, async () => {
2124
+ let raw;
2125
+ try {
2126
+ raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2127
+ } catch (err) {
2128
+ const e = err;
2129
+ if (e.code === "ENOENT") {
2130
+ return;
2131
+ }
2132
+ throw err;
2133
+ }
2134
+ const lines = raw.split("\n").filter((l) => l.length > 0);
2135
+ if (lines.length <= maxEntries) {
2136
+ return;
2137
+ }
2138
+ const trimmed = lines.slice(-maxEntries);
2139
+ await fs4.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
2140
+ encoding: "utf8",
2141
+ mode: 384
2142
+ });
2143
+ });
2144
+ }
1993
2145
  async load(sessionId) {
1994
2146
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
1995
2147
  return [];
@@ -2077,7 +2229,18 @@ var HistoryStore = class {
2077
2229
  }
2078
2230
  };
2079
2231
 
2232
+ // src/tui/history.ts
2233
+ import { promises as fs5 } from "fs";
2234
+ import * as path3 from "path";
2235
+ async function saveHistory(file, history) {
2236
+ await fs5.mkdir(path3.dirname(file), { recursive: true });
2237
+ const lines = history.map((entry) => JSON.stringify(entry));
2238
+ await fs5.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2239
+ }
2240
+
2080
2241
  // src/core/session-manager.ts
2242
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2243
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
2081
2244
  var SessionManager = class {
2082
2245
  constructor(registry, spawner, store, options = {}) {
2083
2246
  this.registry = registry;
@@ -2156,6 +2319,9 @@ var SessionManager = class {
2156
2319
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2157
2320
  throw err;
2158
2321
  }
2322
+ if (params.upstreamSessionId === "") {
2323
+ return this.doResurrectFromImport(params);
2324
+ }
2159
2325
  const plan = planSpawn(agentDef, params.agentArgs ?? []);
2160
2326
  const agent = this.spawner({
2161
2327
  agentId: params.agentId,
@@ -2192,15 +2358,55 @@ var SessionManager = class {
2192
2358
  idleTimeoutMs: this.idleTimeoutMs,
2193
2359
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2194
2360
  historyStore: this.histories,
2195
- seedHistory: params.seedHistory,
2196
2361
  currentModel: params.currentModel,
2197
2362
  currentMode: params.currentMode,
2198
2363
  agentCommands: params.agentCommands,
2199
- firstPromptSeeded: true
2364
+ // Only gate the first-prompt title heuristic when we actually have
2365
+ // a title to preserve. A title-less session (lost to a write race
2366
+ // or never seeded) should re-derive from the next prompt rather
2367
+ // than stay stuck.
2368
+ firstPromptSeeded: !!params.title,
2369
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
2200
2370
  });
2201
2371
  await this.attachManagerHooks(session);
2202
2372
  return session;
2203
2373
  }
2374
+ // First-attach path for a session that was created via import(). The
2375
+ // on-disk meta.json carries upstreamSessionId="" as the import
2376
+ // marker; bootstrap a fresh agent (gets a real upstream id) and kick
2377
+ // off seedFromImport so the agent absorbs the historical transcript.
2378
+ // attachManagerHooks rewrites meta.json with the new upstreamSessionId,
2379
+ // so subsequent resurrects of this session use the normal session/load
2380
+ // path.
2381
+ async doResurrectFromImport(params) {
2382
+ const fresh = await this.bootstrapAgent({
2383
+ agentId: params.agentId,
2384
+ cwd: params.cwd,
2385
+ agentArgs: params.agentArgs,
2386
+ mcpServers: []
2387
+ });
2388
+ const session = new Session({
2389
+ sessionId: params.hydraSessionId,
2390
+ cwd: params.cwd,
2391
+ agentId: params.agentId,
2392
+ agent: fresh.agent,
2393
+ upstreamSessionId: fresh.upstreamSessionId,
2394
+ agentMeta: fresh.agentMeta,
2395
+ title: params.title,
2396
+ agentArgs: params.agentArgs,
2397
+ idleTimeoutMs: this.idleTimeoutMs,
2398
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2399
+ historyStore: this.histories,
2400
+ currentModel: params.currentModel,
2401
+ currentMode: params.currentMode,
2402
+ agentCommands: params.agentCommands,
2403
+ firstPromptSeeded: !!params.title,
2404
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
2405
+ });
2406
+ await this.attachManagerHooks(session);
2407
+ void session.seedFromImport().catch(() => void 0);
2408
+ return session;
2409
+ }
2204
2410
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
2205
2411
  // → session/new. Shared by create() and the /hydra switch path so both
2206
2412
  // go through the same env / capabilities / error-handling.
@@ -2280,28 +2486,20 @@ var SessionManager = class {
2280
2486
  }).catch(() => void 0);
2281
2487
  });
2282
2488
  this.sessions.set(session.sessionId, session);
2283
- await this.store.write(
2284
- recordFromMemorySession({
2285
- sessionId: session.sessionId,
2286
- upstreamSessionId: session.upstreamSessionId,
2287
- agentId: session.agentId,
2288
- cwd: session.cwd,
2289
- title: session.title,
2290
- agentArgs: session.agentArgs,
2291
- currentModel: session.currentModel,
2292
- currentMode: session.currentMode
2293
- })
2294
- ).catch(() => void 0);
2489
+ await this.enqueueMetaWrite(session.sessionId, async () => {
2490
+ const existing = await this.store.read(session.sessionId);
2491
+ const merged = mergeForPersistence(session, existing);
2492
+ await this.store.write(merged);
2493
+ }).catch(() => void 0);
2295
2494
  }
2296
2495
  // Resolve a session's recorded history without forcing a resurrect.
2297
- // Returns the in-memory snapshot if the session is hot, falls back
2298
- // to the on-disk history file otherwise. Returns undefined if the
2299
- // session id is unknown to both the live map and disk store, so the
2300
- // caller can distinguish "no history yet" (empty array) from "404".
2496
+ // Always loads from disk that's the source of truth whether the
2497
+ // session is hot or cold. Returns undefined if the session id is
2498
+ // unknown to both the live map and disk store, so the caller can
2499
+ // distinguish "no history yet" (empty array) from "404".
2301
2500
  async getHistory(sessionId) {
2302
- const live = this.sessions.get(sessionId);
2303
- if (live) {
2304
- return live.getHistorySnapshot();
2501
+ if (this.sessions.has(sessionId)) {
2502
+ return this.histories.load(sessionId).catch(() => []);
2305
2503
  }
2306
2504
  const record = await this.store.read(sessionId);
2307
2505
  if (!record) {
@@ -2314,20 +2512,41 @@ var SessionManager = class {
2314
2512
  if (!record) {
2315
2513
  return void 0;
2316
2514
  }
2317
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
2515
+ let title = record.title;
2516
+ if (!title) {
2517
+ title = await this.deriveTitleFromHistory(sessionId);
2518
+ }
2318
2519
  return {
2319
2520
  hydraSessionId: record.sessionId,
2320
2521
  upstreamSessionId: record.upstreamSessionId,
2321
2522
  agentId: record.agentId,
2322
2523
  cwd: record.cwd,
2323
- title: record.title,
2524
+ title,
2324
2525
  agentArgs: record.agentArgs,
2325
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
2326
2526
  currentModel: record.currentModel,
2327
2527
  currentMode: record.currentMode,
2328
- agentCommands: record.agentCommands
2528
+ agentCommands: record.agentCommands,
2529
+ createdAt: record.createdAt
2329
2530
  };
2330
2531
  }
2532
+ // Best-effort: peek at the persisted history's first prompt and use
2533
+ // its first line (capped to 200 chars) as a session title. Returns
2534
+ // undefined if no usable prompt is found or any I/O fails.
2535
+ async deriveTitleFromHistory(sessionId) {
2536
+ const history = await this.histories.load(sessionId).catch(() => []);
2537
+ for (const entry of history) {
2538
+ const params = entry.params;
2539
+ if (params?.update?.sessionUpdate !== "prompt_received") {
2540
+ continue;
2541
+ }
2542
+ const text = extractPromptText(params.update.prompt);
2543
+ const line = firstLine(text, 200);
2544
+ if (line) {
2545
+ return line;
2546
+ }
2547
+ }
2548
+ return void 0;
2549
+ }
2331
2550
  get(sessionId) {
2332
2551
  return this.sessions.get(sessionId);
2333
2552
  }
@@ -2402,6 +2621,111 @@ var SessionManager = class {
2402
2621
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
2403
2622
  return entries;
2404
2623
  }
2624
+ // Build an export bundle for a session, reading meta + history from
2625
+ // disk. Backfills lineageId if the on-disk record pre-dates that
2626
+ // field. Returns undefined if the session doesn't exist. Callers
2627
+ // populate the bundle's exportedFrom metadata themselves.
2628
+ async exportBundle(sessionId) {
2629
+ const record = await this.store.read(sessionId);
2630
+ if (!record) {
2631
+ return void 0;
2632
+ }
2633
+ let withLineage;
2634
+ if (record.lineageId) {
2635
+ withLineage = record;
2636
+ } else {
2637
+ const lineageId = generateLineageId();
2638
+ const backfilled = { ...record, lineageId };
2639
+ await this.enqueueMetaWrite(sessionId, async () => {
2640
+ const latest = await this.store.read(sessionId);
2641
+ if (!latest) {
2642
+ return;
2643
+ }
2644
+ if (latest.lineageId) {
2645
+ return;
2646
+ }
2647
+ await this.store.write({ ...latest, lineageId });
2648
+ }).catch(() => void 0);
2649
+ withLineage = backfilled;
2650
+ }
2651
+ const history = await this.histories.load(sessionId).catch(() => []);
2652
+ const promptHistory = await loadPromptHistorySafely(sessionId);
2653
+ return { record: withLineage, history, promptHistory };
2654
+ }
2655
+ // Create a local session from an imported bundle. Without `replace`,
2656
+ // a bundle with a lineageId we already have on disk throws
2657
+ // BundleAlreadyImported citing the existing local id. With
2658
+ // `replace: true`, the existing record is overwritten in-place (its
2659
+ // local sessionId is preserved so bookmarks/Slack thread links still
2660
+ // resolve), and any live in-memory session is closed so the next
2661
+ // attach triggers the import-reseed path.
2662
+ async importBundle(bundle, opts = {}) {
2663
+ const existing = await this.store.findByLineageId(bundle.session.lineageId);
2664
+ if (existing) {
2665
+ if (!opts.replace) {
2666
+ const err = new Error(
2667
+ `bundle already imported as ${existing.sessionId}`
2668
+ );
2669
+ err.code = JsonRpcErrorCodes.BundleAlreadyImported;
2670
+ err.existingSessionId = existing.sessionId;
2671
+ throw err;
2672
+ }
2673
+ const live = this.sessions.get(existing.sessionId);
2674
+ if (live) {
2675
+ await live.close({ deleteRecord: false }).catch(() => void 0);
2676
+ }
2677
+ await this.writeImportedRecord({
2678
+ sessionId: existing.sessionId,
2679
+ bundle,
2680
+ preservedCreatedAt: existing.createdAt
2681
+ });
2682
+ return {
2683
+ sessionId: existing.sessionId,
2684
+ importedFromSessionId: bundle.session.sessionId,
2685
+ replaced: true
2686
+ };
2687
+ }
2688
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
2689
+ await this.writeImportedRecord({ sessionId: newId, bundle });
2690
+ return {
2691
+ sessionId: newId,
2692
+ importedFromSessionId: bundle.session.sessionId,
2693
+ replaced: false
2694
+ };
2695
+ }
2696
+ // Write the imported bundle's history.jsonl, prompt-history (if
2697
+ // present), and meta.json. upstreamSessionId is left empty as the
2698
+ // marker that the first attach should bootstrap a fresh agent and
2699
+ // run seedFromImport rather than calling session/load.
2700
+ async writeImportedRecord(args) {
2701
+ await this.histories.rewrite(
2702
+ args.sessionId,
2703
+ args.bundle.history
2704
+ );
2705
+ if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
2706
+ await saveHistory(
2707
+ paths.tuiHistoryFile(args.sessionId),
2708
+ args.bundle.promptHistory
2709
+ ).catch(() => void 0);
2710
+ }
2711
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2712
+ await this.enqueueMetaWrite(args.sessionId, async () => {
2713
+ await this.store.write({
2714
+ sessionId: args.sessionId,
2715
+ lineageId: args.bundle.session.lineageId,
2716
+ upstreamSessionId: "",
2717
+ importedFromSessionId: args.bundle.session.sessionId,
2718
+ agentId: args.bundle.session.agentId,
2719
+ cwd: args.bundle.session.cwd,
2720
+ title: args.bundle.session.title,
2721
+ currentModel: args.bundle.session.currentModel,
2722
+ currentMode: args.bundle.session.currentMode,
2723
+ agentCommands: args.bundle.session.agentCommands,
2724
+ createdAt: args.preservedCreatedAt ?? now,
2725
+ updatedAt: now
2726
+ });
2727
+ });
2728
+ }
2405
2729
  async deleteRecord(sessionId) {
2406
2730
  const record = await this.store.read(sessionId);
2407
2731
  if (!record) {
@@ -2487,10 +2811,64 @@ var SessionManager = class {
2487
2811
  await Promise.allSettled(sessions.map((s) => s.close()));
2488
2812
  this.sessions.clear();
2489
2813
  }
2814
+ // Wait for every pending meta.json write to settle. Daemon shutdown
2815
+ // hooks call this so a SIGTERM doesn't kill the process mid-write
2816
+ // and lose a freshly-set title (or model/mode/commands).
2817
+ async flushMetaWrites() {
2818
+ const pending = [...this.metaWriteQueues.values()];
2819
+ if (pending.length === 0) {
2820
+ return;
2821
+ }
2822
+ await Promise.allSettled(pending);
2823
+ }
2490
2824
  };
2825
+ function mergeForPersistence(session, existing) {
2826
+ const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
2827
+ if (c.description !== void 0) {
2828
+ return { name: c.name, description: c.description };
2829
+ }
2830
+ return { name: c.name };
2831
+ }) : void 0;
2832
+ const agentCommands = persistedCommands ?? existing?.agentCommands;
2833
+ return recordFromMemorySession({
2834
+ sessionId: session.sessionId,
2835
+ lineageId: existing?.lineageId ?? generateLineageId(),
2836
+ upstreamSessionId: session.upstreamSessionId,
2837
+ importedFromSessionId: existing?.importedFromSessionId,
2838
+ agentId: session.agentId,
2839
+ cwd: session.cwd,
2840
+ title: session.title,
2841
+ agentArgs: session.agentArgs,
2842
+ currentModel: session.currentModel ?? existing?.currentModel,
2843
+ currentMode: session.currentMode ?? existing?.currentMode,
2844
+ agentCommands,
2845
+ createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
2846
+ });
2847
+ }
2848
+ async function loadPromptHistorySafely(sessionId) {
2849
+ try {
2850
+ const raw = await fs6.readFile(paths.tuiHistoryFile(sessionId), "utf8");
2851
+ const out = [];
2852
+ for (const line of raw.split("\n")) {
2853
+ if (line.length === 0) {
2854
+ continue;
2855
+ }
2856
+ try {
2857
+ const decoded = JSON.parse(line);
2858
+ if (typeof decoded === "string") {
2859
+ out.push(decoded);
2860
+ }
2861
+ } catch {
2862
+ }
2863
+ }
2864
+ return out;
2865
+ } catch {
2866
+ return [];
2867
+ }
2868
+ }
2491
2869
  async function historyMtimeIso(sessionId) {
2492
2870
  try {
2493
- const st = await fs5.stat(paths.historyFile(sessionId));
2871
+ const st = await fs6.stat(paths.historyFile(sessionId));
2494
2872
  return new Date(st.mtimeMs).toISOString();
2495
2873
  } catch {
2496
2874
  return void 0;
@@ -2499,9 +2877,9 @@ async function historyMtimeIso(sessionId) {
2499
2877
 
2500
2878
  // src/core/extensions.ts
2501
2879
  import { spawn as spawn2 } from "child_process";
2502
- import * as fs6 from "fs";
2880
+ import * as fs7 from "fs";
2503
2881
  import * as fsp from "fs/promises";
2504
- import * as path3 from "path";
2882
+ import * as path4 from "path";
2505
2883
  var RESTART_BASE_MS = 1e3;
2506
2884
  var RESTART_CAP_MS = 6e4;
2507
2885
  var STOP_GRACE_MS = 3e3;
@@ -2743,7 +3121,7 @@ var ExtensionManager = class {
2743
3121
  if (!entry.endsWith(".pid")) {
2744
3122
  continue;
2745
3123
  }
2746
- const pidPath = path3.join(paths.extensionsDir(), entry);
3124
+ const pidPath = path4.join(paths.extensionsDir(), entry);
2747
3125
  let pid;
2748
3126
  try {
2749
3127
  const raw = await fsp.readFile(pidPath, "utf8");
@@ -2782,7 +3160,7 @@ var ExtensionManager = class {
2782
3160
  }
2783
3161
  const ext = entry.config;
2784
3162
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2785
- const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
3163
+ const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
2786
3164
  flags: "a"
2787
3165
  });
2788
3166
  logStream.write(
@@ -2832,7 +3210,7 @@ var ExtensionManager = class {
2832
3210
  }
2833
3211
  if (typeof child.pid === "number") {
2834
3212
  try {
2835
- fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3213
+ fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2836
3214
  `, {
2837
3215
  encoding: "utf8",
2838
3216
  mode: 384
@@ -2857,7 +3235,7 @@ var ExtensionManager = class {
2857
3235
  });
2858
3236
  child.on("exit", (code, signal) => {
2859
3237
  try {
2860
- fs6.unlinkSync(paths.extensionPidFile(ext.name));
3238
+ fs7.unlinkSync(paths.extensionPidFile(ext.name));
2861
3239
  } catch {
2862
3240
  }
2863
3241
  logStream.write(
@@ -2966,6 +3344,75 @@ function constantTimeEqual(a, b) {
2966
3344
  }
2967
3345
 
2968
3346
  // src/daemon/routes/sessions.ts
3347
+ import * as os2 from "os";
3348
+
3349
+ // src/core/bundle.ts
3350
+ import { z as z5 } from "zod";
3351
+ var HistoryEntrySchema = z5.object({
3352
+ method: z5.string(),
3353
+ params: z5.unknown(),
3354
+ recordedAt: z5.number()
3355
+ });
3356
+ var BundleSession = z5.object({
3357
+ // The exporter's local id. Regenerated fresh on import (sessionId is
3358
+ // the local namespace; lineageId is what survives across hops).
3359
+ sessionId: z5.string(),
3360
+ // Required on bundles — the export path backfills if the source
3361
+ // record was written before lineageId existed.
3362
+ lineageId: z5.string(),
3363
+ agentId: z5.string(),
3364
+ cwd: z5.string(),
3365
+ title: z5.string().optional(),
3366
+ currentModel: z5.string().optional(),
3367
+ currentMode: z5.string().optional(),
3368
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
3369
+ createdAt: z5.string(),
3370
+ updatedAt: z5.string()
3371
+ });
3372
+ var Bundle = z5.object({
3373
+ version: z5.literal(1),
3374
+ exportedAt: z5.string(),
3375
+ exportedFrom: z5.object({
3376
+ hydraVersion: z5.string(),
3377
+ machine: z5.string()
3378
+ }),
3379
+ session: BundleSession,
3380
+ history: z5.array(HistoryEntrySchema),
3381
+ promptHistory: z5.array(z5.string()).optional()
3382
+ });
3383
+ function encodeBundle(params) {
3384
+ const bundle = {
3385
+ version: 1,
3386
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
3387
+ exportedFrom: {
3388
+ hydraVersion: params.hydraVersion,
3389
+ machine: params.machine
3390
+ },
3391
+ session: {
3392
+ sessionId: params.record.sessionId,
3393
+ lineageId: params.record.lineageId,
3394
+ agentId: params.record.agentId,
3395
+ cwd: params.record.cwd,
3396
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
3397
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
3398
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
3399
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
3400
+ createdAt: params.record.createdAt,
3401
+ updatedAt: params.record.updatedAt
3402
+ },
3403
+ history: params.history
3404
+ };
3405
+ if (params.promptHistory !== void 0) {
3406
+ bundle.promptHistory = params.promptHistory;
3407
+ }
3408
+ return bundle;
3409
+ }
3410
+ function decodeBundle(raw) {
3411
+ return Bundle.parse(raw);
3412
+ }
3413
+
3414
+ // src/daemon/routes/sessions.ts
3415
+ var HYDRA_VERSION = "0.1.0";
2969
3416
  function registerSessionRoutes(app, manager, defaults) {
2970
3417
  app.get("/v1/sessions", async (request) => {
2971
3418
  const query = request.query;
@@ -3023,6 +3470,61 @@ function registerSessionRoutes(app, manager, defaults) {
3023
3470
  }
3024
3471
  reply.code(204).send();
3025
3472
  });
3473
+ app.get("/v1/sessions/:id/export", async (request, reply) => {
3474
+ const raw = request.params.id;
3475
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
3476
+ const exported = await manager.exportBundle(id);
3477
+ if (!exported) {
3478
+ reply.code(404).send({ error: "session not found" });
3479
+ return;
3480
+ }
3481
+ const bundle = encodeBundle({
3482
+ record: exported.record,
3483
+ history: exported.history,
3484
+ promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
3485
+ hydraVersion: HYDRA_VERSION,
3486
+ machine: os2.hostname()
3487
+ });
3488
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3489
+ reply.header(
3490
+ "Content-Disposition",
3491
+ `attachment; filename="hydra-${id}-${stamp}.hydra"`
3492
+ );
3493
+ reply.code(200).send(bundle);
3494
+ });
3495
+ app.post("/v1/sessions/import", async (request, reply) => {
3496
+ const body = request.body ?? {};
3497
+ if (body.bundle === void 0) {
3498
+ reply.code(400).send({ error: "missing bundle" });
3499
+ return;
3500
+ }
3501
+ let bundle;
3502
+ try {
3503
+ bundle = decodeBundle(body.bundle);
3504
+ } catch (err) {
3505
+ reply.code(400).send({
3506
+ error: "invalid bundle",
3507
+ details: err.message
3508
+ });
3509
+ return;
3510
+ }
3511
+ try {
3512
+ const result = await manager.importBundle(bundle, {
3513
+ replace: body.replace === true
3514
+ });
3515
+ reply.code(201).send(result);
3516
+ } catch (err) {
3517
+ const e = err;
3518
+ if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
3519
+ reply.code(409).send({
3520
+ error: "bundle already imported",
3521
+ existingSessionId: e.existingSessionId
3522
+ });
3523
+ return;
3524
+ }
3525
+ reply.code(500).send({ error: e.message });
3526
+ }
3527
+ });
3026
3528
  app.get("/v1/sessions/:id/history", async (request, reply) => {
3027
3529
  const raw = request.params.id;
3028
3530
  const query = request.query;
@@ -3031,16 +3533,22 @@ function registerSessionRoutes(app, manager, defaults) {
3031
3533
  const live = manager.get(id);
3032
3534
  let snapshot;
3033
3535
  let unsubscribe;
3536
+ let snapshotDone = false;
3537
+ const pending = [];
3034
3538
  if (live) {
3035
- snapshot = live.getHistorySnapshot();
3036
3539
  if (follow) {
3037
3540
  unsubscribe = live.onBroadcast((entry) => {
3038
3541
  if (reply.raw.writableEnded) {
3039
3542
  return;
3040
3543
  }
3041
- reply.raw.write(JSON.stringify(entry) + "\n");
3544
+ if (snapshotDone) {
3545
+ reply.raw.write(JSON.stringify(entry) + "\n");
3546
+ } else {
3547
+ pending.push(entry);
3548
+ }
3042
3549
  });
3043
3550
  }
3551
+ snapshot = await live.getHistorySnapshot();
3044
3552
  } else {
3045
3553
  const cold = await manager.getHistory(id);
3046
3554
  if (cold === void 0) {
@@ -3052,9 +3560,23 @@ function registerSessionRoutes(app, manager, defaults) {
3052
3560
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
3053
3561
  reply.raw.setHeader("Cache-Control", "no-cache");
3054
3562
  reply.raw.statusCode = 200;
3563
+ const snapshotKeys = /* @__PURE__ */ new Set();
3055
3564
  for (const entry of snapshot ?? []) {
3056
3565
  reply.raw.write(JSON.stringify(entry) + "\n");
3566
+ const e = entry;
3567
+ if (typeof e.recordedAt === "number") {
3568
+ snapshotKeys.add(String(e.recordedAt));
3569
+ }
3057
3570
  }
3571
+ for (const entry of pending) {
3572
+ const e = entry;
3573
+ const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
3574
+ if (key && snapshotKeys.has(key)) {
3575
+ continue;
3576
+ }
3577
+ reply.raw.write(JSON.stringify(entry) + "\n");
3578
+ }
3579
+ snapshotDone = true;
3058
3580
  if (!unsubscribe) {
3059
3581
  reply.raw.end();
3060
3582
  return reply;
@@ -3299,7 +3821,7 @@ function wsToMessageStream(ws) {
3299
3821
  }
3300
3822
 
3301
3823
  // src/daemon/acp-ws.ts
3302
- var HYDRA_VERSION = "0.1.0";
3824
+ var HYDRA_VERSION2 = "0.1.0";
3303
3825
  var HYDRA_PROTOCOL_VERSION = 1;
3304
3826
  function registerAcpWsEndpoint(app, deps) {
3305
3827
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -3345,7 +3867,7 @@ function registerAcpWsEndpoint(app, deps) {
3345
3867
  agentArgs: hydraMeta.agentArgs
3346
3868
  });
3347
3869
  const client = bindClientToSession(connection, session, state);
3348
- session.attach(client, "full");
3870
+ await session.attach(client, "full");
3349
3871
  state.attached.set(session.sessionId, {
3350
3872
  sessionId: session.sessionId,
3351
3873
  clientId: client.clientId
@@ -3364,14 +3886,22 @@ function registerAcpWsEndpoint(app, deps) {
3364
3886
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
3365
3887
  let session = deps.manager.get(lookupId);
3366
3888
  if (!session) {
3367
- let resurrectParams = hydraHints ? {
3368
- hydraSessionId: params.sessionId,
3369
- upstreamSessionId: hydraHints.upstreamSessionId,
3370
- agentId: hydraHints.agentId,
3371
- cwd: hydraHints.cwd,
3372
- title: hydraHints.title,
3373
- agentArgs: hydraHints.agentArgs
3374
- } : await deps.manager.loadFromDisk(lookupId);
3889
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
3890
+ let resurrectParams = fromDisk;
3891
+ if (hydraHints) {
3892
+ resurrectParams = {
3893
+ hydraSessionId: params.sessionId,
3894
+ upstreamSessionId: hydraHints.upstreamSessionId,
3895
+ agentId: hydraHints.agentId,
3896
+ cwd: hydraHints.cwd,
3897
+ title: hydraHints.title ?? fromDisk?.title,
3898
+ agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
3899
+ currentModel: fromDisk?.currentModel,
3900
+ currentMode: fromDisk?.currentMode,
3901
+ agentCommands: fromDisk?.agentCommands,
3902
+ createdAt: fromDisk?.createdAt
3903
+ };
3904
+ }
3375
3905
  if (!resurrectParams) {
3376
3906
  const err = new Error(
3377
3907
  `session ${params.sessionId} not found and no resume hints provided`
@@ -3387,13 +3917,13 @@ function registerAcpWsEndpoint(app, deps) {
3387
3917
  state,
3388
3918
  params.clientInfo
3389
3919
  );
3390
- const replay = session.attach(client, params.historyPolicy);
3920
+ const replay = await session.attach(client, params.historyPolicy);
3391
3921
  state.attached.set(session.sessionId, {
3392
3922
  sessionId: session.sessionId,
3393
3923
  clientId: client.clientId
3394
3924
  });
3395
3925
  app.log.info(
3396
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
3926
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
3397
3927
  );
3398
3928
  for (const note of replay) {
3399
3929
  await connection.notify(note.method, note.params);
@@ -3489,7 +4019,7 @@ function registerAcpWsEndpoint(app, deps) {
3489
4019
  session = await deps.manager.resurrect(fromDisk);
3490
4020
  }
3491
4021
  const client = bindClientToSession(connection, session, state);
3492
- const replay = session.attach(client, "pending_only");
4022
+ const replay = await session.attach(client, "pending_only");
3493
4023
  state.attached.set(session.sessionId, {
3494
4024
  sessionId: session.sessionId,
3495
4025
  clientId: client.clientId
@@ -3555,7 +4085,7 @@ function buildResponseMeta(session) {
3555
4085
  function buildInitializeResult() {
3556
4086
  return {
3557
4087
  protocolVersion: HYDRA_PROTOCOL_VERSION,
3558
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
4088
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
3559
4089
  agentCapabilities: {
3560
4090
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
3561
4091
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -3594,7 +4124,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
3594
4124
  }
3595
4125
 
3596
4126
  // src/daemon/server.ts
3597
- var HYDRA_VERSION2 = "0.1.0";
4127
+ var HYDRA_VERSION3 = "0.1.0";
3598
4128
  async function startDaemon(config) {
3599
4129
  ensureLoopbackOrTls(config);
3600
4130
  const httpsOptions = config.daemon.tls ? {
@@ -3628,7 +4158,7 @@ async function startDaemon(config) {
3628
4158
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
3629
4159
  });
3630
4160
  const extensions = new ExtensionManager(extensionList(config));
3631
- registerHealthRoutes(app, HYDRA_VERSION2);
4161
+ registerHealthRoutes(app, HYDRA_VERSION3);
3632
4162
  registerSessionRoutes(app, manager, {
3633
4163
  agentId: config.defaultAgent,
3634
4164
  cwd: config.defaultCwd
@@ -3672,9 +4202,10 @@ async function startDaemon(config) {
3672
4202
  const shutdown = async () => {
3673
4203
  await extensions.stop();
3674
4204
  await manager.closeAll();
4205
+ await manager.flushMetaWrites();
3675
4206
  await app.close();
3676
4207
  try {
3677
- fs7.unlinkSync(paths.pidFile());
4208
+ fs8.unlinkSync(paths.pidFile());
3678
4209
  } catch {
3679
4210
  }
3680
4211
  try {