@hydra-acp/cli 0.1.1 → 0.1.3

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
@@ -17,6 +17,11 @@ function hydraHome() {
17
17
  if (override && override.length > 0) {
18
18
  return path.resolve(override);
19
19
  }
20
+ if (process.env.VITEST) {
21
+ throw new Error(
22
+ "HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
23
+ );
24
+ }
20
25
  return path.join(os.homedir(), ".hydra-acp");
21
26
  }
22
27
  var ROOT_ENV, paths;
@@ -34,11 +39,17 @@ var init_paths = __esm({
34
39
  agentsDir: () => path.join(hydraHome(), "agents"),
35
40
  agentDir: (id) => path.join(hydraHome(), "agents", id),
36
41
  sessionsDir: () => path.join(hydraHome(), "sessions"),
37
- sessionFile: (id) => path.join(hydraHome(), "sessions", `${id}.json`),
42
+ // One directory per session id under sessions/. Co-locates the
43
+ // session record, its transcript, and any future per-session state
44
+ // (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
45
+ sessionDir: (id) => path.join(hydraHome(), "sessions", id),
46
+ sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
47
+ historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
38
48
  extensionsDir: () => path.join(hydraHome(), "extensions"),
39
49
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
40
50
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
41
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
51
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
52
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
42
53
  };
43
54
  }
44
55
  });
@@ -152,7 +163,11 @@ var init_config = __esm({
152
163
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
153
164
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
154
165
  // or 0 to disable throttling entirely.
155
- repaintThrottleMs: z.number().int().nonnegative().default(1e3)
166
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3),
167
+ // Cap on logical lines retained in the in-memory scrollback render
168
+ // buffer. Oldest lines are dropped on overflow. The on-disk session
169
+ // history is unaffected; this only bounds the TUI's local view buffer.
170
+ maxScrollbackLines: z.number().int().positive().default(1e4)
156
171
  });
157
172
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
158
173
  ExtensionBody = z.object({
@@ -177,7 +192,7 @@ var init_config = __esm({
177
192
  // recency and truncated to this count. `--all` overrides in the CLI.
178
193
  sessionListColdLimit: z.number().int().nonnegative().default(20),
179
194
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
180
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
195
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
181
196
  });
182
197
  }
183
198
  });
@@ -215,6 +230,35 @@ function extractHydraMeta(meta) {
215
230
  out.resume = parsed.data;
216
231
  }
217
232
  }
233
+ if (typeof obj.currentModel === "string") {
234
+ out.currentModel = obj.currentModel;
235
+ }
236
+ if (typeof obj.currentMode === "string") {
237
+ out.currentMode = obj.currentMode;
238
+ }
239
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
240
+ out.turnStartedAt = obj.turnStartedAt;
241
+ }
242
+ if (Array.isArray(obj.availableCommands)) {
243
+ const cmds = [];
244
+ for (const raw of obj.availableCommands) {
245
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
246
+ continue;
247
+ }
248
+ const c = raw;
249
+ if (typeof c.name !== "string") {
250
+ continue;
251
+ }
252
+ const cmd = { name: c.name };
253
+ if (typeof c.description === "string") {
254
+ cmd.description = c.description;
255
+ }
256
+ cmds.push(cmd);
257
+ }
258
+ if (cmds.length > 0) {
259
+ out.availableCommands = cmds;
260
+ }
261
+ }
218
262
  return out;
219
263
  }
220
264
  function mergeMeta(passthrough, ours) {
@@ -502,6 +546,25 @@ function withCode(err, code) {
502
546
  err.code = code;
503
547
  return err;
504
548
  }
549
+ function isStateUpdate(method, params) {
550
+ if (method !== "session/update") {
551
+ return false;
552
+ }
553
+ const obj = params ?? {};
554
+ const kind = obj.update?.sessionUpdate;
555
+ return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
556
+ }
557
+ function sameAdvertisedCommands(a, b) {
558
+ if (a.length !== b.length) {
559
+ return false;
560
+ }
561
+ for (let i = 0; i < a.length; i++) {
562
+ if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
563
+ return false;
564
+ }
565
+ }
566
+ return true;
567
+ }
505
568
  function captureInternalChunk(capture, params) {
506
569
  const obj = params ?? {};
507
570
  const update = obj.update ?? {};
@@ -564,7 +627,7 @@ function firstLine(text, max) {
564
627
  }
565
628
  return void 0;
566
629
  }
567
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session;
630
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, Session, STATE_UPDATE_KINDS;
568
631
  var init_session = __esm({
569
632
  "src/core/session.ts"() {
570
633
  "use strict";
@@ -586,14 +649,26 @@ var init_session = __esm({
586
649
  agentMeta;
587
650
  agentArgs;
588
651
  title;
652
+ // Snapshot state delivered to attaching clients via the attach
653
+ // response _meta rather than via history replay (which would be
654
+ // stale-prone for snapshot-shaped events).
655
+ currentModel;
656
+ currentMode;
589
657
  updatedAt;
590
658
  clients = /* @__PURE__ */ new Map();
591
659
  history = [];
660
+ historyStore;
592
661
  promptQueue = [];
593
662
  promptInFlight = false;
594
663
  closed = false;
595
664
  closeHandlers = [];
596
665
  titleHandlers = [];
666
+ // Subscribers notified after every entry that's actually persisted to
667
+ // history (skipping snapshot-shaped events filtered by
668
+ // recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
669
+ // endpoint uses this to tail a live session's conversation stream
670
+ // without participating in turns or prompts.
671
+ broadcastHandlers = [];
597
672
  // True once we've observed our first session/prompt; gates the
598
673
  // first-prompt-seeded title so subsequent prompts don't churn it.
599
674
  firstPromptSeeded = false;
@@ -613,12 +688,18 @@ var init_session = __esm({
613
688
  idleTimer;
614
689
  spawnReplacementAgent;
615
690
  agentChangeHandlers = [];
616
- // Last available_commands_update we observed from the agent. Stored so
617
- // we can re-broadcast a merged (hydra ∪ agent) list whenever either
618
- // half changes most importantly when a fresh client attaches and
619
- // replays history, since the in-cache update is the daemon's merged
620
- // form (the agent's raw form is never broadcast).
691
+ // Last available_commands_update we observed from the agent. Stored
692
+ // so we can re-broadcast a merged (hydra ∪ agent) list whenever
693
+ // either half changes, and persisted to meta.json so a fresh attach
694
+ // can deliver the merged list via _meta without depending on history
695
+ // replay.
621
696
  agentAdvertisedCommands = [];
697
+ // Persist hooks for snapshot-shaped state. SessionManager hooks these
698
+ // to mirror changes into meta.json so cold-resurrect attaches can
699
+ // surface the latest snapshot via the attach response _meta.
700
+ agentCommandsHandlers = [];
701
+ modelHandlers = [];
702
+ modeHandlers = [];
622
703
  constructor(init) {
623
704
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
624
705
  this.cwd = init.cwd;
@@ -628,11 +709,22 @@ var init_session = __esm({
628
709
  this.agentMeta = init.agentMeta;
629
710
  this.agentArgs = init.agentArgs;
630
711
  this.title = init.title;
712
+ this.currentModel = init.currentModel;
713
+ this.currentMode = init.currentMode;
714
+ if (init.agentCommands && init.agentCommands.length > 0) {
715
+ this.agentAdvertisedCommands = [...init.agentCommands];
716
+ }
631
717
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
632
718
  this.spawnReplacementAgent = init.spawnReplacementAgent;
719
+ if (init.firstPromptSeeded) {
720
+ this.firstPromptSeeded = true;
721
+ }
722
+ this.historyStore = init.historyStore;
723
+ if (init.seedHistory && init.seedHistory.length > 0) {
724
+ this.history = [...init.seedHistory];
725
+ }
633
726
  this.updatedAt = Date.now();
634
727
  this.wireAgent(this.agent);
635
- this.broadcastMergedCommands();
636
728
  }
637
729
  broadcastMergedCommands() {
638
730
  const merged = [
@@ -661,8 +753,15 @@ var init_session = __esm({
661
753
  }
662
754
  const agentCmds = extractAdvertisedCommands(params);
663
755
  if (agentCmds !== null) {
664
- this.agentAdvertisedCommands = agentCmds;
665
- this.broadcastMergedCommands();
756
+ this.setAgentAdvertisedCommands(agentCmds);
757
+ return;
758
+ }
759
+ if (this.maybeApplyAgentModel(params)) {
760
+ this.recordAndBroadcast("session/update", params);
761
+ return;
762
+ }
763
+ if (this.maybeApplyAgentMode(params)) {
764
+ this.recordAndBroadcast("session/update", params);
666
765
  return;
667
766
  }
668
767
  this.maybeApplyAgentSessionInfo(params);
@@ -684,6 +783,50 @@ var init_session = __esm({
684
783
  get attachedCount() {
685
784
  return this.clients.size;
686
785
  }
786
+ // 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.
792
+ 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;
808
+ }
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];
815
+ }
816
+ // Subscribe to recordable broadcast entries — fires once per entry
817
+ // that lands in history (so snapshot-shaped session_info/model/mode/
818
+ // available_commands updates do NOT trigger this; they're broadcast
819
+ // live but not recorded). Returns an unsubscribe function the caller
820
+ // must invoke when done.
821
+ onBroadcast(handler) {
822
+ this.broadcastHandlers.push(handler);
823
+ return () => {
824
+ const i = this.broadcastHandlers.indexOf(handler);
825
+ if (i >= 0) {
826
+ this.broadcastHandlers.splice(i, 1);
827
+ }
828
+ };
829
+ }
687
830
  attach(client, historyPolicy) {
688
831
  if (this.closed) {
689
832
  throw withCode(
@@ -740,13 +883,19 @@ var init_session = __esm({
740
883
  this.broadcastPromptReceived(client, params);
741
884
  this.maybeSeedTitleFromPrompt(params);
742
885
  return this.enqueuePrompt(async () => {
743
- const response = await this.agent.connection.request(
744
- "session/prompt",
745
- {
746
- ...params,
747
- sessionId: this.upstreamSessionId
748
- }
749
- );
886
+ let response;
887
+ try {
888
+ response = await this.agent.connection.request(
889
+ "session/prompt",
890
+ {
891
+ ...params,
892
+ sessionId: this.upstreamSessionId
893
+ }
894
+ );
895
+ } catch (err) {
896
+ this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
897
+ throw err;
898
+ }
750
899
  this.broadcastTurnComplete(client.clientId, response);
751
900
  return response;
752
901
  });
@@ -834,6 +983,13 @@ var init_session = __esm({
834
983
  return;
835
984
  }
836
985
  this.cancelIdleTimer();
986
+ if (opts.regenTitle && this.firstPromptSeeded) {
987
+ const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
988
+ await Promise.race([
989
+ this.runTitleRegen().catch(() => void 0),
990
+ new Promise((r) => setTimeout(r, timeoutMs).unref?.())
991
+ ]);
992
+ }
837
993
  await this.agent.kill().catch(() => void 0);
838
994
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
839
995
  }
@@ -882,13 +1038,98 @@ var init_session = __esm({
882
1038
  }
883
1039
  const promptParams = params ?? {};
884
1040
  const text = extractPromptText(promptParams.prompt);
885
- const seed = firstLine(text, 80);
1041
+ const seed = firstLine(text, 200);
886
1042
  if (!seed) {
887
1043
  return;
888
1044
  }
889
1045
  this.firstPromptSeeded = true;
890
1046
  this.setTitle(seed);
891
1047
  }
1048
+ // Apply an agent-emitted current_model_update. Returns true if the
1049
+ // notification was a model update (caller still needs to broadcast
1050
+ // it). Returns false otherwise so the caller can try the next kind.
1051
+ maybeApplyAgentModel(params) {
1052
+ const obj = params ?? {};
1053
+ const update = obj.update ?? {};
1054
+ if (update.sessionUpdate !== "current_model_update") {
1055
+ return false;
1056
+ }
1057
+ const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
1058
+ if (raw === void 0) {
1059
+ return true;
1060
+ }
1061
+ const trimmed = raw.trim();
1062
+ if (!trimmed || trimmed === this.currentModel) {
1063
+ return true;
1064
+ }
1065
+ this.currentModel = trimmed;
1066
+ for (const handler of this.modelHandlers) {
1067
+ try {
1068
+ handler(trimmed);
1069
+ } catch {
1070
+ }
1071
+ }
1072
+ return true;
1073
+ }
1074
+ maybeApplyAgentMode(params) {
1075
+ const obj = params ?? {};
1076
+ const update = obj.update ?? {};
1077
+ if (update.sessionUpdate !== "current_mode_update") {
1078
+ return false;
1079
+ }
1080
+ const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
1081
+ if (raw === void 0) {
1082
+ return true;
1083
+ }
1084
+ const trimmed = raw.trim();
1085
+ if (!trimmed || trimmed === this.currentMode) {
1086
+ return true;
1087
+ }
1088
+ this.currentMode = trimmed;
1089
+ for (const handler of this.modeHandlers) {
1090
+ try {
1091
+ handler(trimmed);
1092
+ } catch {
1093
+ }
1094
+ }
1095
+ return true;
1096
+ }
1097
+ // Update the cached agent command list, fire persist handlers, and
1098
+ // broadcast the merged list to attached clients. Idempotent on a
1099
+ // structurally identical list so we don't churn meta.json on noisy
1100
+ // re-emissions.
1101
+ setAgentAdvertisedCommands(commands) {
1102
+ if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
1103
+ this.broadcastMergedCommands();
1104
+ return;
1105
+ }
1106
+ this.agentAdvertisedCommands = commands;
1107
+ for (const handler of this.agentCommandsHandlers) {
1108
+ try {
1109
+ handler(commands);
1110
+ } catch {
1111
+ }
1112
+ }
1113
+ this.broadcastMergedCommands();
1114
+ }
1115
+ // Subscribe to snapshot-state updates. SessionManager wires these to
1116
+ // persist the new value into meta.json so cold resurrect can restore
1117
+ // them via the attach response _meta.
1118
+ onAgentCommandsChange(handler) {
1119
+ this.agentCommandsHandlers.push(handler);
1120
+ }
1121
+ onModelChange(handler) {
1122
+ this.modelHandlers.push(handler);
1123
+ }
1124
+ onModeChange(handler) {
1125
+ this.modeHandlers.push(handler);
1126
+ }
1127
+ // Returns a freshly merged command list (hydra ∪ agent) for callers
1128
+ // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1129
+ // assembling the attach response.
1130
+ mergedAvailableCommands() {
1131
+ return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1132
+ }
892
1133
  // Pick up an agent-emitted session_info_update and store its title
893
1134
  // as our canonical record. The notification is also forwarded to
894
1135
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1176,7 +1417,8 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1176
1417
  }
1177
1418
  this.idleTimer = setTimeout(() => {
1178
1419
  this.idleTimer = void 0;
1179
- void this.close({ deleteRecord: false }).catch(() => void 0);
1420
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1421
+ void this.close(opts).catch(() => void 0);
1180
1422
  }, this.idleTimeoutMs);
1181
1423
  if (typeof this.idleTimer.unref === "function") {
1182
1424
  this.idleTimer.unref();
@@ -1199,9 +1441,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1199
1441
  }
1200
1442
  recordAndBroadcast(method, params, excludeClientId) {
1201
1443
  const rewritten = this.rewriteForClient(params);
1202
- this.history.push({ method, params: rewritten, recordedAt: Date.now() });
1203
- if (this.history.length > 1e3) {
1204
- this.history = this.history.slice(-500);
1444
+ const recordable = !isStateUpdate(method, rewritten);
1445
+ if (recordable) {
1446
+ const entry = {
1447
+ method,
1448
+ params: rewritten,
1449
+ recordedAt: Date.now()
1450
+ };
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
+ }
1457
+ 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(
1462
+ () => void 0
1463
+ );
1464
+ }
1465
+ }
1466
+ for (const handler of this.broadcastHandlers) {
1467
+ try {
1468
+ handler(entry);
1469
+ } catch {
1470
+ }
1471
+ }
1205
1472
  }
1206
1473
  this.updatedAt = Date.now();
1207
1474
  for (const client of this.clients.values()) {
@@ -1297,6 +1564,12 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1297
1564
  }
1298
1565
  }
1299
1566
  };
1567
+ STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1568
+ "session_info_update",
1569
+ "current_model_update",
1570
+ "current_mode_update",
1571
+ "available_commands_update"
1572
+ ]);
1300
1573
  }
1301
1574
  });
1302
1575
 
@@ -1430,6 +1703,147 @@ var init_daemon_bootstrap = __esm({
1430
1703
  }
1431
1704
  });
1432
1705
 
1706
+ // src/cli/session-row.ts
1707
+ function toRow(s, now = Date.now()) {
1708
+ return {
1709
+ session: stripHydraSessionPrefix(s.sessionId),
1710
+ upstream: s.upstreamSessionId ?? "-",
1711
+ status: (s.status ?? "live").toUpperCase(),
1712
+ clients: s.status === "cold" ? "-" : String(s.attachedClients),
1713
+ agent: s.agentId ?? "?",
1714
+ age: formatRelativeAge(s.updatedAt, now),
1715
+ title: s.title ?? "-",
1716
+ cwd: s.cwd
1717
+ };
1718
+ }
1719
+ function computeWidths(rows) {
1720
+ return {
1721
+ session: maxLen(HEADER.session, rows.map((r) => r.session)),
1722
+ upstream: maxLen(HEADER.upstream, rows.map((r) => r.upstream)),
1723
+ status: maxLen(HEADER.status, rows.map((r) => r.status)),
1724
+ clients: maxLen(HEADER.clients, rows.map((r) => r.clients)),
1725
+ agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
1726
+ age: maxLen(HEADER.age, rows.map((r) => r.age)),
1727
+ title: maxLen(HEADER.title, rows.map((r) => r.title))
1728
+ };
1729
+ }
1730
+ function formatRelativeAge(iso, now) {
1731
+ if (!iso) {
1732
+ return "?";
1733
+ }
1734
+ const t = Date.parse(iso);
1735
+ if (Number.isNaN(t)) {
1736
+ return "?";
1737
+ }
1738
+ const diff = Math.max(0, now - t);
1739
+ const sec = Math.floor(diff / 1e3);
1740
+ if (sec < 60) {
1741
+ return "<1m";
1742
+ }
1743
+ const min = Math.floor(sec / 60);
1744
+ if (min < 60) {
1745
+ return `${min}m`;
1746
+ }
1747
+ const hr = Math.floor(min / 60);
1748
+ if (hr < 24) {
1749
+ return `${hr}h`;
1750
+ }
1751
+ const day = Math.floor(hr / 24);
1752
+ if (day < 14) {
1753
+ return `${day}d`;
1754
+ }
1755
+ const week = Math.floor(day / 7);
1756
+ if (week < 9) {
1757
+ return `${week}w`;
1758
+ }
1759
+ const month = Math.floor(day / 30);
1760
+ if (month < 12) {
1761
+ return `${month}mo`;
1762
+ }
1763
+ const year = Math.floor(day / 365);
1764
+ return `${year}y`;
1765
+ }
1766
+ function maxLen(headerCell, values) {
1767
+ let max = headerCell.length;
1768
+ for (const v of values) {
1769
+ if (v.length > max) {
1770
+ max = v.length;
1771
+ }
1772
+ }
1773
+ return max;
1774
+ }
1775
+ function formatRow(r, w, maxWidth) {
1776
+ const fixed = [
1777
+ r.session.padEnd(w.session),
1778
+ r.upstream.padEnd(w.upstream),
1779
+ r.status.padEnd(w.status),
1780
+ r.clients.padStart(w.clients),
1781
+ r.agent.padEnd(w.agent),
1782
+ r.age.padStart(w.age)
1783
+ ].join(SEP);
1784
+ if (maxWidth === void 0) {
1785
+ return [fixed, r.title.padEnd(w.title), r.cwd].join(SEP);
1786
+ }
1787
+ const titleCap = Math.min(w.title, TITLE_MAX_WIDTH);
1788
+ const budget = maxWidth - fixed.length - SEP.length;
1789
+ if (budget <= 0) {
1790
+ return fixed.slice(0, maxWidth);
1791
+ }
1792
+ const titleNatural = Math.min(r.title.length, titleCap);
1793
+ let titleAlloc = titleNatural + SEP.length + MIN_CWD <= budget ? titleCap : Math.max(0, budget - SEP.length - MIN_CWD);
1794
+ titleAlloc = Math.min(titleAlloc, Math.max(0, budget - SEP.length - 1));
1795
+ const titleCell = truncateRight(r.title, titleAlloc).padEnd(titleAlloc);
1796
+ const cwdBudget = Math.max(0, budget - titleAlloc - SEP.length);
1797
+ const cwdCell = truncateMiddle(r.cwd, cwdBudget);
1798
+ return [fixed, titleCell, cwdCell].join(SEP);
1799
+ }
1800
+ function truncateRight(s, max) {
1801
+ if (max <= 0) {
1802
+ return "";
1803
+ }
1804
+ if (s.length <= max) {
1805
+ return s;
1806
+ }
1807
+ if (max === 1) {
1808
+ return "\u2026";
1809
+ }
1810
+ return s.slice(0, max - 1) + "\u2026";
1811
+ }
1812
+ function truncateMiddle(s, max) {
1813
+ if (max <= 0) {
1814
+ return "";
1815
+ }
1816
+ if (s.length <= max) {
1817
+ return s;
1818
+ }
1819
+ if (max === 1) {
1820
+ return "\u2026";
1821
+ }
1822
+ const head = Math.ceil((max - 1) / 2);
1823
+ const tail = max - 1 - head;
1824
+ return s.slice(0, head) + "\u2026" + s.slice(s.length - tail);
1825
+ }
1826
+ var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
1827
+ var init_session_row = __esm({
1828
+ "src/cli/session-row.ts"() {
1829
+ "use strict";
1830
+ init_session();
1831
+ HEADER = {
1832
+ session: "SESSION",
1833
+ upstream: "UPSTREAM",
1834
+ status: "STATUS",
1835
+ clients: "CLIENTS",
1836
+ agent: "AGENT",
1837
+ age: "AGE",
1838
+ title: "TITLE",
1839
+ cwd: "CWD"
1840
+ };
1841
+ SEP = " ";
1842
+ MIN_CWD = 8;
1843
+ TITLE_MAX_WIDTH = 40;
1844
+ }
1845
+ });
1846
+
1433
1847
  // src/cli/commands/sessions.ts
1434
1848
  async function runSessionsList(opts = {}) {
1435
1849
  const config = await loadConfig();
@@ -1465,44 +1879,13 @@ async function runSessionsList(opts = {}) {
1465
1879
  visible = [...sorted.slice(0, liveCount), ...coldSlice];
1466
1880
  truncated = hiddenCold;
1467
1881
  }
1468
- const rows = visible.map((s) => ({
1469
- session: stripHydraSessionPrefix(s.sessionId),
1470
- upstream: s.upstreamSessionId ?? "-",
1471
- status: (s.status ?? "live").toUpperCase(),
1472
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
1473
- agent: s.agentId ?? "?",
1474
- title: s.title ?? "-",
1475
- cwd: s.cwd
1476
- }));
1477
- const header = {
1478
- session: "SESSION",
1479
- upstream: "UPSTREAM",
1480
- status: "STATUS",
1481
- clients: "CLIENTS",
1482
- agent: "AGENT",
1483
- title: "TITLE",
1484
- cwd: "CWD"
1485
- };
1486
- const widths = {
1487
- session: maxLen(header.session, rows.map((r) => r.session)),
1488
- upstream: maxLen(header.upstream, rows.map((r) => r.upstream)),
1489
- status: maxLen(header.status, rows.map((r) => r.status)),
1490
- clients: maxLen(header.clients, rows.map((r) => r.clients)),
1491
- agent: maxLen(header.agent, rows.map((r) => r.agent)),
1492
- title: maxLen(header.title, rows.map((r) => r.title))
1493
- };
1494
- const formatRow2 = (r) => [
1495
- r.session.padEnd(widths.session),
1496
- r.upstream.padEnd(widths.upstream),
1497
- r.status.padEnd(widths.status),
1498
- r.clients.padStart(widths.clients),
1499
- r.agent.padEnd(widths.agent),
1500
- r.title.padEnd(widths.title),
1501
- r.cwd
1502
- ].join(" ");
1503
- process.stdout.write(formatRow2(header) + "\n");
1882
+ const now = Date.now();
1883
+ const rows = visible.map((s) => toRow(s, now));
1884
+ const widths = computeWidths(rows);
1885
+ const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
1886
+ process.stdout.write(formatRow(HEADER, widths, maxWidth) + "\n");
1504
1887
  for (const r of rows) {
1505
- process.stdout.write(formatRow2(r) + "\n");
1888
+ process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
1506
1889
  }
1507
1890
  if (truncated > 0) {
1508
1891
  process.stdout.write(
@@ -1512,15 +1895,6 @@ async function runSessionsList(opts = {}) {
1512
1895
  );
1513
1896
  }
1514
1897
  }
1515
- function maxLen(headerCell, values) {
1516
- let max = headerCell.length;
1517
- for (const v of values) {
1518
- if (v.length > max) {
1519
- max = v.length;
1520
- }
1521
- }
1522
- return max;
1523
- }
1524
1898
  async function runSessionsKill(id) {
1525
1899
  if (!id) {
1526
1900
  process.stderr.write("Usage: hydra-acp sessions kill <session-id>\n");
@@ -1528,6 +1902,25 @@ async function runSessionsKill(id) {
1528
1902
  }
1529
1903
  const config = await loadConfig();
1530
1904
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
1905
+ const response = await fetch(`${baseUrl}/v1/sessions/${id}/kill`, {
1906
+ method: "POST",
1907
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
1908
+ });
1909
+ if (!response.ok && response.status !== 204) {
1910
+ process.stderr.write(`Daemon returned HTTP ${response.status}
1911
+ `);
1912
+ process.exit(1);
1913
+ }
1914
+ process.stdout.write(`Killed ${id}
1915
+ `);
1916
+ }
1917
+ async function runSessionsRm(id) {
1918
+ if (!id) {
1919
+ process.stderr.write("Usage: hydra-acp sessions rm <session-id>\n");
1920
+ process.exit(2);
1921
+ }
1922
+ const config = await loadConfig();
1923
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
1531
1924
  const response = await fetch(`${baseUrl}/v1/sessions/${id}`, {
1532
1925
  method: "DELETE",
1533
1926
  headers: { Authorization: `Bearer ${config.daemon.authToken}` }
@@ -1537,7 +1930,7 @@ async function runSessionsKill(id) {
1537
1930
  `);
1538
1931
  process.exit(1);
1539
1932
  }
1540
- process.stdout.write(`Killed ${id}
1933
+ process.stdout.write(`Removed ${id}
1541
1934
  `);
1542
1935
  }
1543
1936
  function httpBase(host, port, tls) {
@@ -1548,17 +1941,252 @@ var init_sessions = __esm({
1548
1941
  "src/cli/commands/sessions.ts"() {
1549
1942
  "use strict";
1550
1943
  init_config();
1551
- init_session();
1944
+ init_session_row();
1945
+ }
1946
+ });
1947
+
1948
+ // src/shim/resilient-ws.ts
1949
+ import { setTimeout as sleep3 } from "timers/promises";
1950
+ import { WebSocket } from "ws";
1951
+ function isResponse(msg) {
1952
+ return !("method" in msg) && "id" in msg && msg.id !== void 0;
1953
+ }
1954
+ async function openWs(url, subprotocols) {
1955
+ return new Promise((resolve2, reject) => {
1956
+ const ws = new WebSocket(url, subprotocols);
1957
+ const onOpen = () => {
1958
+ ws.off("error", onError);
1959
+ resolve2(wsToMessageStream(ws));
1960
+ };
1961
+ const onError = (err) => {
1962
+ ws.off("open", onOpen);
1963
+ reject(err);
1964
+ };
1965
+ ws.once("open", onOpen);
1966
+ ws.once("error", onError);
1967
+ });
1968
+ }
1969
+ var BACKOFF_INITIAL_MS, BACKOFF_MAX_MS, BACKOFF_MULTIPLIER, MAX_RECONNECT_ATTEMPTS, ResilientWsStream;
1970
+ var init_resilient_ws = __esm({
1971
+ "src/shim/resilient-ws.ts"() {
1972
+ "use strict";
1973
+ init_ws_stream();
1974
+ init_types();
1975
+ BACKOFF_INITIAL_MS = 200;
1976
+ BACKOFF_MAX_MS = 5e3;
1977
+ BACKOFF_MULTIPLIER = 2;
1978
+ MAX_RECONNECT_ATTEMPTS = 60;
1979
+ ResilientWsStream = class {
1980
+ constructor(opts) {
1981
+ this.opts = opts;
1982
+ }
1983
+ opts;
1984
+ current;
1985
+ outboundQueue = [];
1986
+ messageHandlers = [];
1987
+ closeHandlers = [];
1988
+ destroyed = false;
1989
+ firstConnect = true;
1990
+ reconnectInFlight;
1991
+ connectGate;
1992
+ releaseConnectGate;
1993
+ pendingRequests = /* @__PURE__ */ new Map();
1994
+ async start() {
1995
+ await this.connectWithRetry();
1996
+ }
1997
+ onMessage(handler) {
1998
+ this.messageHandlers.push(handler);
1999
+ }
2000
+ onClose(handler) {
2001
+ this.closeHandlers.push(handler);
2002
+ }
2003
+ async send(message) {
2004
+ if (this.destroyed) {
2005
+ throw new Error("resilient ws stream is destroyed");
2006
+ }
2007
+ if (this.connectGate || !this.current) {
2008
+ this.outboundQueue.push(message);
2009
+ return;
2010
+ }
2011
+ try {
2012
+ await this.current.send(message);
2013
+ } catch (err) {
2014
+ this.outboundQueue.push(message);
2015
+ this.scheduleReconnect(err);
2016
+ }
2017
+ }
2018
+ // Send a request directly and resolve when the matching response arrives
2019
+ // on the same connection. Used by onConnect handlers to await replay-attach
2020
+ // responses before letting the outbound queue drain. Bypasses the
2021
+ // connectGate intentionally.
2022
+ async request(message) {
2023
+ if (this.destroyed) {
2024
+ throw new Error("resilient ws stream is destroyed");
2025
+ }
2026
+ if (!this.current) {
2027
+ throw new Error("resilient ws stream not connected");
2028
+ }
2029
+ const id = message.id;
2030
+ const promise = new Promise((resolve2, reject) => {
2031
+ this.pendingRequests.set(id, { resolve: resolve2, reject });
2032
+ });
2033
+ try {
2034
+ await this.current.send(message);
2035
+ } catch (err) {
2036
+ this.pendingRequests.delete(id);
2037
+ throw err;
2038
+ }
2039
+ return promise;
2040
+ }
2041
+ async close() {
2042
+ this.destroyed = true;
2043
+ if (this.current) {
2044
+ await this.current.close().catch(() => void 0);
2045
+ }
2046
+ for (const handler of this.closeHandlers) {
2047
+ handler();
2048
+ }
2049
+ }
2050
+ async connectWithRetry() {
2051
+ let attempt = 0;
2052
+ let backoff = BACKOFF_INITIAL_MS;
2053
+ while (!this.destroyed) {
2054
+ try {
2055
+ const stream = await openWs(this.opts.url, this.opts.subprotocols);
2056
+ this.bindStream(stream);
2057
+ const wasFirst = this.firstConnect;
2058
+ this.firstConnect = false;
2059
+ this.connectGate = new Promise((resolve2) => {
2060
+ this.releaseConnectGate = resolve2;
2061
+ });
2062
+ try {
2063
+ if (this.opts.onConnect) {
2064
+ try {
2065
+ await this.opts.onConnect(wasFirst);
2066
+ } catch (err) {
2067
+ this.log(
2068
+ `hydra-acp: post-connect handler failed: ${err.message}`
2069
+ );
2070
+ }
2071
+ }
2072
+ } finally {
2073
+ this.releaseConnectGate?.();
2074
+ this.releaseConnectGate = void 0;
2075
+ this.connectGate = void 0;
2076
+ }
2077
+ await this.flushQueue();
2078
+ return;
2079
+ } catch (err) {
2080
+ attempt += 1;
2081
+ if (this.opts.onConnectFailure) {
2082
+ this.opts.onConnectFailure(err);
2083
+ }
2084
+ if (attempt >= MAX_RECONNECT_ATTEMPTS) {
2085
+ throw new Error(
2086
+ `hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
2087
+ );
2088
+ }
2089
+ this.log(
2090
+ `hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
2091
+ );
2092
+ await sleep3(backoff);
2093
+ backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
2094
+ }
2095
+ }
2096
+ }
2097
+ bindStream(stream) {
2098
+ this.current = stream;
2099
+ stream.onMessage((msg) => {
2100
+ if (isResponse(msg)) {
2101
+ const pending = this.pendingRequests.get(msg.id);
2102
+ if (pending) {
2103
+ this.pendingRequests.delete(msg.id);
2104
+ pending.resolve(msg);
2105
+ }
2106
+ }
2107
+ for (const handler of this.messageHandlers) {
2108
+ handler(msg);
2109
+ }
2110
+ });
2111
+ stream.onClose((err) => {
2112
+ if (this.destroyed) {
2113
+ return;
2114
+ }
2115
+ this.current = void 0;
2116
+ if (this.pendingRequests.size > 0) {
2117
+ const reason = err ?? new Error("ws closed before response");
2118
+ for (const { reject } of this.pendingRequests.values()) {
2119
+ reject(reason);
2120
+ }
2121
+ this.pendingRequests.clear();
2122
+ }
2123
+ this.scheduleReconnect(err);
2124
+ });
2125
+ }
2126
+ async flushQueue() {
2127
+ if (!this.current) {
2128
+ return;
2129
+ }
2130
+ const queue = this.outboundQueue;
2131
+ this.outboundQueue = [];
2132
+ for (const msg of queue) {
2133
+ try {
2134
+ await this.current.send(msg);
2135
+ } catch (err) {
2136
+ this.outboundQueue.unshift(msg);
2137
+ this.scheduleReconnect(err);
2138
+ return;
2139
+ }
2140
+ }
2141
+ }
2142
+ scheduleReconnect(err) {
2143
+ if (this.destroyed || this.reconnectInFlight) {
2144
+ return;
2145
+ }
2146
+ this.log(
2147
+ `hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
2148
+ );
2149
+ if (this.opts.onDisconnect) {
2150
+ try {
2151
+ this.opts.onDisconnect(err);
2152
+ } catch (hookErr) {
2153
+ this.log(
2154
+ `hydra-acp: onDisconnect handler threw: ${hookErr.message}`
2155
+ );
2156
+ }
2157
+ }
2158
+ this.reconnectInFlight = (async () => {
2159
+ try {
2160
+ await this.connectWithRetry();
2161
+ } catch (final) {
2162
+ for (const handler of this.closeHandlers) {
2163
+ handler(final);
2164
+ }
2165
+ this.destroyed = true;
2166
+ } finally {
2167
+ this.reconnectInFlight = void 0;
2168
+ }
2169
+ })();
2170
+ }
2171
+ log(line) {
2172
+ if (this.opts.log) {
2173
+ this.opts.log(line);
2174
+ return;
2175
+ }
2176
+ process.stderr.write(`${line}
2177
+ `);
2178
+ }
2179
+ };
1552
2180
  }
1553
2181
  });
1554
2182
 
1555
2183
  // src/tui/history.ts
1556
- import { promises as fs8 } from "fs";
2184
+ import { promises as fs10 } from "fs";
1557
2185
  import * as path4 from "path";
1558
2186
  async function loadHistory(file) {
1559
2187
  let text;
1560
2188
  try {
1561
- text = await fs8.readFile(file, "utf8");
2189
+ text = await fs10.readFile(file, "utf8");
1562
2190
  } catch (err) {
1563
2191
  if (err.code === "ENOENT") {
1564
2192
  return [];
@@ -1598,9 +2226,9 @@ function appendEntry(history, entry) {
1598
2226
  return out;
1599
2227
  }
1600
2228
  async function saveHistory(file, history) {
1601
- await fs8.mkdir(path4.dirname(file), { recursive: true });
2229
+ await fs10.mkdir(path4.dirname(file), { recursive: true });
1602
2230
  const lines = history.map((entry) => JSON.stringify(entry));
1603
- await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2231
+ await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1604
2232
  }
1605
2233
  var HISTORY_CAP;
1606
2234
  var init_history = __esm({
@@ -1641,6 +2269,26 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
1641
2269
  title: s.title
1642
2270
  }));
1643
2271
  }
2272
+ async function killSession(config, id, fetchImpl = fetch) {
2273
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2274
+ const response = await fetchImpl(`${base}/v1/sessions/${id}/kill`, {
2275
+ method: "POST",
2276
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2277
+ });
2278
+ if (!response.ok && response.status !== 204 && response.status !== 404) {
2279
+ throw new Error(`daemon returned HTTP ${response.status}`);
2280
+ }
2281
+ }
2282
+ async function deleteSession(config, id, fetchImpl = fetch) {
2283
+ const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2284
+ const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
2285
+ method: "DELETE",
2286
+ headers: { Authorization: `Bearer ${config.daemon.authToken}` }
2287
+ });
2288
+ if (!response.ok && response.status !== 204 && response.status !== 404) {
2289
+ throw new Error(`daemon returned HTTP ${response.status}`);
2290
+ }
2291
+ }
1644
2292
  function pickMostRecent(sessions, cwd) {
1645
2293
  const matching = sessions.filter((s) => s.cwd === cwd);
1646
2294
  if (matching.length === 0) {
@@ -1668,147 +2316,401 @@ async function pickSession(term, opts) {
1668
2316
  if (opts.sessions.length === 0) {
1669
2317
  return { kind: "new" };
1670
2318
  }
1671
- const score = (s) => {
1672
- if (s.status !== "live") {
1673
- return 0;
2319
+ const sortSessions = (sessions) => {
2320
+ const score = (s) => {
2321
+ if (s.status !== "live") {
2322
+ return 0;
2323
+ }
2324
+ return s.cwd === opts.cwd ? 2 : 1;
2325
+ };
2326
+ return [...sessions].sort((a, b) => {
2327
+ const tier = score(b) - score(a);
2328
+ if (tier !== 0) {
2329
+ return tier;
2330
+ }
2331
+ return b.updatedAt.localeCompare(a.updatedAt);
2332
+ });
2333
+ };
2334
+ let visible = sortSessions(opts.sessions);
2335
+ let rows = visible.map((s) => toRow(s, Date.now()));
2336
+ let widths = computeWidths(rows);
2337
+ let total = 1 + visible.length;
2338
+ let selectedIdx = 0;
2339
+ let scrollOffset = 0;
2340
+ let mode = "normal";
2341
+ let pendingAction = null;
2342
+ let transientStatus = null;
2343
+ let termHeight = readTermHeight(term);
2344
+ let termWidth = readTermWidth(term);
2345
+ let viewportSize = 0;
2346
+ let newSessionLabel = "";
2347
+ let headerLine = "";
2348
+ let sessionLines = [];
2349
+ let startRow = 1;
2350
+ const computeLayout = () => {
2351
+ termHeight = readTermHeight(term);
2352
+ termWidth = readTermWidth(term);
2353
+ const maxViewportRows = Math.max(3, termHeight - 6);
2354
+ viewportSize = Math.min(visible.length, maxViewportRows);
2355
+ const rowMaxWidth = Math.max(10, termWidth - ROW_PREFIX_WIDTH);
2356
+ newSessionLabel = formatNewSessionLabel(opts.cwd, rowMaxWidth);
2357
+ headerLine = formatRow(HEADER, widths, rowMaxWidth);
2358
+ sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth));
2359
+ };
2360
+ const rebuildRows = () => {
2361
+ rows = visible.map((s) => toRow(s, Date.now()));
2362
+ widths = computeWidths(rows);
2363
+ total = 1 + visible.length;
2364
+ computeLayout();
2365
+ };
2366
+ const adjustScroll = () => {
2367
+ if (selectedIdx === 0) {
2368
+ return;
2369
+ }
2370
+ const sessionIdx = selectedIdx - 1;
2371
+ if (sessionIdx < scrollOffset) {
2372
+ scrollOffset = sessionIdx;
2373
+ } else if (sessionIdx >= scrollOffset + viewportSize) {
2374
+ scrollOffset = sessionIdx - viewportSize + 1;
2375
+ } else if (scrollOffset + viewportSize > visible.length) {
2376
+ scrollOffset = Math.max(0, visible.length - viewportSize);
1674
2377
  }
1675
- return s.cwd === opts.cwd ? 2 : 1;
1676
2378
  };
1677
- const sorted = [...opts.sessions].sort((a, b) => {
1678
- const tier = score(b) - score(a);
1679
- if (tier !== 0) {
1680
- return tier;
2379
+ const paintNewItem = () => {
2380
+ if (selectedIdx === 0) {
2381
+ term.brightWhite.bgBlue.noFormat(`\u276F ${newSessionLabel}`);
2382
+ } else {
2383
+ term.noFormat(` ${newSessionLabel}`);
1681
2384
  }
1682
- return b.updatedAt.localeCompare(a.updatedAt);
1683
- });
1684
- const liveCount = sorted.filter((s) => s.status !== "cold").length;
1685
- const coldSlice = sorted.slice(liveCount, liveCount + opts.coldLimit);
1686
- const hiddenCold = sorted.length - liveCount - coldSlice.length;
1687
- const visible = [...sorted.slice(0, liveCount), ...coldSlice];
1688
- const rows = visible.map(toRow);
1689
- const widths = computeWidths(rows);
1690
- const newSessionLabel = `+ New session in ${opts.cwd}`;
1691
- const items = [newSessionLabel, ...rows.map((r) => formatRow(r, widths))];
1692
- term("\n");
1693
- term.bold("Select a session")("\n");
1694
- if (hiddenCold > 0) {
1695
- term.dim(`(${hiddenCold} older cold session${hiddenCold === 1 ? "" : "s"} hidden; use \`hydra-acp sessions --all\` to view)
1696
- `);
1697
- }
1698
- term.dim(formatRow(HEADER, widths))("\n");
1699
- const onCtrlC = (name) => {
1700
- if (name === "CTRL_C") {
1701
- term.grabInput(false);
1702
- term("\n");
1703
- process.exit(130);
2385
+ };
2386
+ const paintSessionRow = (sessionIdx) => {
2387
+ const label = sessionLines[sessionIdx] ?? "";
2388
+ if (selectedIdx === sessionIdx + 1) {
2389
+ term.brightWhite.bgBlue.noFormat(`\u276F ${label}`);
2390
+ } else {
2391
+ term.noFormat(` ${label}`);
1704
2392
  }
1705
2393
  };
1706
- term.on("key", onCtrlC);
1707
- let response;
1708
- try {
1709
- response = await term.singleColumnMenu(items, {
1710
- cancelable: true,
1711
- exitOnUnexpectedKey: false,
1712
- selectedIndex: 0,
1713
- style: term.brightWhite,
1714
- selectedStyle: term.brightWhite.bgBlue,
1715
- keyBindings: {
1716
- ENTER: "submit",
1717
- KP_ENTER: "submit",
1718
- UP: "previous",
1719
- DOWN: "next",
1720
- TAB: "next",
1721
- SHIFT_TAB: "previous",
1722
- HOME: "first",
1723
- END: "last",
1724
- ESCAPE: "cancel",
1725
- CTRL_C: "cancel"
1726
- }
1727
- }).promise;
1728
- } finally {
1729
- term.off("key", onCtrlC);
1730
- }
1731
- term("\n");
1732
- if (response.canceled || response.selectedIndex === void 0) {
1733
- return { kind: "abort" };
1734
- }
1735
- if (response.selectedIndex === 0) {
1736
- return { kind: "new" };
1737
- }
1738
- const session = visible[response.selectedIndex - 1];
1739
- if (!session) {
1740
- return { kind: "abort" };
1741
- }
1742
- const result = {
1743
- kind: "attach",
1744
- sessionId: session.sessionId
2394
+ const formatIndicator = () => {
2395
+ const above = scrollOffset;
2396
+ const below = Math.max(0, visible.length - scrollOffset - viewportSize);
2397
+ if (above === 0 && below === 0) {
2398
+ return "";
2399
+ }
2400
+ const parts = [];
2401
+ if (above > 0) {
2402
+ parts.push(`\u2191 ${above} above`);
2403
+ }
2404
+ if (below > 0) {
2405
+ parts.push(`\u2193 ${below} below`);
2406
+ }
2407
+ return ` ${parts.join(" \xB7 ")}`;
1745
2408
  };
1746
- if (session.agentId !== void 0) {
1747
- result.agentId = session.agentId;
1748
- }
1749
- return result;
1750
- }
1751
- function toRow(s) {
1752
- return {
1753
- session: stripHydraSessionPrefix(s.sessionId),
1754
- upstream: s.upstreamSessionId ?? "-",
1755
- status: s.status.toUpperCase(),
1756
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
1757
- agent: s.agentId ?? "?",
1758
- title: s.title ?? "-",
1759
- cwd: s.cwd
2409
+ const shortId2 = (sessionId) => stripHydraSessionPrefix(sessionId);
2410
+ const paintIndicator = () => {
2411
+ term.moveTo(1, indicatorRow()).eraseLineAfter();
2412
+ if (mode === "confirm-kill" && pendingAction) {
2413
+ term.brightYellow.noFormat(` kill ${shortId2(pendingAction.sessionId)}? [y/N]`);
2414
+ return;
2415
+ }
2416
+ if (mode === "confirm-delete" && pendingAction) {
2417
+ term.brightRed.noFormat(` delete ${shortId2(pendingAction.sessionId)}? [y/N]`);
2418
+ return;
2419
+ }
2420
+ if (mode === "busy" && pendingAction) {
2421
+ term.dim.noFormat(` working on ${shortId2(pendingAction.sessionId)}\u2026`);
2422
+ return;
2423
+ }
2424
+ if (transientStatus !== null) {
2425
+ term.dim.noFormat(` ${transientStatus}`);
2426
+ return;
2427
+ }
2428
+ term.dim.noFormat(formatIndicator());
1760
2429
  };
1761
- }
1762
- function computeWidths(rows) {
1763
- return {
1764
- session: maxLen4(HEADER.session, rows.map((r) => r.session)),
1765
- upstream: maxLen4(HEADER.upstream, rows.map((r) => r.upstream)),
1766
- status: maxLen4(HEADER.status, rows.map((r) => r.status)),
1767
- clients: maxLen4(HEADER.clients, rows.map((r) => r.clients)),
1768
- agent: maxLen4(HEADER.agent, rows.map((r) => r.agent)),
1769
- title: maxLen4(HEADER.title, rows.map((r) => r.title))
2430
+ const indicatorRow = () => startRow + 3 + viewportSize;
2431
+ const sessionRow = (sessionIdx) => startRow + 3 + (sessionIdx - scrollOffset);
2432
+ const renderFromScratch = () => {
2433
+ computeLayout();
2434
+ adjustScroll();
2435
+ startRow = 1;
2436
+ term.moveTo(1, 1).eraseDisplayBelow();
2437
+ paintNewItem();
2438
+ term("\n\n");
2439
+ term.dim.noFormat(` ${headerLine}`)("\n");
2440
+ for (let v = 0; v < viewportSize; v++) {
2441
+ paintSessionRow(scrollOffset + v);
2442
+ term("\n");
2443
+ }
2444
+ paintIndicator();
2445
+ term("\n");
1770
2446
  };
1771
- }
1772
- function maxLen4(headerCell, values) {
1773
- let max = headerCell.length;
1774
- for (const v of values) {
1775
- if (v.length > max) {
1776
- max = v.length;
2447
+ const repaintNewItem = () => {
2448
+ term.moveTo(1, startRow).eraseLineAfter();
2449
+ paintNewItem();
2450
+ };
2451
+ const repaintSessionRow = (sessionIdx) => {
2452
+ if (sessionIdx < scrollOffset || sessionIdx >= scrollOffset + viewportSize) {
2453
+ return;
1777
2454
  }
1778
- }
1779
- return max;
2455
+ term.moveTo(1, sessionRow(sessionIdx)).eraseLineAfter();
2456
+ paintSessionRow(sessionIdx);
2457
+ };
2458
+ const repaintViewport = () => {
2459
+ for (let v = 0; v < viewportSize; v++) {
2460
+ const row = startRow + 3 + v;
2461
+ term.moveTo(1, row).eraseLineAfter();
2462
+ const sessionIdx = scrollOffset + v;
2463
+ if (sessionIdx < visible.length) {
2464
+ paintSessionRow(sessionIdx);
2465
+ }
2466
+ }
2467
+ paintIndicator();
2468
+ };
2469
+ renderFromScratch();
2470
+ term.hideCursor();
2471
+ return await new Promise((resolve2) => {
2472
+ let resolved = false;
2473
+ const onResize = () => {
2474
+ if (resolved) {
2475
+ return;
2476
+ }
2477
+ renderFromScratch();
2478
+ };
2479
+ const cleanup = () => {
2480
+ if (resolved) {
2481
+ return;
2482
+ }
2483
+ resolved = true;
2484
+ term.off("key", onKey);
2485
+ term.off("resize", onResize);
2486
+ term.grabInput(false);
2487
+ term.hideCursor(false);
2488
+ term.moveTo(1, indicatorRow() + 1);
2489
+ term("\n");
2490
+ };
2491
+ const refresh = async (preferredId) => {
2492
+ try {
2493
+ const next = await listSessions(opts.config);
2494
+ visible = sortSessions(next);
2495
+ rebuildRows();
2496
+ if (preferredId !== void 0) {
2497
+ const idx = visible.findIndex((s) => s.sessionId === preferredId);
2498
+ if (idx >= 0) {
2499
+ selectedIdx = idx + 1;
2500
+ }
2501
+ }
2502
+ if (selectedIdx > total - 1) {
2503
+ selectedIdx = Math.max(0, total - 1);
2504
+ }
2505
+ if (scrollOffset + viewportSize > visible.length) {
2506
+ scrollOffset = Math.max(0, visible.length - viewportSize);
2507
+ }
2508
+ adjustScroll();
2509
+ renderFromScratch();
2510
+ } catch (err) {
2511
+ transientStatus = `refresh failed: ${err.message}`;
2512
+ renderFromScratch();
2513
+ }
2514
+ };
2515
+ const performAction = async (kind) => {
2516
+ if (!pendingAction) {
2517
+ return;
2518
+ }
2519
+ const target = pendingAction;
2520
+ mode = "busy";
2521
+ paintIndicator();
2522
+ try {
2523
+ if (kind === "kill") {
2524
+ await killSession(opts.config, target.sessionId);
2525
+ } else {
2526
+ await deleteSession(opts.config, target.sessionId);
2527
+ }
2528
+ mode = "normal";
2529
+ pendingAction = null;
2530
+ await refresh(kind === "kill" ? target.sessionId : void 0);
2531
+ } catch (err) {
2532
+ mode = "normal";
2533
+ pendingAction = null;
2534
+ transientStatus = `${kind} failed: ${err.message}`;
2535
+ paintIndicator();
2536
+ }
2537
+ };
2538
+ const move = (delta) => {
2539
+ const next = Math.min(total - 1, Math.max(0, selectedIdx + delta));
2540
+ if (next === selectedIdx) {
2541
+ return;
2542
+ }
2543
+ const old = selectedIdx;
2544
+ const oldScroll = scrollOffset;
2545
+ selectedIdx = next;
2546
+ adjustScroll();
2547
+ if (scrollOffset !== oldScroll) {
2548
+ repaintViewport();
2549
+ if (old === 0 || selectedIdx === 0) {
2550
+ repaintNewItem();
2551
+ }
2552
+ return;
2553
+ }
2554
+ if (old === 0) {
2555
+ repaintNewItem();
2556
+ } else {
2557
+ repaintSessionRow(old - 1);
2558
+ }
2559
+ if (selectedIdx === 0) {
2560
+ repaintNewItem();
2561
+ } else {
2562
+ repaintSessionRow(selectedIdx - 1);
2563
+ }
2564
+ };
2565
+ const clearTransient = () => {
2566
+ if (transientStatus === null) {
2567
+ return false;
2568
+ }
2569
+ transientStatus = null;
2570
+ paintIndicator();
2571
+ return true;
2572
+ };
2573
+ const onKey = (name, _matches, data) => {
2574
+ if (mode === "busy") {
2575
+ return;
2576
+ }
2577
+ if (mode === "confirm-kill" || mode === "confirm-delete") {
2578
+ if (data?.isCharacter && (name === "y" || name === "Y")) {
2579
+ const kind = mode === "confirm-kill" ? "kill" : "delete";
2580
+ void performAction(kind);
2581
+ return;
2582
+ }
2583
+ if (name === "ESCAPE" || name === "CTRL_C" || name === "ENTER" || name === "KP_ENTER" || data?.isCharacter && (name === "n" || name === "N")) {
2584
+ mode = "normal";
2585
+ pendingAction = null;
2586
+ paintIndicator();
2587
+ return;
2588
+ }
2589
+ return;
2590
+ }
2591
+ clearTransient();
2592
+ if (data?.isCharacter) {
2593
+ if ((name === "k" || name === "K") && selectedIdx > 0) {
2594
+ const session = visible[selectedIdx - 1];
2595
+ if (!session) {
2596
+ return;
2597
+ }
2598
+ pendingAction = {
2599
+ sessionId: session.sessionId,
2600
+ cwd: session.cwd,
2601
+ status: session.status
2602
+ };
2603
+ mode = "confirm-kill";
2604
+ paintIndicator();
2605
+ return;
2606
+ }
2607
+ if ((name === "d" || name === "D") && selectedIdx > 0) {
2608
+ const session = visible[selectedIdx - 1];
2609
+ if (!session) {
2610
+ return;
2611
+ }
2612
+ if (session.status === "live") {
2613
+ transientStatus = "session is live \u2014 press k to kill it first";
2614
+ paintIndicator();
2615
+ return;
2616
+ }
2617
+ pendingAction = {
2618
+ sessionId: session.sessionId,
2619
+ cwd: session.cwd,
2620
+ status: session.status
2621
+ };
2622
+ mode = "confirm-delete";
2623
+ paintIndicator();
2624
+ return;
2625
+ }
2626
+ return;
2627
+ }
2628
+ switch (name) {
2629
+ case "UP":
2630
+ case "SHIFT_TAB":
2631
+ move(-1);
2632
+ return;
2633
+ case "DOWN":
2634
+ case "TAB":
2635
+ move(1);
2636
+ return;
2637
+ case "PAGE_UP":
2638
+ move(-viewportSize);
2639
+ return;
2640
+ case "PAGE_DOWN":
2641
+ move(viewportSize);
2642
+ return;
2643
+ case "HOME":
2644
+ move(-total);
2645
+ return;
2646
+ case "END":
2647
+ move(total);
2648
+ return;
2649
+ case "ENTER":
2650
+ case "KP_ENTER": {
2651
+ cleanup();
2652
+ if (selectedIdx === 0) {
2653
+ resolve2({ kind: "new" });
2654
+ return;
2655
+ }
2656
+ const session = visible[selectedIdx - 1];
2657
+ if (!session) {
2658
+ resolve2({ kind: "abort" });
2659
+ return;
2660
+ }
2661
+ const result = {
2662
+ kind: "attach",
2663
+ sessionId: session.sessionId
2664
+ };
2665
+ if (session.agentId !== void 0) {
2666
+ result.agentId = session.agentId;
2667
+ }
2668
+ resolve2(result);
2669
+ return;
2670
+ }
2671
+ case "ESCAPE":
2672
+ case "CTRL_C":
2673
+ cleanup();
2674
+ resolve2({ kind: "abort" });
2675
+ return;
2676
+ }
2677
+ };
2678
+ term.grabInput({});
2679
+ term.on("key", onKey);
2680
+ term.on("resize", onResize);
2681
+ });
1780
2682
  }
1781
- function formatRow(r, w) {
1782
- return [
1783
- r.session.padEnd(w.session),
1784
- r.upstream.padEnd(w.upstream),
1785
- r.status.padEnd(w.status),
1786
- r.clients.padStart(w.clients),
1787
- r.agent.padEnd(w.agent),
1788
- r.title.padEnd(w.title),
1789
- r.cwd
1790
- ].join(" ");
2683
+ function readTermHeight(term) {
2684
+ return term.height ?? 24;
1791
2685
  }
1792
- var HEADER;
2686
+ function readTermWidth(term) {
2687
+ return term.width ?? 80;
2688
+ }
2689
+ function formatNewSessionLabel(cwd, maxWidth) {
2690
+ const prefix = "+ New session in ";
2691
+ const budget = Math.max(1, maxWidth - prefix.length);
2692
+ return prefix + truncateMiddle(cwd, budget);
2693
+ }
2694
+ var ROW_PREFIX_WIDTH;
1793
2695
  var init_picker = __esm({
1794
2696
  "src/tui/picker.ts"() {
1795
2697
  "use strict";
2698
+ init_session_row();
1796
2699
  init_session();
1797
- HEADER = {
1798
- session: "SESSION",
1799
- upstream: "UPSTREAM",
1800
- status: "STATUS",
1801
- clients: "CLIENTS",
1802
- agent: "AGENT",
1803
- title: "TITLE",
1804
- cwd: "CWD"
1805
- };
2700
+ init_discovery();
2701
+ ROW_PREFIX_WIDTH = 2;
1806
2702
  }
1807
2703
  });
1808
2704
 
1809
2705
  // src/tui/screen.ts
1810
2706
  import stringWidth from "string-width";
1811
2707
  import wrapAnsi from "wrap-ansi";
2708
+ function formattedLineSig(zone, width, line) {
2709
+ if (!line) {
2710
+ return `${zone}|${width}|empty`;
2711
+ }
2712
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}`;
2713
+ }
1812
2714
  function computePromptVisualRows(buffer, room) {
1813
2715
  const rows = [];
1814
2716
  for (let i = 0; i < buffer.length; i++) {
@@ -1893,16 +2795,16 @@ function writeStyled(term, text, style) {
1893
2795
  term.bold.red.noFormat(text);
1894
2796
  return;
1895
2797
  case "tool-status-pending":
1896
- term.dim.yellow.noFormat(text);
2798
+ term.dim.noFormat(text);
1897
2799
  return;
1898
2800
  case "tool-status-running":
1899
- term.bold.yellow.noFormat(text);
2801
+ term.brightYellow.noFormat(text);
1900
2802
  return;
1901
2803
  case "tool-status-cancelled":
1902
2804
  term.dim.noFormat(text);
1903
2805
  return;
1904
2806
  case "plan":
1905
- term.magenta.noFormat(text);
2807
+ term.brightYellow.noFormat(text);
1906
2808
  return;
1907
2809
  case "plan-done":
1908
2810
  term.green.noFormat(text);
@@ -2087,13 +2989,15 @@ function mapKeyName(name) {
2087
2989
  return "ctrl-u";
2088
2990
  case "CTRL_W":
2089
2991
  return "ctrl-w";
2992
+ case "CTRL_Y":
2993
+ return "ctrl-y";
2090
2994
  case "ESCAPE":
2091
2995
  return "escape";
2092
2996
  default:
2093
2997
  return null;
2094
2998
  }
2095
2999
  }
2096
- 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, Screen, shortId;
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;
2097
3001
  var init_screen = __esm({
2098
3002
  "src/tui/screen.ts"() {
2099
3003
  "use strict";
@@ -2107,6 +3011,7 @@ var init_screen = __esm({
2107
3011
  MAX_COMPLETION_ROWS = 6;
2108
3012
  CONFIRM_PROMPT_ROWS = 2;
2109
3013
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
3014
+ DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
2110
3015
  Screen = class {
2111
3016
  term;
2112
3017
  dispatcher;
@@ -2127,6 +3032,27 @@ var init_screen = __esm({
2127
3032
  lastRepaintAt = 0;
2128
3033
  throttledRepaintTimer = null;
2129
3034
  contentRepaintThrottleMs;
3035
+ maxScrollbackLines;
3036
+ // Wrap memoization: each FormattedLine that lands in this.lines gets a
3037
+ // monotonic id assigned via trackLine(); wrapCache holds the pre-wrapped
3038
+ // FormattedLine[] for that id at wrapCacheWidth. Width changes flush the
3039
+ // whole cache; in-place body mutation (streaming) and splices invalidate
3040
+ // affected ids. Result: steady-state repaints only wrap newly-appended
3041
+ // lines, not the entire history.
3042
+ nextLineId = 1;
3043
+ lineIds = /* @__PURE__ */ new WeakMap();
3044
+ wrapCache = /* @__PURE__ */ new Map();
3045
+ wrapCacheWidth = 0;
3046
+ // Per-row signature of what was painted to each terminal row on the
3047
+ // previous repaint. drawX methods funnel through paintRow(), which
3048
+ // skips the moveTo+eraseLineAfter+write sequence when the new
3049
+ // signature matches the previous frame. Eliminates flicker during
3050
+ // the 1Hz busy-tick: only rows whose content actually changed
3051
+ // (banner elapsed, tools-block summary) get re-emitted instead of
3052
+ // every visible row. Cleared on dimension change.
3053
+ lastFrameRows = /* @__PURE__ */ new Map();
3054
+ lastFrameW = 0;
3055
+ lastFrameH = 0;
2130
3056
  permissionPrompt = null;
2131
3057
  confirmPrompt = null;
2132
3058
  completions = [];
@@ -2141,6 +3067,7 @@ var init_screen = __esm({
2141
3067
  queued: 0
2142
3068
  };
2143
3069
  header = { agent: "?", cwd: "?", sessionId: "?" };
3070
+ lastWindowTitle = null;
2144
3071
  resizeHandler;
2145
3072
  keyHandler;
2146
3073
  mouseHandler;
@@ -2161,6 +3088,7 @@ var init_screen = __esm({
2161
3088
  this.dispatcher = opts.dispatcher;
2162
3089
  this.onKey = opts.onKey;
2163
3090
  this.contentRepaintThrottleMs = opts.repaintThrottleMs ?? DEFAULT_CONTENT_REPAINT_THROTTLE_MS;
3091
+ this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
2164
3092
  this.resizeHandler = () => this.repaint();
2165
3093
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
2166
3094
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -2261,13 +3189,17 @@ var init_screen = __esm({
2261
3189
  }
2262
3190
  this.streamingActive = false;
2263
3191
  this.lines.push(...lines);
3192
+ this.trackLines(lines);
2264
3193
  this.adjustScrollForLineChange(lines.length);
3194
+ this.trimScrollback();
2265
3195
  this.scheduleRepaint();
2266
3196
  }
2267
3197
  appendLine(line) {
2268
3198
  this.streamingActive = false;
2269
3199
  this.lines.push(line);
3200
+ this.trackLine(line);
2270
3201
  this.adjustScrollForLineChange(1);
3202
+ this.trimScrollback();
2271
3203
  this.scheduleRepaint();
2272
3204
  }
2273
3205
  // When scrolled away from the bottom, shift scrollOffset to keep the
@@ -2279,6 +3211,40 @@ var init_screen = __esm({
2279
3211
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
2280
3212
  }
2281
3213
  }
3214
+ trackLine(line) {
3215
+ this.lineIds.set(line, this.nextLineId++);
3216
+ }
3217
+ trackLines(lines) {
3218
+ for (const line of lines) {
3219
+ this.trackLine(line);
3220
+ }
3221
+ }
3222
+ forgetLine(line) {
3223
+ const id = this.lineIds.get(line);
3224
+ if (id !== void 0) {
3225
+ this.wrapCache.delete(id);
3226
+ }
3227
+ }
3228
+ // Drop oldest lines once scrollback exceeds the configured cap. Removes
3229
+ // their wrap-cache entries and shifts keyedBlocks indices in sync;
3230
+ // blocks whose lines fully fell off the head are dropped (a later
3231
+ // upsert for that key will start a fresh block at the bottom).
3232
+ trimScrollback() {
3233
+ const overflow = this.lines.length - this.maxScrollbackLines;
3234
+ if (overflow <= 0) {
3235
+ return;
3236
+ }
3237
+ const removed = this.lines.splice(0, overflow);
3238
+ for (const line of removed) {
3239
+ this.forgetLine(line);
3240
+ }
3241
+ for (const [key, range] of [...this.keyedBlocks.entries()]) {
3242
+ range.start -= overflow;
3243
+ if (range.start < 0) {
3244
+ this.keyedBlocks.delete(key);
3245
+ }
3246
+ }
3247
+ }
2282
3248
  // Append-or-replace a single-line block keyed by `key`. Thin wrapper
2283
3249
  // around upsertLines for the common one-row case (tool calls).
2284
3250
  upsertLine(key, line) {
@@ -2300,7 +3266,15 @@ var init_screen = __esm({
2300
3266
  touchesEnd = oldEnd >= this.lines.length;
2301
3267
  const delta = newLines.length - existing.count;
2302
3268
  scrollDelta = delta;
2303
- this.lines.splice(existing.start, existing.count, ...newLines);
3269
+ const removed = this.lines.splice(
3270
+ existing.start,
3271
+ existing.count,
3272
+ ...newLines
3273
+ );
3274
+ for (const line of removed) {
3275
+ this.forgetLine(line);
3276
+ }
3277
+ this.trackLines(newLines);
2304
3278
  existing.count = newLines.length;
2305
3279
  if (delta !== 0) {
2306
3280
  for (const [k, range] of this.keyedBlocks) {
@@ -2317,11 +3291,13 @@ var init_screen = __esm({
2317
3291
  count: newLines.length
2318
3292
  });
2319
3293
  this.lines.push(...newLines);
3294
+ this.trackLines(newLines);
2320
3295
  }
2321
3296
  if (touchesEnd) {
2322
3297
  this.streamingActive = false;
2323
3298
  }
2324
3299
  this.adjustScrollForLineChange(scrollDelta);
3300
+ this.trimScrollback();
2325
3301
  this.scheduleRepaint();
2326
3302
  }
2327
3303
  // Append fragments of a streaming message (e.g. agent_message_chunk). The
@@ -2338,6 +3314,7 @@ var init_screen = __esm({
2338
3314
  if (this.streamingActive && this.lines.length > 0) {
2339
3315
  const last = this.lines[this.lines.length - 1];
2340
3316
  if (last) {
3317
+ this.forgetLine(last);
2341
3318
  last.body += first ?? "";
2342
3319
  }
2343
3320
  } else {
@@ -2345,7 +3322,9 @@ var init_screen = __esm({
2345
3322
  const last = this.lines[this.lines.length - 1];
2346
3323
  const isBlank = last && last.body === "" && (!last.prefix || last.prefix === "");
2347
3324
  if (!isBlank) {
2348
- this.lines.push({ body: "" });
3325
+ const sep = { body: "" };
3326
+ this.lines.push(sep);
3327
+ this.trackLine(sep);
2349
3328
  added += 1;
2350
3329
  }
2351
3330
  }
@@ -2358,25 +3337,48 @@ var init_screen = __esm({
2358
3337
  initial.prefixStyle = prefixStyle;
2359
3338
  }
2360
3339
  this.lines.push(initial);
3340
+ this.trackLine(initial);
2361
3341
  added += 1;
2362
3342
  }
2363
3343
  const continuationPrefix = " ".repeat(prefix.length);
2364
3344
  for (const piece of rest) {
2365
- this.lines.push({
3345
+ const cont = {
2366
3346
  prefix: continuationPrefix,
2367
3347
  body: piece,
2368
3348
  bodyStyle
2369
- });
3349
+ };
3350
+ this.lines.push(cont);
3351
+ this.trackLine(cont);
2370
3352
  added += 1;
2371
3353
  }
2372
3354
  this.streamingActive = true;
2373
3355
  this.adjustScrollForLineChange(added);
3356
+ this.trimScrollback();
2374
3357
  this.scheduleRepaint();
2375
3358
  }
2376
3359
  setHeader(header) {
2377
3360
  this.header = { ...this.header, ...header };
3361
+ this.syncWindowTitle();
2378
3362
  this.repaint();
2379
3363
  }
3364
+ // Push the current session title (or short session id, as fallback) to
3365
+ // the host terminal via OSC 2. Supported by xterm/foot/iTerm2/Alacritty/
3366
+ // most modern emulators; ignored harmlessly elsewhere.
3367
+ syncWindowTitle() {
3368
+ const title = this.header.title?.trim();
3369
+ const fallback = shortId(this.header.sessionId) || "hydra";
3370
+ const raw = title && title.length > 0 ? title : fallback;
3371
+ const clean = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
3372
+ if (clean === this.lastWindowTitle) {
3373
+ return;
3374
+ }
3375
+ this.lastWindowTitle = clean;
3376
+ process.stdout.write(`\x1B]2;${clean}\x1B\\`);
3377
+ }
3378
+ clearWindowTitle() {
3379
+ this.lastWindowTitle = null;
3380
+ process.stdout.write("\x1B]2;\x1B\\");
3381
+ }
2380
3382
  setBanner(banner) {
2381
3383
  this.banner = { ...this.banner, ...banner };
2382
3384
  this.drawBanner();
@@ -2385,6 +3387,8 @@ var init_screen = __esm({
2385
3387
  clearScrollback() {
2386
3388
  this.lines = [];
2387
3389
  this.keyedBlocks.clear();
3390
+ this.wrapCache.clear();
3391
+ this.wrapCacheWidth = 0;
2388
3392
  this.streamingActive = false;
2389
3393
  this.scrollOffset = 0;
2390
3394
  this.repaint();
@@ -2407,7 +3411,10 @@ var init_screen = __esm({
2407
3411
  return;
2408
3412
  }
2409
3413
  const touchesEnd = existing.start + existing.count >= this.lines.length;
2410
- this.lines.splice(existing.start, existing.count);
3414
+ const removed = this.lines.splice(existing.start, existing.count);
3415
+ for (const line of removed) {
3416
+ this.forgetLine(line);
3417
+ }
2411
3418
  this.keyedBlocks.delete(key);
2412
3419
  for (const [, range] of this.keyedBlocks) {
2413
3420
  if (range.start > existing.start) {
@@ -2481,9 +3488,12 @@ var init_screen = __esm({
2481
3488
  if (last && last.body === "" && (last.prefix === void 0 || last.prefix === "")) {
2482
3489
  return;
2483
3490
  }
2484
- this.lines.push({ body: "" });
3491
+ const sep = { body: "" };
3492
+ this.lines.push(sep);
3493
+ this.trackLine(sep);
2485
3494
  this.streamingActive = false;
2486
3495
  this.adjustScrollForLineChange(1);
3496
+ this.trimScrollback();
2487
3497
  this.scheduleRepaint();
2488
3498
  }
2489
3499
  // The dispatcher is the source of truth for prompt state. If the prompt
@@ -2555,9 +3565,11 @@ var init_screen = __esm({
2555
3565
  return Math.max(0, bottom - top + 1);
2556
3566
  }
2557
3567
  maxScrollOffset() {
2558
- const wrapped = this.wrapLines(this.lines, this.term.width);
2559
- const visible = this.scrollbackVisibleRows();
2560
- return Math.max(0, wrapped.length - visible);
3568
+ const { rows } = this.wrapTail(
3569
+ this.term.width,
3570
+ Number.POSITIVE_INFINITY
3571
+ );
3572
+ return Math.max(0, rows.length - this.scrollbackVisibleRows());
2561
3573
  }
2562
3574
  // Used by content mutators to coalesce rapid updates. Repaints fire
2563
3575
  // at most once per contentRepaintThrottleMs; if a paint happened
@@ -2590,6 +3602,22 @@ var init_screen = __esm({
2590
3602
  this.repaint();
2591
3603
  }, this.contentRepaintThrottleMs - elapsed);
2592
3604
  }
3605
+ // Funnel for every row that any drawX method renders. Skips emitting
3606
+ // moveTo+eraseLineAfter+paint when the row's signature matches the
3607
+ // previous frame's. The signature must capture everything that affects
3608
+ // visible output for that row (width, FormattedLine fields, banner
3609
+ // state, etc.) so identical sigs guarantee identical bytes.
3610
+ paintRow(row, signature, paint) {
3611
+ if (row < 1 || row > this.term.height) {
3612
+ return;
3613
+ }
3614
+ if (this.lastFrameRows.get(row) === signature) {
3615
+ return;
3616
+ }
3617
+ this.lastFrameRows.set(row, signature);
3618
+ this.term.moveTo(1, row).eraseLineAfter();
3619
+ paint();
3620
+ }
2593
3621
  repaint() {
2594
3622
  if (this.repaintPaused > 0) {
2595
3623
  this.repaintPending = true;
@@ -2605,6 +3633,11 @@ var init_screen = __esm({
2605
3633
  if (w < 20 || h < 8) {
2606
3634
  return;
2607
3635
  }
3636
+ if (w !== this.lastFrameW || h !== this.lastFrameH) {
3637
+ this.lastFrameRows.clear();
3638
+ this.lastFrameW = w;
3639
+ this.lastFrameH = h;
3640
+ }
2608
3641
  this.drawHeader();
2609
3642
  this.drawSeparator(HEADER_ROWS);
2610
3643
  this.drawScrollback();
@@ -2620,22 +3653,40 @@ var init_screen = __esm({
2620
3653
  }
2621
3654
  drawHeader() {
2622
3655
  const w = this.term.width;
2623
- this.term.moveTo(1, 1).eraseLineAfter();
2624
3656
  const usage = formatUsage(this.header.usage);
2625
- const cwdRoom = Math.max(8, w - 40 - (usage ? usage.length + 3 : 0));
2626
- this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(shortId(this.header.sessionId));
2627
- if (usage) {
2628
- const col = Math.max(1, w - usage.length + 1);
2629
- this.term.moveTo(col, 1);
2630
- this.term.dim(usage);
2631
- }
3657
+ const sid = shortId(this.header.sessionId);
3658
+ const title = this.header.title?.trim();
3659
+ const sig = `hdr|${w}|${this.header.agent}|${this.header.cwd}|${sid}|${title ?? ""}|${usage ?? ""}`;
3660
+ this.paintRow(1, sig, () => {
3661
+ const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3662
+ const variableRoom = Math.max(8, w - fixed);
3663
+ let cwdRoom;
3664
+ let titleRoom;
3665
+ if (title) {
3666
+ const cwdMin = Math.min(this.header.cwd.length, 12);
3667
+ const titleCap = Math.max(8, variableRoom - cwdMin);
3668
+ titleRoom = Math.min(title.length, titleCap);
3669
+ cwdRoom = Math.max(8, variableRoom - titleRoom);
3670
+ } else {
3671
+ titleRoom = 0;
3672
+ cwdRoom = variableRoom;
3673
+ }
3674
+ this.term.bold("hydra")(" \xB7 ").cyan.noFormat(this.header.agent)(" \xB7 ").dim.noFormat(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
3675
+ if (title) {
3676
+ this.term(" \xB7 ").bold.noFormat(truncate(title, titleRoom));
3677
+ }
3678
+ if (usage) {
3679
+ const col = Math.max(1, w - usage.length + 1);
3680
+ this.term.moveTo(col, 1);
3681
+ this.term.dim(usage);
3682
+ }
3683
+ });
2632
3684
  }
2633
3685
  drawSeparator(row) {
2634
- if (row < 1 || row > this.term.height) {
2635
- return;
2636
- }
2637
- this.term.moveTo(1, row).eraseLineAfter();
2638
- this.term.dim("\u2500".repeat(this.term.width));
3686
+ const w = this.term.width;
3687
+ this.paintRow(row, `sep|${w}`, () => {
3688
+ this.term.dim("\u2500".repeat(w));
3689
+ });
2639
3690
  }
2640
3691
  drawScrollback() {
2641
3692
  const w = this.term.width;
@@ -2644,21 +3695,30 @@ var init_screen = __esm({
2644
3695
  if (visibleRows <= 0) {
2645
3696
  return;
2646
3697
  }
2647
- const wrapped = this.wrapLines(this.lines, w);
2648
- const max = Math.max(0, wrapped.length - visibleRows);
2649
- if (this.scrollOffset > max) {
2650
- this.scrollOffset = max;
3698
+ const { rows: wrapped, exhausted } = this.wrapTail(
3699
+ w,
3700
+ visibleRows + this.scrollOffset
3701
+ );
3702
+ if (exhausted) {
3703
+ const max = Math.max(0, wrapped.length - visibleRows);
3704
+ if (this.scrollOffset > max) {
3705
+ this.scrollOffset = max;
3706
+ }
2651
3707
  }
2652
3708
  const end = wrapped.length - this.scrollOffset;
2653
3709
  const start = Math.max(0, end - visibleRows);
2654
3710
  const slice = wrapped.slice(start, end);
3711
+ const padTop = Math.max(0, visibleRows - slice.length);
2655
3712
  for (let i = 0; i < visibleRows; i++) {
2656
3713
  const row = top + i;
2657
- this.term.moveTo(1, row).eraseLineAfter();
2658
- const line = slice[i];
2659
- if (line) {
2660
- this.writeFormattedLine(line, w);
2661
- }
3714
+ const sliceIdx = i - padTop;
3715
+ const line = sliceIdx >= 0 ? slice[sliceIdx] : void 0;
3716
+ const sig = formattedLineSig("sb", w, line);
3717
+ this.paintRow(row, sig, () => {
3718
+ if (line) {
3719
+ this.writeFormattedLine(line, w);
3720
+ }
3721
+ });
2662
3722
  }
2663
3723
  }
2664
3724
  queuedRows() {
@@ -2689,26 +3749,27 @@ var init_screen = __esm({
2689
3749
  }
2690
3750
  for (let i = 0; i < rows; i++) {
2691
3751
  const row = completionTop + i;
2692
- this.term.moveTo(1, row).eraseLineAfter();
2693
3752
  const item = this.completions[i];
2694
- if (!item) {
2695
- continue;
2696
- }
2697
3753
  const isLast = i === rows - 1 && this.completions.length > MAX_COMPLETION_ROWS;
2698
- if (isLast) {
2699
- this.term.dim(
2700
- ` + ${this.completions.length - MAX_COMPLETION_ROWS + 1} more match(es)`
2701
- );
2702
- continue;
2703
- }
2704
- const namePadded = item.name.padEnd(nameWidth);
2705
- const desc = item.description ?? "";
2706
- const remaining = w - namePadded.length - 4;
2707
- const truncated = remaining > 0 ? truncate(desc, remaining) : "";
2708
- this.term(" ").brightCyan(namePadded);
2709
- if (truncated.length > 0) {
2710
- this.term(" ").dim(truncated);
2711
- }
3754
+ const overflow = this.completions.length - MAX_COMPLETION_ROWS + 1;
3755
+ const sig = item ? isLast ? `comp|${w}|overflow|${overflow}` : `comp|${w}|${nameWidth}|${item.name}|${item.description ?? ""}` : `comp|${w}|empty`;
3756
+ this.paintRow(row, sig, () => {
3757
+ if (!item) {
3758
+ return;
3759
+ }
3760
+ if (isLast) {
3761
+ this.term.dim(` + ${overflow} more match(es)`);
3762
+ return;
3763
+ }
3764
+ const namePadded = item.name.padEnd(nameWidth);
3765
+ const desc = item.description ?? "";
3766
+ const remaining = w - namePadded.length - 4;
3767
+ const truncated = remaining > 0 ? truncate(desc, remaining) : "";
3768
+ this.term(" ").brightCyan(namePadded);
3769
+ if (truncated.length > 0) {
3770
+ this.term(" ").dim(truncated);
3771
+ }
3772
+ });
2712
3773
  }
2713
3774
  }
2714
3775
  drawQueuedZone() {
@@ -2723,17 +3784,19 @@ var init_screen = __esm({
2723
3784
  const queuedTop = queuedBottom - rows + 1;
2724
3785
  for (let i = 0; i < rows; i++) {
2725
3786
  const row = queuedTop + i;
2726
- this.term.moveTo(1, row).eraseLineAfter();
2727
3787
  const text = this.queuedTexts[i];
2728
- if (text === void 0) {
2729
- continue;
2730
- }
2731
3788
  const isLast = i === rows - 1 && this.queuedTexts.length > MAX_QUEUED_ROWS;
2732
3789
  const overflow = this.queuedTexts.length - MAX_QUEUED_ROWS;
2733
- const summary = isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
2734
- const display = ` \u23F3 ${summary}`;
2735
- const padded = display + " ".repeat(Math.max(0, w - display.length));
2736
- this.term.bgBlue.brightWhite.noFormat(padded);
3790
+ const summary = text === void 0 ? "" : isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
3791
+ const sig = text === void 0 ? `queued|${w}|empty` : `queued|${w}|${isLast ? "ovf" : "row"}|${summary}`;
3792
+ this.paintRow(row, sig, () => {
3793
+ if (text === void 0) {
3794
+ return;
3795
+ }
3796
+ const display = ` \u23F3 ${summary}`;
3797
+ const padded = display + " ".repeat(Math.max(0, w - display.length));
3798
+ this.term.bgBlue.brightWhite.noFormat(padded);
3799
+ });
2737
3800
  }
2738
3801
  }
2739
3802
  drawPrompt() {
@@ -2754,19 +3817,30 @@ var init_screen = __esm({
2754
3817
  for (let i = 0; i < layout.rendered; i++) {
2755
3818
  const vr = visualRows[layout.windowStart + i];
2756
3819
  const row = top + i;
2757
- this.term.moveTo(1, row).eraseLineAfter();
2758
- if (!vr) {
2759
- continue;
2760
- }
2761
- if (vr.bufferIdx === 0 && vr.startCol === 0) {
2762
- this.term.brightWhite("> ");
2763
- } else if (vr.startCol === 0) {
2764
- this.term.dim("\xB7 ");
2765
- } else {
2766
- this.term(" ");
3820
+ let gutter = "wrap";
3821
+ let slice = "";
3822
+ if (vr) {
3823
+ if (vr.bufferIdx === 0 && vr.startCol === 0) {
3824
+ gutter = "first";
3825
+ } else if (vr.startCol === 0) {
3826
+ gutter = "newline";
3827
+ }
3828
+ slice = (state.buffer[vr.bufferIdx] ?? "").slice(vr.startCol, vr.endCol);
2767
3829
  }
2768
- const line = state.buffer[vr.bufferIdx] ?? "";
2769
- this.term.noFormat(line.slice(vr.startCol, vr.endCol));
3830
+ const sig = vr ? `prompt|${this.term.width}|${gutter}|${slice}` : `prompt|${this.term.width}|empty`;
3831
+ this.paintRow(row, sig, () => {
3832
+ if (!vr) {
3833
+ return;
3834
+ }
3835
+ if (gutter === "first") {
3836
+ this.term.brightWhite("> ");
3837
+ } else if (gutter === "newline") {
3838
+ this.term.dim("\xB7 ");
3839
+ } else {
3840
+ this.term(" ");
3841
+ }
3842
+ this.term.noFormat(slice);
3843
+ });
2770
3844
  }
2771
3845
  }
2772
3846
  drawConfirmPrompt() {
@@ -2776,10 +3850,12 @@ var init_screen = __esm({
2776
3850
  }
2777
3851
  const w = this.term.width;
2778
3852
  const top = this.term.height - CONFIRM_PROMPT_ROWS - BANNER_ROWS + 1;
2779
- this.term.moveTo(1, top).eraseLineAfter();
2780
- this.term.brightYellow(` ? ${truncate(spec.question, w - 4)}`);
2781
- this.term.moveTo(1, top + 1).eraseLineAfter();
2782
- this.term.dim(` ${truncate(spec.hint, w - 2)}`);
3853
+ this.paintRow(top, `confirm|q|${w}|${spec.question}`, () => {
3854
+ this.term.brightYellow(` ? ${truncate(spec.question, w - 4)}`);
3855
+ });
3856
+ this.paintRow(top + 1, `confirm|h|${w}|${spec.hint}`, () => {
3857
+ this.term.dim(` ${truncate(spec.hint, w - 2)}`);
3858
+ });
2783
3859
  }
2784
3860
  drawPermissionPrompt() {
2785
3861
  const spec = this.permissionPrompt;
@@ -2790,21 +3866,20 @@ var init_screen = __esm({
2790
3866
  const rows = this.permissionRows();
2791
3867
  const top = this.term.height - rows - BANNER_ROWS + 1;
2792
3868
  let row = top;
2793
- const writeRow = (paint) => {
3869
+ const writeRow = (sig, paint) => {
2794
3870
  if (row >= top + rows) {
2795
3871
  return;
2796
3872
  }
2797
- this.term.moveTo(1, row).eraseLineAfter();
2798
- paint();
3873
+ this.paintRow(row, sig, paint);
2799
3874
  row += 1;
2800
3875
  };
2801
- writeRow(() => {
3876
+ writeRow(`perm|t|${w}|${spec.title}`, () => {
2802
3877
  this.term.brightYellow(` \u{1F512} ${truncate(spec.title, w - 5)}`);
2803
3878
  });
2804
- writeRow(() => {
3879
+ writeRow(`perm|sub|${w}`, () => {
2805
3880
  this.term.dim(" This action requires approval");
2806
3881
  });
2807
- writeRow(() => {
3882
+ writeRow(`perm|q|${w}`, () => {
2808
3883
  this.term(" Do you want to proceed?");
2809
3884
  });
2810
3885
  for (let i = 0; i < spec.options.length; i++) {
@@ -2818,7 +3893,7 @@ var init_screen = __esm({
2818
3893
  const isSel = i === spec.selectedIndex;
2819
3894
  const marker = isSel ? "\u276F" : " ";
2820
3895
  const body = ` ${marker} ${i + 1}. ${truncate(opt.label, w - 8)}`;
2821
- writeRow(() => {
3896
+ writeRow(`perm|o|${w}|${i}|${isSel ? "1" : "0"}|${opt.label}`, () => {
2822
3897
  if (isSel) {
2823
3898
  this.term.brightCyan(body);
2824
3899
  } else {
@@ -2826,36 +3901,42 @@ var init_screen = __esm({
2826
3901
  }
2827
3902
  });
2828
3903
  }
2829
- writeRow(() => {
3904
+ writeRow(`perm|hint|${w}`, () => {
2830
3905
  this.term.dim(" \u2191/\u2193 choose \xB7 Enter submit \xB7 Esc cancel \xB7 1\u20139 quick-pick");
2831
3906
  });
2832
3907
  }
2833
3908
  drawBanner() {
2834
3909
  const row = this.term.height;
2835
- this.term.moveTo(1, row).eraseLineAfter();
2836
- const dot = this.banner.status === "running" ? "\u25CF" : "\u25CB";
2837
- const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
2838
- if (this.banner.status === "running") {
2839
- this.term.brightYellow(`${dot} ${this.banner.status}`);
2840
- if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
2841
- this.term(" ").dim(formatElapsed(this.banner.elapsedMs));
3910
+ const w = this.term.width;
3911
+ const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
3912
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}`;
3913
+ this.paintRow(row, sig, () => {
3914
+ const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
3915
+ const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
3916
+ if (this.banner.status === "busy") {
3917
+ this.term.brightYellow(`${dot} ${this.banner.status}`);
3918
+ if (elapsedStr) {
3919
+ this.term(" ").dim(elapsedStr);
3920
+ }
3921
+ } else if (this.banner.status === "disconnected") {
3922
+ this.term.brightRed(`${dot} ${this.banner.status}`);
3923
+ } else {
3924
+ this.term.brightGreen(`${dot} ${this.banner.status}`);
2842
3925
  }
2843
- } else {
2844
- this.term.brightGreen(`${dot} ${this.banner.status}`);
2845
- }
2846
- if (this.banner.queued > 0) {
2847
- this.term(" \xB7 ").brightYellow(`${this.banner.queued} queued`);
2848
- }
2849
- if (this.scrollOffset > 0) {
2850
- this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
2851
- }
2852
- this.term(" \xB7 ");
2853
- if (this.banner.planMode) {
2854
- this.term.brightMagenta(planLabel);
2855
- } else {
2856
- this.term.dim(planLabel);
2857
- }
2858
- this.term(" \xB7 ").dim(this.banner.hint);
3926
+ if (this.banner.queued > 0) {
3927
+ this.term(" \xB7 ").brightYellow(`${this.banner.queued} queued`);
3928
+ }
3929
+ if (this.scrollOffset > 0) {
3930
+ this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
3931
+ }
3932
+ this.term(" \xB7 ");
3933
+ if (this.banner.planMode) {
3934
+ this.term.brightYellow(planLabel);
3935
+ } else {
3936
+ this.term.dim(planLabel);
3937
+ }
3938
+ this.term(" \xB7 ").dim(this.banner.hint);
3939
+ });
2859
3940
  }
2860
3941
  placeCursor() {
2861
3942
  if (this.permissionPrompt) {
@@ -2905,37 +3986,83 @@ var init_screen = __esm({
2905
3986
  4 + this.permissionPrompt.options.length
2906
3987
  );
2907
3988
  }
2908
- wrapLines(lines, width) {
3989
+ // Walk this.lines from the tail, accumulating wrapped rows via the
3990
+ // wrap cache, until we have at least `needed` rows or run out. Returns
3991
+ // the collected rows in original (top-down) order plus an `exhausted`
3992
+ // flag that's true iff we reached the head of this.lines. The hot path
3993
+ // (drawScrollback) only ever asks for `visibleRows + scrollOffset`
3994
+ // rows, so a 10k-line scrollback costs ~50 cache hits per repaint
3995
+ // instead of 10k. With `needed = Infinity` this walks everything and
3996
+ // doubles as a total-row counter for maxScrollOffset.
3997
+ wrapTail(width, needed) {
2909
3998
  if (width <= 4) {
2910
- return lines;
3999
+ const take = Math.min(needed, this.lines.length);
4000
+ return {
4001
+ rows: this.lines.slice(this.lines.length - take),
4002
+ exhausted: needed >= this.lines.length
4003
+ };
2911
4004
  }
2912
- const out = [];
2913
- for (const line of lines) {
2914
- const prefix = line.prefix ?? "";
2915
- const room = Math.max(1, width - prefix.length);
2916
- const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
2917
- for (let i = 0; i < chunks.length; i++) {
2918
- const chunk = chunks[i] ?? "";
2919
- const wrappedLine = {
2920
- prefix: i === 0 ? line.prefix : " ".repeat(prefix.length),
2921
- body: chunk
2922
- };
2923
- if (line.prefixStyle !== void 0) {
2924
- wrappedLine.prefixStyle = line.prefixStyle;
2925
- }
2926
- if (line.bodyStyle !== void 0) {
2927
- wrappedLine.bodyStyle = line.bodyStyle;
2928
- }
2929
- if (line.fillRow) {
2930
- wrappedLine.fillRow = true;
2931
- }
2932
- if (line.ansi) {
2933
- wrappedLine.ansi = true;
2934
- }
2935
- out.push(wrappedLine);
4005
+ if (this.wrapCacheWidth !== width) {
4006
+ this.wrapCache.clear();
4007
+ this.wrapCacheWidth = width;
4008
+ }
4009
+ if (needed <= 0 || this.lines.length === 0) {
4010
+ return { rows: [], exhausted: true };
4011
+ }
4012
+ const batches = [];
4013
+ let total = 0;
4014
+ let stoppedAt = 0;
4015
+ for (let i = this.lines.length - 1; i >= 0; i--) {
4016
+ const wrapped = this.wrapOne(this.lines[i], width);
4017
+ batches.push(wrapped);
4018
+ total += wrapped.length;
4019
+ stoppedAt = i;
4020
+ if (total >= needed) {
4021
+ break;
4022
+ }
4023
+ }
4024
+ const rows = [];
4025
+ for (let i = batches.length - 1; i >= 0; i--) {
4026
+ rows.push(...batches[i]);
4027
+ }
4028
+ return { rows, exhausted: stoppedAt === 0 };
4029
+ }
4030
+ wrapOne(line, width) {
4031
+ const id = this.lineIds.get(line);
4032
+ if (id !== void 0) {
4033
+ const cached = this.wrapCache.get(id);
4034
+ if (cached) {
4035
+ return cached;
4036
+ }
4037
+ }
4038
+ const prefix = line.prefix ?? "";
4039
+ const room = Math.max(1, width - prefix.length);
4040
+ const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
4041
+ const wrapped = [];
4042
+ for (let i = 0; i < chunks.length; i++) {
4043
+ const chunk = chunks[i] ?? "";
4044
+ const wrappedLine = {
4045
+ prefix: i === 0 ? line.prefix : " ".repeat(prefix.length),
4046
+ body: chunk
4047
+ };
4048
+ if (line.prefixStyle !== void 0) {
4049
+ wrappedLine.prefixStyle = line.prefixStyle;
4050
+ }
4051
+ if (line.bodyStyle !== void 0) {
4052
+ wrappedLine.bodyStyle = line.bodyStyle;
4053
+ }
4054
+ if (line.fillRow) {
4055
+ wrappedLine.fillRow = true;
2936
4056
  }
4057
+ if (line.ansi) {
4058
+ wrappedLine.ansi = true;
4059
+ }
4060
+ wrapped.push(wrappedLine);
4061
+ }
4062
+ if (id !== void 0) {
4063
+ this.wrapCache.set(id, wrapped);
2937
4064
  }
2938
- return out;
4065
+ return wrapped;
2939
4066
  }
2940
4067
  writeFormattedLine(line, width) {
2941
4068
  if (line.prefix) {
@@ -2974,6 +4101,10 @@ var init_input = __esm({
2974
4101
  savedDraft = null;
2975
4102
  history = [];
2976
4103
  turnRunning = false;
4104
+ // Single-slot kill ring. The most recent killed text (^U, ^K, ^W) lands
4105
+ // here so ^Y can yank it back. Standard readline keeps a stack; we
4106
+ // only keep one slot because that's what 99% of yank uses look like.
4107
+ killBuffer = "";
2977
4108
  constructor(opts = {}) {
2978
4109
  this.history = [...opts.history ?? []];
2979
4110
  this.planMode = opts.planMode ?? false;
@@ -3082,6 +4213,9 @@ var init_input = __esm({
3082
4213
  case "ctrl-w":
3083
4214
  this.killWord();
3084
4215
  return [];
4216
+ case "ctrl-y":
4217
+ this.yank();
4218
+ return [];
3085
4219
  case "escape":
3086
4220
  return [];
3087
4221
  }
@@ -3175,11 +4309,19 @@ var init_input = __esm({
3175
4309
  }
3176
4310
  killLine() {
3177
4311
  const line = this.currentLine();
4312
+ const killed = line.slice(0, this.col);
4313
+ if (killed.length > 0) {
4314
+ this.killBuffer = killed;
4315
+ }
3178
4316
  this.setCurrentLine(line.slice(this.col));
3179
4317
  this.col = 0;
3180
4318
  }
3181
4319
  killToEnd() {
3182
4320
  const line = this.currentLine();
4321
+ const killed = line.slice(this.col);
4322
+ if (killed.length > 0) {
4323
+ this.killBuffer = killed;
4324
+ }
3183
4325
  this.setCurrentLine(line.slice(0, this.col));
3184
4326
  }
3185
4327
  killWord() {
@@ -3195,9 +4337,19 @@ var init_input = __esm({
3195
4337
  while (i > 0 && !/\s/.test(line[i - 1] ?? "")) {
3196
4338
  i -= 1;
3197
4339
  }
4340
+ const killed = line.slice(i, this.col);
4341
+ if (killed.length > 0) {
4342
+ this.killBuffer = killed;
4343
+ }
3198
4344
  this.setCurrentLine(line.slice(0, i) + line.slice(this.col));
3199
4345
  this.col = i;
3200
4346
  }
4347
+ yank() {
4348
+ if (this.killBuffer.length === 0) {
4349
+ return;
4350
+ }
4351
+ this.insertText(this.killBuffer);
4352
+ }
3201
4353
  moveLeft() {
3202
4354
  if (this.col > 0) {
3203
4355
  this.col -= 1;
@@ -3340,10 +4492,19 @@ function mapUpdate(update) {
3340
4492
  return mapUsage(u);
3341
4493
  case "available_commands_update":
3342
4494
  return mapAvailableCommands(u);
4495
+ case "session_info_update":
4496
+ return mapSessionInfo(u);
3343
4497
  default:
3344
4498
  return { kind: "unknown", sessionUpdate: tag, raw: update };
3345
4499
  }
3346
4500
  }
4501
+ function mapSessionInfo(u) {
4502
+ const title = readString(u, "title");
4503
+ if (title === void 0) {
4504
+ return null;
4505
+ }
4506
+ return { kind: "session-info", title };
4507
+ }
3347
4508
  function mapAvailableCommands(u) {
3348
4509
  const list = u.availableCommands ?? u.commands;
3349
4510
  if (!Array.isArray(list)) {
@@ -3584,6 +4745,8 @@ function formatEvent(event) {
3584
4745
  return [];
3585
4746
  case "available-commands":
3586
4747
  return [];
4748
+ case "session-info":
4749
+ return [];
3587
4750
  case "unknown":
3588
4751
  return [];
3589
4752
  }
@@ -3849,8 +5012,8 @@ var init_format = __esm({
3849
5012
  });
3850
5013
 
3851
5014
  // src/tui/app.ts
3852
- import WebSocket2 from "ws";
3853
- import { once } from "events";
5015
+ import { appendFileSync, statSync, renameSync } from "fs";
5016
+ import { nanoid as nanoid3 } from "nanoid";
3854
5017
  import termkit from "terminal-kit";
3855
5018
  async function runTuiApp(opts) {
3856
5019
  const config = await ensureConfig();
@@ -3867,9 +5030,33 @@ async function runSession(term, config, opts) {
3867
5030
  term.grabInput(false);
3868
5031
  process.exit(0);
3869
5032
  }
3870
- const ws = await openWs2(config);
3871
- const stream = wsToMessageStream(ws);
5033
+ const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5034
+ term.cyan(launchLabel)("\n");
5035
+ const protocol = config.daemon.tls ? "wss" : "ws";
5036
+ const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
5037
+ const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
5038
+ let onReconnect = null;
5039
+ let onDisconnectHook = null;
5040
+ const stream = new ResilientWsStream({
5041
+ url: wsUrl,
5042
+ subprotocols,
5043
+ onConnect: async (firstConnect) => {
5044
+ if (firstConnect) {
5045
+ return;
5046
+ }
5047
+ if (onReconnect) {
5048
+ await onReconnect();
5049
+ }
5050
+ },
5051
+ onDisconnect: (err) => {
5052
+ if (onDisconnectHook) {
5053
+ onDisconnectHook(err);
5054
+ }
5055
+ },
5056
+ log: () => void 0
5057
+ });
3872
5058
  const conn = new JsonRpcConnection(stream);
5059
+ await stream.start();
3873
5060
  let bufferedEvents = [];
3874
5061
  let applyRenderEvent = null;
3875
5062
  const appendRender = (event) => {
@@ -3891,8 +5078,9 @@ async function runSession(term, config, opts) {
3891
5078
  const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
3892
5079
  if (before === 0 && pendingTurns > 0) {
3893
5080
  sessionBusySince = Date.now();
5081
+ dispatcherRef?.setTurnRunning(true);
3894
5082
  if (screenReady) {
3895
- screenRef.setBanner({ status: "running", elapsedMs: 0 });
5083
+ screenRef.setBanner({ status: "busy", elapsedMs: 0 });
3896
5084
  }
3897
5085
  if (sessionElapsedTimer === null && screenReady) {
3898
5086
  sessionElapsedTimer = setInterval(() => {
@@ -3901,10 +5089,11 @@ async function runSession(term, config, opts) {
3901
5089
  }
3902
5090
  screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
3903
5091
  renderToolsBlock();
3904
- }, 5e3);
5092
+ }, 1e3);
3905
5093
  }
3906
5094
  } else if (before > 0 && pendingTurns === 0) {
3907
5095
  sessionBusySince = null;
5096
+ dispatcherRef?.setTurnRunning(false);
3908
5097
  if (sessionElapsedTimer !== null) {
3909
5098
  clearInterval(sessionElapsedTimer);
3910
5099
  sessionElapsedTimer = null;
@@ -3918,9 +5107,11 @@ async function runSession(term, config, opts) {
3918
5107
  }
3919
5108
  };
3920
5109
  let screenRef = null;
5110
+ let dispatcherRef = null;
3921
5111
  conn.onNotification("session/update", (params) => {
3922
5112
  const { update } = params ?? {};
3923
5113
  const event = mapUpdate(update);
5114
+ debugLogUpdate(update, event);
3924
5115
  if (event?.kind === "user-text") {
3925
5116
  adjustPendingTurns(1);
3926
5117
  } else if (event?.kind === "turn-complete") {
@@ -4033,6 +5224,11 @@ async function runSession(term, config, opts) {
4033
5224
  let resolvedSessionId = ctx.sessionId;
4034
5225
  let resolvedAgentId = ctx.agentId;
4035
5226
  let resolvedCwd = ctx.cwd;
5227
+ let resolvedTitle;
5228
+ let initialModel;
5229
+ let initialMode;
5230
+ let initialCommands;
5231
+ let initialTurnStartedAt;
4036
5232
  if (ctx.sessionId === "__new__") {
4037
5233
  const created = await conn.request("session/new", {
4038
5234
  cwd: ctx.cwd,
@@ -4048,6 +5244,17 @@ async function runSession(term, config, opts) {
4048
5244
  if (hydraMeta.cwd) {
4049
5245
  resolvedCwd = hydraMeta.cwd;
4050
5246
  }
5247
+ if (hydraMeta.name) {
5248
+ resolvedTitle = hydraMeta.name;
5249
+ }
5250
+ initialModel = hydraMeta.currentModel;
5251
+ initialMode = hydraMeta.currentMode;
5252
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
5253
+ if (hydraMeta.availableCommands) {
5254
+ initialCommands = hydraMeta.availableCommands.map(
5255
+ (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
5256
+ );
5257
+ }
4051
5258
  } else {
4052
5259
  const attached = await conn.request("session/attach", {
4053
5260
  sessionId: ctx.sessionId,
@@ -4063,16 +5270,31 @@ async function runSession(term, config, opts) {
4063
5270
  if (hydraMeta.cwd) {
4064
5271
  resolvedCwd = hydraMeta.cwd;
4065
5272
  }
5273
+ if (hydraMeta.name) {
5274
+ resolvedTitle = hydraMeta.name;
5275
+ }
5276
+ initialModel = hydraMeta.currentModel;
5277
+ initialMode = hydraMeta.currentMode;
5278
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
5279
+ if (hydraMeta.availableCommands) {
5280
+ initialCommands = hydraMeta.availableCommands.map(
5281
+ (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
5282
+ );
5283
+ }
4066
5284
  }
4067
- void upstreamSessionId;
4068
- const historyFile = paths.tuiHistoryFile();
5285
+ const historyFile = paths.tuiHistoryFile(resolvedSessionId);
4069
5286
  let history = await loadHistory(historyFile).catch(() => []);
4070
5287
  const dispatcher = new InputDispatcher({ history });
5288
+ dispatcherRef = dispatcher;
5289
+ if (pendingTurns > 0) {
5290
+ dispatcher.setTurnRunning(true);
5291
+ }
4071
5292
  let turnInFlight = null;
4072
5293
  const screen = new Screen({
4073
5294
  term,
4074
5295
  dispatcher,
4075
5296
  repaintThrottleMs: config.tui.repaintThrottleMs,
5297
+ maxScrollbackLines: config.tui.maxScrollbackLines,
4076
5298
  onKey: (events) => {
4077
5299
  for (const ev of events) {
4078
5300
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -4102,7 +5324,7 @@ async function runSession(term, config, opts) {
4102
5324
  { name: "/demo-plan", description: "Inject synthetic plan events (UI test)" },
4103
5325
  { name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
4104
5326
  ];
4105
- let agentCommands = [];
5327
+ let agentCommands = initialCommands ?? [];
4106
5328
  const allCommands = () => {
4107
5329
  const seen = /* @__PURE__ */ new Set();
4108
5330
  const out = [];
@@ -4226,17 +5448,31 @@ async function runSession(term, config, opts) {
4226
5448
  screen.setHeader({
4227
5449
  agent: headerName,
4228
5450
  cwd: resolvedCwd,
4229
- sessionId: resolvedSessionId
5451
+ sessionId: resolvedSessionId,
5452
+ title: resolvedTitle
4230
5453
  });
5454
+ if (initialMode) {
5455
+ screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
5456
+ }
5457
+ if (initialModel) {
5458
+ screen.appendLines(formatEvent({ kind: "model-changed", model: initialModel }));
5459
+ }
4231
5460
  let finishSession = null;
4232
5461
  const sessionDone = new Promise((resolve2) => {
4233
5462
  finishSession = resolve2;
4234
5463
  });
5464
+ const cancelRemoteTurn = () => {
5465
+ conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
5466
+ };
4235
5467
  const sigintHandler = () => {
4236
5468
  if (turnInFlight) {
4237
5469
  turnInFlight.cancel();
4238
5470
  return;
4239
5471
  }
5472
+ if (pendingTurns > 0) {
5473
+ cancelRemoteTurn();
5474
+ return;
5475
+ }
4240
5476
  void requestExit();
4241
5477
  };
4242
5478
  let exitConfirmation = null;
@@ -4311,12 +5547,10 @@ async function runSession(term, config, opts) {
4311
5547
  };
4312
5548
  const teardown = () => {
4313
5549
  process.off("SIGINT", sigintHandler);
5550
+ screen.clearWindowTitle();
4314
5551
  screen.stop();
4315
5552
  saveHistory(historyFile, history).catch(() => void 0);
4316
- try {
4317
- ws.close();
4318
- } catch {
4319
- }
5553
+ void stream.close().catch(() => void 0);
4320
5554
  };
4321
5555
  const stop = (code = 0) => {
4322
5556
  teardown();
@@ -4329,22 +5563,32 @@ async function runSession(term, config, opts) {
4329
5563
  }
4330
5564
  };
4331
5565
  const switchSession = async () => {
4332
- const resume = finishSession;
4333
- if (!resume) {
5566
+ if (!finishSession) {
4334
5567
  return;
4335
5568
  }
4336
- finishSession = null;
4337
- teardown();
5569
+ const pendingDraft = dispatcher.state().buffer.join("\n");
5570
+ if (pendingDraft.replace(/\s+$/, "").length > 0) {
5571
+ history = appendEntry(history, pendingDraft);
5572
+ dispatcher.setHistory(history);
5573
+ }
5574
+ screen.pauseRepaint();
5575
+ screen.stop();
5576
+ saveHistory(historyFile, history).catch(() => void 0);
4338
5577
  const sessions = await listSessions(config);
4339
5578
  const choice = await pickSession(term, {
4340
5579
  cwd: resolvedCwd,
4341
5580
  sessions,
4342
- coldLimit: config.sessionListColdLimit
5581
+ config
4343
5582
  });
4344
5583
  if (choice.kind === "abort") {
4345
- resume({ ...opts, sessionId: resolvedSessionId, cwd: resolvedCwd });
5584
+ screen.start();
5585
+ screen.resumeRepaint();
4346
5586
  return;
4347
5587
  }
5588
+ const resume = finishSession;
5589
+ finishSession = null;
5590
+ process.off("SIGINT", sigintHandler);
5591
+ void stream.close().catch(() => void 0);
4348
5592
  if (choice.kind === "new") {
4349
5593
  const { sessionId: _drop, ...rest } = opts;
4350
5594
  void _drop;
@@ -4369,6 +5613,8 @@ async function runSession(term, config, opts) {
4369
5613
  case "cancel":
4370
5614
  if (turnInFlight) {
4371
5615
  turnInFlight.cancel();
5616
+ } else if (pendingTurns > 0) {
5617
+ cancelRemoteTurn();
4372
5618
  }
4373
5619
  if (promptQueue.length > (workerActive ? 1 : 0)) {
4374
5620
  promptQueue.length = workerActive ? 1 : 0;
@@ -4556,7 +5802,6 @@ async function runSession(term, config, opts) {
4556
5802
  const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
4557
5803
  adjustPendingTurns(1);
4558
5804
  appendRender({ kind: "user-text", text });
4559
- dispatcher.setTurnRunning(true);
4560
5805
  let cancelled = false;
4561
5806
  turnInFlight = {
4562
5807
  cancel: () => {
@@ -4586,7 +5831,6 @@ async function runSession(term, config, opts) {
4586
5831
  });
4587
5832
  } finally {
4588
5833
  turnInFlight = null;
4589
- dispatcher.setTurnRunning(false);
4590
5834
  adjustPendingTurns(-1);
4591
5835
  appendRender(
4592
5836
  stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
@@ -4642,7 +5886,7 @@ async function runSession(term, config, opts) {
4642
5886
  const elapsed = end - toolsBlockStartedAt;
4643
5887
  let summary;
4644
5888
  if (total === 0) {
4645
- summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `no tools \xB7 took ${formatElapsed(elapsed)}`;
5889
+ summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
4646
5890
  } else {
4647
5891
  const noun = total === 1 ? "tool" : "tools";
4648
5892
  const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
@@ -4709,6 +5953,12 @@ async function runSession(term, config, opts) {
4709
5953
  refreshCompletions();
4710
5954
  return;
4711
5955
  }
5956
+ if (event.kind === "session-info") {
5957
+ if (event.title !== void 0) {
5958
+ screen.setHeader({ title: event.title });
5959
+ }
5960
+ return;
5961
+ }
4712
5962
  if (event.kind === "usage-update") {
4713
5963
  let changed = false;
4714
5964
  if (event.used !== void 0 && usage.used !== event.used) {
@@ -4739,6 +5989,12 @@ async function runSession(term, config, opts) {
4739
5989
  if (formatted2.length > 0) {
4740
5990
  screen.appendLines(formatted2);
4741
5991
  }
5992
+ screen.clearKey("tools");
5993
+ screen.clearKey("plan");
5994
+ toolStates.clear();
5995
+ toolCallOrder.length = 0;
5996
+ toolsExpanded = false;
5997
+ toolsBlockEndedAt = null;
4742
5998
  startToolsBlock();
4743
5999
  screen.redraw();
4744
6000
  return;
@@ -4779,12 +6035,10 @@ async function runSession(term, config, opts) {
4779
6035
  if (event.kind === "turn-complete") {
4780
6036
  closeAgentText();
4781
6037
  screen.clearKey("plan");
4782
- if (toolCallOrder.length > 0) {
6038
+ if (toolsBlockStartedAt !== null) {
4783
6039
  toolsBlockEndedAt = Date.now();
4784
6040
  renderToolsBlock();
4785
6041
  screen.clearKey("tools");
4786
- } else if (toolsBlockStartedAt !== null) {
4787
- screen.removeBlock("tools");
4788
6042
  }
4789
6043
  toolStates.clear();
4790
6044
  toolCallOrder.length = 0;
@@ -4804,6 +6058,108 @@ async function runSession(term, config, opts) {
4804
6058
  } finally {
4805
6059
  screen.resumeRepaint();
4806
6060
  }
6061
+ if (initialTurnStartedAt !== void 0 && pendingTurns > 0) {
6062
+ sessionBusySince = initialTurnStartedAt;
6063
+ screen.setBanner({
6064
+ status: "busy",
6065
+ elapsedMs: Date.now() - initialTurnStartedAt
6066
+ });
6067
+ if (sessionElapsedTimer === null) {
6068
+ sessionElapsedTimer = setInterval(() => {
6069
+ if (sessionBusySince === null || screenRef === null) {
6070
+ return;
6071
+ }
6072
+ screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
6073
+ renderToolsBlock();
6074
+ }, 1e3);
6075
+ }
6076
+ startToolsBlock();
6077
+ }
6078
+ const resetInFlightUiState = () => {
6079
+ if (pendingPermission) {
6080
+ const resolve2 = pendingPermission.resolve;
6081
+ pendingPermission = null;
6082
+ screen.setPermissionPrompt(null);
6083
+ resolve2({ outcome: { outcome: "cancelled" } });
6084
+ }
6085
+ closeAgentText();
6086
+ if (toolsBlockStartedAt !== null) {
6087
+ toolsBlockEndedAt = Date.now();
6088
+ renderToolsBlock();
6089
+ screen.clearKey("tools");
6090
+ toolStates.clear();
6091
+ toolCallOrder.length = 0;
6092
+ toolsBlockStartedAt = null;
6093
+ toolsBlockEndedAt = null;
6094
+ toolsExpanded = false;
6095
+ }
6096
+ screen.clearKey("plan");
6097
+ if (pendingTurns > 0) {
6098
+ adjustPendingTurns(-pendingTurns);
6099
+ }
6100
+ };
6101
+ onDisconnectHook = () => {
6102
+ screen.setBanner({ status: "disconnected", elapsedMs: void 0 });
6103
+ };
6104
+ onReconnect = async () => {
6105
+ resetInFlightUiState();
6106
+ const initReq = {
6107
+ jsonrpc: "2.0",
6108
+ id: `tui-reinit-${nanoid3()}`,
6109
+ method: "initialize",
6110
+ params: {
6111
+ protocolVersion: 1,
6112
+ clientCapabilities: {
6113
+ fs: { readTextFile: false, writeTextFile: false },
6114
+ terminal: false
6115
+ },
6116
+ clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
6117
+ }
6118
+ };
6119
+ try {
6120
+ await stream.request(initReq);
6121
+ } catch {
6122
+ }
6123
+ const attachReq = {
6124
+ jsonrpc: "2.0",
6125
+ id: `tui-reattach-${nanoid3()}`,
6126
+ method: "session/attach",
6127
+ params: {
6128
+ sessionId: resolvedSessionId,
6129
+ historyPolicy: "none",
6130
+ clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
6131
+ ...upstreamSessionId !== void 0 ? {
6132
+ _meta: {
6133
+ [HYDRA_META_KEY]: {
6134
+ resume: {
6135
+ upstreamSessionId,
6136
+ agentId: resolvedAgentId,
6137
+ cwd: resolvedCwd
6138
+ }
6139
+ }
6140
+ }
6141
+ } : {}
6142
+ }
6143
+ };
6144
+ try {
6145
+ const resp = await stream.request(attachReq);
6146
+ if (resp.error) {
6147
+ throw new Error(resp.error.message);
6148
+ }
6149
+ } catch (err) {
6150
+ screen.appendLines([
6151
+ {
6152
+ prefix: " ",
6153
+ body: `reattach failed: ${err.message}`,
6154
+ bodyStyle: "tool-status-fail"
6155
+ }
6156
+ ]);
6157
+ }
6158
+ screen.setBanner({
6159
+ status: pendingTurns > 0 ? "busy" : "ready",
6160
+ elapsedMs: pendingTurns > 0 ? 0 : void 0
6161
+ });
6162
+ };
4807
6163
  conn.onClose((err) => {
4808
6164
  if (err) {
4809
6165
  term.red(`
@@ -4848,7 +6204,7 @@ async function resolveSession(term, config, opts) {
4848
6204
  const choice = await pickSession(term, {
4849
6205
  cwd,
4850
6206
  sessions,
4851
- coldLimit: config.sessionListColdLimit
6207
+ config
4852
6208
  });
4853
6209
  if (choice.kind === "abort") {
4854
6210
  return null;
@@ -4869,23 +6225,47 @@ function newCtx(opts, cwd, config) {
4869
6225
  cwd
4870
6226
  };
4871
6227
  }
4872
- async function openWs2(config) {
4873
- const protocol = config.daemon.tls ? "wss" : "ws";
4874
- const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4875
- const ws = new WebSocket2(url, [
4876
- "acp.v1",
4877
- `hydra-acp-token.${config.daemon.authToken}`
4878
- ]);
4879
- await once(ws, "open");
4880
- return ws;
6228
+ function debugLogUpdate(update, event) {
6229
+ writeDebugLine({
6230
+ src: "session/update",
6231
+ update,
6232
+ event: event === null ? null : { kind: event.kind }
6233
+ });
6234
+ }
6235
+ function writeDebugLine(payload) {
6236
+ const override = process.env.HYDRA_TUI_DEBUG_LOG;
6237
+ const target = override === void 0 ? paths.tuiLogFile() : override;
6238
+ if (target.length === 0) {
6239
+ return;
6240
+ }
6241
+ try {
6242
+ rotateIfBig(target);
6243
+ const line = JSON.stringify({
6244
+ t: (/* @__PURE__ */ new Date()).toISOString(),
6245
+ ...payload
6246
+ });
6247
+ appendFileSync(target, `${line}
6248
+ `);
6249
+ } catch {
6250
+ }
6251
+ }
6252
+ function rotateIfBig(target) {
6253
+ try {
6254
+ const stat3 = statSync(target);
6255
+ if (stat3.size < TUI_LOG_MAX_BYTES) {
6256
+ return;
6257
+ }
6258
+ renameSync(target, `${target}.0`);
6259
+ } catch {
6260
+ }
4881
6261
  }
4882
- var PLAN_PREFIX_TEXT;
6262
+ var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
4883
6263
  var init_app = __esm({
4884
6264
  "src/tui/app.ts"() {
4885
6265
  "use strict";
4886
6266
  init_connection();
4887
- init_ws_stream();
4888
6267
  init_types();
6268
+ init_resilient_ws();
4889
6269
  init_config();
4890
6270
  init_daemon_bootstrap();
4891
6271
  init_paths();
@@ -4897,6 +6277,7 @@ var init_app = __esm({
4897
6277
  init_render_update();
4898
6278
  init_format();
4899
6279
  PLAN_PREFIX_TEXT = "Plan mode is on. Outline what you would do without making any changes. Do not edit files, run shell commands, or otherwise execute side effects; produce a plan only.";
6280
+ TUI_LOG_MAX_BYTES = 5 * 1024 * 1024;
4900
6281
  }
4901
6282
  });
4902
6283
 
@@ -5013,7 +6394,7 @@ import { setTimeout as sleep2 } from "timers/promises";
5013
6394
 
5014
6395
  // src/daemon/server.ts
5015
6396
  init_config();
5016
- import * as fs6 from "fs";
6397
+ import * as fs8 from "fs";
5017
6398
  import * as fsp2 from "fs/promises";
5018
6399
  import Fastify from "fastify";
5019
6400
  import websocketPlugin from "@fastify/websocket";
@@ -5185,6 +6566,9 @@ function planSpawn(agent, extraArgs = []) {
5185
6566
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
5186
6567
  }
5187
6568
 
6569
+ // src/core/session-manager.ts
6570
+ import * as fs6 from "fs/promises";
6571
+
5188
6572
  // src/core/agent-instance.ts
5189
6573
  import { spawn } from "child_process";
5190
6574
 
@@ -5330,6 +6714,10 @@ init_paths();
5330
6714
  import * as fs4 from "fs/promises";
5331
6715
  import * as path2 from "path";
5332
6716
  import { z as z4 } from "zod";
6717
+ var PersistedAgentCommand = z4.object({
6718
+ name: z4.string(),
6719
+ description: z4.string().optional()
6720
+ });
5333
6721
  var SessionRecord = z4.object({
5334
6722
  version: z4.literal(1),
5335
6723
  sessionId: z4.string(),
@@ -5338,6 +6726,13 @@ var SessionRecord = z4.object({
5338
6726
  cwd: z4.string(),
5339
6727
  title: z4.string().optional(),
5340
6728
  agentArgs: z4.array(z4.string()).optional(),
6729
+ // Snapshot of "what is currently true about this session" carried in
6730
+ // meta.json so a late-attaching or cold-resurrected client can be
6731
+ // told via the attach response _meta without depending on history
6732
+ // replay of a snapshot-shaped notification.
6733
+ currentModel: z4.string().optional(),
6734
+ currentMode: z4.string().optional(),
6735
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
5341
6736
  createdAt: z4.string(),
5342
6737
  updatedAt: z4.string()
5343
6738
  });
@@ -5350,7 +6745,7 @@ function assertSafeId(id) {
5350
6745
  var SessionStore = class {
5351
6746
  async write(record) {
5352
6747
  assertSafeId(record.sessionId);
5353
- await fs4.mkdir(paths.sessionsDir(), { recursive: true });
6748
+ await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
5354
6749
  const full = { version: 1, ...record };
5355
6750
  await fs4.writeFile(
5356
6751
  paths.sessionFile(record.sessionId),
@@ -5383,10 +6778,18 @@ var SessionStore = class {
5383
6778
  return;
5384
6779
  }
5385
6780
  try {
5386
- await fs4.unlink(paths.sessionFile(sessionId));
6781
+ await fs4.unlink(paths.sessionFile(sessionId));
6782
+ } catch (err) {
6783
+ const e = err;
6784
+ if (e.code !== "ENOENT") {
6785
+ throw err;
6786
+ }
6787
+ }
6788
+ try {
6789
+ await fs4.rmdir(paths.sessionDir(sessionId));
5387
6790
  } catch (err) {
5388
6791
  const e = err;
5389
- if (e.code !== "ENOENT") {
6792
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
5390
6793
  throw err;
5391
6794
  }
5392
6795
  }
@@ -5404,11 +6807,7 @@ var SessionStore = class {
5404
6807
  }
5405
6808
  const records = [];
5406
6809
  for (const entry of entries) {
5407
- if (!entry.endsWith(".json")) {
5408
- continue;
5409
- }
5410
- const id = entry.slice(0, -".json".length);
5411
- const record = await this.read(id);
6810
+ const record = await this.read(entry);
5412
6811
  if (record) {
5413
6812
  records.push(record);
5414
6813
  }
@@ -5425,18 +6824,146 @@ function recordFromMemorySession(args) {
5425
6824
  cwd: args.cwd,
5426
6825
  title: args.title,
5427
6826
  agentArgs: args.agentArgs,
6827
+ currentModel: args.currentModel,
6828
+ currentMode: args.currentMode,
6829
+ agentCommands: args.agentCommands,
5428
6830
  createdAt: args.createdAt ?? now,
5429
6831
  updatedAt: args.updatedAt ?? now
5430
6832
  };
5431
6833
  }
5432
6834
 
6835
+ // src/core/history-store.ts
6836
+ init_paths();
6837
+ import * as fs5 from "fs/promises";
6838
+ var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
6839
+ var MAX_ENTRIES = 1e3;
6840
+ var HistoryStore = class {
6841
+ // Serialize writes per session id so appends and rewrites don't
6842
+ // interleave JSONL lines on disk. The chain swallows errors so one
6843
+ // failed append doesn't poison every subsequent write.
6844
+ writeQueues = /* @__PURE__ */ new Map();
6845
+ async append(sessionId, entry) {
6846
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6847
+ return;
6848
+ }
6849
+ return this.enqueue(sessionId, async () => {
6850
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
6851
+ const line = JSON.stringify(entry) + "\n";
6852
+ await fs5.appendFile(paths.historyFile(sessionId), line, {
6853
+ encoding: "utf8",
6854
+ mode: 384
6855
+ });
6856
+ });
6857
+ }
6858
+ async rewrite(sessionId, entries) {
6859
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6860
+ return;
6861
+ }
6862
+ return this.enqueue(sessionId, async () => {
6863
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
6864
+ const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
6865
+ await fs5.writeFile(paths.historyFile(sessionId), body, {
6866
+ encoding: "utf8",
6867
+ mode: 384
6868
+ });
6869
+ });
6870
+ }
6871
+ async load(sessionId) {
6872
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6873
+ return [];
6874
+ }
6875
+ const pending = this.writeQueues.get(sessionId);
6876
+ if (pending) {
6877
+ await pending;
6878
+ }
6879
+ let raw;
6880
+ try {
6881
+ raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
6882
+ } catch (err) {
6883
+ const e = err;
6884
+ if (e.code === "ENOENT") {
6885
+ return [];
6886
+ }
6887
+ throw err;
6888
+ }
6889
+ const out = [];
6890
+ for (const line of raw.split("\n")) {
6891
+ if (line.length === 0) {
6892
+ continue;
6893
+ }
6894
+ let parsed;
6895
+ try {
6896
+ parsed = JSON.parse(line);
6897
+ } catch {
6898
+ continue;
6899
+ }
6900
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
6901
+ continue;
6902
+ }
6903
+ const obj = parsed;
6904
+ if (typeof obj.method !== "string") {
6905
+ continue;
6906
+ }
6907
+ if (typeof obj.recordedAt !== "number") {
6908
+ continue;
6909
+ }
6910
+ out.push({
6911
+ method: obj.method,
6912
+ params: obj.params,
6913
+ recordedAt: obj.recordedAt
6914
+ });
6915
+ }
6916
+ if (out.length > MAX_ENTRIES) {
6917
+ return out.slice(-MAX_ENTRIES);
6918
+ }
6919
+ return out;
6920
+ }
6921
+ async delete(sessionId) {
6922
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
6923
+ return;
6924
+ }
6925
+ return this.enqueue(sessionId, async () => {
6926
+ try {
6927
+ await fs5.unlink(paths.historyFile(sessionId));
6928
+ } catch (err) {
6929
+ const e = err;
6930
+ if (e.code !== "ENOENT") {
6931
+ throw err;
6932
+ }
6933
+ }
6934
+ try {
6935
+ await fs5.rmdir(paths.sessionDir(sessionId));
6936
+ } catch (err) {
6937
+ const e = err;
6938
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
6939
+ throw err;
6940
+ }
6941
+ }
6942
+ });
6943
+ }
6944
+ enqueue(sessionId, task) {
6945
+ const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
6946
+ const task$ = prev.then(task, task);
6947
+ const settled = task$.catch(() => void 0);
6948
+ this.writeQueues.set(sessionId, settled);
6949
+ void settled.finally(() => {
6950
+ if (this.writeQueues.get(sessionId) === settled) {
6951
+ this.writeQueues.delete(sessionId);
6952
+ }
6953
+ });
6954
+ return task$;
6955
+ }
6956
+ };
6957
+
5433
6958
  // src/core/session-manager.ts
6959
+ init_paths();
5434
6960
  init_types();
5435
6961
  var SessionManager = class {
5436
6962
  constructor(registry, spawner, store, options = {}) {
5437
6963
  this.registry = registry;
5438
6964
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
5439
6965
  this.store = store ?? new SessionStore();
6966
+ this.histories = new HistoryStore();
5440
6967
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
5441
6968
  }
5442
6969
  registry;
@@ -5444,7 +6971,12 @@ var SessionManager = class {
5444
6971
  resurrectionInflight = /* @__PURE__ */ new Map();
5445
6972
  spawner;
5446
6973
  store;
6974
+ histories;
5447
6975
  idleTimeoutMs;
6976
+ // Serialize meta.json read-modify-write operations per session id so
6977
+ // concurrent snapshot updates (e.g. an agent emitting model + mode
6978
+ // back-to-back) don't lose writes via interleaved reads.
6979
+ metaWriteQueues = /* @__PURE__ */ new Map();
5448
6980
  async create(params) {
5449
6981
  const fresh = await this.bootstrapAgent({
5450
6982
  agentId: params.agentId,
@@ -5461,7 +6993,8 @@ var SessionManager = class {
5461
6993
  title: params.title,
5462
6994
  agentArgs: params.agentArgs,
5463
6995
  idleTimeoutMs: this.idleTimeoutMs,
5464
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
6996
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
6997
+ historyStore: this.histories
5465
6998
  });
5466
6999
  await this.attachManagerHooks(session);
5467
7000
  return session;
@@ -5537,7 +7070,13 @@ var SessionManager = class {
5537
7070
  title: params.title,
5538
7071
  agentArgs: params.agentArgs,
5539
7072
  idleTimeoutMs: this.idleTimeoutMs,
5540
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
7073
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
7074
+ historyStore: this.histories,
7075
+ seedHistory: params.seedHistory,
7076
+ currentModel: params.currentModel,
7077
+ currentMode: params.currentMode,
7078
+ agentCommands: params.agentCommands,
7079
+ firstPromptSeeded: true
5541
7080
  });
5542
7081
  await this.attachManagerHooks(session);
5543
7082
  return session;
@@ -5591,6 +7130,7 @@ var SessionManager = class {
5591
7130
  this.sessions.delete(session.sessionId);
5592
7131
  if (deleteRecord) {
5593
7132
  void this.store.delete(session.sessionId).catch(() => void 0);
7133
+ void this.histories.delete(session.sessionId).catch(() => void 0);
5594
7134
  }
5595
7135
  });
5596
7136
  session.onTitleChange((title) => {
@@ -5601,6 +7141,24 @@ var SessionManager = class {
5601
7141
  () => void 0
5602
7142
  );
5603
7143
  });
7144
+ session.onModelChange((model) => {
7145
+ void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
7146
+ () => void 0
7147
+ );
7148
+ });
7149
+ session.onModeChange((mode) => {
7150
+ void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
7151
+ () => void 0
7152
+ );
7153
+ });
7154
+ session.onAgentCommandsChange((commands) => {
7155
+ void this.persistSnapshot(session.sessionId, {
7156
+ agentCommands: commands.map((c) => ({
7157
+ name: c.name,
7158
+ ...c.description !== void 0 ? { description: c.description } : {}
7159
+ }))
7160
+ }).catch(() => void 0);
7161
+ });
5604
7162
  this.sessions.set(session.sessionId, session);
5605
7163
  await this.store.write(
5606
7164
  recordFromMemorySession({
@@ -5609,22 +7167,45 @@ var SessionManager = class {
5609
7167
  agentId: session.agentId,
5610
7168
  cwd: session.cwd,
5611
7169
  title: session.title,
5612
- agentArgs: session.agentArgs
7170
+ agentArgs: session.agentArgs,
7171
+ currentModel: session.currentModel,
7172
+ currentMode: session.currentMode
5613
7173
  })
5614
7174
  ).catch(() => void 0);
5615
7175
  }
7176
+ // 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".
7181
+ async getHistory(sessionId) {
7182
+ const live = this.sessions.get(sessionId);
7183
+ if (live) {
7184
+ return live.getHistorySnapshot();
7185
+ }
7186
+ const record = await this.store.read(sessionId);
7187
+ if (!record) {
7188
+ return void 0;
7189
+ }
7190
+ return this.histories.load(sessionId).catch(() => []);
7191
+ }
5616
7192
  async loadFromDisk(sessionId) {
5617
7193
  const record = await this.store.read(sessionId);
5618
7194
  if (!record) {
5619
7195
  return void 0;
5620
7196
  }
7197
+ const seedHistory = await this.histories.load(sessionId).catch(() => []);
5621
7198
  return {
5622
7199
  hydraSessionId: record.sessionId,
5623
7200
  upstreamSessionId: record.upstreamSessionId,
5624
7201
  agentId: record.agentId,
5625
7202
  cwd: record.cwd,
5626
7203
  title: record.title,
5627
- agentArgs: record.agentArgs
7204
+ agentArgs: record.agentArgs,
7205
+ seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
7206
+ currentModel: record.currentModel,
7207
+ currentMode: record.currentMode,
7208
+ agentCommands: record.agentCommands
5628
7209
  };
5629
7210
  }
5630
7211
  get(sessionId) {
@@ -5666,13 +7247,14 @@ var SessionManager = class {
5666
7247
  continue;
5667
7248
  }
5668
7249
  liveIds.add(session.sessionId);
7250
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
5669
7251
  entries.push({
5670
7252
  sessionId: session.sessionId,
5671
7253
  upstreamSessionId: session.upstreamSessionId,
5672
7254
  cwd: session.cwd,
5673
7255
  title: session.title,
5674
7256
  agentId: session.agentId,
5675
- updatedAt: new Date(session.updatedAt).toISOString(),
7257
+ updatedAt: used,
5676
7258
  attachedClients: session.attachedCount,
5677
7259
  status: "live"
5678
7260
  });
@@ -5685,13 +7267,14 @@ var SessionManager = class {
5685
7267
  if (filter.cwd && r.cwd !== filter.cwd) {
5686
7268
  continue;
5687
7269
  }
7270
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
5688
7271
  entries.push({
5689
7272
  sessionId: r.sessionId,
5690
7273
  upstreamSessionId: r.upstreamSessionId,
5691
7274
  cwd: r.cwd,
5692
7275
  title: r.title,
5693
7276
  agentId: r.agentId,
5694
- updatedAt: r.updatedAt,
7277
+ updatedAt: used,
5695
7278
  attachedClients: 0,
5696
7279
  status: "cold"
5697
7280
  });
@@ -5707,19 +7290,25 @@ var SessionManager = class {
5707
7290
  await this.store.delete(sessionId).catch(() => void 0);
5708
7291
  return true;
5709
7292
  }
7293
+ async hasRecord(sessionId) {
7294
+ const record = await this.store.read(sessionId).catch(() => void 0);
7295
+ return record !== void 0;
7296
+ }
5710
7297
  // Persist a title update from Session.setTitle. The on-disk record
5711
7298
  // was written at create time; updating it here keeps the session
5712
7299
  // record's title in sync with what was broadcast to clients so a
5713
7300
  // daemon restart (and later resurrect) restores the same title.
5714
7301
  async persistTitle(sessionId, title) {
5715
- const record = await this.store.read(sessionId);
5716
- if (!record) {
5717
- return;
5718
- }
5719
- await this.store.write({
5720
- ...record,
5721
- title,
5722
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7302
+ await this.enqueueMetaWrite(sessionId, async () => {
7303
+ const record = await this.store.read(sessionId);
7304
+ if (!record) {
7305
+ return;
7306
+ }
7307
+ await this.store.write({
7308
+ ...record,
7309
+ title,
7310
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7311
+ });
5723
7312
  });
5724
7313
  }
5725
7314
  // Persist an agent swap from /hydra switch. The on-disk record's
@@ -5727,28 +7316,71 @@ var SessionManager = class {
5727
7316
  // later resurrect) brings the session back up on the agent the user
5728
7317
  // most recently switched to, not the one it was originally created on.
5729
7318
  async persistAgentChange(sessionId, agentId, upstreamSessionId) {
5730
- const record = await this.store.read(sessionId);
5731
- if (!record) {
5732
- return;
5733
- }
5734
- await this.store.write({
5735
- ...record,
5736
- agentId,
5737
- upstreamSessionId,
5738
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7319
+ await this.enqueueMetaWrite(sessionId, async () => {
7320
+ const record = await this.store.read(sessionId);
7321
+ if (!record) {
7322
+ return;
7323
+ }
7324
+ await this.store.write({
7325
+ ...record,
7326
+ agentId,
7327
+ upstreamSessionId,
7328
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7329
+ });
7330
+ });
7331
+ }
7332
+ // Update one or more snapshot fields (model, mode, commands) in
7333
+ // meta.json. Used so cold-resurrect can deliver the latest snapshot
7334
+ // to attaching clients via the attach response _meta. No-op if the
7335
+ // session record has gone away (race with deleteRecord).
7336
+ async persistSnapshot(sessionId, update) {
7337
+ await this.enqueueMetaWrite(sessionId, async () => {
7338
+ const record = await this.store.read(sessionId);
7339
+ if (!record) {
7340
+ return;
7341
+ }
7342
+ await this.store.write({
7343
+ ...record,
7344
+ ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
7345
+ ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
7346
+ ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
7347
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7348
+ });
5739
7349
  });
5740
7350
  }
7351
+ // Serialize meta.json writes per session id so concurrent
7352
+ // read-modify-write operations don't interleave reads.
7353
+ enqueueMetaWrite(sessionId, task) {
7354
+ const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
7355
+ const next = prev.then(task, task);
7356
+ const settled = next.catch(() => void 0);
7357
+ this.metaWriteQueues.set(sessionId, settled);
7358
+ void settled.finally(() => {
7359
+ if (this.metaWriteQueues.get(sessionId) === settled) {
7360
+ this.metaWriteQueues.delete(sessionId);
7361
+ }
7362
+ });
7363
+ return next;
7364
+ }
5741
7365
  async closeAll() {
5742
7366
  const sessions = [...this.sessions.values()];
5743
7367
  await Promise.allSettled(sessions.map((s) => s.close()));
5744
7368
  this.sessions.clear();
5745
7369
  }
5746
7370
  };
7371
+ async function historyMtimeIso(sessionId) {
7372
+ try {
7373
+ const st = await fs6.stat(paths.historyFile(sessionId));
7374
+ return new Date(st.mtimeMs).toISOString();
7375
+ } catch {
7376
+ return void 0;
7377
+ }
7378
+ }
5747
7379
 
5748
7380
  // src/core/extensions.ts
5749
7381
  init_paths();
5750
7382
  import { spawn as spawn2 } from "child_process";
5751
- import * as fs5 from "fs";
7383
+ import * as fs7 from "fs";
5752
7384
  import * as fsp from "fs/promises";
5753
7385
  import * as path3 from "path";
5754
7386
  var RESTART_BASE_MS = 1e3;
@@ -6031,7 +7663,7 @@ var ExtensionManager = class {
6031
7663
  }
6032
7664
  const ext = entry.config;
6033
7665
  const command = ext.command.length > 0 ? ext.command : [ext.name];
6034
- const logStream = fs5.createWriteStream(paths.extensionLogFile(ext.name), {
7666
+ const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
6035
7667
  flags: "a"
6036
7668
  });
6037
7669
  logStream.write(
@@ -6081,7 +7713,7 @@ var ExtensionManager = class {
6081
7713
  }
6082
7714
  if (typeof child.pid === "number") {
6083
7715
  try {
6084
- fs5.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7716
+ fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6085
7717
  `, {
6086
7718
  encoding: "utf8",
6087
7719
  mode: 384
@@ -6106,7 +7738,7 @@ var ExtensionManager = class {
6106
7738
  });
6107
7739
  child.on("exit", (code, signal) => {
6108
7740
  try {
6109
- fs5.unlinkSync(paths.extensionPidFile(ext.name));
7741
+ fs7.unlinkSync(paths.extensionPidFile(ext.name));
6110
7742
  } catch {
6111
7743
  }
6112
7744
  logStream.write(
@@ -6222,8 +7854,7 @@ init_config();
6222
7854
  function registerSessionRoutes(app, manager, defaults) {
6223
7855
  app.get("/v1/sessions", async (request) => {
6224
7856
  const query = request.query;
6225
- const all = query?.all === "true" || query?.all === "1";
6226
- const sessions = await manager.list({ cwd: query?.cwd, all });
7857
+ const sessions = await manager.list({ cwd: query?.cwd });
6227
7858
  return { sessions };
6228
7859
  });
6229
7860
  app.post("/v1/sessions", async (request, reply) => {
@@ -6245,6 +7876,22 @@ function registerSessionRoutes(app, manager, defaults) {
6245
7876
  reply.code(500).send({ error: err.message });
6246
7877
  }
6247
7878
  });
7879
+ app.post("/v1/sessions/:id/kill", async (request, reply) => {
7880
+ const raw = request.params.id;
7881
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
7882
+ const session = manager.get(id);
7883
+ if (session) {
7884
+ await session.close({ deleteRecord: false });
7885
+ reply.code(204).send();
7886
+ return;
7887
+ }
7888
+ const exists = await manager.hasRecord(id);
7889
+ if (!exists) {
7890
+ reply.code(404).send({ error: "session not found" });
7891
+ return;
7892
+ }
7893
+ reply.code(204).send();
7894
+ });
6248
7895
  app.delete("/v1/sessions/:id", async (request, reply) => {
6249
7896
  const raw = request.params.id;
6250
7897
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -6261,6 +7908,50 @@ function registerSessionRoutes(app, manager, defaults) {
6261
7908
  }
6262
7909
  reply.code(204).send();
6263
7910
  });
7911
+ app.get("/v1/sessions/:id/history", async (request, reply) => {
7912
+ const raw = request.params.id;
7913
+ const query = request.query;
7914
+ const follow = query?.follow === "1" || query?.follow === "true";
7915
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
7916
+ const live = manager.get(id);
7917
+ let snapshot;
7918
+ let unsubscribe;
7919
+ if (live) {
7920
+ snapshot = live.getHistorySnapshot();
7921
+ if (follow) {
7922
+ unsubscribe = live.onBroadcast((entry) => {
7923
+ if (reply.raw.writableEnded) {
7924
+ return;
7925
+ }
7926
+ reply.raw.write(JSON.stringify(entry) + "\n");
7927
+ });
7928
+ }
7929
+ } else {
7930
+ const cold = await manager.getHistory(id);
7931
+ if (cold === void 0) {
7932
+ reply.code(404).send({ error: "session not found" });
7933
+ return reply;
7934
+ }
7935
+ snapshot = cold;
7936
+ }
7937
+ reply.raw.setHeader("Content-Type", "application/x-ndjson");
7938
+ reply.raw.setHeader("Cache-Control", "no-cache");
7939
+ reply.raw.statusCode = 200;
7940
+ for (const entry of snapshot ?? []) {
7941
+ reply.raw.write(JSON.stringify(entry) + "\n");
7942
+ }
7943
+ if (!unsubscribe) {
7944
+ reply.raw.end();
7945
+ return reply;
7946
+ }
7947
+ request.raw.on("close", () => {
7948
+ unsubscribe?.();
7949
+ if (!reply.raw.writableEnded) {
7950
+ reply.raw.end();
7951
+ }
7952
+ });
7953
+ return reply;
7954
+ });
6264
7955
  }
6265
7956
 
6266
7957
  // src/daemon/routes/agents.ts
@@ -6408,6 +8099,16 @@ function parseRegisterBody(body) {
6408
8099
  };
6409
8100
  }
6410
8101
 
8102
+ // src/daemon/routes/config.ts
8103
+ function registerConfigRoutes(app, defaults) {
8104
+ app.get("/v1/config", async () => {
8105
+ return {
8106
+ defaultAgent: defaults.defaultAgent,
8107
+ defaultCwd: defaults.defaultCwd
8108
+ };
8109
+ });
8110
+ }
8111
+
6411
8112
  // src/daemon/acp-ws.ts
6412
8113
  init_connection();
6413
8114
  init_ws_stream();
@@ -6651,6 +8352,19 @@ function buildResponseMeta(session) {
6651
8352
  if (session.agentArgs && session.agentArgs.length > 0) {
6652
8353
  ours.agentArgs = session.agentArgs;
6653
8354
  }
8355
+ if (session.currentModel !== void 0) {
8356
+ ours.currentModel = session.currentModel;
8357
+ }
8358
+ if (session.currentMode !== void 0) {
8359
+ ours.currentMode = session.currentMode;
8360
+ }
8361
+ const commands = session.mergedAvailableCommands();
8362
+ if (commands.length > 0) {
8363
+ ours.availableCommands = commands;
8364
+ }
8365
+ if (session.turnStartedAt !== void 0) {
8366
+ ours.turnStartedAt = session.turnStartedAt;
8367
+ }
6654
8368
  return mergeMeta(session.agentMeta, ours);
6655
8369
  }
6656
8370
  function buildInitializeResult() {
@@ -6736,6 +8450,10 @@ async function startDaemon(config) {
6736
8450
  });
6737
8451
  registerAgentRoutes(app, registry);
6738
8452
  registerExtensionRoutes(app, extensions);
8453
+ registerConfigRoutes(app, {
8454
+ defaultAgent: config.defaultAgent,
8455
+ defaultCwd: config.defaultCwd
8456
+ });
6739
8457
  registerAcpWsEndpoint(app, {
6740
8458
  config,
6741
8459
  manager,
@@ -6771,7 +8489,7 @@ async function startDaemon(config) {
6771
8489
  await manager.closeAll();
6772
8490
  await app.close();
6773
8491
  try {
6774
- fs6.unlinkSync(paths.pidFile());
8492
+ fs8.unlinkSync(paths.pidFile());
6775
8493
  } catch {
6776
8494
  }
6777
8495
  try {
@@ -6810,13 +8528,13 @@ function ensureLoopbackOrTls(config) {
6810
8528
  init_daemon_bootstrap();
6811
8529
 
6812
8530
  // src/cli/commands/log-tail.ts
6813
- import * as fs7 from "fs";
8531
+ import * as fs9 from "fs";
6814
8532
  import * as fsp3 from "fs/promises";
6815
8533
  async function runLogTail(logPath, argv, notFoundMessage) {
6816
8534
  const opts = parseLogTailFlags(argv);
6817
- let stat2;
8535
+ let stat3;
6818
8536
  try {
6819
- stat2 = await fsp3.stat(logPath);
8537
+ stat3 = await fsp3.stat(logPath);
6820
8538
  } catch (err) {
6821
8539
  const e = err;
6822
8540
  if (e.code === "ENOENT") {
@@ -6827,14 +8545,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
6827
8545
  }
6828
8546
  throw err;
6829
8547
  }
6830
- let position = await printTail(logPath, stat2.size, opts.tail);
8548
+ let position = await printTail(logPath, stat3.size, opts.tail);
6831
8549
  if (!opts.follow) {
6832
8550
  return;
6833
8551
  }
6834
8552
  process.stdout.write(`-- following ${logPath} --
6835
8553
  `);
6836
8554
  let pending = false;
6837
- const watcher = fs7.watch(logPath, () => {
8555
+ const watcher = fs9.watch(logPath, () => {
6838
8556
  if (pending) {
6839
8557
  return;
6840
8558
  }
@@ -7592,226 +9310,7 @@ function maxLen3(headerCell, values) {
7592
9310
  // src/shim/proxy.ts
7593
9311
  init_config();
7594
9312
  init_daemon_bootstrap();
7595
-
7596
- // src/shim/resilient-ws.ts
7597
- init_ws_stream();
7598
- init_types();
7599
- import { setTimeout as sleep3 } from "timers/promises";
7600
- import { WebSocket } from "ws";
7601
- var BACKOFF_INITIAL_MS = 200;
7602
- var BACKOFF_MAX_MS = 5e3;
7603
- var BACKOFF_MULTIPLIER = 2;
7604
- var MAX_RECONNECT_ATTEMPTS = 60;
7605
- var ResilientWsStream = class {
7606
- constructor(opts) {
7607
- this.opts = opts;
7608
- }
7609
- opts;
7610
- current;
7611
- outboundQueue = [];
7612
- messageHandlers = [];
7613
- closeHandlers = [];
7614
- destroyed = false;
7615
- firstConnect = true;
7616
- reconnectInFlight;
7617
- connectGate;
7618
- releaseConnectGate;
7619
- pendingRequests = /* @__PURE__ */ new Map();
7620
- async start() {
7621
- await this.connectWithRetry();
7622
- }
7623
- onMessage(handler) {
7624
- this.messageHandlers.push(handler);
7625
- }
7626
- onClose(handler) {
7627
- this.closeHandlers.push(handler);
7628
- }
7629
- async send(message) {
7630
- if (this.destroyed) {
7631
- throw new Error("resilient ws stream is destroyed");
7632
- }
7633
- if (this.connectGate || !this.current) {
7634
- this.outboundQueue.push(message);
7635
- return;
7636
- }
7637
- try {
7638
- await this.current.send(message);
7639
- } catch (err) {
7640
- this.outboundQueue.push(message);
7641
- this.scheduleReconnect(err);
7642
- }
7643
- }
7644
- // Send a request directly and resolve when the matching response arrives
7645
- // on the same connection. Used by onConnect handlers to await replay-attach
7646
- // responses before letting the outbound queue drain. Bypasses the
7647
- // connectGate intentionally.
7648
- async request(message) {
7649
- if (this.destroyed) {
7650
- throw new Error("resilient ws stream is destroyed");
7651
- }
7652
- if (!this.current) {
7653
- throw new Error("resilient ws stream not connected");
7654
- }
7655
- const id = message.id;
7656
- const promise = new Promise((resolve2, reject) => {
7657
- this.pendingRequests.set(id, { resolve: resolve2, reject });
7658
- });
7659
- try {
7660
- await this.current.send(message);
7661
- } catch (err) {
7662
- this.pendingRequests.delete(id);
7663
- throw err;
7664
- }
7665
- return promise;
7666
- }
7667
- async close() {
7668
- this.destroyed = true;
7669
- if (this.current) {
7670
- await this.current.close().catch(() => void 0);
7671
- }
7672
- for (const handler of this.closeHandlers) {
7673
- handler();
7674
- }
7675
- }
7676
- async connectWithRetry() {
7677
- let attempt = 0;
7678
- let backoff = BACKOFF_INITIAL_MS;
7679
- while (!this.destroyed) {
7680
- try {
7681
- const stream = await openWs(this.opts.url, this.opts.subprotocols);
7682
- this.bindStream(stream);
7683
- const wasFirst = this.firstConnect;
7684
- this.firstConnect = false;
7685
- this.connectGate = new Promise((resolve2) => {
7686
- this.releaseConnectGate = resolve2;
7687
- });
7688
- try {
7689
- if (this.opts.onConnect) {
7690
- try {
7691
- await this.opts.onConnect(wasFirst);
7692
- } catch (err) {
7693
- this.log(
7694
- `hydra-acp: post-connect handler failed: ${err.message}`
7695
- );
7696
- }
7697
- }
7698
- } finally {
7699
- this.releaseConnectGate?.();
7700
- this.releaseConnectGate = void 0;
7701
- this.connectGate = void 0;
7702
- }
7703
- await this.flushQueue();
7704
- return;
7705
- } catch (err) {
7706
- attempt += 1;
7707
- if (this.opts.onConnectFailure) {
7708
- this.opts.onConnectFailure(err);
7709
- }
7710
- if (attempt >= MAX_RECONNECT_ATTEMPTS) {
7711
- throw new Error(
7712
- `hydra-acp: gave up reconnecting after ${attempt} attempts: ${err.message}`
7713
- );
7714
- }
7715
- this.log(
7716
- `hydra-acp: connect attempt ${attempt} failed (${err.message}); retrying in ${backoff}ms`
7717
- );
7718
- await sleep3(backoff);
7719
- backoff = Math.min(backoff * BACKOFF_MULTIPLIER, BACKOFF_MAX_MS);
7720
- }
7721
- }
7722
- }
7723
- bindStream(stream) {
7724
- this.current = stream;
7725
- stream.onMessage((msg) => {
7726
- if (isResponse(msg)) {
7727
- const pending = this.pendingRequests.get(msg.id);
7728
- if (pending) {
7729
- this.pendingRequests.delete(msg.id);
7730
- pending.resolve(msg);
7731
- }
7732
- }
7733
- for (const handler of this.messageHandlers) {
7734
- handler(msg);
7735
- }
7736
- });
7737
- stream.onClose((err) => {
7738
- if (this.destroyed) {
7739
- return;
7740
- }
7741
- this.current = void 0;
7742
- if (this.pendingRequests.size > 0) {
7743
- const reason = err ?? new Error("ws closed before response");
7744
- for (const { reject } of this.pendingRequests.values()) {
7745
- reject(reason);
7746
- }
7747
- this.pendingRequests.clear();
7748
- }
7749
- this.scheduleReconnect(err);
7750
- });
7751
- }
7752
- async flushQueue() {
7753
- if (!this.current) {
7754
- return;
7755
- }
7756
- const queue = this.outboundQueue;
7757
- this.outboundQueue = [];
7758
- for (const msg of queue) {
7759
- try {
7760
- await this.current.send(msg);
7761
- } catch (err) {
7762
- this.outboundQueue.unshift(msg);
7763
- this.scheduleReconnect(err);
7764
- return;
7765
- }
7766
- }
7767
- }
7768
- scheduleReconnect(err) {
7769
- if (this.destroyed || this.reconnectInFlight) {
7770
- return;
7771
- }
7772
- this.log(
7773
- `hydra-acp: connection lost (${err?.message ?? "no error"}); reconnecting...`
7774
- );
7775
- this.reconnectInFlight = (async () => {
7776
- try {
7777
- await this.connectWithRetry();
7778
- } catch (final) {
7779
- for (const handler of this.closeHandlers) {
7780
- handler(final);
7781
- }
7782
- this.destroyed = true;
7783
- } finally {
7784
- this.reconnectInFlight = void 0;
7785
- }
7786
- })();
7787
- }
7788
- log(line) {
7789
- if (this.opts.log) {
7790
- this.opts.log(line);
7791
- return;
7792
- }
7793
- process.stderr.write(`${line}
7794
- `);
7795
- }
7796
- };
7797
- function isResponse(msg) {
7798
- return !("method" in msg) && "id" in msg && msg.id !== void 0;
7799
- }
7800
- async function openWs(url, subprotocols) {
7801
- return new Promise((resolve2, reject) => {
7802
- const ws = new WebSocket(url, subprotocols);
7803
- const onOpen = () => {
7804
- ws.off("error", onError);
7805
- resolve2(wsToMessageStream(ws));
7806
- };
7807
- const onError = (err) => {
7808
- ws.off("open", onOpen);
7809
- reject(err);
7810
- };
7811
- ws.once("open", onOpen);
7812
- ws.once("error", onError);
7813
- });
7814
- }
9313
+ init_resilient_ws();
7815
9314
 
7816
9315
  // src/shim/session-tracker.ts
7817
9316
  init_types();
@@ -8232,6 +9731,10 @@ async function main() {
8232
9731
  await runSessionsKill(positional[2]);
8233
9732
  return;
8234
9733
  }
9734
+ if (sub === "rm") {
9735
+ await runSessionsRm(positional[2]);
9736
+ return;
9737
+ }
8235
9738
  process.stderr.write(`Unknown sessions subcommand: ${sub}
8236
9739
  `);
8237
9740
  process.exit(2);
@@ -8343,7 +9846,8 @@ function printHelp() {
8343
9846
  " hydra-acp daemon start|stop|restart|status",
8344
9847
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
8345
9848
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
8346
- " hydra-acp sessions kill <id> Kill a session (live or cold)",
9849
+ " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
9850
+ " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
8347
9851
  " hydra-acp extensions list List configured extensions and live state",
8348
9852
  " hydra-acp extensions add <name> [opts] Add an extension to config",
8349
9853
  " hydra-acp extensions remove <name> Remove an extension from config",