@hydra-acp/cli 0.1.2 → 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;
@@ -43,7 +48,8 @@ var init_paths = __esm({
43
48
  extensionsDir: () => path.join(hydraHome(), "extensions"),
44
49
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
45
50
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
46
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
51
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
52
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
47
53
  };
48
54
  }
49
55
  });
@@ -157,7 +163,11 @@ var init_config = __esm({
157
163
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
158
164
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
159
165
  // or 0 to disable throttling entirely.
160
- 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)
161
171
  });
162
172
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
163
173
  ExtensionBody = z.object({
@@ -182,7 +192,7 @@ var init_config = __esm({
182
192
  // recency and truncated to this count. `--all` overrides in the CLI.
183
193
  sessionListColdLimit: z.number().int().nonnegative().default(20),
184
194
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
185
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
195
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
186
196
  });
187
197
  }
188
198
  });
@@ -226,6 +236,9 @@ function extractHydraMeta(meta) {
226
236
  if (typeof obj.currentMode === "string") {
227
237
  out.currentMode = obj.currentMode;
228
238
  }
239
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
240
+ out.turnStartedAt = obj.turnStartedAt;
241
+ }
229
242
  if (Array.isArray(obj.availableCommands)) {
230
243
  const cmds = [];
231
244
  for (const raw of obj.availableCommands) {
@@ -703,6 +716,9 @@ var init_session = __esm({
703
716
  }
704
717
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
705
718
  this.spawnReplacementAgent = init.spawnReplacementAgent;
719
+ if (init.firstPromptSeeded) {
720
+ this.firstPromptSeeded = true;
721
+ }
706
722
  this.historyStore = init.historyStore;
707
723
  if (init.seedHistory && init.seedHistory.length > 0) {
708
724
  this.history = [...init.seedHistory];
@@ -767,6 +783,29 @@ var init_session = __esm({
767
783
  get attachedCount() {
768
784
  return this.clients.size;
769
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
+ }
770
809
  // Snapshot of the current in-memory replay history. Used by the
771
810
  // HTTP history endpoint to deliver the "what's accumulated so far"
772
811
  // prefix before optionally tailing with onBroadcast. Returns a copy
@@ -844,13 +883,19 @@ var init_session = __esm({
844
883
  this.broadcastPromptReceived(client, params);
845
884
  this.maybeSeedTitleFromPrompt(params);
846
885
  return this.enqueuePrompt(async () => {
847
- const response = await this.agent.connection.request(
848
- "session/prompt",
849
- {
850
- ...params,
851
- sessionId: this.upstreamSessionId
852
- }
853
- );
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
+ }
854
899
  this.broadcastTurnComplete(client.clientId, response);
855
900
  return response;
856
901
  });
@@ -938,6 +983,13 @@ var init_session = __esm({
938
983
  return;
939
984
  }
940
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
+ }
941
993
  await this.agent.kill().catch(() => void 0);
942
994
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
943
995
  }
@@ -986,7 +1038,7 @@ var init_session = __esm({
986
1038
  }
987
1039
  const promptParams = params ?? {};
988
1040
  const text = extractPromptText(promptParams.prompt);
989
- const seed = firstLine(text, 80);
1041
+ const seed = firstLine(text, 200);
990
1042
  if (!seed) {
991
1043
  return;
992
1044
  }
@@ -1365,7 +1417,8 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1365
1417
  }
1366
1418
  this.idleTimer = setTimeout(() => {
1367
1419
  this.idleTimer = void 0;
1368
- 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);
1369
1422
  }, this.idleTimeoutMs);
1370
1423
  if (typeof this.idleTimer.unref === "function") {
1371
1424
  this.idleTimer.unref();
@@ -1650,6 +1703,147 @@ var init_daemon_bootstrap = __esm({
1650
1703
  }
1651
1704
  });
1652
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
+
1653
1847
  // src/cli/commands/sessions.ts
1654
1848
  async function runSessionsList(opts = {}) {
1655
1849
  const config = await loadConfig();
@@ -1685,44 +1879,13 @@ async function runSessionsList(opts = {}) {
1685
1879
  visible = [...sorted.slice(0, liveCount), ...coldSlice];
1686
1880
  truncated = hiddenCold;
1687
1881
  }
1688
- const rows = visible.map((s) => ({
1689
- session: stripHydraSessionPrefix(s.sessionId),
1690
- upstream: s.upstreamSessionId ?? "-",
1691
- status: (s.status ?? "live").toUpperCase(),
1692
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
1693
- agent: s.agentId ?? "?",
1694
- title: s.title ?? "-",
1695
- cwd: s.cwd
1696
- }));
1697
- const header = {
1698
- session: "SESSION",
1699
- upstream: "UPSTREAM",
1700
- status: "STATUS",
1701
- clients: "CLIENTS",
1702
- agent: "AGENT",
1703
- title: "TITLE",
1704
- cwd: "CWD"
1705
- };
1706
- const widths = {
1707
- session: maxLen(header.session, rows.map((r) => r.session)),
1708
- upstream: maxLen(header.upstream, rows.map((r) => r.upstream)),
1709
- status: maxLen(header.status, rows.map((r) => r.status)),
1710
- clients: maxLen(header.clients, rows.map((r) => r.clients)),
1711
- agent: maxLen(header.agent, rows.map((r) => r.agent)),
1712
- title: maxLen(header.title, rows.map((r) => r.title))
1713
- };
1714
- const formatRow2 = (r) => [
1715
- r.session.padEnd(widths.session),
1716
- r.upstream.padEnd(widths.upstream),
1717
- r.status.padEnd(widths.status),
1718
- r.clients.padStart(widths.clients),
1719
- r.agent.padEnd(widths.agent),
1720
- r.title.padEnd(widths.title),
1721
- r.cwd
1722
- ].join(" ");
1723
- 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");
1724
1887
  for (const r of rows) {
1725
- process.stdout.write(formatRow2(r) + "\n");
1888
+ process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
1726
1889
  }
1727
1890
  if (truncated > 0) {
1728
1891
  process.stdout.write(
@@ -1732,15 +1895,6 @@ async function runSessionsList(opts = {}) {
1732
1895
  );
1733
1896
  }
1734
1897
  }
1735
- function maxLen(headerCell, values) {
1736
- let max = headerCell.length;
1737
- for (const v of values) {
1738
- if (v.length > max) {
1739
- max = v.length;
1740
- }
1741
- }
1742
- return max;
1743
- }
1744
1898
  async function runSessionsKill(id) {
1745
1899
  if (!id) {
1746
1900
  process.stderr.write("Usage: hydra-acp sessions kill <session-id>\n");
@@ -1748,6 +1902,25 @@ async function runSessionsKill(id) {
1748
1902
  }
1749
1903
  const config = await loadConfig();
1750
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);
1751
1924
  const response = await fetch(`${baseUrl}/v1/sessions/${id}`, {
1752
1925
  method: "DELETE",
1753
1926
  headers: { Authorization: `Bearer ${config.daemon.authToken}` }
@@ -1757,7 +1930,7 @@ async function runSessionsKill(id) {
1757
1930
  `);
1758
1931
  process.exit(1);
1759
1932
  }
1760
- process.stdout.write(`Killed ${id}
1933
+ process.stdout.write(`Removed ${id}
1761
1934
  `);
1762
1935
  }
1763
1936
  function httpBase(host, port, tls) {
@@ -1768,7 +1941,7 @@ var init_sessions = __esm({
1768
1941
  "src/cli/commands/sessions.ts"() {
1769
1942
  "use strict";
1770
1943
  init_config();
1771
- init_session();
1944
+ init_session_row();
1772
1945
  }
1773
1946
  });
1774
1947
 
@@ -2008,12 +2181,12 @@ var init_resilient_ws = __esm({
2008
2181
  });
2009
2182
 
2010
2183
  // src/tui/history.ts
2011
- import { promises as fs9 } from "fs";
2184
+ import { promises as fs10 } from "fs";
2012
2185
  import * as path4 from "path";
2013
2186
  async function loadHistory(file) {
2014
2187
  let text;
2015
2188
  try {
2016
- text = await fs9.readFile(file, "utf8");
2189
+ text = await fs10.readFile(file, "utf8");
2017
2190
  } catch (err) {
2018
2191
  if (err.code === "ENOENT") {
2019
2192
  return [];
@@ -2053,9 +2226,9 @@ function appendEntry(history, entry) {
2053
2226
  return out;
2054
2227
  }
2055
2228
  async function saveHistory(file, history) {
2056
- await fs9.mkdir(path4.dirname(file), { recursive: true });
2229
+ await fs10.mkdir(path4.dirname(file), { recursive: true });
2057
2230
  const lines = history.map((entry) => JSON.stringify(entry));
2058
- await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2231
+ await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2059
2232
  }
2060
2233
  var HISTORY_CAP;
2061
2234
  var init_history = __esm({
@@ -2096,6 +2269,26 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
2096
2269
  title: s.title
2097
2270
  }));
2098
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
+ }
2099
2292
  function pickMostRecent(sessions, cwd) {
2100
2293
  const matching = sessions.filter((s) => s.cwd === cwd);
2101
2294
  if (matching.length === 0) {
@@ -2123,147 +2316,401 @@ async function pickSession(term, opts) {
2123
2316
  if (opts.sessions.length === 0) {
2124
2317
  return { kind: "new" };
2125
2318
  }
2126
- const score = (s) => {
2127
- if (s.status !== "live") {
2128
- 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);
2129
2377
  }
2130
- return s.cwd === opts.cwd ? 2 : 1;
2131
2378
  };
2132
- const sorted = [...opts.sessions].sort((a, b) => {
2133
- const tier = score(b) - score(a);
2134
- if (tier !== 0) {
2135
- return tier;
2379
+ const paintNewItem = () => {
2380
+ if (selectedIdx === 0) {
2381
+ term.brightWhite.bgBlue.noFormat(`\u276F ${newSessionLabel}`);
2382
+ } else {
2383
+ term.noFormat(` ${newSessionLabel}`);
2136
2384
  }
2137
- return b.updatedAt.localeCompare(a.updatedAt);
2138
- });
2139
- const liveCount = sorted.filter((s) => s.status !== "cold").length;
2140
- const coldSlice = sorted.slice(liveCount, liveCount + opts.coldLimit);
2141
- const hiddenCold = sorted.length - liveCount - coldSlice.length;
2142
- const visible = [...sorted.slice(0, liveCount), ...coldSlice];
2143
- const rows = visible.map(toRow);
2144
- const widths = computeWidths(rows);
2145
- const newSessionLabel = `+ New session in ${opts.cwd}`;
2146
- const items = [newSessionLabel, ...rows.map((r) => formatRow(r, widths))];
2147
- term("\n");
2148
- term.bold("Select a session")("\n");
2149
- if (hiddenCold > 0) {
2150
- term.dim(`(${hiddenCold} older cold session${hiddenCold === 1 ? "" : "s"} hidden; use \`hydra-acp sessions --all\` to view)
2151
- `);
2152
- }
2153
- term.dim(formatRow(HEADER, widths))("\n");
2154
- const onCtrlC = (name) => {
2155
- if (name === "CTRL_C") {
2156
- term.grabInput(false);
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}`);
2392
+ }
2393
+ };
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 ")}`;
2408
+ };
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());
2429
+ };
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);
2157
2442
  term("\n");
2158
- process.exit(130);
2159
2443
  }
2444
+ paintIndicator();
2445
+ term("\n");
2160
2446
  };
2161
- term.on("key", onCtrlC);
2162
- let response;
2163
- try {
2164
- response = await term.singleColumnMenu(items, {
2165
- cancelable: true,
2166
- exitOnUnexpectedKey: false,
2167
- selectedIndex: 0,
2168
- style: term.brightWhite,
2169
- selectedStyle: term.brightWhite.bgBlue,
2170
- keyBindings: {
2171
- ENTER: "submit",
2172
- KP_ENTER: "submit",
2173
- UP: "previous",
2174
- DOWN: "next",
2175
- TAB: "next",
2176
- SHIFT_TAB: "previous",
2177
- HOME: "first",
2178
- END: "last",
2179
- ESCAPE: "cancel",
2180
- CTRL_C: "cancel"
2181
- }
2182
- }).promise;
2183
- } finally {
2184
- term.off("key", onCtrlC);
2185
- }
2186
- term("\n");
2187
- if (response.canceled || response.selectedIndex === void 0) {
2188
- return { kind: "abort" };
2189
- }
2190
- if (response.selectedIndex === 0) {
2191
- return { kind: "new" };
2192
- }
2193
- const session = visible[response.selectedIndex - 1];
2194
- if (!session) {
2195
- return { kind: "abort" };
2196
- }
2197
- const result = {
2198
- kind: "attach",
2199
- sessionId: session.sessionId
2447
+ const repaintNewItem = () => {
2448
+ term.moveTo(1, startRow).eraseLineAfter();
2449
+ paintNewItem();
2200
2450
  };
2201
- if (session.agentId !== void 0) {
2202
- result.agentId = session.agentId;
2203
- }
2204
- return result;
2205
- }
2206
- function toRow(s) {
2207
- return {
2208
- session: stripHydraSessionPrefix(s.sessionId),
2209
- upstream: s.upstreamSessionId ?? "-",
2210
- status: s.status.toUpperCase(),
2211
- clients: s.status === "cold" ? "-" : String(s.attachedClients),
2212
- agent: s.agentId ?? "?",
2213
- title: s.title ?? "-",
2214
- cwd: s.cwd
2451
+ const repaintSessionRow = (sessionIdx) => {
2452
+ if (sessionIdx < scrollOffset || sessionIdx >= scrollOffset + viewportSize) {
2453
+ return;
2454
+ }
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();
2215
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
+ });
2216
2682
  }
2217
- function computeWidths(rows) {
2218
- return {
2219
- session: maxLen4(HEADER.session, rows.map((r) => r.session)),
2220
- upstream: maxLen4(HEADER.upstream, rows.map((r) => r.upstream)),
2221
- status: maxLen4(HEADER.status, rows.map((r) => r.status)),
2222
- clients: maxLen4(HEADER.clients, rows.map((r) => r.clients)),
2223
- agent: maxLen4(HEADER.agent, rows.map((r) => r.agent)),
2224
- title: maxLen4(HEADER.title, rows.map((r) => r.title))
2225
- };
2683
+ function readTermHeight(term) {
2684
+ return term.height ?? 24;
2226
2685
  }
2227
- function maxLen4(headerCell, values) {
2228
- let max = headerCell.length;
2229
- for (const v of values) {
2230
- if (v.length > max) {
2231
- max = v.length;
2232
- }
2233
- }
2234
- return max;
2686
+ function readTermWidth(term) {
2687
+ return term.width ?? 80;
2235
2688
  }
2236
- function formatRow(r, w) {
2237
- return [
2238
- r.session.padEnd(w.session),
2239
- r.upstream.padEnd(w.upstream),
2240
- r.status.padEnd(w.status),
2241
- r.clients.padStart(w.clients),
2242
- r.agent.padEnd(w.agent),
2243
- r.title.padEnd(w.title),
2244
- r.cwd
2245
- ].join(" ");
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);
2246
2693
  }
2247
- var HEADER;
2694
+ var ROW_PREFIX_WIDTH;
2248
2695
  var init_picker = __esm({
2249
2696
  "src/tui/picker.ts"() {
2250
2697
  "use strict";
2698
+ init_session_row();
2251
2699
  init_session();
2252
- HEADER = {
2253
- session: "SESSION",
2254
- upstream: "UPSTREAM",
2255
- status: "STATUS",
2256
- clients: "CLIENTS",
2257
- agent: "AGENT",
2258
- title: "TITLE",
2259
- cwd: "CWD"
2260
- };
2700
+ init_discovery();
2701
+ ROW_PREFIX_WIDTH = 2;
2261
2702
  }
2262
2703
  });
2263
2704
 
2264
2705
  // src/tui/screen.ts
2265
2706
  import stringWidth from "string-width";
2266
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
+ }
2267
2714
  function computePromptVisualRows(buffer, room) {
2268
2715
  const rows = [];
2269
2716
  for (let i = 0; i < buffer.length; i++) {
@@ -2348,16 +2795,16 @@ function writeStyled(term, text, style) {
2348
2795
  term.bold.red.noFormat(text);
2349
2796
  return;
2350
2797
  case "tool-status-pending":
2351
- term.dim.yellow.noFormat(text);
2798
+ term.dim.noFormat(text);
2352
2799
  return;
2353
2800
  case "tool-status-running":
2354
- term.bold.yellow.noFormat(text);
2801
+ term.brightYellow.noFormat(text);
2355
2802
  return;
2356
2803
  case "tool-status-cancelled":
2357
2804
  term.dim.noFormat(text);
2358
2805
  return;
2359
2806
  case "plan":
2360
- term.magenta.noFormat(text);
2807
+ term.brightYellow.noFormat(text);
2361
2808
  return;
2362
2809
  case "plan-done":
2363
2810
  term.green.noFormat(text);
@@ -2542,13 +2989,15 @@ function mapKeyName(name) {
2542
2989
  return "ctrl-u";
2543
2990
  case "CTRL_W":
2544
2991
  return "ctrl-w";
2992
+ case "CTRL_Y":
2993
+ return "ctrl-y";
2545
2994
  case "ESCAPE":
2546
2995
  return "escape";
2547
2996
  default:
2548
2997
  return null;
2549
2998
  }
2550
2999
  }
2551
- 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;
2552
3001
  var init_screen = __esm({
2553
3002
  "src/tui/screen.ts"() {
2554
3003
  "use strict";
@@ -2562,6 +3011,7 @@ var init_screen = __esm({
2562
3011
  MAX_COMPLETION_ROWS = 6;
2563
3012
  CONFIRM_PROMPT_ROWS = 2;
2564
3013
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
3014
+ DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
2565
3015
  Screen = class {
2566
3016
  term;
2567
3017
  dispatcher;
@@ -2582,6 +3032,27 @@ var init_screen = __esm({
2582
3032
  lastRepaintAt = 0;
2583
3033
  throttledRepaintTimer = null;
2584
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;
2585
3056
  permissionPrompt = null;
2586
3057
  confirmPrompt = null;
2587
3058
  completions = [];
@@ -2596,6 +3067,7 @@ var init_screen = __esm({
2596
3067
  queued: 0
2597
3068
  };
2598
3069
  header = { agent: "?", cwd: "?", sessionId: "?" };
3070
+ lastWindowTitle = null;
2599
3071
  resizeHandler;
2600
3072
  keyHandler;
2601
3073
  mouseHandler;
@@ -2616,6 +3088,7 @@ var init_screen = __esm({
2616
3088
  this.dispatcher = opts.dispatcher;
2617
3089
  this.onKey = opts.onKey;
2618
3090
  this.contentRepaintThrottleMs = opts.repaintThrottleMs ?? DEFAULT_CONTENT_REPAINT_THROTTLE_MS;
3091
+ this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
2619
3092
  this.resizeHandler = () => this.repaint();
2620
3093
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
2621
3094
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -2716,13 +3189,17 @@ var init_screen = __esm({
2716
3189
  }
2717
3190
  this.streamingActive = false;
2718
3191
  this.lines.push(...lines);
3192
+ this.trackLines(lines);
2719
3193
  this.adjustScrollForLineChange(lines.length);
3194
+ this.trimScrollback();
2720
3195
  this.scheduleRepaint();
2721
3196
  }
2722
3197
  appendLine(line) {
2723
3198
  this.streamingActive = false;
2724
3199
  this.lines.push(line);
3200
+ this.trackLine(line);
2725
3201
  this.adjustScrollForLineChange(1);
3202
+ this.trimScrollback();
2726
3203
  this.scheduleRepaint();
2727
3204
  }
2728
3205
  // When scrolled away from the bottom, shift scrollOffset to keep the
@@ -2734,6 +3211,40 @@ var init_screen = __esm({
2734
3211
  this.scrollOffset = Math.max(0, this.scrollOffset + delta);
2735
3212
  }
2736
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
+ }
2737
3248
  // Append-or-replace a single-line block keyed by `key`. Thin wrapper
2738
3249
  // around upsertLines for the common one-row case (tool calls).
2739
3250
  upsertLine(key, line) {
@@ -2755,7 +3266,15 @@ var init_screen = __esm({
2755
3266
  touchesEnd = oldEnd >= this.lines.length;
2756
3267
  const delta = newLines.length - existing.count;
2757
3268
  scrollDelta = delta;
2758
- 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);
2759
3278
  existing.count = newLines.length;
2760
3279
  if (delta !== 0) {
2761
3280
  for (const [k, range] of this.keyedBlocks) {
@@ -2772,11 +3291,13 @@ var init_screen = __esm({
2772
3291
  count: newLines.length
2773
3292
  });
2774
3293
  this.lines.push(...newLines);
3294
+ this.trackLines(newLines);
2775
3295
  }
2776
3296
  if (touchesEnd) {
2777
3297
  this.streamingActive = false;
2778
3298
  }
2779
3299
  this.adjustScrollForLineChange(scrollDelta);
3300
+ this.trimScrollback();
2780
3301
  this.scheduleRepaint();
2781
3302
  }
2782
3303
  // Append fragments of a streaming message (e.g. agent_message_chunk). The
@@ -2793,6 +3314,7 @@ var init_screen = __esm({
2793
3314
  if (this.streamingActive && this.lines.length > 0) {
2794
3315
  const last = this.lines[this.lines.length - 1];
2795
3316
  if (last) {
3317
+ this.forgetLine(last);
2796
3318
  last.body += first ?? "";
2797
3319
  }
2798
3320
  } else {
@@ -2800,7 +3322,9 @@ var init_screen = __esm({
2800
3322
  const last = this.lines[this.lines.length - 1];
2801
3323
  const isBlank = last && last.body === "" && (!last.prefix || last.prefix === "");
2802
3324
  if (!isBlank) {
2803
- this.lines.push({ body: "" });
3325
+ const sep = { body: "" };
3326
+ this.lines.push(sep);
3327
+ this.trackLine(sep);
2804
3328
  added += 1;
2805
3329
  }
2806
3330
  }
@@ -2813,25 +3337,48 @@ var init_screen = __esm({
2813
3337
  initial.prefixStyle = prefixStyle;
2814
3338
  }
2815
3339
  this.lines.push(initial);
3340
+ this.trackLine(initial);
2816
3341
  added += 1;
2817
3342
  }
2818
3343
  const continuationPrefix = " ".repeat(prefix.length);
2819
3344
  for (const piece of rest) {
2820
- this.lines.push({
3345
+ const cont = {
2821
3346
  prefix: continuationPrefix,
2822
3347
  body: piece,
2823
3348
  bodyStyle
2824
- });
3349
+ };
3350
+ this.lines.push(cont);
3351
+ this.trackLine(cont);
2825
3352
  added += 1;
2826
3353
  }
2827
3354
  this.streamingActive = true;
2828
3355
  this.adjustScrollForLineChange(added);
3356
+ this.trimScrollback();
2829
3357
  this.scheduleRepaint();
2830
3358
  }
2831
3359
  setHeader(header) {
2832
3360
  this.header = { ...this.header, ...header };
3361
+ this.syncWindowTitle();
2833
3362
  this.repaint();
2834
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
+ }
2835
3382
  setBanner(banner) {
2836
3383
  this.banner = { ...this.banner, ...banner };
2837
3384
  this.drawBanner();
@@ -2840,6 +3387,8 @@ var init_screen = __esm({
2840
3387
  clearScrollback() {
2841
3388
  this.lines = [];
2842
3389
  this.keyedBlocks.clear();
3390
+ this.wrapCache.clear();
3391
+ this.wrapCacheWidth = 0;
2843
3392
  this.streamingActive = false;
2844
3393
  this.scrollOffset = 0;
2845
3394
  this.repaint();
@@ -2862,7 +3411,10 @@ var init_screen = __esm({
2862
3411
  return;
2863
3412
  }
2864
3413
  const touchesEnd = existing.start + existing.count >= this.lines.length;
2865
- 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
+ }
2866
3418
  this.keyedBlocks.delete(key);
2867
3419
  for (const [, range] of this.keyedBlocks) {
2868
3420
  if (range.start > existing.start) {
@@ -2936,9 +3488,12 @@ var init_screen = __esm({
2936
3488
  if (last && last.body === "" && (last.prefix === void 0 || last.prefix === "")) {
2937
3489
  return;
2938
3490
  }
2939
- this.lines.push({ body: "" });
3491
+ const sep = { body: "" };
3492
+ this.lines.push(sep);
3493
+ this.trackLine(sep);
2940
3494
  this.streamingActive = false;
2941
3495
  this.adjustScrollForLineChange(1);
3496
+ this.trimScrollback();
2942
3497
  this.scheduleRepaint();
2943
3498
  }
2944
3499
  // The dispatcher is the source of truth for prompt state. If the prompt
@@ -3010,9 +3565,11 @@ var init_screen = __esm({
3010
3565
  return Math.max(0, bottom - top + 1);
3011
3566
  }
3012
3567
  maxScrollOffset() {
3013
- const wrapped = this.wrapLines(this.lines, this.term.width);
3014
- const visible = this.scrollbackVisibleRows();
3015
- 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());
3016
3573
  }
3017
3574
  // Used by content mutators to coalesce rapid updates. Repaints fire
3018
3575
  // at most once per contentRepaintThrottleMs; if a paint happened
@@ -3045,6 +3602,22 @@ var init_screen = __esm({
3045
3602
  this.repaint();
3046
3603
  }, this.contentRepaintThrottleMs - elapsed);
3047
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
+ }
3048
3621
  repaint() {
3049
3622
  if (this.repaintPaused > 0) {
3050
3623
  this.repaintPending = true;
@@ -3060,6 +3633,11 @@ var init_screen = __esm({
3060
3633
  if (w < 20 || h < 8) {
3061
3634
  return;
3062
3635
  }
3636
+ if (w !== this.lastFrameW || h !== this.lastFrameH) {
3637
+ this.lastFrameRows.clear();
3638
+ this.lastFrameW = w;
3639
+ this.lastFrameH = h;
3640
+ }
3063
3641
  this.drawHeader();
3064
3642
  this.drawSeparator(HEADER_ROWS);
3065
3643
  this.drawScrollback();
@@ -3075,38 +3653,40 @@ var init_screen = __esm({
3075
3653
  }
3076
3654
  drawHeader() {
3077
3655
  const w = this.term.width;
3078
- this.term.moveTo(1, 1).eraseLineAfter();
3079
3656
  const usage = formatUsage(this.header.usage);
3080
3657
  const sid = shortId(this.header.sessionId);
3081
3658
  const title = this.header.title?.trim();
3082
- const fixed = "hydra \xB7 ".length + this.header.agent.length + " \xB7 ".length + " \xB7 ".length + sid.length + (title ? " \xB7 ".length : 0) + (usage ? usage.length + 3 : 0);
3083
- const variableRoom = Math.max(8, w - fixed);
3084
- let cwdRoom;
3085
- let titleRoom;
3086
- if (title) {
3087
- const cwdCap = Math.max(8, Math.floor(variableRoom / 2));
3088
- cwdRoom = Math.min(this.header.cwd.length, cwdCap);
3089
- titleRoom = Math.max(8, variableRoom - cwdRoom);
3090
- } else {
3091
- titleRoom = 0;
3092
- cwdRoom = variableRoom;
3093
- }
3094
- this.term.bold("hydra")(" \xB7 ").cyan(this.header.agent)(" \xB7 ").dim(truncate(this.header.cwd, cwdRoom))(" \xB7 ").yellow(sid);
3095
- if (title) {
3096
- this.term(" \xB7 ").bold(truncate(title, titleRoom));
3097
- }
3098
- if (usage) {
3099
- const col = Math.max(1, w - usage.length + 1);
3100
- this.term.moveTo(col, 1);
3101
- this.term.dim(usage);
3102
- }
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
+ });
3103
3684
  }
3104
3685
  drawSeparator(row) {
3105
- if (row < 1 || row > this.term.height) {
3106
- return;
3107
- }
3108
- this.term.moveTo(1, row).eraseLineAfter();
3109
- 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
+ });
3110
3690
  }
3111
3691
  drawScrollback() {
3112
3692
  const w = this.term.width;
@@ -3115,21 +3695,30 @@ var init_screen = __esm({
3115
3695
  if (visibleRows <= 0) {
3116
3696
  return;
3117
3697
  }
3118
- const wrapped = this.wrapLines(this.lines, w);
3119
- const max = Math.max(0, wrapped.length - visibleRows);
3120
- if (this.scrollOffset > max) {
3121
- 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
+ }
3122
3707
  }
3123
3708
  const end = wrapped.length - this.scrollOffset;
3124
3709
  const start = Math.max(0, end - visibleRows);
3125
3710
  const slice = wrapped.slice(start, end);
3711
+ const padTop = Math.max(0, visibleRows - slice.length);
3126
3712
  for (let i = 0; i < visibleRows; i++) {
3127
3713
  const row = top + i;
3128
- this.term.moveTo(1, row).eraseLineAfter();
3129
- const line = slice[i];
3130
- if (line) {
3131
- this.writeFormattedLine(line, w);
3132
- }
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
+ });
3133
3722
  }
3134
3723
  }
3135
3724
  queuedRows() {
@@ -3160,26 +3749,27 @@ var init_screen = __esm({
3160
3749
  }
3161
3750
  for (let i = 0; i < rows; i++) {
3162
3751
  const row = completionTop + i;
3163
- this.term.moveTo(1, row).eraseLineAfter();
3164
3752
  const item = this.completions[i];
3165
- if (!item) {
3166
- continue;
3167
- }
3168
3753
  const isLast = i === rows - 1 && this.completions.length > MAX_COMPLETION_ROWS;
3169
- if (isLast) {
3170
- this.term.dim(
3171
- ` + ${this.completions.length - MAX_COMPLETION_ROWS + 1} more match(es)`
3172
- );
3173
- continue;
3174
- }
3175
- const namePadded = item.name.padEnd(nameWidth);
3176
- const desc = item.description ?? "";
3177
- const remaining = w - namePadded.length - 4;
3178
- const truncated = remaining > 0 ? truncate(desc, remaining) : "";
3179
- this.term(" ").brightCyan(namePadded);
3180
- if (truncated.length > 0) {
3181
- this.term(" ").dim(truncated);
3182
- }
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
+ });
3183
3773
  }
3184
3774
  }
3185
3775
  drawQueuedZone() {
@@ -3194,17 +3784,19 @@ var init_screen = __esm({
3194
3784
  const queuedTop = queuedBottom - rows + 1;
3195
3785
  for (let i = 0; i < rows; i++) {
3196
3786
  const row = queuedTop + i;
3197
- this.term.moveTo(1, row).eraseLineAfter();
3198
3787
  const text = this.queuedTexts[i];
3199
- if (text === void 0) {
3200
- continue;
3201
- }
3202
3788
  const isLast = i === rows - 1 && this.queuedTexts.length > MAX_QUEUED_ROWS;
3203
3789
  const overflow = this.queuedTexts.length - MAX_QUEUED_ROWS;
3204
- const summary = isLast ? `+ ${overflow + 1} more queued` : truncate(firstLine2(text), w - 4);
3205
- const display = ` \u23F3 ${summary}`;
3206
- const padded = display + " ".repeat(Math.max(0, w - display.length));
3207
- 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
+ });
3208
3800
  }
3209
3801
  }
3210
3802
  drawPrompt() {
@@ -3225,19 +3817,30 @@ var init_screen = __esm({
3225
3817
  for (let i = 0; i < layout.rendered; i++) {
3226
3818
  const vr = visualRows[layout.windowStart + i];
3227
3819
  const row = top + i;
3228
- this.term.moveTo(1, row).eraseLineAfter();
3229
- if (!vr) {
3230
- continue;
3231
- }
3232
- if (vr.bufferIdx === 0 && vr.startCol === 0) {
3233
- this.term.brightWhite("> ");
3234
- } else if (vr.startCol === 0) {
3235
- this.term.dim("\xB7 ");
3236
- } else {
3237
- 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);
3238
3829
  }
3239
- const line = state.buffer[vr.bufferIdx] ?? "";
3240
- 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
+ });
3241
3844
  }
3242
3845
  }
3243
3846
  drawConfirmPrompt() {
@@ -3247,10 +3850,12 @@ var init_screen = __esm({
3247
3850
  }
3248
3851
  const w = this.term.width;
3249
3852
  const top = this.term.height - CONFIRM_PROMPT_ROWS - BANNER_ROWS + 1;
3250
- this.term.moveTo(1, top).eraseLineAfter();
3251
- this.term.brightYellow(` ? ${truncate(spec.question, w - 4)}`);
3252
- this.term.moveTo(1, top + 1).eraseLineAfter();
3253
- 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
+ });
3254
3859
  }
3255
3860
  drawPermissionPrompt() {
3256
3861
  const spec = this.permissionPrompt;
@@ -3261,21 +3866,20 @@ var init_screen = __esm({
3261
3866
  const rows = this.permissionRows();
3262
3867
  const top = this.term.height - rows - BANNER_ROWS + 1;
3263
3868
  let row = top;
3264
- const writeRow = (paint) => {
3869
+ const writeRow = (sig, paint) => {
3265
3870
  if (row >= top + rows) {
3266
3871
  return;
3267
3872
  }
3268
- this.term.moveTo(1, row).eraseLineAfter();
3269
- paint();
3873
+ this.paintRow(row, sig, paint);
3270
3874
  row += 1;
3271
3875
  };
3272
- writeRow(() => {
3876
+ writeRow(`perm|t|${w}|${spec.title}`, () => {
3273
3877
  this.term.brightYellow(` \u{1F512} ${truncate(spec.title, w - 5)}`);
3274
3878
  });
3275
- writeRow(() => {
3879
+ writeRow(`perm|sub|${w}`, () => {
3276
3880
  this.term.dim(" This action requires approval");
3277
3881
  });
3278
- writeRow(() => {
3882
+ writeRow(`perm|q|${w}`, () => {
3279
3883
  this.term(" Do you want to proceed?");
3280
3884
  });
3281
3885
  for (let i = 0; i < spec.options.length; i++) {
@@ -3289,7 +3893,7 @@ var init_screen = __esm({
3289
3893
  const isSel = i === spec.selectedIndex;
3290
3894
  const marker = isSel ? "\u276F" : " ";
3291
3895
  const body = ` ${marker} ${i + 1}. ${truncate(opt.label, w - 8)}`;
3292
- writeRow(() => {
3896
+ writeRow(`perm|o|${w}|${i}|${isSel ? "1" : "0"}|${opt.label}`, () => {
3293
3897
  if (isSel) {
3294
3898
  this.term.brightCyan(body);
3295
3899
  } else {
@@ -3297,38 +3901,42 @@ var init_screen = __esm({
3297
3901
  }
3298
3902
  });
3299
3903
  }
3300
- writeRow(() => {
3904
+ writeRow(`perm|hint|${w}`, () => {
3301
3905
  this.term.dim(" \u2191/\u2193 choose \xB7 Enter submit \xB7 Esc cancel \xB7 1\u20139 quick-pick");
3302
3906
  });
3303
3907
  }
3304
3908
  drawBanner() {
3305
3909
  const row = this.term.height;
3306
- this.term.moveTo(1, row).eraseLineAfter();
3307
- const dot = this.banner.status === "running" ? "\u25CF" : "\u25CB";
3308
- const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
3309
- if (this.banner.status === "running") {
3310
- this.term.brightYellow(`${dot} ${this.banner.status}`);
3311
- if (this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3) {
3312
- 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}`);
3313
3925
  }
3314
- } else if (this.banner.status === "disconnected") {
3315
- this.term.brightRed(`${dot} ${this.banner.status}`);
3316
- } else {
3317
- this.term.brightGreen(`${dot} ${this.banner.status}`);
3318
- }
3319
- if (this.banner.queued > 0) {
3320
- this.term(" \xB7 ").brightYellow(`${this.banner.queued} queued`);
3321
- }
3322
- if (this.scrollOffset > 0) {
3323
- this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
3324
- }
3325
- this.term(" \xB7 ");
3326
- if (this.banner.planMode) {
3327
- this.term.brightMagenta(planLabel);
3328
- } else {
3329
- this.term.dim(planLabel);
3330
- }
3331
- 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
+ });
3332
3940
  }
3333
3941
  placeCursor() {
3334
3942
  if (this.permissionPrompt) {
@@ -3378,37 +3986,83 @@ var init_screen = __esm({
3378
3986
  4 + this.permissionPrompt.options.length
3379
3987
  );
3380
3988
  }
3381
- 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) {
3382
3998
  if (width <= 4) {
3383
- 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
+ };
3384
4004
  }
3385
- const out = [];
3386
- for (const line of lines) {
3387
- const prefix = line.prefix ?? "";
3388
- const room = Math.max(1, width - prefix.length);
3389
- const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
3390
- for (let i = 0; i < chunks.length; i++) {
3391
- const chunk = chunks[i] ?? "";
3392
- const wrappedLine = {
3393
- prefix: i === 0 ? line.prefix : " ".repeat(prefix.length),
3394
- body: chunk
3395
- };
3396
- if (line.prefixStyle !== void 0) {
3397
- wrappedLine.prefixStyle = line.prefixStyle;
3398
- }
3399
- if (line.bodyStyle !== void 0) {
3400
- wrappedLine.bodyStyle = line.bodyStyle;
3401
- }
3402
- if (line.fillRow) {
3403
- wrappedLine.fillRow = true;
3404
- }
3405
- if (line.ansi) {
3406
- wrappedLine.ansi = true;
3407
- }
3408
- 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;
3409
4050
  }
4051
+ if (line.bodyStyle !== void 0) {
4052
+ wrappedLine.bodyStyle = line.bodyStyle;
4053
+ }
4054
+ if (line.fillRow) {
4055
+ wrappedLine.fillRow = true;
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);
3410
4064
  }
3411
- return out;
4065
+ return wrapped;
3412
4066
  }
3413
4067
  writeFormattedLine(line, width) {
3414
4068
  if (line.prefix) {
@@ -3447,6 +4101,10 @@ var init_input = __esm({
3447
4101
  savedDraft = null;
3448
4102
  history = [];
3449
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 = "";
3450
4108
  constructor(opts = {}) {
3451
4109
  this.history = [...opts.history ?? []];
3452
4110
  this.planMode = opts.planMode ?? false;
@@ -3555,6 +4213,9 @@ var init_input = __esm({
3555
4213
  case "ctrl-w":
3556
4214
  this.killWord();
3557
4215
  return [];
4216
+ case "ctrl-y":
4217
+ this.yank();
4218
+ return [];
3558
4219
  case "escape":
3559
4220
  return [];
3560
4221
  }
@@ -3648,11 +4309,19 @@ var init_input = __esm({
3648
4309
  }
3649
4310
  killLine() {
3650
4311
  const line = this.currentLine();
4312
+ const killed = line.slice(0, this.col);
4313
+ if (killed.length > 0) {
4314
+ this.killBuffer = killed;
4315
+ }
3651
4316
  this.setCurrentLine(line.slice(this.col));
3652
4317
  this.col = 0;
3653
4318
  }
3654
4319
  killToEnd() {
3655
4320
  const line = this.currentLine();
4321
+ const killed = line.slice(this.col);
4322
+ if (killed.length > 0) {
4323
+ this.killBuffer = killed;
4324
+ }
3656
4325
  this.setCurrentLine(line.slice(0, this.col));
3657
4326
  }
3658
4327
  killWord() {
@@ -3668,9 +4337,19 @@ var init_input = __esm({
3668
4337
  while (i > 0 && !/\s/.test(line[i - 1] ?? "")) {
3669
4338
  i -= 1;
3670
4339
  }
4340
+ const killed = line.slice(i, this.col);
4341
+ if (killed.length > 0) {
4342
+ this.killBuffer = killed;
4343
+ }
3671
4344
  this.setCurrentLine(line.slice(0, i) + line.slice(this.col));
3672
4345
  this.col = i;
3673
4346
  }
4347
+ yank() {
4348
+ if (this.killBuffer.length === 0) {
4349
+ return;
4350
+ }
4351
+ this.insertText(this.killBuffer);
4352
+ }
3674
4353
  moveLeft() {
3675
4354
  if (this.col > 0) {
3676
4355
  this.col -= 1;
@@ -4333,6 +5012,7 @@ var init_format = __esm({
4333
5012
  });
4334
5013
 
4335
5014
  // src/tui/app.ts
5015
+ import { appendFileSync, statSync, renameSync } from "fs";
4336
5016
  import { nanoid as nanoid3 } from "nanoid";
4337
5017
  import termkit from "terminal-kit";
4338
5018
  async function runTuiApp(opts) {
@@ -4350,6 +5030,8 @@ async function runSession(term, config, opts) {
4350
5030
  term.grabInput(false);
4351
5031
  process.exit(0);
4352
5032
  }
5033
+ const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5034
+ term.cyan(launchLabel)("\n");
4353
5035
  const protocol = config.daemon.tls ? "wss" : "ws";
4354
5036
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
4355
5037
  const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
@@ -4396,8 +5078,9 @@ async function runSession(term, config, opts) {
4396
5078
  const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
4397
5079
  if (before === 0 && pendingTurns > 0) {
4398
5080
  sessionBusySince = Date.now();
5081
+ dispatcherRef?.setTurnRunning(true);
4399
5082
  if (screenReady) {
4400
- screenRef.setBanner({ status: "running", elapsedMs: 0 });
5083
+ screenRef.setBanner({ status: "busy", elapsedMs: 0 });
4401
5084
  }
4402
5085
  if (sessionElapsedTimer === null && screenReady) {
4403
5086
  sessionElapsedTimer = setInterval(() => {
@@ -4406,10 +5089,11 @@ async function runSession(term, config, opts) {
4406
5089
  }
4407
5090
  screenRef.setBanner({ elapsedMs: Date.now() - sessionBusySince });
4408
5091
  renderToolsBlock();
4409
- }, 5e3);
5092
+ }, 1e3);
4410
5093
  }
4411
5094
  } else if (before > 0 && pendingTurns === 0) {
4412
5095
  sessionBusySince = null;
5096
+ dispatcherRef?.setTurnRunning(false);
4413
5097
  if (sessionElapsedTimer !== null) {
4414
5098
  clearInterval(sessionElapsedTimer);
4415
5099
  sessionElapsedTimer = null;
@@ -4423,9 +5107,11 @@ async function runSession(term, config, opts) {
4423
5107
  }
4424
5108
  };
4425
5109
  let screenRef = null;
5110
+ let dispatcherRef = null;
4426
5111
  conn.onNotification("session/update", (params) => {
4427
5112
  const { update } = params ?? {};
4428
5113
  const event = mapUpdate(update);
5114
+ debugLogUpdate(update, event);
4429
5115
  if (event?.kind === "user-text") {
4430
5116
  adjustPendingTurns(1);
4431
5117
  } else if (event?.kind === "turn-complete") {
@@ -4542,6 +5228,7 @@ async function runSession(term, config, opts) {
4542
5228
  let initialModel;
4543
5229
  let initialMode;
4544
5230
  let initialCommands;
5231
+ let initialTurnStartedAt;
4545
5232
  if (ctx.sessionId === "__new__") {
4546
5233
  const created = await conn.request("session/new", {
4547
5234
  cwd: ctx.cwd,
@@ -4562,6 +5249,7 @@ async function runSession(term, config, opts) {
4562
5249
  }
4563
5250
  initialModel = hydraMeta.currentModel;
4564
5251
  initialMode = hydraMeta.currentMode;
5252
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
4565
5253
  if (hydraMeta.availableCommands) {
4566
5254
  initialCommands = hydraMeta.availableCommands.map(
4567
5255
  (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
@@ -4587,20 +5275,26 @@ async function runSession(term, config, opts) {
4587
5275
  }
4588
5276
  initialModel = hydraMeta.currentModel;
4589
5277
  initialMode = hydraMeta.currentMode;
5278
+ initialTurnStartedAt = hydraMeta.turnStartedAt;
4590
5279
  if (hydraMeta.availableCommands) {
4591
5280
  initialCommands = hydraMeta.availableCommands.map(
4592
5281
  (c) => c.description !== void 0 ? { name: c.name, description: c.description } : { name: c.name }
4593
5282
  );
4594
5283
  }
4595
5284
  }
4596
- const historyFile = paths.tuiHistoryFile();
5285
+ const historyFile = paths.tuiHistoryFile(resolvedSessionId);
4597
5286
  let history = await loadHistory(historyFile).catch(() => []);
4598
5287
  const dispatcher = new InputDispatcher({ history });
5288
+ dispatcherRef = dispatcher;
5289
+ if (pendingTurns > 0) {
5290
+ dispatcher.setTurnRunning(true);
5291
+ }
4599
5292
  let turnInFlight = null;
4600
5293
  const screen = new Screen({
4601
5294
  term,
4602
5295
  dispatcher,
4603
5296
  repaintThrottleMs: config.tui.repaintThrottleMs,
5297
+ maxScrollbackLines: config.tui.maxScrollbackLines,
4604
5298
  onKey: (events) => {
4605
5299
  for (const ev of events) {
4606
5300
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -4767,11 +5461,18 @@ async function runSession(term, config, opts) {
4767
5461
  const sessionDone = new Promise((resolve2) => {
4768
5462
  finishSession = resolve2;
4769
5463
  });
5464
+ const cancelRemoteTurn = () => {
5465
+ conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
5466
+ };
4770
5467
  const sigintHandler = () => {
4771
5468
  if (turnInFlight) {
4772
5469
  turnInFlight.cancel();
4773
5470
  return;
4774
5471
  }
5472
+ if (pendingTurns > 0) {
5473
+ cancelRemoteTurn();
5474
+ return;
5475
+ }
4775
5476
  void requestExit();
4776
5477
  };
4777
5478
  let exitConfirmation = null;
@@ -4846,6 +5547,7 @@ async function runSession(term, config, opts) {
4846
5547
  };
4847
5548
  const teardown = () => {
4848
5549
  process.off("SIGINT", sigintHandler);
5550
+ screen.clearWindowTitle();
4849
5551
  screen.stop();
4850
5552
  saveHistory(historyFile, history).catch(() => void 0);
4851
5553
  void stream.close().catch(() => void 0);
@@ -4861,22 +5563,32 @@ async function runSession(term, config, opts) {
4861
5563
  }
4862
5564
  };
4863
5565
  const switchSession = async () => {
4864
- const resume = finishSession;
4865
- if (!resume) {
5566
+ if (!finishSession) {
4866
5567
  return;
4867
5568
  }
4868
- finishSession = null;
4869
- 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);
4870
5577
  const sessions = await listSessions(config);
4871
5578
  const choice = await pickSession(term, {
4872
5579
  cwd: resolvedCwd,
4873
5580
  sessions,
4874
- coldLimit: config.sessionListColdLimit
5581
+ config
4875
5582
  });
4876
5583
  if (choice.kind === "abort") {
4877
- resume({ ...opts, sessionId: resolvedSessionId, cwd: resolvedCwd });
5584
+ screen.start();
5585
+ screen.resumeRepaint();
4878
5586
  return;
4879
5587
  }
5588
+ const resume = finishSession;
5589
+ finishSession = null;
5590
+ process.off("SIGINT", sigintHandler);
5591
+ void stream.close().catch(() => void 0);
4880
5592
  if (choice.kind === "new") {
4881
5593
  const { sessionId: _drop, ...rest } = opts;
4882
5594
  void _drop;
@@ -4901,6 +5613,8 @@ async function runSession(term, config, opts) {
4901
5613
  case "cancel":
4902
5614
  if (turnInFlight) {
4903
5615
  turnInFlight.cancel();
5616
+ } else if (pendingTurns > 0) {
5617
+ cancelRemoteTurn();
4904
5618
  }
4905
5619
  if (promptQueue.length > (workerActive ? 1 : 0)) {
4906
5620
  promptQueue.length = workerActive ? 1 : 0;
@@ -5088,7 +5802,6 @@ async function runSession(term, config, opts) {
5088
5802
  const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
5089
5803
  adjustPendingTurns(1);
5090
5804
  appendRender({ kind: "user-text", text });
5091
- dispatcher.setTurnRunning(true);
5092
5805
  let cancelled = false;
5093
5806
  turnInFlight = {
5094
5807
  cancel: () => {
@@ -5118,7 +5831,6 @@ async function runSession(term, config, opts) {
5118
5831
  });
5119
5832
  } finally {
5120
5833
  turnInFlight = null;
5121
- dispatcher.setTurnRunning(false);
5122
5834
  adjustPendingTurns(-1);
5123
5835
  appendRender(
5124
5836
  stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
@@ -5174,7 +5886,7 @@ async function runSession(term, config, opts) {
5174
5886
  const elapsed = end - toolsBlockStartedAt;
5175
5887
  let summary;
5176
5888
  if (total === 0) {
5177
- summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `no tools \xB7 took ${formatElapsed(elapsed)}`;
5889
+ summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
5178
5890
  } else {
5179
5891
  const noun = total === 1 ? "tool" : "tools";
5180
5892
  const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
@@ -5277,6 +5989,12 @@ async function runSession(term, config, opts) {
5277
5989
  if (formatted2.length > 0) {
5278
5990
  screen.appendLines(formatted2);
5279
5991
  }
5992
+ screen.clearKey("tools");
5993
+ screen.clearKey("plan");
5994
+ toolStates.clear();
5995
+ toolCallOrder.length = 0;
5996
+ toolsExpanded = false;
5997
+ toolsBlockEndedAt = null;
5280
5998
  startToolsBlock();
5281
5999
  screen.redraw();
5282
6000
  return;
@@ -5317,12 +6035,10 @@ async function runSession(term, config, opts) {
5317
6035
  if (event.kind === "turn-complete") {
5318
6036
  closeAgentText();
5319
6037
  screen.clearKey("plan");
5320
- if (toolCallOrder.length > 0) {
6038
+ if (toolsBlockStartedAt !== null) {
5321
6039
  toolsBlockEndedAt = Date.now();
5322
6040
  renderToolsBlock();
5323
6041
  screen.clearKey("tools");
5324
- } else if (toolsBlockStartedAt !== null) {
5325
- screen.removeBlock("tools");
5326
6042
  }
5327
6043
  toolStates.clear();
5328
6044
  toolCallOrder.length = 0;
@@ -5342,6 +6058,23 @@ async function runSession(term, config, opts) {
5342
6058
  } finally {
5343
6059
  screen.resumeRepaint();
5344
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
+ }
5345
6078
  const resetInFlightUiState = () => {
5346
6079
  if (pendingPermission) {
5347
6080
  const resolve2 = pendingPermission.resolve;
@@ -5351,13 +6084,9 @@ async function runSession(term, config, opts) {
5351
6084
  }
5352
6085
  closeAgentText();
5353
6086
  if (toolsBlockStartedAt !== null) {
5354
- if (toolCallOrder.length > 0) {
5355
- toolsBlockEndedAt = Date.now();
5356
- renderToolsBlock();
5357
- screen.clearKey("tools");
5358
- } else {
5359
- screen.removeBlock("tools");
5360
- }
6087
+ toolsBlockEndedAt = Date.now();
6088
+ renderToolsBlock();
6089
+ screen.clearKey("tools");
5361
6090
  toolStates.clear();
5362
6091
  toolCallOrder.length = 0;
5363
6092
  toolsBlockStartedAt = null;
@@ -5427,7 +6156,7 @@ async function runSession(term, config, opts) {
5427
6156
  ]);
5428
6157
  }
5429
6158
  screen.setBanner({
5430
- status: pendingTurns > 0 ? "running" : "ready",
6159
+ status: pendingTurns > 0 ? "busy" : "ready",
5431
6160
  elapsedMs: pendingTurns > 0 ? 0 : void 0
5432
6161
  });
5433
6162
  };
@@ -5475,7 +6204,7 @@ async function resolveSession(term, config, opts) {
5475
6204
  const choice = await pickSession(term, {
5476
6205
  cwd,
5477
6206
  sessions,
5478
- coldLimit: config.sessionListColdLimit
6207
+ config
5479
6208
  });
5480
6209
  if (choice.kind === "abort") {
5481
6210
  return null;
@@ -5496,7 +6225,41 @@ function newCtx(opts, cwd, config) {
5496
6225
  cwd
5497
6226
  };
5498
6227
  }
5499
- var PLAN_PREFIX_TEXT;
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
+ }
6261
+ }
6262
+ var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
5500
6263
  var init_app = __esm({
5501
6264
  "src/tui/app.ts"() {
5502
6265
  "use strict";
@@ -5514,6 +6277,7 @@ var init_app = __esm({
5514
6277
  init_render_update();
5515
6278
  init_format();
5516
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;
5517
6281
  }
5518
6282
  });
5519
6283
 
@@ -5630,7 +6394,7 @@ import { setTimeout as sleep2 } from "timers/promises";
5630
6394
 
5631
6395
  // src/daemon/server.ts
5632
6396
  init_config();
5633
- import * as fs7 from "fs";
6397
+ import * as fs8 from "fs";
5634
6398
  import * as fsp2 from "fs/promises";
5635
6399
  import Fastify from "fastify";
5636
6400
  import websocketPlugin from "@fastify/websocket";
@@ -5802,6 +6566,9 @@ function planSpawn(agent, extraArgs = []) {
5802
6566
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
5803
6567
  }
5804
6568
 
6569
+ // src/core/session-manager.ts
6570
+ import * as fs6 from "fs/promises";
6571
+
5805
6572
  // src/core/agent-instance.ts
5806
6573
  import { spawn } from "child_process";
5807
6574
 
@@ -6189,6 +6956,7 @@ var HistoryStore = class {
6189
6956
  };
6190
6957
 
6191
6958
  // src/core/session-manager.ts
6959
+ init_paths();
6192
6960
  init_types();
6193
6961
  var SessionManager = class {
6194
6962
  constructor(registry, spawner, store, options = {}) {
@@ -6307,7 +7075,8 @@ var SessionManager = class {
6307
7075
  seedHistory: params.seedHistory,
6308
7076
  currentModel: params.currentModel,
6309
7077
  currentMode: params.currentMode,
6310
- agentCommands: params.agentCommands
7078
+ agentCommands: params.agentCommands,
7079
+ firstPromptSeeded: true
6311
7080
  });
6312
7081
  await this.attachManagerHooks(session);
6313
7082
  return session;
@@ -6478,13 +7247,14 @@ var SessionManager = class {
6478
7247
  continue;
6479
7248
  }
6480
7249
  liveIds.add(session.sessionId);
7250
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
6481
7251
  entries.push({
6482
7252
  sessionId: session.sessionId,
6483
7253
  upstreamSessionId: session.upstreamSessionId,
6484
7254
  cwd: session.cwd,
6485
7255
  title: session.title,
6486
7256
  agentId: session.agentId,
6487
- updatedAt: new Date(session.updatedAt).toISOString(),
7257
+ updatedAt: used,
6488
7258
  attachedClients: session.attachedCount,
6489
7259
  status: "live"
6490
7260
  });
@@ -6497,13 +7267,14 @@ var SessionManager = class {
6497
7267
  if (filter.cwd && r.cwd !== filter.cwd) {
6498
7268
  continue;
6499
7269
  }
7270
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
6500
7271
  entries.push({
6501
7272
  sessionId: r.sessionId,
6502
7273
  upstreamSessionId: r.upstreamSessionId,
6503
7274
  cwd: r.cwd,
6504
7275
  title: r.title,
6505
7276
  agentId: r.agentId,
6506
- updatedAt: r.updatedAt,
7277
+ updatedAt: used,
6507
7278
  attachedClients: 0,
6508
7279
  status: "cold"
6509
7280
  });
@@ -6519,6 +7290,10 @@ var SessionManager = class {
6519
7290
  await this.store.delete(sessionId).catch(() => void 0);
6520
7291
  return true;
6521
7292
  }
7293
+ async hasRecord(sessionId) {
7294
+ const record = await this.store.read(sessionId).catch(() => void 0);
7295
+ return record !== void 0;
7296
+ }
6522
7297
  // Persist a title update from Session.setTitle. The on-disk record
6523
7298
  // was written at create time; updating it here keeps the session
6524
7299
  // record's title in sync with what was broadcast to clients so a
@@ -6593,11 +7368,19 @@ var SessionManager = class {
6593
7368
  this.sessions.clear();
6594
7369
  }
6595
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
+ }
6596
7379
 
6597
7380
  // src/core/extensions.ts
6598
7381
  init_paths();
6599
7382
  import { spawn as spawn2 } from "child_process";
6600
- import * as fs6 from "fs";
7383
+ import * as fs7 from "fs";
6601
7384
  import * as fsp from "fs/promises";
6602
7385
  import * as path3 from "path";
6603
7386
  var RESTART_BASE_MS = 1e3;
@@ -6880,7 +7663,7 @@ var ExtensionManager = class {
6880
7663
  }
6881
7664
  const ext = entry.config;
6882
7665
  const command = ext.command.length > 0 ? ext.command : [ext.name];
6883
- const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
7666
+ const logStream = fs7.createWriteStream(paths.extensionLogFile(ext.name), {
6884
7667
  flags: "a"
6885
7668
  });
6886
7669
  logStream.write(
@@ -6930,7 +7713,7 @@ var ExtensionManager = class {
6930
7713
  }
6931
7714
  if (typeof child.pid === "number") {
6932
7715
  try {
6933
- fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7716
+ fs7.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
6934
7717
  `, {
6935
7718
  encoding: "utf8",
6936
7719
  mode: 384
@@ -6955,7 +7738,7 @@ var ExtensionManager = class {
6955
7738
  });
6956
7739
  child.on("exit", (code, signal) => {
6957
7740
  try {
6958
- fs6.unlinkSync(paths.extensionPidFile(ext.name));
7741
+ fs7.unlinkSync(paths.extensionPidFile(ext.name));
6959
7742
  } catch {
6960
7743
  }
6961
7744
  logStream.write(
@@ -7093,6 +7876,22 @@ function registerSessionRoutes(app, manager, defaults) {
7093
7876
  reply.code(500).send({ error: err.message });
7094
7877
  }
7095
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
+ });
7096
7895
  app.delete("/v1/sessions/:id", async (request, reply) => {
7097
7896
  const raw = request.params.id;
7098
7897
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -7300,6 +8099,16 @@ function parseRegisterBody(body) {
7300
8099
  };
7301
8100
  }
7302
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
+
7303
8112
  // src/daemon/acp-ws.ts
7304
8113
  init_connection();
7305
8114
  init_ws_stream();
@@ -7553,6 +8362,9 @@ function buildResponseMeta(session) {
7553
8362
  if (commands.length > 0) {
7554
8363
  ours.availableCommands = commands;
7555
8364
  }
8365
+ if (session.turnStartedAt !== void 0) {
8366
+ ours.turnStartedAt = session.turnStartedAt;
8367
+ }
7556
8368
  return mergeMeta(session.agentMeta, ours);
7557
8369
  }
7558
8370
  function buildInitializeResult() {
@@ -7638,6 +8450,10 @@ async function startDaemon(config) {
7638
8450
  });
7639
8451
  registerAgentRoutes(app, registry);
7640
8452
  registerExtensionRoutes(app, extensions);
8453
+ registerConfigRoutes(app, {
8454
+ defaultAgent: config.defaultAgent,
8455
+ defaultCwd: config.defaultCwd
8456
+ });
7641
8457
  registerAcpWsEndpoint(app, {
7642
8458
  config,
7643
8459
  manager,
@@ -7673,7 +8489,7 @@ async function startDaemon(config) {
7673
8489
  await manager.closeAll();
7674
8490
  await app.close();
7675
8491
  try {
7676
- fs7.unlinkSync(paths.pidFile());
8492
+ fs8.unlinkSync(paths.pidFile());
7677
8493
  } catch {
7678
8494
  }
7679
8495
  try {
@@ -7712,13 +8528,13 @@ function ensureLoopbackOrTls(config) {
7712
8528
  init_daemon_bootstrap();
7713
8529
 
7714
8530
  // src/cli/commands/log-tail.ts
7715
- import * as fs8 from "fs";
8531
+ import * as fs9 from "fs";
7716
8532
  import * as fsp3 from "fs/promises";
7717
8533
  async function runLogTail(logPath, argv, notFoundMessage) {
7718
8534
  const opts = parseLogTailFlags(argv);
7719
- let stat2;
8535
+ let stat3;
7720
8536
  try {
7721
- stat2 = await fsp3.stat(logPath);
8537
+ stat3 = await fsp3.stat(logPath);
7722
8538
  } catch (err) {
7723
8539
  const e = err;
7724
8540
  if (e.code === "ENOENT") {
@@ -7729,14 +8545,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
7729
8545
  }
7730
8546
  throw err;
7731
8547
  }
7732
- let position = await printTail(logPath, stat2.size, opts.tail);
8548
+ let position = await printTail(logPath, stat3.size, opts.tail);
7733
8549
  if (!opts.follow) {
7734
8550
  return;
7735
8551
  }
7736
8552
  process.stdout.write(`-- following ${logPath} --
7737
8553
  `);
7738
8554
  let pending = false;
7739
- const watcher = fs8.watch(logPath, () => {
8555
+ const watcher = fs9.watch(logPath, () => {
7740
8556
  if (pending) {
7741
8557
  return;
7742
8558
  }
@@ -8915,6 +9731,10 @@ async function main() {
8915
9731
  await runSessionsKill(positional[2]);
8916
9732
  return;
8917
9733
  }
9734
+ if (sub === "rm") {
9735
+ await runSessionsRm(positional[2]);
9736
+ return;
9737
+ }
8918
9738
  process.stderr.write(`Unknown sessions subcommand: ${sub}
8919
9739
  `);
8920
9740
  process.exit(2);
@@ -9026,7 +9846,8 @@ function printHelp() {
9026
9846
  " hydra-acp daemon start|stop|restart|status",
9027
9847
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
9028
9848
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
9029
- " 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)",
9030
9851
  " hydra-acp extensions list List configured extensions and live state",
9031
9852
  " hydra-acp extensions add <name> [opts] Add an extension to config",
9032
9853
  " hydra-acp extensions remove <name> Remove an extension from config",