@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/cli.js CHANGED
@@ -89,8 +89,7 @@ async function ensureConfig() {
89
89
  if (e.code !== "ENOENT") {
90
90
  throw err;
91
91
  }
92
- const config = defaultConfig();
93
- await writeConfig(config);
92
+ const config = await writeMinimalInitConfig();
94
93
  process.stderr.write(
95
94
  `hydra-acp: initialized ${paths.config()} with a fresh auth token.
96
95
  `
@@ -99,9 +98,23 @@ async function ensureConfig() {
99
98
  }
100
99
  return loadConfig();
101
100
  }
102
- async function writeConfig(config) {
101
+ async function writeMinimalInitConfig(authToken) {
102
+ const token = authToken ?? generateAuthToken();
103
+ const minimal = { daemon: { authToken: token } };
103
104
  await fs.mkdir(paths.home(), { recursive: true });
104
- await fs.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
105
+ await fs.writeFile(paths.config(), JSON.stringify(minimal, null, 2) + "\n", {
106
+ encoding: "utf8",
107
+ mode: 384
108
+ });
109
+ return HydraConfig.parse(minimal);
110
+ }
111
+ async function updateConfigField(mutate) {
112
+ const path6 = paths.config();
113
+ const text = await fs.readFile(path6, "utf8");
114
+ const raw = JSON.parse(text);
115
+ mutate(raw);
116
+ HydraConfig.parse(raw);
117
+ await fs.writeFile(path6, JSON.stringify(raw, null, 2) + "\n", {
105
118
  encoding: "utf8",
106
119
  mode: 384
107
120
  });
@@ -115,13 +128,6 @@ function generateAuthToken() {
115
128
  }
116
129
  return `hydra_token_${hex}`;
117
130
  }
118
- function defaultConfig() {
119
- return HydraConfig.parse({
120
- daemon: {
121
- authToken: generateAuthToken()
122
- }
123
- });
124
- }
125
131
  function expandHome(p) {
126
132
  if (p === "~" || p === "$HOME") {
127
133
  return homedir2();
@@ -150,7 +156,7 @@ var init_config = __esm({
150
156
  authToken: z.string().min(16),
151
157
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
152
158
  tls: TlsConfig.optional(),
153
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
159
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
154
160
  });
155
161
  RegistryConfig = z.object({
156
162
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -277,7 +283,8 @@ var init_types = __esm({
277
283
  SessionNotFound: -32001,
278
284
  PermissionDenied: -32002,
279
285
  AlreadyAttached: -32003,
280
- AgentNotInstalled: -32005
286
+ AgentNotInstalled: -32005,
287
+ BundleAlreadyImported: -32010
281
288
  };
282
289
  InitializeParams = z3.object({
283
290
  protocolVersion: z3.number().optional(),
@@ -401,9 +408,9 @@ var init_connection = __esm({
401
408
  }
402
409
  const id = nanoid();
403
410
  const message = { jsonrpc: "2.0", id, method, params };
404
- const response = new Promise((resolve2, reject) => {
411
+ const response = new Promise((resolve4, reject) => {
405
412
  this.pending.set(id, {
406
- resolve: (result) => resolve2(result),
413
+ resolve: (result) => resolve4(result),
407
414
  reject
408
415
  });
409
416
  this.stream.send(message).catch((err) => {
@@ -627,7 +634,7 @@ function firstLine(text, max) {
627
634
  }
628
635
  return void 0;
629
636
  }
630
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
637
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
631
638
  var init_session = __esm({
632
639
  "src/core/session.ts"() {
633
640
  "use strict";
@@ -636,6 +643,8 @@ var init_session = __esm({
636
643
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
637
644
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
638
645
  HYDRA_SESSION_PREFIX = "hydra_session_";
646
+ MAX_HISTORY_ENTRIES = 1e3;
647
+ COMPACT_EVERY = 200;
639
648
  Session = class {
640
649
  sessionId;
641
650
  cwd;
@@ -655,8 +664,8 @@ var init_session = __esm({
655
664
  currentModel;
656
665
  currentMode;
657
666
  updatedAt;
667
+ createdAt;
658
668
  clients = /* @__PURE__ */ new Map();
659
- history = [];
660
669
  historyStore;
661
670
  promptQueue = [];
662
671
  promptInFlight = false;
@@ -672,6 +681,15 @@ var init_session = __esm({
672
681
  // True once we've observed our first session/prompt; gates the
673
682
  // first-prompt-seeded title so subsequent prompts don't churn it.
674
683
  firstPromptSeeded = false;
684
+ // Wall-clock when the active prompt started, undefined when idle.
685
+ // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
686
+ // Drives the mid-turn elapsed counter delivered to fresh attachers.
687
+ promptStartedAt;
688
+ // Counts appends since the last compaction. When it hits COMPACT_EVERY
689
+ // we ask the history store to trim the file to the most recent
690
+ // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
691
+ // file-size checks.
692
+ appendCount = 0;
675
693
  // Permission requests that have been broadcast to one or more
676
694
  // clients but have not yet resolved. Replayed to clients that
677
695
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -686,6 +704,12 @@ var init_session = __esm({
686
704
  internalPromptCapture;
687
705
  idleTimeoutMs;
688
706
  idleTimer;
707
+ // Time of the last recordable broadcast (or session creation, if
708
+ // none yet). Drives the inactivity-based idle close; deliberately
709
+ // does NOT include snapshot state pings (model/mode/title/commands)
710
+ // or attach/detach, which would otherwise let passive observers
711
+ // and noisy state churn keep a quiet session alive forever.
712
+ lastRecordedAt;
689
713
  spawnReplacementAgent;
690
714
  agentChangeHandlers = [];
691
715
  // Last available_commands_update we observed from the agent. Stored
@@ -720,11 +744,11 @@ var init_session = __esm({
720
744
  this.firstPromptSeeded = true;
721
745
  }
722
746
  this.historyStore = init.historyStore;
723
- if (init.seedHistory && init.seedHistory.length > 0) {
724
- this.history = [...init.seedHistory];
725
- }
726
747
  this.updatedAt = Date.now();
748
+ this.createdAt = init.createdAt ?? this.updatedAt;
749
+ this.lastRecordedAt = this.updatedAt;
727
750
  this.wireAgent(this.agent);
751
+ this.scheduleIdleCheck();
728
752
  }
729
753
  broadcastMergedCommands() {
730
754
  const merged = [
@@ -784,34 +808,20 @@ var init_session = __esm({
784
808
  return this.clients.size;
785
809
  }
786
810
  // Wall-clock when the in-flight agent turn began, or undefined when
787
- // idle. Derived from history: the most recent prompt_received without
788
- // a later turn_complete is the outstanding turn, and its recordedAt
789
- // is when the prompt was first broadcast. Used by buildResponseMeta
790
- // so a fresh client reattaching mid-turn boots up with the busy
791
- // banner showing real elapsed time.
811
+ // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
812
+ // so the daemon can hand a fresh attacher mid-turn the right elapsed
813
+ // time without scanning history.
792
814
  get turnStartedAt() {
793
- for (let i = this.history.length - 1; i >= 0; i--) {
794
- const entry = this.history[i];
795
- if (!entry) {
796
- continue;
797
- }
798
- const params = entry.params;
799
- const kind = params?.update?.sessionUpdate;
800
- if (kind === "turn_complete") {
801
- return void 0;
802
- }
803
- if (kind === "prompt_received") {
804
- return entry.recordedAt;
805
- }
806
- }
807
- return void 0;
815
+ return this.promptStartedAt;
808
816
  }
809
- // Snapshot of the current in-memory replay history. Used by the
810
- // HTTP history endpoint to deliver the "what's accumulated so far"
811
- // prefix before optionally tailing with onBroadcast. Returns a copy
812
- // so callers can't mutate our cache.
813
- getHistorySnapshot() {
814
- return [...this.history];
817
+ // Read the persisted history from disk. Returns [] if no history
818
+ // file exists (fresh session, never prompted). Used by attach() and
819
+ // the HTTP /history endpoint.
820
+ async getHistorySnapshot() {
821
+ if (!this.historyStore) {
822
+ return [];
823
+ }
824
+ return this.historyStore.load(this.sessionId).catch(() => []);
815
825
  }
816
826
  // Subscribe to recordable broadcast entries — fires once per entry
817
827
  // that lands in history (so snapshot-shaped session_info/model/mode/
@@ -827,6 +837,10 @@ var init_session = __esm({
827
837
  }
828
838
  };
829
839
  }
840
+ // Register a client and (asynchronously) load the replay slice it
841
+ // should receive. Validation errors throw synchronously so callers
842
+ // can rely on either the registration being in effect or having
843
+ // thrown; the disk-load is the only async work.
830
844
  attach(client, historyPolicy) {
831
845
  if (this.closed) {
832
846
  throw withCode(
@@ -842,14 +856,10 @@ var init_session = __esm({
842
856
  }
843
857
  this.clients.set(client.clientId, client);
844
858
  this.updatedAt = Date.now();
845
- this.cancelIdleTimer();
846
- if (historyPolicy === "none") {
847
- return [];
848
- }
849
- if (historyPolicy === "pending_only") {
850
- return [];
859
+ if (historyPolicy === "none" || historyPolicy === "pending_only") {
860
+ return Promise.resolve([]);
851
861
  }
852
- return [...this.history];
862
+ return this.getHistorySnapshot();
853
863
  }
854
864
  // Dispatch in-flight permission requests to a freshly-attached
855
865
  // client. Called by the daemon's WS handler *after* it finishes
@@ -863,7 +873,6 @@ var init_session = __esm({
863
873
  detach(clientId) {
864
874
  if (this.clients.delete(clientId)) {
865
875
  this.updatedAt = Date.now();
866
- this.maybeStartIdleTimer();
867
876
  }
868
877
  }
869
878
  async prompt(clientId, params) {
@@ -909,6 +918,7 @@ var init_session = __esm({
909
918
  if (client.clientInfo?.version) {
910
919
  sentBy.version = client.clientInfo.version;
911
920
  }
921
+ this.promptStartedAt = Date.now();
912
922
  this.recordAndBroadcast(
913
923
  "session/update",
914
924
  {
@@ -945,6 +955,7 @@ var init_session = __esm({
945
955
  if (stopReason !== void 0) {
946
956
  update.stopReason = stopReason;
947
957
  }
958
+ this.promptStartedAt = void 0;
948
959
  this.recordAndBroadcast(
949
960
  "session/update",
950
961
  {
@@ -1130,6 +1141,12 @@ var init_session = __esm({
1130
1141
  mergedAvailableCommands() {
1131
1142
  return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1132
1143
  }
1144
+ // The agent's own advertised commands (not merged with hydra verbs).
1145
+ // Used by SessionManager to persist into meta.json so cold resurrect
1146
+ // can re-deliver via the attach response _meta.
1147
+ agentOnlyAdvertisedCommands() {
1148
+ return [...this.agentAdvertisedCommands];
1149
+ }
1133
1150
  // Pick up an agent-emitted session_info_update and store its title
1134
1151
  // as our canonical record. The notification is also forwarded to
1135
1152
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1261,7 +1278,7 @@ var init_session = __esm({
1261
1278
  const spawnAgent = this.spawnReplacementAgent;
1262
1279
  return this.enqueuePrompt(async () => {
1263
1280
  const oldAgentId = this.agentId;
1264
- const transcript = this.buildSwitchTranscript(oldAgentId);
1281
+ const transcript = await this.buildSwitchTranscript(oldAgentId);
1265
1282
  const fresh = await spawnAgent({
1266
1283
  agentId: newAgentId,
1267
1284
  cwd: this.cwd,
@@ -1293,15 +1310,20 @@ var init_session = __esm({
1293
1310
  return { stopReason: "end_turn" };
1294
1311
  });
1295
1312
  }
1296
- // Walk this.history (rewritten-for-clients notification cache) and
1297
- // produce a labeled transcript suitable for handing to a fresh agent.
1298
- // Includes user prompts, agent replies, and tool-call outcomes; skips
1299
- // hydra-synthesized markers (so multi-hop switches don't accumulate
1300
- // banners) and other update kinds we don't think the next agent
1301
- // benefits from re-seeing (plans, thoughts, mode/model/usage).
1302
- buildSwitchTranscript(prevAgentId) {
1313
+ // Walk the persisted history and produce a labeled transcript suitable
1314
+ // for handing to a fresh agent. Includes user prompts, agent replies,
1315
+ // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
1316
+ // switches don't accumulate banners) and other update kinds we don't
1317
+ // think the next agent benefits from re-seeing (plans, thoughts,
1318
+ // mode/model/usage).
1319
+ //
1320
+ // The header text defaults to the agent-swap framing; callers like
1321
+ // seedFromImport pass a custom header when the new agent is taking
1322
+ // over an imported session rather than swapping mid-conversation.
1323
+ async buildSwitchTranscript(prevAgentId, headerOverride) {
1303
1324
  const lines = [];
1304
- for (const note of this.history) {
1325
+ const history = await this.getHistorySnapshot();
1326
+ for (const note of history) {
1305
1327
  if (note.method !== "session/update") {
1306
1328
  continue;
1307
1329
  }
@@ -1355,29 +1377,53 @@ var init_session = __esm({
1355
1377
  if (current) {
1356
1378
  coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1357
1379
  }
1380
+ const intro = headerOverride?.intro ?? `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`;
1381
+ 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.`;
1358
1382
  return [
1359
- `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1360
- `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1383
+ intro,
1384
+ followup,
1361
1385
  "",
1362
1386
  "--- begin transcript ---",
1363
1387
  ...coalesced,
1364
1388
  "--- end transcript ---"
1365
1389
  ].join("\n");
1366
1390
  }
1391
+ // Replay the persisted history into a freshly-spawned agent so an
1392
+ // imported session has context. Called by SessionManager.doResurrect
1393
+ // on the first wake-up of a session whose meta.json has an empty
1394
+ // upstreamSessionId (the import marker). Wrapped in enqueuePrompt so
1395
+ // any user prompts arriving mid-seed queue behind it (mirrors the
1396
+ // /hydra switch path so the agent isn't asked to respond to a user
1397
+ // turn before it has absorbed the imported transcript). Best-effort:
1398
+ // if the agent fails to absorb the transcript we still leave the
1399
+ // session usable — the user just continues without context.
1400
+ async seedFromImport() {
1401
+ await this.enqueuePrompt(async () => {
1402
+ const transcript = await this.buildSwitchTranscript(this.agentId, {
1403
+ intro: "You are continuing a conversation that was imported from another hydra. Below is the transcript so far.",
1404
+ followup: "Each line is prefixed with its speaker. Treat this as context for the next user message; do not re-respond to earlier turns."
1405
+ });
1406
+ if (!transcript) {
1407
+ return void 0;
1408
+ }
1409
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1410
+ return void 0;
1411
+ });
1412
+ }
1367
1413
  // Tell every attached client (a) the agent identity has changed
1368
- // (session_info_update with an agentId field clients that already
1369
- // listen for title updates pick this up; older clients ignore unknown
1370
- // fields harmlessly) and (b) drop a visible banner into the transcript
1371
- // so users see the switch rather than just suddenly getting answers
1372
- // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1414
+ // (session_info_update carrying agentId inside _meta["hydra-acp"]
1415
+ // the ACP schema for session_info_update is just title/updatedAt/_meta,
1416
+ // so non-hydra clients harmlessly ignore the extension; hydra-aware
1417
+ // ones read it and relabel) and (b) drop a visible banner into the
1418
+ // transcript so users see the switch rather than just suddenly getting
1419
+ // answers from a different agent. Both updates carry synthetic=true
1373
1420
  // so a future /hydra switch's transcript builder filters them out.
1374
1421
  broadcastAgentSwitch(oldAgentId, newAgentId) {
1375
1422
  this.recordAndBroadcast("session/update", {
1376
1423
  sessionId: this.sessionId,
1377
1424
  update: {
1378
1425
  sessionUpdate: "session_info_update",
1379
- agentId: newAgentId,
1380
- _meta: { "hydra-acp": { synthetic: true } }
1426
+ _meta: { "hydra-acp": { synthetic: true, agentId: newAgentId } }
1381
1427
  }
1382
1428
  });
1383
1429
  this.recordAndBroadcast("session/update", {
@@ -1408,22 +1454,55 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1408
1454
  handler(opts);
1409
1455
  }
1410
1456
  }
1411
- maybeStartIdleTimer() {
1412
- if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1457
+ // Last meaningful activity timestamp. Bumped only by recordable
1458
+ // broadcasts in recordAndBroadcast the same signal historyMtimeIso
1459
+ // uses for the picker. Initialized at construction (and seeded from
1460
+ // the newest entry on resurrect) so the inactivity window starts
1461
+ // ticking from a sensible floor when there's no history yet.
1462
+ get lastActivityAt() {
1463
+ return this.lastRecordedAt;
1464
+ }
1465
+ // (Re-)arm the idle timer to fire when the inactivity window
1466
+ // elapses past lastActivityAt. Called once at construction and after
1467
+ // every recorded broadcast. The previous design gated on
1468
+ // clients.size === 0; we drop that gate because extensions
1469
+ // (slack/notifier/approver/browser) hold persistent attaches that
1470
+ // would otherwise keep a quiet session alive forever.
1471
+ scheduleIdleCheck() {
1472
+ if (this.closed || this.idleTimeoutMs <= 0) {
1413
1473
  return;
1414
1474
  }
1475
+ const dueAt = this.lastActivityAt + this.idleTimeoutMs;
1476
+ this.armIdleTimer(Math.max(0, dueAt - Date.now()));
1477
+ }
1478
+ armIdleTimer(delay) {
1415
1479
  if (this.idleTimer) {
1416
- return;
1480
+ clearTimeout(this.idleTimer);
1417
1481
  }
1418
1482
  this.idleTimer = setTimeout(() => {
1419
1483
  this.idleTimer = void 0;
1420
- const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1421
- void this.close(opts).catch(() => void 0);
1422
- }, this.idleTimeoutMs);
1484
+ this.checkIdle();
1485
+ }, delay);
1423
1486
  if (typeof this.idleTimer.unref === "function") {
1424
1487
  this.idleTimer.unref();
1425
1488
  }
1426
1489
  }
1490
+ checkIdle() {
1491
+ if (this.closed || this.idleTimeoutMs <= 0) {
1492
+ return;
1493
+ }
1494
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
1495
+ this.armIdleTimer(this.idleTimeoutMs);
1496
+ return;
1497
+ }
1498
+ const idle = Date.now() - this.lastActivityAt;
1499
+ if (idle < this.idleTimeoutMs) {
1500
+ this.armIdleTimer(this.idleTimeoutMs - idle);
1501
+ return;
1502
+ }
1503
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1504
+ void this.close(opts).catch(() => void 0);
1505
+ }
1427
1506
  cancelIdleTimer() {
1428
1507
  if (this.idleTimer) {
1429
1508
  clearTimeout(this.idleTimer);
@@ -1448,17 +1527,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1448
1527
  params: rewritten,
1449
1528
  recordedAt: Date.now()
1450
1529
  };
1451
- this.history.push(entry);
1452
- let trimmed = false;
1453
- if (this.history.length > 1e3) {
1454
- this.history = this.history.slice(-500);
1455
- trimmed = true;
1456
- }
1530
+ this.lastRecordedAt = entry.recordedAt;
1531
+ this.appendCount += 1;
1457
1532
  if (this.historyStore) {
1458
- if (trimmed) {
1459
- void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1460
- } else {
1461
- void this.historyStore.append(this.sessionId, entry).catch(
1533
+ const store = this.historyStore;
1534
+ void store.append(this.sessionId, entry).catch(() => void 0);
1535
+ if (this.appendCount >= COMPACT_EVERY) {
1536
+ this.appendCount = 0;
1537
+ void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1462
1538
  () => void 0
1463
1539
  );
1464
1540
  }
@@ -1469,6 +1545,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1469
1545
  } catch {
1470
1546
  }
1471
1547
  }
1548
+ this.scheduleIdleCheck();
1472
1549
  }
1473
1550
  this.updatedAt = Date.now();
1474
1551
  for (const client of this.clients.values()) {
@@ -1487,7 +1564,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1487
1564
  );
1488
1565
  }
1489
1566
  const clientParams = this.rewriteForClient(params);
1490
- return new Promise((resolve2, reject) => {
1567
+ return new Promise((resolve4, reject) => {
1491
1568
  let settled = false;
1492
1569
  const outbound = [];
1493
1570
  const entry = { addClient: sendTo };
@@ -1522,7 +1599,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1522
1599
  result
1523
1600
  }).catch(() => void 0);
1524
1601
  }
1525
- resolve2(result);
1602
+ resolve4(result);
1526
1603
  });
1527
1604
  }).catch((err) => {
1528
1605
  settle(() => reject(err));
@@ -1534,11 +1611,11 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1534
1611
  });
1535
1612
  }
1536
1613
  async enqueuePrompt(task) {
1537
- return new Promise((resolve2, reject) => {
1614
+ return new Promise((resolve4, reject) => {
1538
1615
  const run = async () => {
1539
1616
  try {
1540
1617
  const result = await task();
1541
- resolve2(result);
1618
+ resolve4(result);
1542
1619
  } catch (err) {
1543
1620
  reject(err);
1544
1621
  }
@@ -1573,6 +1650,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1573
1650
  }
1574
1651
  });
1575
1652
 
1653
+ // src/tui/history.ts
1654
+ import { promises as fs6 } from "fs";
1655
+ import * as path3 from "path";
1656
+ async function loadHistory(file) {
1657
+ let text;
1658
+ try {
1659
+ text = await fs6.readFile(file, "utf8");
1660
+ } catch (err) {
1661
+ if (err.code === "ENOENT") {
1662
+ return [];
1663
+ }
1664
+ throw err;
1665
+ }
1666
+ return parseHistory(text);
1667
+ }
1668
+ function parseHistory(text) {
1669
+ const out = [];
1670
+ for (const rawLine of text.split("\n")) {
1671
+ if (rawLine.length === 0) {
1672
+ continue;
1673
+ }
1674
+ try {
1675
+ const decoded = JSON.parse(rawLine);
1676
+ if (typeof decoded === "string") {
1677
+ out.push(decoded);
1678
+ }
1679
+ } catch {
1680
+ }
1681
+ }
1682
+ return out;
1683
+ }
1684
+ function appendEntry(history, entry) {
1685
+ const trimmed = entry.replace(/\n+$/, "");
1686
+ if (trimmed.length === 0) {
1687
+ return history;
1688
+ }
1689
+ if (history.length > 0 && history[history.length - 1] === trimmed) {
1690
+ return history;
1691
+ }
1692
+ const out = history.concat(trimmed);
1693
+ if (out.length > HISTORY_CAP) {
1694
+ return out.slice(out.length - HISTORY_CAP);
1695
+ }
1696
+ return out;
1697
+ }
1698
+ async function saveHistory(file, history) {
1699
+ await fs6.mkdir(path3.dirname(file), { recursive: true });
1700
+ const lines = history.map((entry) => JSON.stringify(entry));
1701
+ await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1702
+ }
1703
+ var HISTORY_CAP;
1704
+ var init_history = __esm({
1705
+ "src/tui/history.ts"() {
1706
+ "use strict";
1707
+ HISTORY_CAP = 500;
1708
+ }
1709
+ });
1710
+
1576
1711
  // src/acp/ws-stream.ts
1577
1712
  function wsToMessageStream(ws) {
1578
1713
  const messageHandlers = [];
@@ -1618,13 +1753,13 @@ function wsToMessageStream(ws) {
1618
1753
  throw new Error("ws is closed");
1619
1754
  }
1620
1755
  const text = JSON.stringify(message);
1621
- await new Promise((resolve2, reject) => {
1756
+ await new Promise((resolve4, reject) => {
1622
1757
  ws.send(text, (err) => {
1623
1758
  if (err) {
1624
1759
  reject(err);
1625
1760
  return;
1626
1761
  }
1627
- resolve2();
1762
+ resolve4();
1628
1763
  });
1629
1764
  });
1630
1765
  },
@@ -1845,6 +1980,8 @@ var init_session_row = __esm({
1845
1980
  });
1846
1981
 
1847
1982
  // src/cli/commands/sessions.ts
1983
+ import * as fs11 from "fs/promises";
1984
+ import * as path5 from "path";
1848
1985
  async function runSessionsList(opts = {}) {
1849
1986
  const config = await loadConfig();
1850
1987
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -1933,6 +2070,111 @@ async function runSessionsRm(id) {
1933
2070
  process.stdout.write(`Removed ${id}
1934
2071
  `);
1935
2072
  }
2073
+ async function runSessionsExport(id, outPath) {
2074
+ if (!id) {
2075
+ process.stderr.write(
2076
+ "Usage: hydra-acp sessions export <session-id> [--out <file>]\n"
2077
+ );
2078
+ process.exit(2);
2079
+ }
2080
+ const config = await loadConfig();
2081
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2082
+ const response = await fetch(
2083
+ `${baseUrl}/v1/sessions/${encodeURIComponent(id)}/export`,
2084
+ {
2085
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2086
+ }
2087
+ );
2088
+ if (!response.ok) {
2089
+ const text = await response.text().catch(() => "");
2090
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2091
+ `);
2092
+ process.exit(1);
2093
+ }
2094
+ const body = await response.text();
2095
+ if (!outPath) {
2096
+ process.stdout.write(body);
2097
+ if (!body.endsWith("\n")) {
2098
+ process.stdout.write("\n");
2099
+ }
2100
+ return;
2101
+ }
2102
+ const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2103
+ await fs11.mkdir(path5.dirname(path5.resolve(resolved)), { recursive: true });
2104
+ await fs11.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2105
+ process.stdout.write(`Wrote ${resolved}
2106
+ `);
2107
+ }
2108
+ async function runSessionsImport(file, opts = {}) {
2109
+ if (!file) {
2110
+ process.stderr.write(
2111
+ "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2112
+ );
2113
+ process.exit(2);
2114
+ }
2115
+ let body;
2116
+ if (file === "-") {
2117
+ body = await readStdin();
2118
+ } else {
2119
+ body = await fs11.readFile(file, "utf8");
2120
+ }
2121
+ let bundle;
2122
+ try {
2123
+ bundle = JSON.parse(body);
2124
+ } catch (err) {
2125
+ process.stderr.write(`Failed to parse bundle: ${err.message}
2126
+ `);
2127
+ process.exit(1);
2128
+ }
2129
+ const config = await loadConfig();
2130
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2131
+ const response = await fetch(`${baseUrl}/v1/sessions/import`, {
2132
+ method: "POST",
2133
+ headers: {
2134
+ "Content-Type": "application/json",
2135
+ Authorization: `Bearer ${config.daemon.authToken}`
2136
+ },
2137
+ body: JSON.stringify({ bundle, replace: opts.replace === true })
2138
+ });
2139
+ if (response.status === 409) {
2140
+ const detail = await response.json().catch(() => ({}));
2141
+ process.stderr.write(
2142
+ `Bundle already imported as ${detail.existingSessionId ?? "unknown"}. Use --replace to overwrite.
2143
+ `
2144
+ );
2145
+ process.exit(1);
2146
+ }
2147
+ if (!response.ok) {
2148
+ const text = await response.text().catch(() => "");
2149
+ process.stderr.write(`Daemon returned HTTP ${response.status}: ${text}
2150
+ `);
2151
+ process.exit(1);
2152
+ }
2153
+ const result = await response.json();
2154
+ process.stdout.write(
2155
+ result.replaced ? `Replaced ${result.sessionId} (from ${result.importedFromSessionId})
2156
+ ` : `Imported as ${result.sessionId} (from ${result.importedFromSessionId})
2157
+ `
2158
+ );
2159
+ }
2160
+ async function readStdin() {
2161
+ const chunks = [];
2162
+ for await (const chunk of process.stdin) {
2163
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
2164
+ }
2165
+ return Buffer.concat(chunks).toString("utf8");
2166
+ }
2167
+ function deriveFilenameFrom(response, id) {
2168
+ const cd = response.headers.get("content-disposition");
2169
+ if (cd) {
2170
+ const match = cd.match(/filename="([^"]+)"/);
2171
+ if (match) {
2172
+ return match[1];
2173
+ }
2174
+ }
2175
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2176
+ return `hydra-${id}-${stamp}.hydra`;
2177
+ }
1936
2178
  function httpBase(host, port, tls) {
1937
2179
  const protocol = tls ? "https" : "http";
1938
2180
  return `${protocol}://${host}:${port}`;
@@ -1952,11 +2194,11 @@ function isResponse(msg) {
1952
2194
  return !("method" in msg) && "id" in msg && msg.id !== void 0;
1953
2195
  }
1954
2196
  async function openWs(url, subprotocols) {
1955
- return new Promise((resolve2, reject) => {
2197
+ return new Promise((resolve4, reject) => {
1956
2198
  const ws = new WebSocket(url, subprotocols);
1957
2199
  const onOpen = () => {
1958
2200
  ws.off("error", onError);
1959
- resolve2(wsToMessageStream(ws));
2201
+ resolve4(wsToMessageStream(ws));
1960
2202
  };
1961
2203
  const onError = (err) => {
1962
2204
  ws.off("open", onOpen);
@@ -2027,8 +2269,8 @@ var init_resilient_ws = __esm({
2027
2269
  throw new Error("resilient ws stream not connected");
2028
2270
  }
2029
2271
  const id = message.id;
2030
- const promise = new Promise((resolve2, reject) => {
2031
- this.pendingRequests.set(id, { resolve: resolve2, reject });
2272
+ const promise = new Promise((resolve4, reject) => {
2273
+ this.pendingRequests.set(id, { resolve: resolve4, reject });
2032
2274
  });
2033
2275
  try {
2034
2276
  await this.current.send(message);
@@ -2056,8 +2298,8 @@ var init_resilient_ws = __esm({
2056
2298
  this.bindStream(stream);
2057
2299
  const wasFirst = this.firstConnect;
2058
2300
  this.firstConnect = false;
2059
- this.connectGate = new Promise((resolve2) => {
2060
- this.releaseConnectGate = resolve2;
2301
+ this.connectGate = new Promise((resolve4) => {
2302
+ this.releaseConnectGate = resolve4;
2061
2303
  });
2062
2304
  try {
2063
2305
  if (this.opts.onConnect) {
@@ -2180,64 +2422,6 @@ var init_resilient_ws = __esm({
2180
2422
  }
2181
2423
  });
2182
2424
 
2183
- // src/tui/history.ts
2184
- import { promises as fs10 } from "fs";
2185
- import * as path4 from "path";
2186
- async function loadHistory(file) {
2187
- let text;
2188
- try {
2189
- text = await fs10.readFile(file, "utf8");
2190
- } catch (err) {
2191
- if (err.code === "ENOENT") {
2192
- return [];
2193
- }
2194
- throw err;
2195
- }
2196
- return parseHistory(text);
2197
- }
2198
- function parseHistory(text) {
2199
- const out = [];
2200
- for (const rawLine of text.split("\n")) {
2201
- if (rawLine.length === 0) {
2202
- continue;
2203
- }
2204
- try {
2205
- const decoded = JSON.parse(rawLine);
2206
- if (typeof decoded === "string") {
2207
- out.push(decoded);
2208
- }
2209
- } catch {
2210
- }
2211
- }
2212
- return out;
2213
- }
2214
- function appendEntry(history, entry) {
2215
- const trimmed = entry.replace(/\n+$/, "");
2216
- if (trimmed.length === 0) {
2217
- return history;
2218
- }
2219
- if (history.length > 0 && history[history.length - 1] === trimmed) {
2220
- return history;
2221
- }
2222
- const out = history.concat(trimmed);
2223
- if (out.length > HISTORY_CAP) {
2224
- return out.slice(out.length - HISTORY_CAP);
2225
- }
2226
- return out;
2227
- }
2228
- async function saveHistory(file, history) {
2229
- await fs10.mkdir(path4.dirname(file), { recursive: true });
2230
- const lines = history.map((entry) => JSON.stringify(entry));
2231
- await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2232
- }
2233
- var HISTORY_CAP;
2234
- var init_history = __esm({
2235
- "src/tui/history.ts"() {
2236
- "use strict";
2237
- HISTORY_CAP = 500;
2238
- }
2239
- });
2240
-
2241
2425
  // src/tui/discovery.ts
2242
2426
  async function listSessions(config, opts = {}, fetchImpl = fetch) {
2243
2427
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2468,7 +2652,7 @@ async function pickSession(term, opts) {
2468
2652
  };
2469
2653
  renderFromScratch();
2470
2654
  term.hideCursor();
2471
- return await new Promise((resolve2) => {
2655
+ return await new Promise((resolve4) => {
2472
2656
  let resolved = false;
2473
2657
  const onResize = () => {
2474
2658
  if (resolved) {
@@ -2590,6 +2774,11 @@ async function pickSession(term, opts) {
2590
2774
  }
2591
2775
  clearTransient();
2592
2776
  if (data?.isCharacter) {
2777
+ if (name === "r" || name === "R") {
2778
+ const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2779
+ void refresh(currentId);
2780
+ return;
2781
+ }
2593
2782
  if ((name === "k" || name === "K") && selectedIdx > 0) {
2594
2783
  const session = visible[selectedIdx - 1];
2595
2784
  if (!session) {
@@ -2650,12 +2839,12 @@ async function pickSession(term, opts) {
2650
2839
  case "KP_ENTER": {
2651
2840
  cleanup();
2652
2841
  if (selectedIdx === 0) {
2653
- resolve2({ kind: "new" });
2842
+ resolve4({ kind: "new" });
2654
2843
  return;
2655
2844
  }
2656
2845
  const session = visible[selectedIdx - 1];
2657
2846
  if (!session) {
2658
- resolve2({ kind: "abort" });
2847
+ resolve4({ kind: "abort" });
2659
2848
  return;
2660
2849
  }
2661
2850
  const result = {
@@ -2665,13 +2854,13 @@ async function pickSession(term, opts) {
2665
2854
  if (session.agentId !== void 0) {
2666
2855
  result.agentId = session.agentId;
2667
2856
  }
2668
- resolve2(result);
2857
+ resolve4(result);
2669
2858
  return;
2670
2859
  }
2671
2860
  case "ESCAPE":
2672
2861
  case "CTRL_C":
2673
2862
  cleanup();
2674
- resolve2({ kind: "abort" });
2863
+ resolve4({ kind: "abort" });
2675
2864
  return;
2676
2865
  }
2677
2866
  };
@@ -2853,6 +3042,12 @@ function wrap(text, width) {
2853
3042
  if (text.length === 0) {
2854
3043
  return [""];
2855
3044
  }
3045
+ if (!NON_ASCII.test(text)) {
3046
+ return wrapAscii(text, width);
3047
+ }
3048
+ return wrapVisible(text, width);
3049
+ }
3050
+ function wrapAscii(text, width) {
2856
3051
  const out = [];
2857
3052
  let remaining = text;
2858
3053
  while (remaining.length > width) {
@@ -2875,14 +3070,78 @@ function wrap(text, width) {
2875
3070
  out.push(remaining);
2876
3071
  return out;
2877
3072
  }
3073
+ function wrapVisible(text, width) {
3074
+ const out = [];
3075
+ const graphemes = [];
3076
+ for (const { segment } of SEGMENTER.segment(text)) {
3077
+ graphemes.push({ seg: segment, w: stringWidth(segment) });
3078
+ }
3079
+ let i = 0;
3080
+ while (i < graphemes.length) {
3081
+ let chunk = "";
3082
+ let chunkW = 0;
3083
+ let lastSpaceI = -1;
3084
+ let chunkAtLastSpace = "";
3085
+ while (i < graphemes.length) {
3086
+ const g = graphemes[i];
3087
+ if (chunkW + g.w > width) {
3088
+ break;
3089
+ }
3090
+ if (g.seg === " ") {
3091
+ lastSpaceI = i;
3092
+ chunkAtLastSpace = chunk;
3093
+ }
3094
+ chunk += g.seg;
3095
+ chunkW += g.w;
3096
+ i += 1;
3097
+ }
3098
+ if (i >= graphemes.length) {
3099
+ out.push(chunk);
3100
+ break;
3101
+ }
3102
+ if (lastSpaceI >= 0) {
3103
+ out.push(chunkAtLastSpace);
3104
+ i = lastSpaceI + 1;
3105
+ } else if (chunk.length === 0) {
3106
+ out.push(graphemes[i].seg);
3107
+ i += 1;
3108
+ } else {
3109
+ out.push(chunk);
3110
+ }
3111
+ }
3112
+ return out;
3113
+ }
2878
3114
  function truncate(text, max) {
2879
- if (text.length <= max) {
3115
+ if (max <= 0) {
3116
+ return "";
3117
+ }
3118
+ if (text.length <= max && !NON_ASCII.test(text)) {
3119
+ return text;
3120
+ }
3121
+ const visible = stringWidth(text);
3122
+ if (visible <= max) {
2880
3123
  return text;
2881
3124
  }
2882
3125
  if (max <= 1) {
2883
- return text.slice(0, max);
3126
+ return takeByWidth(text, max);
2884
3127
  }
2885
- return text.slice(0, max - 1) + "\u2026";
3128
+ return takeByWidth(text, max - 1) + "\u2026";
3129
+ }
3130
+ function takeByWidth(text, budget) {
3131
+ if (budget <= 0) {
3132
+ return "";
3133
+ }
3134
+ let out = "";
3135
+ let used = 0;
3136
+ for (const { segment } of SEGMENTER.segment(text)) {
3137
+ const w = stringWidth(segment);
3138
+ if (used + w > budget) {
3139
+ break;
3140
+ }
3141
+ out += segment;
3142
+ used += w;
3143
+ }
3144
+ return out;
2886
3145
  }
2887
3146
  function firstLine2(text) {
2888
3147
  const idx = text.indexOf("\n");
@@ -2997,7 +3256,7 @@ function mapKeyName(name) {
2997
3256
  return null;
2998
3257
  }
2999
3258
  }
3000
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, shortId;
3259
+ var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, shortId;
3001
3260
  var init_screen = __esm({
3002
3261
  "src/tui/screen.ts"() {
3003
3262
  "use strict";
@@ -3100,6 +3359,11 @@ var init_screen = __esm({
3100
3359
  }
3101
3360
  this.started = true;
3102
3361
  this.term.fullscreen(true);
3362
+ this.lastFrameRows.clear();
3363
+ this.lastFrameW = 0;
3364
+ this.lastFrameH = 0;
3365
+ this.lastWindowTitle = null;
3366
+ process.stdout.write("\x1B[?7l");
3103
3367
  this.term.grabInput({ mouse: "button" });
3104
3368
  this.term.hideCursor(false);
3105
3369
  this.term.on("key", this.keyHandler);
@@ -3119,6 +3383,7 @@ var init_screen = __esm({
3119
3383
  this.term.off("resize", this.resizeHandler);
3120
3384
  this.term.grabInput(false);
3121
3385
  this.term.hideCursor(false);
3386
+ process.stdout.write("\x1B[?7h");
3122
3387
  this.term.fullscreen(false);
3123
3388
  this.term("\n");
3124
3389
  }
@@ -3430,6 +3695,22 @@ var init_screen = __esm({
3430
3695
  redraw() {
3431
3696
  this.repaint();
3432
3697
  }
3698
+ // Forced clean-slate repaint. Drops the per-row signature cache, the
3699
+ // window-title cache, the wrap cache, and clears the terminal before
3700
+ // painting. Wired to ^L so the user can recover when something has
3701
+ // corrupted the visible state and the per-row sig check otherwise
3702
+ // short-circuits the re-emit.
3703
+ fullRedraw() {
3704
+ this.lastFrameRows.clear();
3705
+ this.lastFrameW = 0;
3706
+ this.lastFrameH = 0;
3707
+ this.lastWindowTitle = null;
3708
+ this.wrapCache.clear();
3709
+ this.wrapCacheWidth = 0;
3710
+ process.stdout.write("\x1B[?7l");
3711
+ this.term.clear();
3712
+ this.repaint();
3713
+ }
3433
3714
  // While paused, append* methods buffer state but don't repaint. Calls are
3434
3715
  // counter-based so they nest safely. Resume triggers exactly one repaint
3435
3716
  // if any was requested while paused.
@@ -4083,6 +4364,8 @@ var init_screen = __esm({
4083
4364
  }
4084
4365
  }
4085
4366
  };
4367
+ NON_ASCII = /[^\x20-\x7e]/;
4368
+ SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
4086
4369
  shortId = stripHydraSessionPrefix;
4087
4370
  }
4088
4371
  });
@@ -4500,10 +4783,28 @@ function mapUpdate(update) {
4500
4783
  }
4501
4784
  function mapSessionInfo(u) {
4502
4785
  const title = readString(u, "title");
4503
- if (title === void 0) {
4786
+ const meta = u._meta;
4787
+ let agentId;
4788
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4789
+ const ns = meta["hydra-acp"];
4790
+ if (ns && typeof ns === "object" && !Array.isArray(ns)) {
4791
+ const candidate = ns.agentId;
4792
+ if (typeof candidate === "string") {
4793
+ agentId = candidate;
4794
+ }
4795
+ }
4796
+ }
4797
+ if (title === void 0 && agentId === void 0) {
4504
4798
  return null;
4505
4799
  }
4506
- return { kind: "session-info", title };
4800
+ const event = { kind: "session-info" };
4801
+ if (title !== void 0) {
4802
+ event.title = title;
4803
+ }
4804
+ if (agentId !== void 0) {
4805
+ event.agentId = agentId;
4806
+ }
4807
+ return event;
4507
4808
  }
4508
4809
  function mapAvailableCommands(u) {
4509
4810
  const list = u.availableCommands ?? u.commands;
@@ -4907,8 +5208,9 @@ function formatToolLine(state) {
4907
5208
  title = `${initial} \xB7 ${latest}`;
4908
5209
  }
4909
5210
  return {
4910
- prefix: " ",
4911
- body: `${toolStatusIcon(state.status)} ${title}`,
5211
+ prefix: ` ${toolStatusIcon(state.status)} `,
5212
+ prefixStyle: toolIconStyle(state.status),
5213
+ body: title,
4912
5214
  bodyStyle: toolStatusStyle(state.status)
4913
5215
  };
4914
5216
  }
@@ -4933,6 +5235,22 @@ function toolStatusIcon(status) {
4933
5235
  return "\u25D0";
4934
5236
  }
4935
5237
  }
5238
+ function toolIconStyle(status) {
5239
+ switch (status) {
5240
+ case "completed":
5241
+ case "succeeded":
5242
+ case "ok":
5243
+ return "tool-status-ok";
5244
+ case "failed":
5245
+ case "error":
5246
+ case "rejected":
5247
+ return "tool-status-fail";
5248
+ case "cancelled":
5249
+ return "tool-status-cancelled";
5250
+ default:
5251
+ return "tool-status-running";
5252
+ }
5253
+ }
4936
5254
  function formatPlan(event) {
4937
5255
  if (event.entries.length === 0) {
4938
5256
  return [
@@ -5019,12 +5337,18 @@ async function runTuiApp(opts) {
5019
5337
  const config = await ensureConfig();
5020
5338
  await ensureDaemonReachable(config);
5021
5339
  const term = termkit.terminal;
5340
+ const exitHint = {};
5022
5341
  let nextOpts = opts;
5023
5342
  while (nextOpts !== null) {
5024
- nextOpts = await runSession(term, config, nextOpts);
5343
+ nextOpts = await runSession(term, config, nextOpts, exitHint);
5344
+ }
5345
+ if (exitHint.sessionId) {
5346
+ const short = stripHydraSessionPrefix(exitHint.sessionId);
5347
+ process.stdout.write(`To resume: hydra-acp tui --resume ${short}
5348
+ `);
5025
5349
  }
5026
5350
  }
5027
- async function runSession(term, config, opts) {
5351
+ async function runSession(term, config, opts, exitHint) {
5028
5352
  const ctx = await resolveSession(term, config, opts);
5029
5353
  if (!ctx) {
5030
5354
  term.grabInput(false);
@@ -5132,10 +5456,10 @@ async function runSession(term, config, opts) {
5132
5456
  if (pendingPermission.toolCallId && toolCallId && pendingPermission.toolCallId !== toolCallId) {
5133
5457
  return;
5134
5458
  }
5135
- const resolve2 = pendingPermission.resolve;
5459
+ const resolve4 = pendingPermission.resolve;
5136
5460
  pendingPermission = null;
5137
5461
  screen.setPermissionPrompt(null);
5138
- resolve2(result ?? { outcome: { outcome: "cancelled" } });
5462
+ resolve4(result ?? { outcome: { outcome: "cancelled" } });
5139
5463
  };
5140
5464
  const maybeDismissPermissionByToolUpdate = (update) => {
5141
5465
  if (!pendingPermission?.toolCallId) {
@@ -5168,14 +5492,14 @@ async function runSession(term, config, opts) {
5168
5492
  if (!pendingPermission) {
5169
5493
  return;
5170
5494
  }
5171
- const { options, resolve: resolve2 } = pendingPermission;
5495
+ const { options, resolve: resolve4 } = pendingPermission;
5172
5496
  pendingPermission = null;
5173
5497
  screen.setPermissionPrompt(null);
5174
5498
  if (optionId === null) {
5175
- resolve2({ outcome: { outcome: "cancelled" } });
5499
+ resolve4({ outcome: { outcome: "cancelled" } });
5176
5500
  return;
5177
5501
  }
5178
- resolve2({ outcome: { outcome: "selected", optionId } });
5502
+ resolve4({ outcome: { outcome: "selected", optionId } });
5179
5503
  void options;
5180
5504
  };
5181
5505
  conn.onRequest("session/request_permission", async (params) => {
@@ -5193,12 +5517,12 @@ async function runSession(term, config, opts) {
5193
5517
  ]);
5194
5518
  return { outcome: { outcome: "cancelled" } };
5195
5519
  }
5196
- return new Promise((resolve2) => {
5520
+ return new Promise((resolve4) => {
5197
5521
  pendingPermission = {
5198
5522
  title,
5199
5523
  options,
5200
5524
  selectedIndex: 0,
5201
- resolve: resolve2,
5525
+ resolve: resolve4,
5202
5526
  toolCallId
5203
5527
  };
5204
5528
  refreshPermissionPrompt();
@@ -5236,6 +5560,7 @@ async function runSession(term, config, opts) {
5236
5560
  ...opts.name ? { _meta: { [HYDRA_META_KEY]: { name: opts.name } } } : {}
5237
5561
  });
5238
5562
  resolvedSessionId = created.sessionId;
5563
+ exitHint.sessionId = resolvedSessionId;
5239
5564
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
5240
5565
  upstreamSessionId = hydraMeta.upstreamSessionId;
5241
5566
  if (hydraMeta.agentId) {
@@ -5262,6 +5587,7 @@ async function runSession(term, config, opts) {
5262
5587
  clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
5263
5588
  });
5264
5589
  resolvedSessionId = attached.sessionId;
5590
+ exitHint.sessionId = resolvedSessionId;
5265
5591
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
5266
5592
  upstreamSessionId = hydraMeta.upstreamSessionId;
5267
5593
  if (hydraMeta.agentId) {
@@ -5458,8 +5784,8 @@ async function runSession(term, config, opts) {
5458
5784
  screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
5459
5785
  }
5460
5786
  let finishSession = null;
5461
- const sessionDone = new Promise((resolve2) => {
5462
- finishSession = resolve2;
5787
+ const sessionDone = new Promise((resolve4) => {
5788
+ finishSession = resolve4;
5463
5789
  });
5464
5790
  const cancelRemoteTurn = () => {
5465
5791
  conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
@@ -5631,7 +5957,7 @@ async function runSession(term, config, opts) {
5631
5957
  screen.setBanner({});
5632
5958
  return;
5633
5959
  case "redraw":
5634
- screen.redraw();
5960
+ screen.fullRedraw();
5635
5961
  return;
5636
5962
  case "switch-session":
5637
5963
  void switchSession();
@@ -5957,6 +6283,10 @@ async function runSession(term, config, opts) {
5957
6283
  if (event.title !== void 0) {
5958
6284
  screen.setHeader({ title: event.title });
5959
6285
  }
6286
+ if (event.agentId !== void 0 && event.agentId !== resolvedAgentId) {
6287
+ resolvedAgentId = event.agentId;
6288
+ screen.setHeader({ agent: event.agentId });
6289
+ }
5960
6290
  return;
5961
6291
  }
5962
6292
  if (event.kind === "usage-update") {
@@ -6077,10 +6407,10 @@ async function runSession(term, config, opts) {
6077
6407
  }
6078
6408
  const resetInFlightUiState = () => {
6079
6409
  if (pendingPermission) {
6080
- const resolve2 = pendingPermission.resolve;
6410
+ const resolve4 = pendingPermission.resolve;
6081
6411
  pendingPermission = null;
6082
6412
  screen.setPermissionPrompt(null);
6083
- resolve2({ outcome: { outcome: "cancelled" } });
6413
+ resolve4({ outcome: { outcome: "cancelled" } });
6084
6414
  }
6085
6415
  closeAgentText();
6086
6416
  if (toolsBlockStartedAt !== null) {
@@ -6268,6 +6598,7 @@ var init_app = __esm({
6268
6598
  init_resilient_ws();
6269
6599
  init_config();
6270
6600
  init_daemon_bootstrap();
6601
+ init_session();
6271
6602
  init_paths();
6272
6603
  init_history();
6273
6604
  init_discovery();
@@ -6293,6 +6624,11 @@ var init_tui = __esm({
6293
6624
  }
6294
6625
  });
6295
6626
 
6627
+ // src/cli.ts
6628
+ import { readFileSync } from "fs";
6629
+ import { fileURLToPath } from "url";
6630
+ import { dirname as dirname3, resolve as resolve3 } from "path";
6631
+
6296
6632
  // src/cli/parse-args.ts
6297
6633
  function parseArgs(argv) {
6298
6634
  const positional = [];
@@ -6362,8 +6698,7 @@ async function runInit(flags) {
6362
6698
  existing = void 0;
6363
6699
  }
6364
6700
  if (!existing) {
6365
- const config = defaultConfig();
6366
- await writeConfig(config);
6701
+ const config = await writeMinimalInitConfig();
6367
6702
  process.stdout.write(
6368
6703
  `Initialized ${paths.config()}
6369
6704
  Auth token: ${config.daemon.authToken}
@@ -6372,11 +6707,14 @@ Auth token: ${config.daemon.authToken}
6372
6707
  return;
6373
6708
  }
6374
6709
  if (flagBool(flags, "rotate-token")) {
6375
- existing.daemon.authToken = generateAuthToken();
6376
- await writeConfig(existing);
6710
+ const newToken = generateAuthToken();
6711
+ await updateConfigField((raw) => {
6712
+ const daemon = raw.daemon ??= {};
6713
+ daemon.authToken = newToken;
6714
+ });
6377
6715
  process.stdout.write(
6378
6716
  `Rotated token in ${paths.config()}
6379
- New token: ${existing.daemon.authToken}
6717
+ New token: ${newToken}
6380
6718
  `
6381
6719
  );
6382
6720
  return;
@@ -6394,7 +6732,7 @@ import { setTimeout as sleep2 } from "timers/promises";
6394
6732
 
6395
6733
  // src/daemon/server.ts
6396
6734
  init_config();
6397
- import * as fs8 from "fs";
6735
+ import * as fs9 from "fs";
6398
6736
  import * as fsp2 from "fs/promises";
6399
6737
  import Fastify from "fastify";
6400
6738
  import websocketPlugin from "@fastify/websocket";
@@ -6567,7 +6905,8 @@ function planSpawn(agent, extraArgs = []) {
6567
6905
  }
6568
6906
 
6569
6907
  // src/core/session-manager.ts
6570
- import * as fs6 from "fs/promises";
6908
+ import * as fs7 from "fs/promises";
6909
+ import { customAlphabet as customAlphabet3 } from "nanoid";
6571
6910
 
6572
6911
  // src/core/agent-instance.ts
6573
6912
  import { spawn } from "child_process";
@@ -6626,13 +6965,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
6626
6965
  throw new Error("stream is closed");
6627
6966
  }
6628
6967
  const line = JSON.stringify(message) + "\n";
6629
- await new Promise((resolve2, reject) => {
6968
+ await new Promise((resolve4, reject) => {
6630
6969
  stdin.write(line, (err) => {
6631
6970
  if (err) {
6632
6971
  reject(err);
6633
6972
  return;
6634
6973
  }
6635
- resolve2();
6974
+ resolve4();
6636
6975
  });
6637
6976
  });
6638
6977
  },
@@ -6713,7 +7052,14 @@ init_session();
6713
7052
  init_paths();
6714
7053
  import * as fs4 from "fs/promises";
6715
7054
  import * as path2 from "path";
7055
+ import { customAlphabet as customAlphabet2 } from "nanoid";
6716
7056
  import { z as z4 } from "zod";
7057
+ var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7058
+ var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7059
+ var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7060
+ function generateLineageId() {
7061
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7062
+ }
6717
7063
  var PersistedAgentCommand = z4.object({
6718
7064
  name: z4.string(),
6719
7065
  description: z4.string().optional()
@@ -6721,7 +7067,20 @@ var PersistedAgentCommand = z4.object({
6721
7067
  var SessionRecord = z4.object({
6722
7068
  version: z4.literal(1),
6723
7069
  sessionId: z4.string(),
7070
+ // Optional for back-compat with records written before this field
7071
+ // existed; mergeForPersistence generates one on next write so any
7072
+ // touched session converges to having a lineageId. A record that
7073
+ // never gets written again (truly cold and untouched) just won't
7074
+ // participate in lineage-based dedup, which is correct — it was
7075
+ // never exported, so no incoming bundle can claim its lineage.
7076
+ lineageId: z4.string().optional(),
6724
7077
  upstreamSessionId: z4.string(),
7078
+ // When non-empty, marks a session that was created by import and is
7079
+ // waiting for its first attach to bootstrap a fresh upstream agent
7080
+ // and replay the imported history as a takeover transcript. The
7081
+ // origin's local id at export time, kept for debuggability and as a
7082
+ // breadcrumb in `sessions list` (informational, not used for routing).
7083
+ importedFromSessionId: z4.string().optional(),
6725
7084
  agentId: z4.string(),
6726
7085
  cwd: z4.string(),
6727
7086
  title: z4.string().optional(),
@@ -6794,6 +7153,25 @@ var SessionStore = class {
6794
7153
  }
6795
7154
  }
6796
7155
  }
7156
+ // Find a persisted session by lineageId. Used by SessionManager.import
7157
+ // to detect bundles that have already been imported (lineageId match)
7158
+ // so we can either error out or, with replace:true, overwrite.
7159
+ // Returns undefined if no record has that lineageId. Records that
7160
+ // pre-date the lineageId field simply don't match — which is
7161
+ // correct: they were never exported, so no incoming bundle can
7162
+ // legitimately claim their lineage.
7163
+ async findByLineageId(lineageId) {
7164
+ if (lineageId.length === 0) {
7165
+ return void 0;
7166
+ }
7167
+ const all = await this.list().catch(() => []);
7168
+ for (const record of all) {
7169
+ if (record.lineageId === lineageId) {
7170
+ return record;
7171
+ }
7172
+ }
7173
+ return void 0;
7174
+ }
6797
7175
  async list() {
6798
7176
  let entries;
6799
7177
  try {
@@ -6819,7 +7197,9 @@ function recordFromMemorySession(args) {
6819
7197
  const now = (/* @__PURE__ */ new Date()).toISOString();
6820
7198
  return {
6821
7199
  sessionId: args.sessionId,
7200
+ lineageId: args.lineageId,
6822
7201
  upstreamSessionId: args.upstreamSessionId,
7202
+ importedFromSessionId: args.importedFromSessionId,
6823
7203
  agentId: args.agentId,
6824
7204
  cwd: args.cwd,
6825
7205
  title: args.title,
@@ -6868,6 +7248,36 @@ var HistoryStore = class {
6868
7248
  });
6869
7249
  });
6870
7250
  }
7251
+ // Trim the on-disk history file to the most recent maxEntries lines.
7252
+ // Runs through the same per-session write queue as append/rewrite so
7253
+ // it's safe to invoke alongside ongoing writes; a no-op if the file is
7254
+ // already at or below the cap.
7255
+ async compact(sessionId, maxEntries) {
7256
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
7257
+ return;
7258
+ }
7259
+ return this.enqueue(sessionId, async () => {
7260
+ let raw;
7261
+ try {
7262
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
7263
+ } catch (err) {
7264
+ const e = err;
7265
+ if (e.code === "ENOENT") {
7266
+ return;
7267
+ }
7268
+ throw err;
7269
+ }
7270
+ const lines = raw.split("\n").filter((l) => l.length > 0);
7271
+ if (lines.length <= maxEntries) {
7272
+ return;
7273
+ }
7274
+ const trimmed = lines.slice(-maxEntries);
7275
+ await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
7276
+ encoding: "utf8",
7277
+ mode: 384
7278
+ });
7279
+ });
7280
+ }
6871
7281
  async load(sessionId) {
6872
7282
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
6873
7283
  return [];
@@ -6957,7 +7367,10 @@ var HistoryStore = class {
6957
7367
 
6958
7368
  // src/core/session-manager.ts
6959
7369
  init_paths();
7370
+ init_history();
6960
7371
  init_types();
7372
+ var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7373
+ var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
6961
7374
  var SessionManager = class {
6962
7375
  constructor(registry, spawner, store, options = {}) {
6963
7376
  this.registry = registry;
@@ -7036,6 +7449,9 @@ var SessionManager = class {
7036
7449
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
7037
7450
  throw err;
7038
7451
  }
7452
+ if (params.upstreamSessionId === "") {
7453
+ return this.doResurrectFromImport(params);
7454
+ }
7039
7455
  const plan = planSpawn(agentDef, params.agentArgs ?? []);
7040
7456
  const agent = this.spawner({
7041
7457
  agentId: params.agentId,
@@ -7072,15 +7488,55 @@ var SessionManager = class {
7072
7488
  idleTimeoutMs: this.idleTimeoutMs,
7073
7489
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7074
7490
  historyStore: this.histories,
7075
- seedHistory: params.seedHistory,
7076
7491
  currentModel: params.currentModel,
7077
7492
  currentMode: params.currentMode,
7078
7493
  agentCommands: params.agentCommands,
7079
- firstPromptSeeded: true
7494
+ // Only gate the first-prompt title heuristic when we actually have
7495
+ // a title to preserve. A title-less session (lost to a write race
7496
+ // or never seeded) should re-derive from the next prompt rather
7497
+ // than stay stuck.
7498
+ firstPromptSeeded: !!params.title,
7499
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
7080
7500
  });
7081
7501
  await this.attachManagerHooks(session);
7082
7502
  return session;
7083
7503
  }
7504
+ // First-attach path for a session that was created via import(). The
7505
+ // on-disk meta.json carries upstreamSessionId="" as the import
7506
+ // marker; bootstrap a fresh agent (gets a real upstream id) and kick
7507
+ // off seedFromImport so the agent absorbs the historical transcript.
7508
+ // attachManagerHooks rewrites meta.json with the new upstreamSessionId,
7509
+ // so subsequent resurrects of this session use the normal session/load
7510
+ // path.
7511
+ async doResurrectFromImport(params) {
7512
+ const fresh = await this.bootstrapAgent({
7513
+ agentId: params.agentId,
7514
+ cwd: params.cwd,
7515
+ agentArgs: params.agentArgs,
7516
+ mcpServers: []
7517
+ });
7518
+ const session = new Session({
7519
+ sessionId: params.hydraSessionId,
7520
+ cwd: params.cwd,
7521
+ agentId: params.agentId,
7522
+ agent: fresh.agent,
7523
+ upstreamSessionId: fresh.upstreamSessionId,
7524
+ agentMeta: fresh.agentMeta,
7525
+ title: params.title,
7526
+ agentArgs: params.agentArgs,
7527
+ idleTimeoutMs: this.idleTimeoutMs,
7528
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7529
+ historyStore: this.histories,
7530
+ currentModel: params.currentModel,
7531
+ currentMode: params.currentMode,
7532
+ agentCommands: params.agentCommands,
7533
+ firstPromptSeeded: !!params.title,
7534
+ createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
7535
+ });
7536
+ await this.attachManagerHooks(session);
7537
+ void session.seedFromImport().catch(() => void 0);
7538
+ return session;
7539
+ }
7084
7540
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
7085
7541
  // → session/new. Shared by create() and the /hydra switch path so both
7086
7542
  // go through the same env / capabilities / error-handling.
@@ -7160,28 +7616,20 @@ var SessionManager = class {
7160
7616
  }).catch(() => void 0);
7161
7617
  });
7162
7618
  this.sessions.set(session.sessionId, session);
7163
- await this.store.write(
7164
- recordFromMemorySession({
7165
- sessionId: session.sessionId,
7166
- upstreamSessionId: session.upstreamSessionId,
7167
- agentId: session.agentId,
7168
- cwd: session.cwd,
7169
- title: session.title,
7170
- agentArgs: session.agentArgs,
7171
- currentModel: session.currentModel,
7172
- currentMode: session.currentMode
7173
- })
7174
- ).catch(() => void 0);
7619
+ await this.enqueueMetaWrite(session.sessionId, async () => {
7620
+ const existing = await this.store.read(session.sessionId);
7621
+ const merged = mergeForPersistence(session, existing);
7622
+ await this.store.write(merged);
7623
+ }).catch(() => void 0);
7175
7624
  }
7176
7625
  // Resolve a session's recorded history without forcing a resurrect.
7177
- // Returns the in-memory snapshot if the session is hot, falls back
7178
- // to the on-disk history file otherwise. Returns undefined if the
7179
- // session id is unknown to both the live map and disk store, so the
7180
- // caller can distinguish "no history yet" (empty array) from "404".
7626
+ // Always loads from disk that's the source of truth whether the
7627
+ // session is hot or cold. Returns undefined if the session id is
7628
+ // unknown to both the live map and disk store, so the caller can
7629
+ // distinguish "no history yet" (empty array) from "404".
7181
7630
  async getHistory(sessionId) {
7182
- const live = this.sessions.get(sessionId);
7183
- if (live) {
7184
- return live.getHistorySnapshot();
7631
+ if (this.sessions.has(sessionId)) {
7632
+ return this.histories.load(sessionId).catch(() => []);
7185
7633
  }
7186
7634
  const record = await this.store.read(sessionId);
7187
7635
  if (!record) {
@@ -7194,20 +7642,41 @@ var SessionManager = class {
7194
7642
  if (!record) {
7195
7643
  return void 0;
7196
7644
  }
7197
- const seedHistory = await this.histories.load(sessionId).catch(() => []);
7645
+ let title = record.title;
7646
+ if (!title) {
7647
+ title = await this.deriveTitleFromHistory(sessionId);
7648
+ }
7198
7649
  return {
7199
7650
  hydraSessionId: record.sessionId,
7200
7651
  upstreamSessionId: record.upstreamSessionId,
7201
7652
  agentId: record.agentId,
7202
7653
  cwd: record.cwd,
7203
- title: record.title,
7654
+ title,
7204
7655
  agentArgs: record.agentArgs,
7205
- seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
7206
7656
  currentModel: record.currentModel,
7207
7657
  currentMode: record.currentMode,
7208
- agentCommands: record.agentCommands
7658
+ agentCommands: record.agentCommands,
7659
+ createdAt: record.createdAt
7209
7660
  };
7210
7661
  }
7662
+ // Best-effort: peek at the persisted history's first prompt and use
7663
+ // its first line (capped to 200 chars) as a session title. Returns
7664
+ // undefined if no usable prompt is found or any I/O fails.
7665
+ async deriveTitleFromHistory(sessionId) {
7666
+ const history = await this.histories.load(sessionId).catch(() => []);
7667
+ for (const entry of history) {
7668
+ const params = entry.params;
7669
+ if (params?.update?.sessionUpdate !== "prompt_received") {
7670
+ continue;
7671
+ }
7672
+ const text = extractPromptText(params.update.prompt);
7673
+ const line = firstLine(text, 200);
7674
+ if (line) {
7675
+ return line;
7676
+ }
7677
+ }
7678
+ return void 0;
7679
+ }
7211
7680
  get(sessionId) {
7212
7681
  return this.sessions.get(sessionId);
7213
7682
  }
@@ -7282,6 +7751,111 @@ var SessionManager = class {
7282
7751
  entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
7283
7752
  return entries;
7284
7753
  }
7754
+ // Build an export bundle for a session, reading meta + history from
7755
+ // disk. Backfills lineageId if the on-disk record pre-dates that
7756
+ // field. Returns undefined if the session doesn't exist. Callers
7757
+ // populate the bundle's exportedFrom metadata themselves.
7758
+ async exportBundle(sessionId) {
7759
+ const record = await this.store.read(sessionId);
7760
+ if (!record) {
7761
+ return void 0;
7762
+ }
7763
+ let withLineage;
7764
+ if (record.lineageId) {
7765
+ withLineage = record;
7766
+ } else {
7767
+ const lineageId = generateLineageId();
7768
+ const backfilled = { ...record, lineageId };
7769
+ await this.enqueueMetaWrite(sessionId, async () => {
7770
+ const latest = await this.store.read(sessionId);
7771
+ if (!latest) {
7772
+ return;
7773
+ }
7774
+ if (latest.lineageId) {
7775
+ return;
7776
+ }
7777
+ await this.store.write({ ...latest, lineageId });
7778
+ }).catch(() => void 0);
7779
+ withLineage = backfilled;
7780
+ }
7781
+ const history = await this.histories.load(sessionId).catch(() => []);
7782
+ const promptHistory = await loadPromptHistorySafely(sessionId);
7783
+ return { record: withLineage, history, promptHistory };
7784
+ }
7785
+ // Create a local session from an imported bundle. Without `replace`,
7786
+ // a bundle with a lineageId we already have on disk throws
7787
+ // BundleAlreadyImported citing the existing local id. With
7788
+ // `replace: true`, the existing record is overwritten in-place (its
7789
+ // local sessionId is preserved so bookmarks/Slack thread links still
7790
+ // resolve), and any live in-memory session is closed so the next
7791
+ // attach triggers the import-reseed path.
7792
+ async importBundle(bundle, opts = {}) {
7793
+ const existing = await this.store.findByLineageId(bundle.session.lineageId);
7794
+ if (existing) {
7795
+ if (!opts.replace) {
7796
+ const err = new Error(
7797
+ `bundle already imported as ${existing.sessionId}`
7798
+ );
7799
+ err.code = JsonRpcErrorCodes.BundleAlreadyImported;
7800
+ err.existingSessionId = existing.sessionId;
7801
+ throw err;
7802
+ }
7803
+ const live = this.sessions.get(existing.sessionId);
7804
+ if (live) {
7805
+ await live.close({ deleteRecord: false }).catch(() => void 0);
7806
+ }
7807
+ await this.writeImportedRecord({
7808
+ sessionId: existing.sessionId,
7809
+ bundle,
7810
+ preservedCreatedAt: existing.createdAt
7811
+ });
7812
+ return {
7813
+ sessionId: existing.sessionId,
7814
+ importedFromSessionId: bundle.session.sessionId,
7815
+ replaced: true
7816
+ };
7817
+ }
7818
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
7819
+ await this.writeImportedRecord({ sessionId: newId, bundle });
7820
+ return {
7821
+ sessionId: newId,
7822
+ importedFromSessionId: bundle.session.sessionId,
7823
+ replaced: false
7824
+ };
7825
+ }
7826
+ // Write the imported bundle's history.jsonl, prompt-history (if
7827
+ // present), and meta.json. upstreamSessionId is left empty as the
7828
+ // marker that the first attach should bootstrap a fresh agent and
7829
+ // run seedFromImport rather than calling session/load.
7830
+ async writeImportedRecord(args) {
7831
+ await this.histories.rewrite(
7832
+ args.sessionId,
7833
+ args.bundle.history
7834
+ );
7835
+ if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
7836
+ await saveHistory(
7837
+ paths.tuiHistoryFile(args.sessionId),
7838
+ args.bundle.promptHistory
7839
+ ).catch(() => void 0);
7840
+ }
7841
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7842
+ await this.enqueueMetaWrite(args.sessionId, async () => {
7843
+ await this.store.write({
7844
+ sessionId: args.sessionId,
7845
+ lineageId: args.bundle.session.lineageId,
7846
+ upstreamSessionId: "",
7847
+ importedFromSessionId: args.bundle.session.sessionId,
7848
+ agentId: args.bundle.session.agentId,
7849
+ cwd: args.bundle.session.cwd,
7850
+ title: args.bundle.session.title,
7851
+ currentModel: args.bundle.session.currentModel,
7852
+ currentMode: args.bundle.session.currentMode,
7853
+ agentCommands: args.bundle.session.agentCommands,
7854
+ createdAt: args.preservedCreatedAt ?? now,
7855
+ updatedAt: now
7856
+ });
7857
+ });
7858
+ }
7285
7859
  async deleteRecord(sessionId) {
7286
7860
  const record = await this.store.read(sessionId);
7287
7861
  if (!record) {
@@ -7367,10 +7941,64 @@ var SessionManager = class {
7367
7941
  await Promise.allSettled(sessions.map((s) => s.close()));
7368
7942
  this.sessions.clear();
7369
7943
  }
7944
+ // Wait for every pending meta.json write to settle. Daemon shutdown
7945
+ // hooks call this so a SIGTERM doesn't kill the process mid-write
7946
+ // and lose a freshly-set title (or model/mode/commands).
7947
+ async flushMetaWrites() {
7948
+ const pending = [...this.metaWriteQueues.values()];
7949
+ if (pending.length === 0) {
7950
+ return;
7951
+ }
7952
+ await Promise.allSettled(pending);
7953
+ }
7370
7954
  };
7955
+ function mergeForPersistence(session, existing) {
7956
+ const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
7957
+ if (c.description !== void 0) {
7958
+ return { name: c.name, description: c.description };
7959
+ }
7960
+ return { name: c.name };
7961
+ }) : void 0;
7962
+ const agentCommands = persistedCommands ?? existing?.agentCommands;
7963
+ return recordFromMemorySession({
7964
+ sessionId: session.sessionId,
7965
+ lineageId: existing?.lineageId ?? generateLineageId(),
7966
+ upstreamSessionId: session.upstreamSessionId,
7967
+ importedFromSessionId: existing?.importedFromSessionId,
7968
+ agentId: session.agentId,
7969
+ cwd: session.cwd,
7970
+ title: session.title,
7971
+ agentArgs: session.agentArgs,
7972
+ currentModel: session.currentModel ?? existing?.currentModel,
7973
+ currentMode: session.currentMode ?? existing?.currentMode,
7974
+ agentCommands,
7975
+ createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
7976
+ });
7977
+ }
7978
+ async function loadPromptHistorySafely(sessionId) {
7979
+ try {
7980
+ const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7981
+ const out = [];
7982
+ for (const line of raw.split("\n")) {
7983
+ if (line.length === 0) {
7984
+ continue;
7985
+ }
7986
+ try {
7987
+ const decoded = JSON.parse(line);
7988
+ if (typeof decoded === "string") {
7989
+ out.push(decoded);
7990
+ }
7991
+ } catch {
7992
+ }
7993
+ }
7994
+ return out;
7995
+ } catch {
7996
+ return [];
7997
+ }
7998
+ }
7371
7999
  async function historyMtimeIso(sessionId) {
7372
8000
  try {
7373
- const st = await fs6.stat(paths.historyFile(sessionId));
8001
+ const st = await fs7.stat(paths.historyFile(sessionId));
7374
8002
  return new Date(st.mtimeMs).toISOString();
7375
8003
  } catch {
7376
8004
  return void 0;
@@ -7380,9 +8008,9 @@ async function historyMtimeIso(sessionId) {
7380
8008
  // src/core/extensions.ts
7381
8009
  init_paths();
7382
8010
  import { spawn as spawn2 } from "child_process";
7383
- import * as fs7 from "fs";
8011
+ import * as fs8 from "fs";
7384
8012
  import * as fsp from "fs/promises";
7385
- import * as path3 from "path";
8013
+ import * as path4 from "path";
7386
8014
  var RESTART_BASE_MS = 1e3;
7387
8015
  var RESTART_CAP_MS = 6e4;
7388
8016
  var STOP_GRACE_MS = 3e3;
@@ -7429,9 +8057,9 @@ var ExtensionManager = class {
7429
8057
  } catch {
7430
8058
  }
7431
8059
  tasks.push(
7432
- new Promise((resolve2) => {
8060
+ new Promise((resolve4) => {
7433
8061
  if (child.exitCode !== null || child.signalCode !== null) {
7434
- resolve2();
8062
+ resolve4();
7435
8063
  return;
7436
8064
  }
7437
8065
  const timer = setTimeout(() => {
@@ -7439,11 +8067,11 @@ var ExtensionManager = class {
7439
8067
  child.kill("SIGKILL");
7440
8068
  } catch {
7441
8069
  }
7442
- resolve2();
8070
+ resolve4();
7443
8071
  }, STOP_GRACE_MS);
7444
8072
  child.on("exit", () => {
7445
8073
  clearTimeout(timer);
7446
- resolve2();
8074
+ resolve4();
7447
8075
  });
7448
8076
  })
7449
8077
  );
@@ -7551,8 +8179,8 @@ var ExtensionManager = class {
7551
8179
  if (child.exitCode !== null || child.signalCode !== null) {
7552
8180
  return;
7553
8181
  }
7554
- const exited = new Promise((resolve2) => {
7555
- entry.exitWaiters.push(resolve2);
8182
+ const exited = new Promise((resolve4) => {
8183
+ entry.exitWaiters.push(resolve4);
7556
8184
  });
7557
8185
  try {
7558
8186
  child.kill("SIGTERM");
@@ -7624,7 +8252,7 @@ var ExtensionManager = class {
7624
8252
  if (!entry.endsWith(".pid")) {
7625
8253
  continue;
7626
8254
  }
7627
- const pidPath = path3.join(paths.extensionsDir(), entry);
8255
+ const pidPath = path4.join(paths.extensionsDir(), entry);
7628
8256
  let pid;
7629
8257
  try {
7630
8258
  const raw = await fsp.readFile(pidPath, "utf8");
@@ -7663,7 +8291,7 @@ var ExtensionManager = class {
7663
8291
  }
7664
8292
  const ext = entry.config;
7665
8293
  const command = ext.command.length > 0 ? ext.command : [ext.name];
7666
- const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
8294
+ const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
7667
8295
  flags: "a"
7668
8296
  });
7669
8297
  logStream.write(
@@ -7713,7 +8341,7 @@ var ExtensionManager = class {
7713
8341
  }
7714
8342
  if (typeof child.pid === "number") {
7715
8343
  try {
7716
- fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8344
+ fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7717
8345
  `, {
7718
8346
  encoding: "utf8",
7719
8347
  mode: 384
@@ -7738,7 +8366,7 @@ var ExtensionManager = class {
7738
8366
  });
7739
8367
  child.on("exit", (code, signal) => {
7740
8368
  try {
7741
- fs7.unlinkSync(paths.extensionPidFile(ext.name));
8369
+ fs8.unlinkSync(paths.extensionPidFile(ext.name));
7742
8370
  } catch {
7743
8371
  }
7744
8372
  logStream.write(
@@ -7749,8 +8377,8 @@ var ExtensionManager = class {
7749
8377
  entry.pid = void 0;
7750
8378
  entry.lastExitCode = typeof code === "number" ? code : void 0;
7751
8379
  const waiters = entry.exitWaiters.splice(0);
7752
- for (const resolve2 of waiters) {
7753
- resolve2();
8380
+ for (const resolve4 of waiters) {
8381
+ resolve4();
7754
8382
  }
7755
8383
  if (this.stopping || entry.manuallyStopped) {
7756
8384
  try {
@@ -7851,6 +8479,76 @@ function constantTimeEqual(a, b) {
7851
8479
 
7852
8480
  // src/daemon/routes/sessions.ts
7853
8481
  init_config();
8482
+ import * as os2 from "os";
8483
+
8484
+ // src/core/bundle.ts
8485
+ import { z as z5 } from "zod";
8486
+ var HistoryEntrySchema = z5.object({
8487
+ method: z5.string(),
8488
+ params: z5.unknown(),
8489
+ recordedAt: z5.number()
8490
+ });
8491
+ var BundleSession = z5.object({
8492
+ // The exporter's local id. Regenerated fresh on import (sessionId is
8493
+ // the local namespace; lineageId is what survives across hops).
8494
+ sessionId: z5.string(),
8495
+ // Required on bundles — the export path backfills if the source
8496
+ // record was written before lineageId existed.
8497
+ lineageId: z5.string(),
8498
+ agentId: z5.string(),
8499
+ cwd: z5.string(),
8500
+ title: z5.string().optional(),
8501
+ currentModel: z5.string().optional(),
8502
+ currentMode: z5.string().optional(),
8503
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
8504
+ createdAt: z5.string(),
8505
+ updatedAt: z5.string()
8506
+ });
8507
+ var Bundle = z5.object({
8508
+ version: z5.literal(1),
8509
+ exportedAt: z5.string(),
8510
+ exportedFrom: z5.object({
8511
+ hydraVersion: z5.string(),
8512
+ machine: z5.string()
8513
+ }),
8514
+ session: BundleSession,
8515
+ history: z5.array(HistoryEntrySchema),
8516
+ promptHistory: z5.array(z5.string()).optional()
8517
+ });
8518
+ function encodeBundle(params) {
8519
+ const bundle = {
8520
+ version: 1,
8521
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8522
+ exportedFrom: {
8523
+ hydraVersion: params.hydraVersion,
8524
+ machine: params.machine
8525
+ },
8526
+ session: {
8527
+ sessionId: params.record.sessionId,
8528
+ lineageId: params.record.lineageId,
8529
+ agentId: params.record.agentId,
8530
+ cwd: params.record.cwd,
8531
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
8532
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
8533
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
8534
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
8535
+ createdAt: params.record.createdAt,
8536
+ updatedAt: params.record.updatedAt
8537
+ },
8538
+ history: params.history
8539
+ };
8540
+ if (params.promptHistory !== void 0) {
8541
+ bundle.promptHistory = params.promptHistory;
8542
+ }
8543
+ return bundle;
8544
+ }
8545
+ function decodeBundle(raw) {
8546
+ return Bundle.parse(raw);
8547
+ }
8548
+
8549
+ // src/daemon/routes/sessions.ts
8550
+ init_types();
8551
+ var HYDRA_VERSION = "0.1.0";
7854
8552
  function registerSessionRoutes(app, manager, defaults) {
7855
8553
  app.get("/v1/sessions", async (request) => {
7856
8554
  const query = request.query;
@@ -7908,6 +8606,61 @@ function registerSessionRoutes(app, manager, defaults) {
7908
8606
  }
7909
8607
  reply.code(204).send();
7910
8608
  });
8609
+ app.get("/v1/sessions/:id/export", async (request, reply) => {
8610
+ const raw = request.params.id;
8611
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
8612
+ const exported = await manager.exportBundle(id);
8613
+ if (!exported) {
8614
+ reply.code(404).send({ error: "session not found" });
8615
+ return;
8616
+ }
8617
+ const bundle = encodeBundle({
8618
+ record: exported.record,
8619
+ history: exported.history,
8620
+ promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
8621
+ hydraVersion: HYDRA_VERSION,
8622
+ machine: os2.hostname()
8623
+ });
8624
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
8625
+ reply.header(
8626
+ "Content-Disposition",
8627
+ `attachment; filename="hydra-${id}-${stamp}.hydra"`
8628
+ );
8629
+ reply.code(200).send(bundle);
8630
+ });
8631
+ app.post("/v1/sessions/import", async (request, reply) => {
8632
+ const body = request.body ?? {};
8633
+ if (body.bundle === void 0) {
8634
+ reply.code(400).send({ error: "missing bundle" });
8635
+ return;
8636
+ }
8637
+ let bundle;
8638
+ try {
8639
+ bundle = decodeBundle(body.bundle);
8640
+ } catch (err) {
8641
+ reply.code(400).send({
8642
+ error: "invalid bundle",
8643
+ details: err.message
8644
+ });
8645
+ return;
8646
+ }
8647
+ try {
8648
+ const result = await manager.importBundle(bundle, {
8649
+ replace: body.replace === true
8650
+ });
8651
+ reply.code(201).send(result);
8652
+ } catch (err) {
8653
+ const e = err;
8654
+ if (e.code === JsonRpcErrorCodes.BundleAlreadyImported) {
8655
+ reply.code(409).send({
8656
+ error: "bundle already imported",
8657
+ existingSessionId: e.existingSessionId
8658
+ });
8659
+ return;
8660
+ }
8661
+ reply.code(500).send({ error: e.message });
8662
+ }
8663
+ });
7911
8664
  app.get("/v1/sessions/:id/history", async (request, reply) => {
7912
8665
  const raw = request.params.id;
7913
8666
  const query = request.query;
@@ -7916,16 +8669,22 @@ function registerSessionRoutes(app, manager, defaults) {
7916
8669
  const live = manager.get(id);
7917
8670
  let snapshot;
7918
8671
  let unsubscribe;
8672
+ let snapshotDone = false;
8673
+ const pending = [];
7919
8674
  if (live) {
7920
- snapshot = live.getHistorySnapshot();
7921
8675
  if (follow) {
7922
8676
  unsubscribe = live.onBroadcast((entry) => {
7923
8677
  if (reply.raw.writableEnded) {
7924
8678
  return;
7925
8679
  }
7926
- reply.raw.write(JSON.stringify(entry) + "\n");
8680
+ if (snapshotDone) {
8681
+ reply.raw.write(JSON.stringify(entry) + "\n");
8682
+ } else {
8683
+ pending.push(entry);
8684
+ }
7927
8685
  });
7928
8686
  }
8687
+ snapshot = await live.getHistorySnapshot();
7929
8688
  } else {
7930
8689
  const cold = await manager.getHistory(id);
7931
8690
  if (cold === void 0) {
@@ -7937,9 +8696,23 @@ function registerSessionRoutes(app, manager, defaults) {
7937
8696
  reply.raw.setHeader("Content-Type", "application/x-ndjson");
7938
8697
  reply.raw.setHeader("Cache-Control", "no-cache");
7939
8698
  reply.raw.statusCode = 200;
8699
+ const snapshotKeys = /* @__PURE__ */ new Set();
7940
8700
  for (const entry of snapshot ?? []) {
7941
8701
  reply.raw.write(JSON.stringify(entry) + "\n");
8702
+ const e = entry;
8703
+ if (typeof e.recordedAt === "number") {
8704
+ snapshotKeys.add(String(e.recordedAt));
8705
+ }
8706
+ }
8707
+ for (const entry of pending) {
8708
+ const e = entry;
8709
+ const key = typeof e.recordedAt === "number" ? String(e.recordedAt) : "";
8710
+ if (key && snapshotKeys.has(key)) {
8711
+ continue;
8712
+ }
8713
+ reply.raw.write(JSON.stringify(entry) + "\n");
7942
8714
  }
8715
+ snapshotDone = true;
7943
8716
  if (!unsubscribe) {
7944
8717
  reply.raw.end();
7945
8718
  return reply;
@@ -8114,7 +8887,7 @@ init_connection();
8114
8887
  init_ws_stream();
8115
8888
  init_types();
8116
8889
  import { nanoid as nanoid2 } from "nanoid";
8117
- var HYDRA_VERSION = "0.1.0";
8890
+ var HYDRA_VERSION2 = "0.1.0";
8118
8891
  var HYDRA_PROTOCOL_VERSION = 1;
8119
8892
  function registerAcpWsEndpoint(app, deps) {
8120
8893
  app.get("/acp", { websocket: true }, (socket, request) => {
@@ -8160,7 +8933,7 @@ function registerAcpWsEndpoint(app, deps) {
8160
8933
  agentArgs: hydraMeta.agentArgs
8161
8934
  });
8162
8935
  const client = bindClientToSession(connection, session, state);
8163
- session.attach(client, "full");
8936
+ await session.attach(client, "full");
8164
8937
  state.attached.set(session.sessionId, {
8165
8938
  sessionId: session.sessionId,
8166
8939
  clientId: client.clientId
@@ -8179,14 +8952,22 @@ function registerAcpWsEndpoint(app, deps) {
8179
8952
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
8180
8953
  let session = deps.manager.get(lookupId);
8181
8954
  if (!session) {
8182
- let resurrectParams = hydraHints ? {
8183
- hydraSessionId: params.sessionId,
8184
- upstreamSessionId: hydraHints.upstreamSessionId,
8185
- agentId: hydraHints.agentId,
8186
- cwd: hydraHints.cwd,
8187
- title: hydraHints.title,
8188
- agentArgs: hydraHints.agentArgs
8189
- } : await deps.manager.loadFromDisk(lookupId);
8955
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
8956
+ let resurrectParams = fromDisk;
8957
+ if (hydraHints) {
8958
+ resurrectParams = {
8959
+ hydraSessionId: params.sessionId,
8960
+ upstreamSessionId: hydraHints.upstreamSessionId,
8961
+ agentId: hydraHints.agentId,
8962
+ cwd: hydraHints.cwd,
8963
+ title: hydraHints.title ?? fromDisk?.title,
8964
+ agentArgs: hydraHints.agentArgs ?? fromDisk?.agentArgs,
8965
+ currentModel: fromDisk?.currentModel,
8966
+ currentMode: fromDisk?.currentMode,
8967
+ agentCommands: fromDisk?.agentCommands,
8968
+ createdAt: fromDisk?.createdAt
8969
+ };
8970
+ }
8190
8971
  if (!resurrectParams) {
8191
8972
  const err = new Error(
8192
8973
  `session ${params.sessionId} not found and no resume hints provided`
@@ -8202,13 +8983,13 @@ function registerAcpWsEndpoint(app, deps) {
8202
8983
  state,
8203
8984
  params.clientInfo
8204
8985
  );
8205
- const replay = session.attach(client, params.historyPolicy);
8986
+ const replay = await session.attach(client, params.historyPolicy);
8206
8987
  state.attached.set(session.sessionId, {
8207
8988
  sessionId: session.sessionId,
8208
8989
  clientId: client.clientId
8209
8990
  });
8210
8991
  app.log.info(
8211
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
8992
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
8212
8993
  );
8213
8994
  for (const note of replay) {
8214
8995
  await connection.notify(note.method, note.params);
@@ -8304,7 +9085,7 @@ function registerAcpWsEndpoint(app, deps) {
8304
9085
  session = await deps.manager.resurrect(fromDisk);
8305
9086
  }
8306
9087
  const client = bindClientToSession(connection, session, state);
8307
- const replay = session.attach(client, "pending_only");
9088
+ const replay = await session.attach(client, "pending_only");
8308
9089
  state.attached.set(session.sessionId, {
8309
9090
  sessionId: session.sessionId,
8310
9091
  clientId: client.clientId
@@ -8370,7 +9151,7 @@ function buildResponseMeta(session) {
8370
9151
  function buildInitializeResult() {
8371
9152
  return {
8372
9153
  protocolVersion: HYDRA_PROTOCOL_VERSION,
8373
- agentInfo: { name: "hydra", version: HYDRA_VERSION },
9154
+ agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
8374
9155
  agentCapabilities: {
8375
9156
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
8376
9157
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -8409,7 +9190,7 @@ function bindClientToSession(connection, session, state, clientInfo) {
8409
9190
  }
8410
9191
 
8411
9192
  // src/daemon/server.ts
8412
- var HYDRA_VERSION2 = "0.1.0";
9193
+ var HYDRA_VERSION3 = "0.1.0";
8413
9194
  async function startDaemon(config) {
8414
9195
  ensureLoopbackOrTls(config);
8415
9196
  const httpsOptions = config.daemon.tls ? {
@@ -8443,7 +9224,7 @@ async function startDaemon(config) {
8443
9224
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
8444
9225
  });
8445
9226
  const extensions = new ExtensionManager(extensionList(config));
8446
- registerHealthRoutes(app, HYDRA_VERSION2);
9227
+ registerHealthRoutes(app, HYDRA_VERSION3);
8447
9228
  registerSessionRoutes(app, manager, {
8448
9229
  agentId: config.defaultAgent,
8449
9230
  cwd: config.defaultCwd
@@ -8487,9 +9268,10 @@ async function startDaemon(config) {
8487
9268
  const shutdown = async () => {
8488
9269
  await extensions.stop();
8489
9270
  await manager.closeAll();
9271
+ await manager.flushMetaWrites();
8490
9272
  await app.close();
8491
9273
  try {
8492
- fs8.unlinkSync(paths.pidFile());
9274
+ fs9.unlinkSync(paths.pidFile());
8493
9275
  } catch {
8494
9276
  }
8495
9277
  try {
@@ -8528,7 +9310,7 @@ function ensureLoopbackOrTls(config) {
8528
9310
  init_daemon_bootstrap();
8529
9311
 
8530
9312
  // src/cli/commands/log-tail.ts
8531
- import * as fs9 from "fs";
9313
+ import * as fs10 from "fs";
8532
9314
  import * as fsp3 from "fs/promises";
8533
9315
  async function runLogTail(logPath, argv, notFoundMessage) {
8534
9316
  const opts = parseLogTailFlags(argv);
@@ -8552,7 +9334,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
8552
9334
  process.stdout.write(`-- following ${logPath} --
8553
9335
  `);
8554
9336
  let pending = false;
8555
- const watcher = fs9.watch(logPath, () => {
9337
+ const watcher = fs10.watch(logPath, () => {
8556
9338
  if (pending) {
8557
9339
  return;
8558
9340
  }
@@ -8580,10 +9362,10 @@ async function runLogTail(logPath, argv, notFoundMessage) {
8580
9362
  }
8581
9363
  });
8582
9364
  });
8583
- await new Promise((resolve2) => {
9365
+ await new Promise((resolve4) => {
8584
9366
  const finish = () => {
8585
9367
  watcher.close();
8586
- resolve2();
9368
+ resolve4();
8587
9369
  };
8588
9370
  process.once("SIGINT", finish);
8589
9371
  process.once("SIGTERM", finish);
@@ -9655,14 +10437,16 @@ async function main() {
9655
10437
  process.exit(2);
9656
10438
  return;
9657
10439
  }
9658
- const sessionId2 = resolveOption(flags2, "session-id");
10440
+ const launchResume = flags2.resume;
10441
+ const sessionId2 = typeof launchResume === "string" ? launchResume : resolveOption(flags2, "session-id");
9659
10442
  const name2 = resolveOption(flags2, "name");
9660
10443
  await runShim({ sessionId: sessionId2, agentId, agentArgs, name: name2 });
9661
10444
  return;
9662
10445
  }
9663
10446
  const { positional, flags } = parseArgs(argv);
9664
10447
  if (flags.version === true || positional[0] === "--version") {
9665
- process.stdout.write("hydra-acp 0.1.0\n");
10448
+ process.stdout.write(`hydra-acp ${readVersion()}
10449
+ `);
9666
10450
  return;
9667
10451
  }
9668
10452
  if (flags.help === true) {
@@ -9670,7 +10454,8 @@ async function main() {
9670
10454
  return;
9671
10455
  }
9672
10456
  const subcommand = positional[0];
9673
- const sessionId = resolveOption(flags, "session-id");
10457
+ const resumeFlag = flags.resume;
10458
+ const sessionId = typeof resumeFlag === "string" ? resumeFlag : resolveOption(flags, "session-id");
9674
10459
  const name = resolveOption(flags, "name");
9675
10460
  const agentIdFromFlag = resolveOption(flags, "agent-id");
9676
10461
  if (!subcommand) {
@@ -9735,6 +10520,17 @@ async function main() {
9735
10520
  await runSessionsRm(positional[2]);
9736
10521
  return;
9737
10522
  }
10523
+ if (sub === "export") {
10524
+ const out = resolveOption(flags, "out");
10525
+ await runSessionsExport(positional[2], out);
10526
+ return;
10527
+ }
10528
+ if (sub === "import") {
10529
+ await runSessionsImport(positional[2], {
10530
+ replace: flags.replace === true
10531
+ });
10532
+ return;
10533
+ }
9738
10534
  process.stderr.write(`Unknown sessions subcommand: ${sub}
9739
10535
  `);
9740
10536
  process.exit(2);
@@ -9828,6 +10624,17 @@ async function dispatchTui(flags, base) {
9828
10624
  }
9829
10625
  await runTui(tuiOpts);
9830
10626
  }
10627
+ function readVersion() {
10628
+ try {
10629
+ const here = dirname3(fileURLToPath(import.meta.url));
10630
+ const pkg = JSON.parse(
10631
+ readFileSync(resolve3(here, "../package.json"), "utf8")
10632
+ );
10633
+ return pkg.version ?? "unknown";
10634
+ } catch {
10635
+ return "unknown";
10636
+ }
10637
+ }
9831
10638
  function printHelp() {
9832
10639
  process.stdout.write(
9833
10640
  [
@@ -9841,13 +10648,17 @@ function printHelp() {
9841
10648
  " Shim mode, force daemon to spawn <agent-id>",
9842
10649
  " from the registry. Args after <agent-id>",
9843
10650
  " are forwarded to the agent's command.",
9844
- " hydra-acp --session-id <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
10651
+ " hydra-acp --resume <id> Attach to an existing session (TUI when in a terminal, shim otherwise)",
9845
10652
  " hydra-acp init [--rotate-token] Initialize ~/.hydra-acp/config.json",
9846
10653
  " hydra-acp daemon start|stop|restart|status",
9847
10654
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
9848
10655
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
9849
10656
  " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
9850
10657
  " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
10658
+ " hydra-acp sessions export <id> [--out <file>|.]",
10659
+ " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
10660
+ " hydra-acp sessions import <file>|- [--replace]",
10661
+ " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
9851
10662
  " hydra-acp extensions list List configured extensions and live state",
9852
10663
  " hydra-acp extensions add <name> [opts] Add an extension to config",
9853
10664
  " hydra-acp extensions remove <name> Remove an extension from config",
@@ -9855,15 +10666,16 @@ function printHelp() {
9855
10666
  " hydra-acp extensions logs <name> [-f] [-n N]Tail or follow an extension's log",
9856
10667
  " hydra-acp agents [list] List agents in the cached registry",
9857
10668
  " hydra-acp agents refresh Force a registry re-fetch",
9858
- " hydra-acp tui flags: [--session-id <id>] [--resume] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
9859
- " Smart default: picks an existing live session if any exist in cwd, else creates a new one",
10669
+ " hydra-acp tui flags: [--resume [<id>]] [--new] [--agent-id <id>] [--cwd <path>] [--name <label>]",
10670
+ " --resume <id> attaches to a specific session; bare --resume picks the most-recent",
10671
+ " in cwd. Smart default (no flags): picks if any live sessions exist, else new.",
9860
10672
  " hydra-acp --version Print version",
9861
10673
  " hydra-acp --help Show this help",
9862
10674
  "",
9863
10675
  "Config knob flags accept env-var equivalents (flag wins):",
9864
- " --agent-id HYDRA_ACP_AGENT_ID",
9865
- " --session-id HYDRA_ACP_SESSION_ID",
9866
- " --name HYDRA_ACP_NAME",
10676
+ " --agent-id HYDRA_ACP_AGENT_ID",
10677
+ " --resume / --session-id HYDRA_ACP_SESSION_ID",
10678
+ " --name HYDRA_ACP_NAME",
9867
10679
  ""
9868
10680
  ].join("\n")
9869
10681
  );