@hydra-acp/cli 0.1.2 → 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 fs6 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";
@@ -20,6 +20,11 @@ function hydraHome() {
20
20
  if (override && override.length > 0) {
21
21
  return path.resolve(override);
22
22
  }
23
+ if (process.env.VITEST) {
24
+ throw new Error(
25
+ "HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
26
+ );
27
+ }
23
28
  return path.join(os.homedir(), ".hydra-acp");
24
29
  }
25
30
  var paths = {
@@ -41,7 +46,8 @@ var paths = {
41
46
  extensionsDir: () => path.join(hydraHome(), "extensions"),
42
47
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
43
48
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
44
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
49
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
50
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
45
51
  };
46
52
 
47
53
  // src/core/config.ts
@@ -56,7 +62,7 @@ var DaemonConfig = z.object({
56
62
  authToken: z.string().min(16),
57
63
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
58
64
  tls: TlsConfig.optional(),
59
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
65
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
60
66
  });
61
67
  var RegistryConfig = z.object({
62
68
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -69,7 +75,11 @@ var TuiConfig = z.object({
69
75
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
70
76
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
71
77
  // or 0 to disable throttling entirely.
72
- repaintThrottleMs: z.number().int().nonnegative().default(1e3)
78
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3),
79
+ // Cap on logical lines retained in the in-memory scrollback render
80
+ // buffer. Oldest lines are dropped on overflow. The on-disk session
81
+ // history is unaffected; this only bounds the TUI's local view buffer.
82
+ maxScrollbackLines: z.number().int().positive().default(1e4)
73
83
  });
74
84
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
75
85
  var ExtensionBody = z.object({
@@ -94,7 +104,7 @@ var HydraConfig = z.object({
94
104
  // recency and truncated to this count. `--all` overrides in the CLI.
95
105
  sessionListColdLimit: z.number().int().nonnegative().default(20),
96
106
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
97
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
107
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
98
108
  });
99
109
  function extensionList(config) {
100
110
  return Object.entries(config.extensions).map(([name, body]) => ({
@@ -127,8 +137,7 @@ async function ensureConfig() {
127
137
  if (e.code !== "ENOENT") {
128
138
  throw err;
129
139
  }
130
- const config = defaultConfig();
131
- await writeConfig(config);
140
+ const config = await writeMinimalInitConfig();
132
141
  process.stderr.write(
133
142
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
134
143
  `
@@ -144,6 +153,16 @@ async function writeConfig(config) {
144
153
  mode: 384
145
154
  });
146
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
+ }
147
166
  function generateAuthToken() {
148
167
  const bytes = new Uint8Array(32);
149
168
  crypto.getRandomValues(bytes);
@@ -337,6 +356,10 @@ function planSpawn(agent, extraArgs = []) {
337
356
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
338
357
  }
339
358
 
359
+ // src/core/session-manager.ts
360
+ import * as fs6 from "fs/promises";
361
+ import { customAlphabet as customAlphabet3 } from "nanoid";
362
+
340
363
  // src/core/agent-instance.ts
341
364
  import { spawn } from "child_process";
342
365
 
@@ -351,7 +374,8 @@ var JsonRpcErrorCodes = {
351
374
  SessionNotFound: -32001,
352
375
  PermissionDenied: -32002,
353
376
  AlreadyAttached: -32003,
354
- AgentNotInstalled: -32005
377
+ AgentNotInstalled: -32005,
378
+ BundleAlreadyImported: -32010
355
379
  };
356
380
  var InitializeParams = z3.object({
357
381
  protocolVersion: z3.number().optional(),
@@ -421,6 +445,9 @@ function extractHydraMeta(meta) {
421
445
  if (typeof obj.currentMode === "string") {
422
446
  out.currentMode = obj.currentMode;
423
447
  }
448
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
449
+ out.turnStartedAt = obj.turnStartedAt;
450
+ }
424
451
  if (Array.isArray(obj.availableCommands)) {
425
452
  const cmds = [];
426
453
  for (const raw of obj.availableCommands) {
@@ -797,6 +824,8 @@ function hydraCommandsAsAdvertised() {
797
824
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
798
825
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
799
826
  var HYDRA_SESSION_PREFIX = "hydra_session_";
827
+ var MAX_HISTORY_ENTRIES = 1e3;
828
+ var COMPACT_EVERY = 200;
800
829
  var Session = class {
801
830
  sessionId;
802
831
  cwd;
@@ -816,8 +845,8 @@ var Session = class {
816
845
  currentModel;
817
846
  currentMode;
818
847
  updatedAt;
848
+ createdAt;
819
849
  clients = /* @__PURE__ */ new Map();
820
- history = [];
821
850
  historyStore;
822
851
  promptQueue = [];
823
852
  promptInFlight = false;
@@ -833,6 +862,15 @@ var Session = class {
833
862
  // True once we've observed our first session/prompt; gates the
834
863
  // first-prompt-seeded title so subsequent prompts don't churn it.
835
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;
836
874
  // Permission requests that have been broadcast to one or more
837
875
  // clients but have not yet resolved. Replayed to clients that
838
876
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -847,6 +885,12 @@ var Session = class {
847
885
  internalPromptCapture;
848
886
  idleTimeoutMs;
849
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;
850
894
  spawnReplacementAgent;
851
895
  agentChangeHandlers = [];
852
896
  // Last available_commands_update we observed from the agent. Stored
@@ -877,12 +921,15 @@ var Session = class {
877
921
  }
878
922
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
879
923
  this.spawnReplacementAgent = init.spawnReplacementAgent;
880
- this.historyStore = init.historyStore;
881
- if (init.seedHistory && init.seedHistory.length > 0) {
882
- this.history = [...init.seedHistory];
924
+ if (init.firstPromptSeeded) {
925
+ this.firstPromptSeeded = true;
883
926
  }
927
+ this.historyStore = init.historyStore;
884
928
  this.updatedAt = Date.now();
929
+ this.createdAt = init.createdAt ?? this.updatedAt;
930
+ this.lastRecordedAt = this.updatedAt;
885
931
  this.wireAgent(this.agent);
932
+ this.scheduleIdleCheck();
886
933
  }
887
934
  broadcastMergedCommands() {
888
935
  const merged = [
@@ -941,12 +988,21 @@ var Session = class {
941
988
  get attachedCount() {
942
989
  return this.clients.size;
943
990
  }
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];
991
+ // Wall-clock when the in-flight agent turn began, or undefined when
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.
995
+ get turnStartedAt() {
996
+ return this.promptStartedAt;
997
+ }
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(() => []);
950
1006
  }
951
1007
  // Subscribe to recordable broadcast entries — fires once per entry
952
1008
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -962,6 +1018,10 @@ var Session = class {
962
1018
  }
963
1019
  };
964
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.
965
1025
  attach(client, historyPolicy) {
966
1026
  if (this.closed) {
967
1027
  throw withCode(
@@ -977,14 +1037,10 @@ var Session = class {
977
1037
  }
978
1038
  this.clients.set(client.clientId, client);
979
1039
  this.updatedAt = Date.now();
980
- this.cancelIdleTimer();
981
- if (historyPolicy === "none") {
982
- return [];
983
- }
984
- if (historyPolicy === "pending_only") {
985
- return [];
1040
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
1041
+ return Promise.resolve([]);
986
1042
  }
987
- return [...this.history];
1043
+ return this.getHistorySnapshot();
988
1044
  }
989
1045
  // Dispatch in-flight permission requests to a freshly-attached
990
1046
  // client. Called by the daemon's WS handler *after* it finishes
@@ -998,7 +1054,6 @@ var Session = class {
998
1054
  detach(clientId) {
999
1055
  if (this.clients.delete(clientId)) {
1000
1056
  this.updatedAt = Date.now();
1001
- this.maybeStartIdleTimer();
1002
1057
  }
1003
1058
  }
1004
1059
  async prompt(clientId, params) {
@@ -1018,13 +1073,19 @@ var Session = class {
1018
1073
  this.broadcastPromptReceived(client, params);
1019
1074
  this.maybeSeedTitleFromPrompt(params);
1020
1075
  return this.enqueuePrompt(async () => {
1021
- const response = await this.agent.connection.request(
1022
- "session/prompt",
1023
- {
1024
- ...params,
1025
- sessionId: this.upstreamSessionId
1026
- }
1027
- );
1076
+ let response;
1077
+ try {
1078
+ response = await this.agent.connection.request(
1079
+ "session/prompt",
1080
+ {
1081
+ ...params,
1082
+ sessionId: this.upstreamSessionId
1083
+ }
1084
+ );
1085
+ } catch (err) {
1086
+ this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
1087
+ throw err;
1088
+ }
1028
1089
  this.broadcastTurnComplete(client.clientId, response);
1029
1090
  return response;
1030
1091
  });
@@ -1038,6 +1099,7 @@ var Session = class {
1038
1099
  if (client.clientInfo?.version) {
1039
1100
  sentBy.version = client.clientInfo.version;
1040
1101
  }
1102
+ this.promptStartedAt = Date.now();
1041
1103
  this.recordAndBroadcast(
1042
1104
  "session/update",
1043
1105
  {
@@ -1074,6 +1136,7 @@ var Session = class {
1074
1136
  if (stopReason !== void 0) {
1075
1137
  update.stopReason = stopReason;
1076
1138
  }
1139
+ this.promptStartedAt = void 0;
1077
1140
  this.recordAndBroadcast(
1078
1141
  "session/update",
1079
1142
  {
@@ -1112,6 +1175,13 @@ var Session = class {
1112
1175
  return;
1113
1176
  }
1114
1177
  this.cancelIdleTimer();
1178
+ if (opts.regenTitle && this.firstPromptSeeded) {
1179
+ const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
1180
+ await Promise.race([
1181
+ this.runTitleRegen().catch(() => void 0),
1182
+ new Promise((r) => setTimeout(r, timeoutMs).unref?.())
1183
+ ]);
1184
+ }
1115
1185
  await this.agent.kill().catch(() => void 0);
1116
1186
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
1117
1187
  }
@@ -1160,7 +1230,7 @@ var Session = class {
1160
1230
  }
1161
1231
  const promptParams = params ?? {};
1162
1232
  const text = extractPromptText(promptParams.prompt);
1163
- const seed = firstLine(text, 80);
1233
+ const seed = firstLine(text, 200);
1164
1234
  if (!seed) {
1165
1235
  return;
1166
1236
  }
@@ -1252,6 +1322,12 @@ var Session = class {
1252
1322
  mergedAvailableCommands() {
1253
1323
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1254
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
+ }
1255
1331
  // Pick up an agent-emitted session_info_update and store its title
1256
1332
  // as our canonical record. The notification is also forwarded to
1257
1333
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1383,7 +1459,7 @@ var Session = class {
1383
1459
  const spawnAgent = this.spawnReplacementAgent;
1384
1460
  return this.enqueuePrompt(async () => {
1385
1461
  const oldAgentId = this.agentId;
1386
- const transcript = this.buildSwitchTranscript(oldAgentId);
1462
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1387
1463
  const fresh = await spawnAgent({
1388
1464
  agentId: newAgentId,
1389
1465
  cwd: this.cwd,
@@ -1415,15 +1491,20 @@ var Session = class {
1415
1491
  return { stopReason: "end_turn" };
1416
1492
  });
1417
1493
  }
1418
- // Walk this.history (rewritten-for-clients notification cache) and
1419
- // produce a labeled transcript suitable for handing to a fresh agent.
1420
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1421
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1422
- // banners) and other update kinds we don't think the next agent
1423
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1424
- 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) {
1425
1505
  const lines = [];
1426
- for (const note of this.history) {
1506
+ const history = await this.getHistorySnapshot();
1507
+ for (const note of history) {
1427
1508
  if (note.method !== "session/update") {
1428
1509
  continue;
1429
1510
  }
@@ -1477,29 +1558,53 @@ var Session = class {
1477
1558
  if (current) {
1478
1559
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1479
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.`;
1480
1563
  return [
1481
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1482
- `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,
1483
1566
  "",
1484
1567
  "--- begin transcript ---",
1485
1568
  ...coalesced,
1486
1569
  "--- end transcript ---"
1487
1570
  ].join("\n");
1488
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
+ }
1489
1594
  // Tell every attached client (a) the agent identity has changed
1490
- // (session_info_update with an agentId field clients that already
1491
- // listen for title updates pick this up; older clients ignore unknown
1492
- // fields harmlessly) and (b) drop a visible banner into the transcript
1493
- // so users see the switch rather than just suddenly getting answers
1494
- // 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
1495
1601
  // so a future /hydra switch's transcript builder filters them out.
1496
1602
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1497
1603
  this.recordAndBroadcast("session/update", {
1498
1604
  sessionId: this.sessionId,
1499
1605
  update: {
1500
1606
  sessionUpdate: "session_info_update",
1501
- agentId: newAgentId,
1502
- _meta: { "hydra-acp": { synthetic: true } }
1607
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1503
1608
  }
1504
1609
  });
1505
1610
  this.recordAndBroadcast("session/update", {
@@ -1530,21 +1635,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1530
1635
  handler(opts);
1531
1636
  }
1532
1637
  }
1533
- maybeStartIdleTimer() {
1534
- 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) {
1535
1654
  return;
1536
1655
  }
1656
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1657
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1658
+ }
1659
+ armIdleTimer(delay) {
1537
1660
  if (this.idleTimer) {
1538
- return;
1661
+ clearTimeout(this.idleTimer);
1539
1662
  }
1540
1663
  this.idleTimer = setTimeout(() => {
1541
1664
  this.idleTimer = void 0;
1542
- void this.close({ deleteRecord: false }).catch(() => void 0);
1543
- }, this.idleTimeoutMs);
1665
+ this.checkIdle();
1666
+ }, delay);
1544
1667
  if (typeof this.idleTimer.unref === "function") {
1545
1668
  this.idleTimer.unref();
1546
1669
  }
1547
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
+ }
1548
1687
  cancelIdleTimer() {
1549
1688
  if (this.idleTimer) {
1550
1689
  clearTimeout(this.idleTimer);
@@ -1569,17 +1708,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1569
1708
  params: rewritten,
1570
1709
  recordedAt: Date.now()
1571
1710
  };
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
- }
1711
+ this.lastRecordedAt = entry.recordedAt;
1712
+ this.appendCount += 1;
1578
1713
  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(
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(
1583
1719
  () => void 0
1584
1720
  );
1585
1721
  }
@@ -1590,6 +1726,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1590
1726
  } catch {
1591
1727
  }
1592
1728
  }
1729
+ this.scheduleIdleCheck();
1593
1730
  }
1594
1731
  this.updatedAt = Date.now();
1595
1732
  for (const client of this.clients.values()) {
@@ -1780,7 +1917,14 @@ function firstLine(text, max) {
1780
1917
  // src/core/session-store.ts
1781
1918
  import * as fs3 from "fs/promises";
1782
1919
  import * as path2 from "path";
1920
+ import { customAlphabet as customAlphabet2 } from "nanoid";
1783
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
+ }
1784
1928
  var PersistedAgentCommand = z4.object({
1785
1929
  name: z4.string(),
1786
1930
  description: z4.string().optional()
@@ -1788,7 +1932,20 @@ var PersistedAgentCommand = z4.object({
1788
1932
  var SessionRecord = z4.object({
1789
1933
  version: z4.literal(1),
1790
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(),
1791
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(),
1792
1949
  agentId: z4.string(),
1793
1950
  cwd: z4.string(),
1794
1951
  title: z4.string().optional(),
@@ -1861,6 +2018,25 @@ var SessionStore = class {
1861
2018
  }
1862
2019
  }
1863
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
+ }
1864
2040
  async list() {
1865
2041
  let entries;
1866
2042
  try {
@@ -1886,7 +2062,9 @@ function recordFromMemorySession(args) {
1886
2062
  const now = (/* @__PURE__ */ new Date()).toISOString();
1887
2063
  return {
1888
2064
  sessionId: args.sessionId,
2065
+ lineageId: args.lineageId,
1889
2066
  upstreamSessionId: args.upstreamSessionId,
2067
+ importedFromSessionId: args.importedFromSessionId,
1890
2068
  agentId: args.agentId,
1891
2069
  cwd: args.cwd,
1892
2070
  title: args.title,
@@ -1934,6 +2112,36 @@ var HistoryStore = class {
1934
2112
  });
1935
2113
  });
1936
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
+ }
1937
2145
  async load(sessionId) {
1938
2146
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
1939
2147
  return [];
@@ -2021,7 +2229,18 @@ var HistoryStore = class {
2021
2229
  }
2022
2230
  };
2023
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
+
2024
2241
  // src/core/session-manager.ts
2242
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2243
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
2025
2244
  var SessionManager = class {
2026
2245
  constructor(registry, spawner, store, options = {}) {
2027
2246
  this.registry = registry;
@@ -2100,6 +2319,9 @@ var SessionManager = class {
2100
2319
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
2101
2320
  throw err;
2102
2321
  }
2322
+ if (params.upstreamSessionId === "") {
2323
+ return this.doResurrectFromImport(params);
2324
+ }
2103
2325
  const plan = planSpawn(agentDef, params.agentArgs ?? []);
2104
2326
  const agent = this.spawner({
2105
2327
  agentId: params.agentId,
@@ -2136,12 +2358,53 @@ var SessionManager = class {
2136
2358
  idleTimeoutMs: this.idleTimeoutMs,
2137
2359
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2138
2360
  historyStore: this.histories,
2139
- seedHistory: params.seedHistory,
2140
2361
  currentModel: params.currentModel,
2141
2362
  currentMode: params.currentMode,
2142
- agentCommands: params.agentCommands
2363
+ agentCommands: params.agentCommands,
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
2370
+ });
2371
+ await this.attachManagerHooks(session);
2372
+ return session;
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
2143
2405
  });
2144
2406
  await this.attachManagerHooks(session);
2407
+ void session.seedFromImport().catch(() => void 0);
2145
2408
  return session;
2146
2409
  }
2147
2410
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
@@ -2223,28 +2486,20 @@ var SessionManager = class {
2223
2486
  }).catch(() => void 0);
2224
2487
  });
2225
2488
  this.sessions.set(session.sessionId, session);
2226
- await this.store.write(
2227
- recordFromMemorySession({
2228
- sessionId: session.sessionId,
2229
- upstreamSessionId: session.upstreamSessionId,
2230
- agentId: session.agentId,
2231
- cwd: session.cwd,
2232
- title: session.title,
2233
- agentArgs: session.agentArgs,
2234
- currentModel: session.currentModel,
2235
- currentMode: session.currentMode
2236
- })
2237
- ).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);
2238
2494
  }
2239
2495
  // 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".
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".
2244
2500
  async getHistory(sessionId) {
2245
- const live = this.sessions.get(sessionId);
2246
- if (live) {
2247
- return live.getHistorySnapshot();
2501
+ if (this.sessions.has(sessionId)) {
2502
+ return this.histories.load(sessionId).catch(() => []);
2248
2503
  }
2249
2504
  const record = await this.store.read(sessionId);
2250
2505
  if (!record) {
@@ -2257,20 +2512,41 @@ var SessionManager = class {
2257
2512
  if (!record) {
2258
2513
  return void 0;
2259
2514
  }
2260
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
2515
+ let title = record.title;
2516
+ if (!title) {
2517
+ title = await this.deriveTitleFromHistory(sessionId);
2518
+ }
2261
2519
  return {
2262
2520
  hydraSessionId: record.sessionId,
2263
2521
  upstreamSessionId: record.upstreamSessionId,
2264
2522
  agentId: record.agentId,
2265
2523
  cwd: record.cwd,
2266
- title: record.title,
2524
+ title,
2267
2525
  agentArgs: record.agentArgs,
2268
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
2269
2526
  currentModel: record.currentModel,
2270
2527
  currentMode: record.currentMode,
2271
- agentCommands: record.agentCommands
2528
+ agentCommands: record.agentCommands,
2529
+ createdAt: record.createdAt
2272
2530
  };
2273
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
+ }
2274
2550
  get(sessionId) {
2275
2551
  return this.sessions.get(sessionId);
2276
2552
  }
@@ -2310,13 +2586,14 @@ var SessionManager = class {
2310
2586
  continue;
2311
2587
  }
2312
2588
  liveIds.add(session.sessionId);
2589
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
2313
2590
  entries.push({
2314
2591
  sessionId: session.sessionId,
2315
2592
  upstreamSessionId: session.upstreamSessionId,
2316
2593
  cwd: session.cwd,
2317
2594
  title: session.title,
2318
2595
  agentId: session.agentId,
2319
- updatedAt: new Date(session.updatedAt).toISOString(),
2596
+ updatedAt: used,
2320
2597
  attachedClients: session.attachedCount,
2321
2598
  status: "live"
2322
2599
  });
@@ -2329,13 +2606,14 @@ var SessionManager = class {
2329
2606
  if (filter.cwd && r.cwd !== filter.cwd) {
2330
2607
  continue;
2331
2608
  }
2609
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
2332
2610
  entries.push({
2333
2611
  sessionId: r.sessionId,
2334
2612
  upstreamSessionId: r.upstreamSessionId,
2335
2613
  cwd: r.cwd,
2336
2614
  title: r.title,
2337
2615
  agentId: r.agentId,
2338
- updatedAt: r.updatedAt,
2616
+ updatedAt: used,
2339
2617
  attachedClients: 0,
2340
2618
  status: "cold"
2341
2619
  });
@@ -2343,6 +2621,111 @@ var SessionManager = class {
2343
2621
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
2344
2622
  return entries;
2345
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
+ }
2346
2729
  async deleteRecord(sessionId) {
2347
2730
  const record = await this.store.read(sessionId);
2348
2731
  if (!record) {
@@ -2351,6 +2734,10 @@ var SessionManager = class {
2351
2734
  await this.store.delete(sessionId).catch(() => void 0);
2352
2735
  return true;
2353
2736
  }
2737
+ async hasRecord(sessionId) {
2738
+ const record = await this.store.read(sessionId).catch(() => void 0);
2739
+ return record !== void 0;
2740
+ }
2354
2741
  // Persist a title update from Session.setTitle. The on-disk record
2355
2742
  // was written at create time; updating it here keeps the session
2356
2743
  // record's title in sync with what was broadcast to clients so a
@@ -2424,13 +2811,75 @@ var SessionManager = class {
2424
2811
  await Promise.allSettled(sessions.map((s) => s.close()));
2425
2812
  this.sessions.clear();
2426
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
+ }
2427
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
+ }
2869
+ async function historyMtimeIso(sessionId) {
2870
+ try {
2871
+ const st = await fs6.stat(paths.historyFile(sessionId));
2872
+ return new Date(st.mtimeMs).toISOString();
2873
+ } catch {
2874
+ return void 0;
2875
+ }
2876
+ }
2428
2877
 
2429
2878
  // src/core/extensions.ts
2430
2879
  import { spawn as spawn2 } from "child_process";
2431
- import * as fs5 from "fs";
2880
+ import * as fs7 from "fs";
2432
2881
  import * as fsp from "fs/promises";
2433
- import * as path3 from "path";
2882
+ import * as path4 from "path";
2434
2883
  var RESTART_BASE_MS = 1e3;
2435
2884
  var RESTART_CAP_MS = 6e4;
2436
2885
  var STOP_GRACE_MS = 3e3;
@@ -2672,7 +3121,7 @@ var ExtensionManager = class {
2672
3121
  if (!entry.endsWith(".pid")) {
2673
3122
  continue;
2674
3123
  }
2675
- const pidPath = path3.join(paths.extensionsDir(), entry);
3124
+ const pidPath = path4.join(paths.extensionsDir(), entry);
2676
3125
  let pid;
2677
3126
  try {
2678
3127
  const raw = await fsp.readFile(pidPath, "utf8");
@@ -2711,7 +3160,7 @@ var ExtensionManager = class {
2711
3160
  }
2712
3161
  const ext = entry.config;
2713
3162
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2714
- const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
3163
+ const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
2715
3164
  flags: "a"
2716
3165
  });
2717
3166
  logStream.write(
@@ -2761,7 +3210,7 @@ var ExtensionManager = class {
2761
3210
  }
2762
3211
  if (typeof child.pid === "number") {
2763
3212
  try {
2764
- fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3213
+ fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2765
3214
  `, {
2766
3215
  encoding: "utf8",
2767
3216
  mode: 384
@@ -2786,7 +3235,7 @@ var ExtensionManager = class {
2786
3235
  });
2787
3236
  child.on("exit", (code, signal) => {
2788
3237
  try {
2789
- fs5.unlinkSync(paths.extensionPidFile(ext.name));
3238
+ fs7.unlinkSync(paths.extensionPidFile(ext.name));
2790
3239
  } catch {
2791
3240
  }
2792
3241
  logStream.write(
@@ -2895,6 +3344,75 @@ function constantTimeEqual(a, b) {
2895
3344
  }
2896
3345
 
2897
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";
2898
3416
  function registerSessionRoutes(app, manager, defaults) {
2899
3417
  app.get("/v1/sessions", async (request) => {
2900
3418
  const query = request.query;
@@ -2920,6 +3438,22 @@ function registerSessionRoutes(app, manager, defaults) {
2920
3438
  reply.code(500).send({ error: err.message });
2921
3439
  }
2922
3440
  });
3441
+ app.post("/v1/sessions/:id/kill", async (request, reply) => {
3442
+ const raw = request.params.id;
3443
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
3444
+ const session = manager.get(id);
3445
+ if (session) {
3446
+ await session.close({ deleteRecord: false });
3447
+ reply.code(204).send();
3448
+ return;
3449
+ }
3450
+ const exists = await manager.hasRecord(id);
3451
+ if (!exists) {
3452
+ reply.code(404).send({ error: "session not found" });
3453
+ return;
3454
+ }
3455
+ reply.code(204).send();
3456
+ });
2923
3457
  app.delete("/v1/sessions/:id", async (request, reply) => {
2924
3458
  const raw = request.params.id;
2925
3459
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -2936,6 +3470,61 @@ function registerSessionRoutes(app, manager, defaults) {
2936
3470
  }
2937
3471
  reply.code(204).send();
2938
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
+ });
2939
3528
  app.get("/v1/sessions/:id/history", async (request, reply) => {
2940
3529
  const raw = request.params.id;
2941
3530
  const query = request.query;
@@ -2944,16 +3533,22 @@ function registerSessionRoutes(app, manager, defaults) {
2944
3533
  const live = manager.get(id);
2945
3534
  let snapshot;
2946
3535
  let unsubscribe;
3536
+ let snapshotDone = false;
3537
+ const pending = [];
2947
3538
  if (live) {
2948
- snapshot = live.getHistorySnapshot();
2949
3539
  if (follow) {
2950
3540
  unsubscribe = live.onBroadcast((entry) => {
2951
3541
  if (reply.raw.writableEnded) {
2952
3542
  return;
2953
3543
  }
2954
- 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
+ }
2955
3549
  });
2956
3550
  }
3551
+ snapshot = await live.getHistorySnapshot();
2957
3552
  } else {
2958
3553
  const cold = await manager.getHistory(id);
2959
3554
  if (cold === void 0) {
@@ -2965,9 +3560,23 @@ function registerSessionRoutes(app, manager, defaults) {
2965
3560
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
2966
3561
  reply.raw.setHeader("Cache-Control", "no-cache");
2967
3562
  reply.raw.statusCode = 200;
3563
+ const snapshotKeys = /* @__PURE__ */ new Set();
2968
3564
  for (const entry of snapshot ?? []) {
2969
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
+ }
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");
2970
3578
  }
3579
+ snapshotDone = true;
2971
3580
  if (!unsubscribe) {
2972
3581
  reply.raw.end();
2973
3582
  return reply;
@@ -3127,6 +3736,16 @@ function parseRegisterBody(body) {
3127
3736
  };
3128
3737
  }
3129
3738
 
3739
+ // src/daemon/routes/config.ts
3740
+ function registerConfigRoutes(app, defaults) {
3741
+ app.get("/v1/config", async () => {
3742
+ return {
3743
+ defaultAgent: defaults.defaultAgent,
3744
+ defaultCwd: defaults.defaultCwd
3745
+ };
3746
+ });
3747
+ }
3748
+
3130
3749
  // src/daemon/acp-ws.ts
3131
3750
  import { nanoid as nanoid2 } from "nanoid";
3132
3751
 
@@ -3202,7 +3821,7 @@ function wsToMessageStream(ws) {
3202
3821
  }
3203
3822
 
3204
3823
  // src/daemon/acp-ws.ts
3205
- var HYDRA_VERSION = "0.1.0";
3824
+ var HYDRA_VERSION2 = "0.1.0";
3206
3825
  var HYDRA_PROTOCOL_VERSION = 1;
3207
3826
  function registerAcpWsEndpoint(app, deps) {
3208
3827
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -3248,7 +3867,7 @@ function registerAcpWsEndpoint(app, deps) {
3248
3867
  agentArgs: hydraMeta.agentArgs
3249
3868
  });
3250
3869
  const client = bindClientToSession(connection, session, state);
3251
- session.attach(client, "full");
3870
+ await session.attach(client, "full");
3252
3871
  state.attached.set(session.sessionId, {
3253
3872
  sessionId: session.sessionId,
3254
3873
  clientId: client.clientId
@@ -3267,14 +3886,22 @@ function registerAcpWsEndpoint(app, deps) {
3267
3886
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
3268
3887
  let session = deps.manager.get(lookupId);
3269
3888
  if (!session) {
3270
- let resurrectParams = hydraHints ? {
3271
- hydraSessionId: params.sessionId,
3272
- upstreamSessionId: hydraHints.upstreamSessionId,
3273
- agentId: hydraHints.agentId,
3274
- cwd: hydraHints.cwd,
3275
- title: hydraHints.title,
3276
- agentArgs: hydraHints.agentArgs
3277
- } : 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
+ }
3278
3905
  if (!resurrectParams) {
3279
3906
  const err = new Error(
3280
3907
  `session ${params.sessionId} not found and no resume hints provided`
@@ -3290,13 +3917,13 @@ function registerAcpWsEndpoint(app, deps) {
3290
3917
  state,
3291
3918
  params.clientInfo
3292
3919
  );
3293
- const replay = session.attach(client, params.historyPolicy);
3920
+ const replay = await session.attach(client, params.historyPolicy);
3294
3921
  state.attached.set(session.sessionId, {
3295
3922
  sessionId: session.sessionId,
3296
3923
  clientId: client.clientId
3297
3924
  });
3298
3925
  app.log.info(
3299
- `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}`
3300
3927
  );
3301
3928
  for (const note of replay) {
3302
3929
  await connection.notify(note.method, note.params);
@@ -3392,7 +4019,7 @@ function registerAcpWsEndpoint(app, deps) {
3392
4019
  session = await deps.manager.resurrect(fromDisk);
3393
4020
  }
3394
4021
  const client = bindClientToSession(connection, session, state);
3395
- const replay = session.attach(client, "pending_only");
4022
+ const replay = await session.attach(client, "pending_only");
3396
4023
  state.attached.set(session.sessionId, {
3397
4024
  sessionId: session.sessionId,
3398
4025
  clientId: client.clientId
@@ -3450,12 +4077,15 @@ function buildResponseMeta(session) {
3450
4077
  if (commands.length > 0) {
3451
4078
  ours.availableCommands = commands;
3452
4079
  }
4080
+ if (session.turnStartedAt !== void 0) {
4081
+ ours.turnStartedAt = session.turnStartedAt;
4082
+ }
3453
4083
  return mergeMeta(session.agentMeta, ours);
3454
4084
  }
3455
4085
  function buildInitializeResult() {
3456
4086
  return {
3457
4087
  protocolVersion: HYDRA_PROTOCOL_VERSION,
3458
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
4088
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
3459
4089
  agentCapabilities: {
3460
4090
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
3461
4091
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -3494,7 +4124,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
3494
4124
  }
3495
4125
 
3496
4126
  // src/daemon/server.ts
3497
- var HYDRA_VERSION2 = "0.1.0";
4127
+ var HYDRA_VERSION3 = "0.1.0";
3498
4128
  async function startDaemon(config) {
3499
4129
  ensureLoopbackOrTls(config);
3500
4130
  const httpsOptions = config.daemon.tls ? {
@@ -3528,13 +4158,17 @@ async function startDaemon(config) {
3528
4158
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
3529
4159
  });
3530
4160
  const extensions = new ExtensionManager(extensionList(config));
3531
- registerHealthRoutes(app, HYDRA_VERSION2);
4161
+ registerHealthRoutes(app, HYDRA_VERSION3);
3532
4162
  registerSessionRoutes(app, manager, {
3533
4163
  agentId: config.defaultAgent,
3534
4164
  cwd: config.defaultCwd
3535
4165
  });
3536
4166
  registerAgentRoutes(app, registry);
3537
4167
  registerExtensionRoutes(app, extensions);
4168
+ registerConfigRoutes(app, {
4169
+ defaultAgent: config.defaultAgent,
4170
+ defaultCwd: config.defaultCwd
4171
+ });
3538
4172
  registerAcpWsEndpoint(app, {
3539
4173
  config,
3540
4174
  manager,
@@ -3568,9 +4202,10 @@ async function startDaemon(config) {
3568
4202
  const shutdown = async () => {
3569
4203
  await extensions.stop();
3570
4204
  await manager.closeAll();
4205
+ await manager.flushMetaWrites();
3571
4206
  await app.close();
3572
4207
  try {
3573
- fs6.unlinkSync(paths.pidFile());
4208
+ fs8.unlinkSync(paths.pidFile());
3574
4209
  } catch {
3575
4210
  }
3576
4211
  try {