@hydra-acp/cli 0.1.7 → 0.1.8

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
@@ -12,6 +12,19 @@ var __export = (target, all) => {
12
12
  // src/core/paths.ts
13
13
  import * as path from "path";
14
14
  import * as os from "os";
15
+ function shortenHomePath(p) {
16
+ const home = os.homedir();
17
+ if (!home) {
18
+ return p;
19
+ }
20
+ if (p === home) {
21
+ return "~";
22
+ }
23
+ if (p.startsWith(home + "/")) {
24
+ return "~" + p.slice(home.length);
25
+ }
26
+ return p;
27
+ }
15
28
  function hydraHome() {
16
29
  const override = process.env[ROOT_ENV];
17
30
  if (override && override.length > 0) {
@@ -45,6 +58,18 @@ var init_paths = __esm({
45
58
  // machine's binaries cleanly separated. `ls agents/` immediately
46
59
  // shows which platforms have ever installed anything.
47
60
  agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
61
+ // npm install cache for npx-distributed agents. The trailing
62
+ // node<ABI> segment keys on process.versions.modules so a Node
63
+ // major bump (different ABI → native modules incompatible) yields
64
+ // a fresh install rather than failing at require() time.
65
+ agentNpmInstallDir: (id, platformKey, version) => path.join(
66
+ hydraHome(),
67
+ "agents",
68
+ platformKey,
69
+ id,
70
+ version,
71
+ `node${process.versions.modules}`
72
+ ),
48
73
  sessionsDir: () => path.join(hydraHome(), "sessions"),
49
74
  // One directory per session id under sessions/. Co-locates the
50
75
  // session record, its transcript, and any future per-session state
@@ -149,6 +174,9 @@ async function loadConfig() {
149
174
  daemon.authToken = token;
150
175
  return HydraConfig.parse(raw);
151
176
  }
177
+ async function loadConfigReadOnly() {
178
+ return HydraConfigReadOnly.parse(await readConfigFile());
179
+ }
152
180
  async function ensureConfig() {
153
181
  if (!await loadAuthToken()) {
154
182
  const token = generateAuthToken();
@@ -181,7 +209,7 @@ function expandHome(p) {
181
209
  }
182
210
  return p;
183
211
  }
184
- var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig;
212
+ var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig, HydraConfigReadOnly;
185
213
  var init_config = __esm({
186
214
  "src/core/config.ts"() {
187
215
  "use strict";
@@ -197,7 +225,16 @@ var init_config = __esm({
197
225
  authToken: z.string().min(16),
198
226
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
199
227
  tls: TlsConfig.optional(),
200
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
228
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
229
+ // Cap on entries kept in a session's on-disk replay log (history.jsonl).
230
+ // Compaction trims to this many on a periodic basis; reads also slice
231
+ // to the tail at this length as a defensive measure against older
232
+ // daemons that may have written unbounded files.
233
+ sessionHistoryMaxEntries: z.number().int().positive().default(1e3),
234
+ // Bytes of trailing agent stderr buffered per AgentInstance so the
235
+ // daemon can include it in the diagnostic message when a spawn fails.
236
+ // Bump if your agents emit large tracebacks you want surfaced.
237
+ agentStderrTailBytes: z.number().int().positive().default(4096)
201
238
  });
202
239
  RegistryConfig = z.object({
203
240
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -220,7 +257,14 @@ var init_config = __esm({
220
257
  // text selection requires shift+drag to bypass mouse reporting. Set
221
258
  // false to disable capture — wheel scrollback stops working, but
222
259
  // plain click-drag selects text via the terminal emulator.
223
- mouse: z.boolean().default(true)
260
+ mouse: z.boolean().default(true),
261
+ // Size at which the TUI's session/update debug log (tui.log) rotates
262
+ // to tui.log.0 and resets. Bounds on-disk use at ~2x this value.
263
+ logMaxBytes: z.number().int().positive().default(5 * 1024 * 1024),
264
+ // Width cap on the cwd column in the `sessions list` output and the
265
+ // TUI picker. Set higher if you keep deeply-nested working directories
266
+ // and want them visible; the elastic title column shrinks to make room.
267
+ cwdColumnMaxWidth: z.number().int().positive().default(24)
224
268
  });
225
269
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
226
270
  ExtensionBody = z.object({
@@ -256,9 +300,14 @@ var init_config = __esm({
256
300
  tui: TuiConfig.default({
257
301
  repaintThrottleMs: 1e3,
258
302
  maxScrollbackLines: 1e4,
259
- mouse: true
303
+ mouse: true,
304
+ logMaxBytes: 5 * 1024 * 1024,
305
+ cwdColumnMaxWidth: 24
260
306
  })
261
307
  });
308
+ HydraConfigReadOnly = HydraConfig.extend({
309
+ daemon: DaemonConfig.omit({ authToken: true })
310
+ });
262
311
  }
263
312
  });
264
313
 
@@ -332,10 +381,11 @@ function extractHydraMeta(meta) {
332
381
  function mergeMeta(passthrough, ours) {
333
382
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
334
383
  }
335
- var JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
384
+ var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
336
385
  var init_types = __esm({
337
386
  "src/acp/types.ts"() {
338
387
  "use strict";
388
+ ACP_PROTOCOL_VERSION = 1;
339
389
  JsonRpcErrorCodes = {
340
390
  ParseError: -32700,
341
391
  InvalidRequest: -32600,
@@ -528,6 +578,13 @@ var init_connection = __esm({
528
578
  }
529
579
  await this.stream.close();
530
580
  }
581
+ // Force-close with an error. Rejects all pending requests and fires
582
+ // close handlers carrying `err`. Used by transports that detect a
583
+ // failure (e.g. child process crash, spawn ENOENT) the stream itself
584
+ // can't surface as a stdout/stdin error.
585
+ fail(err) {
586
+ this.handleClose(err);
587
+ }
531
588
  handleIncoming(message) {
532
589
  if ("method" in message) {
533
590
  if ("id" in message && message.id !== void 0) {
@@ -739,7 +796,7 @@ function firstLine(text, max) {
739
796
  }
740
797
  return void 0;
741
798
  }
742
- var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, MAX_HISTORY_ENTRIES, COMPACT_EVERY, Session, STATE_UPDATE_KINDS;
799
+ var HYDRA_ID_ALPHABET, generateHydraId, HYDRA_SESSION_PREFIX, DEFAULT_HISTORY_MAX_ENTRIES, Session, STATE_UPDATE_KINDS;
743
800
  var init_session = __esm({
744
801
  "src/core/session.ts"() {
745
802
  "use strict";
@@ -748,8 +805,7 @@ var init_session = __esm({
748
805
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
749
806
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
750
807
  HYDRA_SESSION_PREFIX = "hydra_session_";
751
- MAX_HISTORY_ENTRIES = 1e3;
752
- COMPACT_EVERY = 200;
808
+ DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
753
809
  Session = class {
754
810
  sessionId;
755
811
  cwd;
@@ -791,11 +847,13 @@ var init_session = __esm({
791
847
  // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
792
848
  // Drives the mid-turn elapsed counter delivered to fresh attachers.
793
849
  promptStartedAt;
794
- // Counts appends since the last compaction. When it hits COMPACT_EVERY
850
+ // Counts appends since the last compaction. When it hits compactEvery
795
851
  // we ask the history store to trim the file to the most recent
796
- // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
852
+ // historyMaxEntries. Keeps file growth bounded without per-append
797
853
  // file-size checks.
798
854
  appendCount = 0;
855
+ historyMaxEntries;
856
+ compactEvery;
799
857
  // Permission requests that have been broadcast to one or more
800
858
  // clients but have not yet resolved. Replayed to clients that
801
859
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -852,6 +910,8 @@ var init_session = __esm({
852
910
  this.firstPromptSeeded = true;
853
911
  }
854
912
  this.historyStore = init.historyStore;
913
+ this.historyMaxEntries = init.historyMaxEntries ?? DEFAULT_HISTORY_MAX_ENTRIES;
914
+ this.compactEvery = Math.max(1, Math.floor(this.historyMaxEntries * 0.2));
855
915
  this.updatedAt = Date.now();
856
916
  this.createdAt = init.createdAt ?? this.updatedAt;
857
917
  this.lastRecordedAt = this.updatedAt;
@@ -1690,9 +1750,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1690
1750
  if (this.historyStore) {
1691
1751
  const store = this.historyStore;
1692
1752
  void store.append(this.sessionId, entry).catch(() => void 0);
1693
- if (this.appendCount >= COMPACT_EVERY) {
1753
+ if (this.appendCount >= this.compactEvery) {
1694
1754
  this.appendCount = 0;
1695
- void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
1755
+ void store.compact(this.sessionId, this.historyMaxEntries).catch(
1696
1756
  () => void 0
1697
1757
  );
1698
1758
  }
@@ -1809,9 +1869,187 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1809
1869
  }
1810
1870
  });
1811
1871
 
1872
+ // src/core/session-store.ts
1873
+ import * as fs5 from "fs/promises";
1874
+ import * as path4 from "path";
1875
+ import { customAlphabet as customAlphabet2 } from "nanoid";
1876
+ import { z as z4 } from "zod";
1877
+ function generateLineageId() {
1878
+ return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
1879
+ }
1880
+ function assertSafeId(id) {
1881
+ if (!SESSION_ID_PATTERN.test(id)) {
1882
+ throw new Error(`unsafe session id: ${id}`);
1883
+ }
1884
+ }
1885
+ function recordFromMemorySession(args) {
1886
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1887
+ return {
1888
+ sessionId: args.sessionId,
1889
+ lineageId: args.lineageId,
1890
+ upstreamSessionId: args.upstreamSessionId,
1891
+ importedFromSessionId: args.importedFromSessionId,
1892
+ agentId: args.agentId,
1893
+ cwd: args.cwd,
1894
+ title: args.title,
1895
+ agentArgs: args.agentArgs,
1896
+ currentModel: args.currentModel,
1897
+ currentMode: args.currentMode,
1898
+ currentUsage: args.currentUsage,
1899
+ agentCommands: args.agentCommands,
1900
+ createdAt: args.createdAt ?? now,
1901
+ updatedAt: args.updatedAt ?? now
1902
+ };
1903
+ }
1904
+ var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
1905
+ var init_session_store = __esm({
1906
+ "src/core/session-store.ts"() {
1907
+ "use strict";
1908
+ init_paths();
1909
+ HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1910
+ generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
1911
+ HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
1912
+ PersistedAgentCommand = z4.object({
1913
+ name: z4.string(),
1914
+ description: z4.string().optional()
1915
+ });
1916
+ PersistedUsage = z4.object({
1917
+ used: z4.number().optional(),
1918
+ size: z4.number().optional(),
1919
+ costAmount: z4.number().optional(),
1920
+ costCurrency: z4.string().optional()
1921
+ });
1922
+ SessionRecord = z4.object({
1923
+ version: z4.literal(1),
1924
+ sessionId: z4.string(),
1925
+ // Optional for back-compat with records written before this field
1926
+ // existed; mergeForPersistence generates one on next write so any
1927
+ // touched session converges to having a lineageId. A record that
1928
+ // never gets written again (truly cold and untouched) just won't
1929
+ // participate in lineage-based dedup, which is correct — it was
1930
+ // never exported, so no incoming bundle can claim its lineage.
1931
+ lineageId: z4.string().optional(),
1932
+ upstreamSessionId: z4.string(),
1933
+ // When non-empty, marks a session that was created by import and is
1934
+ // waiting for its first attach to bootstrap a fresh upstream agent
1935
+ // and replay the imported history as a takeover transcript. The
1936
+ // origin's local id at export time, kept for debuggability and as a
1937
+ // breadcrumb in `sessions list` (informational, not used for routing).
1938
+ importedFromSessionId: z4.string().optional(),
1939
+ agentId: z4.string(),
1940
+ cwd: z4.string(),
1941
+ title: z4.string().optional(),
1942
+ agentArgs: z4.array(z4.string()).optional(),
1943
+ // Snapshot of "what is currently true about this session" carried in
1944
+ // meta.json so a late-attaching or cold-resurrected client can be
1945
+ // told via the attach response _meta without depending on history
1946
+ // replay of a snapshot-shaped notification.
1947
+ currentModel: z4.string().optional(),
1948
+ currentMode: z4.string().optional(),
1949
+ currentUsage: PersistedUsage.optional(),
1950
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
1951
+ createdAt: z4.string(),
1952
+ updatedAt: z4.string()
1953
+ });
1954
+ SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
1955
+ SessionStore = class {
1956
+ async write(record) {
1957
+ assertSafeId(record.sessionId);
1958
+ await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1959
+ const full = { version: 1, ...record };
1960
+ await fs5.writeFile(
1961
+ paths.sessionFile(record.sessionId),
1962
+ JSON.stringify(full, null, 2) + "\n",
1963
+ { encoding: "utf8", mode: 384 }
1964
+ );
1965
+ }
1966
+ async read(sessionId) {
1967
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
1968
+ return void 0;
1969
+ }
1970
+ let raw;
1971
+ try {
1972
+ raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
1973
+ } catch (err) {
1974
+ const e = err;
1975
+ if (e.code === "ENOENT") {
1976
+ return void 0;
1977
+ }
1978
+ throw err;
1979
+ }
1980
+ try {
1981
+ return SessionRecord.parse(JSON.parse(raw));
1982
+ } catch {
1983
+ return void 0;
1984
+ }
1985
+ }
1986
+ async delete(sessionId) {
1987
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
1988
+ return;
1989
+ }
1990
+ try {
1991
+ await fs5.unlink(paths.sessionFile(sessionId));
1992
+ } catch (err) {
1993
+ const e = err;
1994
+ if (e.code !== "ENOENT") {
1995
+ throw err;
1996
+ }
1997
+ }
1998
+ try {
1999
+ await fs5.rmdir(paths.sessionDir(sessionId));
2000
+ } catch (err) {
2001
+ const e = err;
2002
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
2003
+ throw err;
2004
+ }
2005
+ }
2006
+ }
2007
+ // Find a persisted session by lineageId. Used by SessionManager.import
2008
+ // to detect bundles that have already been imported (lineageId match)
2009
+ // so we can either error out or, with replace:true, overwrite.
2010
+ // Returns undefined if no record has that lineageId. Records that
2011
+ // pre-date the lineageId field simply don't match — which is
2012
+ // correct: they were never exported, so no incoming bundle can
2013
+ // legitimately claim their lineage.
2014
+ async findByLineageId(lineageId) {
2015
+ if (lineageId.length === 0) {
2016
+ return void 0;
2017
+ }
2018
+ const all = await this.list().catch(() => []);
2019
+ for (const record of all) {
2020
+ if (record.lineageId === lineageId) {
2021
+ return record;
2022
+ }
2023
+ }
2024
+ return void 0;
2025
+ }
2026
+ async list() {
2027
+ let entries;
2028
+ try {
2029
+ entries = await fs5.readdir(paths.sessionsDir());
2030
+ } catch (err) {
2031
+ const e = err;
2032
+ if (e.code === "ENOENT") {
2033
+ return [];
2034
+ }
2035
+ throw err;
2036
+ }
2037
+ const records = [];
2038
+ for (const entry of entries) {
2039
+ const record = await this.read(entry);
2040
+ if (record) {
2041
+ records.push(record);
2042
+ }
2043
+ }
2044
+ return records;
2045
+ }
2046
+ };
2047
+ }
2048
+ });
2049
+
1812
2050
  // src/tui/history.ts
1813
2051
  import { promises as fs7 } from "fs";
1814
- import * as path4 from "path";
2052
+ import * as path5 from "path";
1815
2053
  async function loadHistory(file) {
1816
2054
  let text;
1817
2055
  try {
@@ -1855,7 +2093,7 @@ function appendEntry(history, entry) {
1855
2093
  return out;
1856
2094
  }
1857
2095
  async function saveHistory(file, history) {
1858
- await fs7.mkdir(path4.dirname(file), { recursive: true });
2096
+ await fs7.mkdir(path5.dirname(file), { recursive: true });
1859
2097
  const lines = history.map((entry) => JSON.stringify(entry));
1860
2098
  await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
1861
2099
  }
@@ -1867,6 +2105,113 @@ var init_history = __esm({
1867
2105
  }
1868
2106
  });
1869
2107
 
2108
+ // src/core/hydra-version.ts
2109
+ import { fileURLToPath } from "url";
2110
+ import * as path6 from "path";
2111
+ import * as fs8 from "fs";
2112
+ function resolveVersion() {
2113
+ try {
2114
+ let dir = path6.dirname(fileURLToPath(import.meta.url));
2115
+ for (let i = 0; i < 8; i += 1) {
2116
+ const candidate = path6.join(dir, "package.json");
2117
+ if (fs8.existsSync(candidate)) {
2118
+ const pkg = JSON.parse(fs8.readFileSync(candidate, "utf8"));
2119
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
2120
+ return pkg.version;
2121
+ }
2122
+ }
2123
+ const parent = path6.dirname(dir);
2124
+ if (parent === dir) {
2125
+ break;
2126
+ }
2127
+ dir = parent;
2128
+ }
2129
+ } catch {
2130
+ }
2131
+ return "0.0.0";
2132
+ }
2133
+ var HYDRA_VERSION;
2134
+ var init_hydra_version = __esm({
2135
+ "src/core/hydra-version.ts"() {
2136
+ "use strict";
2137
+ HYDRA_VERSION = resolveVersion();
2138
+ }
2139
+ });
2140
+
2141
+ // src/core/bundle.ts
2142
+ import { z as z5 } from "zod";
2143
+ function encodeBundle(params) {
2144
+ const bundle = {
2145
+ version: 1,
2146
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
2147
+ exportedFrom: {
2148
+ hydraVersion: params.hydraVersion,
2149
+ machine: params.machine
2150
+ },
2151
+ session: {
2152
+ sessionId: params.record.sessionId,
2153
+ lineageId: params.record.lineageId,
2154
+ agentId: params.record.agentId,
2155
+ cwd: params.record.cwd,
2156
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
2157
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
2158
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
2159
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
2160
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
2161
+ createdAt: params.record.createdAt,
2162
+ updatedAt: params.record.updatedAt
2163
+ },
2164
+ history: params.history
2165
+ };
2166
+ if (params.promptHistory !== void 0) {
2167
+ bundle.promptHistory = params.promptHistory;
2168
+ }
2169
+ return bundle;
2170
+ }
2171
+ function decodeBundle(raw) {
2172
+ return Bundle.parse(raw);
2173
+ }
2174
+ var HistoryEntrySchema, BundleSession, Bundle;
2175
+ var init_bundle = __esm({
2176
+ "src/core/bundle.ts"() {
2177
+ "use strict";
2178
+ init_session_store();
2179
+ HistoryEntrySchema = z5.object({
2180
+ method: z5.string(),
2181
+ params: z5.unknown(),
2182
+ recordedAt: z5.number()
2183
+ });
2184
+ BundleSession = z5.object({
2185
+ // The exporter's local id. Regenerated fresh on import (sessionId is
2186
+ // the local namespace; lineageId is what survives across hops).
2187
+ sessionId: z5.string(),
2188
+ // Required on bundles — the export path backfills if the source
2189
+ // record was written before lineageId existed.
2190
+ lineageId: z5.string(),
2191
+ agentId: z5.string(),
2192
+ cwd: z5.string(),
2193
+ title: z5.string().optional(),
2194
+ currentModel: z5.string().optional(),
2195
+ currentMode: z5.string().optional(),
2196
+ currentUsage: PersistedUsage.optional(),
2197
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
2198
+ createdAt: z5.string(),
2199
+ updatedAt: z5.string()
2200
+ });
2201
+ Bundle = z5.object({
2202
+ version: z5.literal(1),
2203
+ exportedAt: z5.string(),
2204
+ exportedFrom: z5.object({
2205
+ hydraVersion: z5.string(),
2206
+ machine: z5.string()
2207
+ }),
2208
+ session: BundleSession,
2209
+ history: z5.array(HistoryEntrySchema),
2210
+ promptHistory: z5.array(z5.string()).optional()
2211
+ });
2212
+ }
2213
+ });
2214
+
1870
2215
  // src/acp/ws-stream.ts
1871
2216
  function wsToMessageStream(ws) {
1872
2217
  const messageHandlers = [];
@@ -1945,7 +2290,7 @@ var init_ws_stream = __esm({
1945
2290
  });
1946
2291
 
1947
2292
  // src/core/daemon-bootstrap.ts
1948
- import { spawn as spawn4 } from "child_process";
2293
+ import { spawn as spawn5 } from "child_process";
1949
2294
  import { setTimeout as sleep } from "timers/promises";
1950
2295
  async function ensureDaemonReachable(config) {
1951
2296
  if (await pingHealth(config)) {
@@ -1972,7 +2317,7 @@ function spawnDaemonDetached() {
1972
2317
  if (!cliPath) {
1973
2318
  throw new Error("Cannot determine hydra-acp binary path to spawn daemon");
1974
2319
  }
1975
- const child = spawn4(
2320
+ const child = spawn5(
1976
2321
  process.execPath,
1977
2322
  [cliPath, "daemon", "start", "--foreground"],
1978
2323
  {
@@ -2020,8 +2365,8 @@ function formatAgentWithModel(agentId, model) {
2020
2365
  }
2021
2366
  return `${agent}${AGENT_MODEL_SEP}${short}`;
2022
2367
  }
2023
- function formatAgentCell(agentId, model, usage) {
2024
- const base = formatAgentWithModel(agentId, model);
2368
+ function formatAgentCell(agentId, usage) {
2369
+ const base = agentId ?? "?";
2025
2370
  if (!usage || typeof usage.costAmount !== "number") {
2026
2371
  return base;
2027
2372
  }
@@ -2058,10 +2403,10 @@ function toRow(s, now = Date.now()) {
2058
2403
  session: stripHydraSessionPrefix(s.sessionId),
2059
2404
  upstream: s.upstreamSessionId ?? "-",
2060
2405
  state: formatState(s.status, s.attachedClients),
2061
- agent: formatAgentCell(s.agentId, s.currentModel, s.currentUsage),
2406
+ agent: formatAgentCell(s.agentId, s.currentUsage),
2062
2407
  age: formatRelativeAge(s.updatedAt, now),
2063
2408
  title: s.title ?? "-",
2064
- cwd: s.cwd
2409
+ cwd: shortenHomePath(s.cwd)
2065
2410
  };
2066
2411
  }
2067
2412
  function formatState(status, clients) {
@@ -2077,6 +2422,7 @@ function computeWidths(rows) {
2077
2422
  state: maxLen(HEADER.state, rows.map((r) => r.state)),
2078
2423
  agent: maxLen(HEADER.agent, rows.map((r) => r.agent)),
2079
2424
  age: maxLen(HEADER.age, rows.map((r) => r.age)),
2425
+ cwd: maxLen(HEADER.cwd, rows.map((r) => r.cwd)),
2080
2426
  title: maxLen(HEADER.title, rows.map((r) => r.title))
2081
2427
  };
2082
2428
  }
@@ -2125,7 +2471,7 @@ function maxLen(headerCell, values) {
2125
2471
  }
2126
2472
  return max;
2127
2473
  }
2128
- function formatRow(r, w, maxWidth) {
2474
+ function formatRow(r, w, maxWidth, cwdMaxWidth = DEFAULT_CWD_MAX_WIDTH) {
2129
2475
  const fixed = [
2130
2476
  r.session.padEnd(w.session),
2131
2477
  r.upstream.padEnd(w.upstream),
@@ -2134,20 +2480,18 @@ function formatRow(r, w, maxWidth) {
2134
2480
  r.age.padStart(w.age)
2135
2481
  ].join(SEP);
2136
2482
  if (maxWidth === void 0) {
2137
- return [fixed, r.title.padEnd(w.title), r.cwd].join(SEP);
2483
+ return [fixed, r.cwd.padEnd(w.cwd), r.title].join(SEP);
2138
2484
  }
2139
- const titleCap = Math.min(w.title, TITLE_MAX_WIDTH);
2140
2485
  const budget = maxWidth - fixed.length - SEP.length;
2141
2486
  if (budget <= 0) {
2142
2487
  return fixed.slice(0, maxWidth);
2143
2488
  }
2144
- const titleNatural = Math.min(r.title.length, titleCap);
2145
- let titleAlloc = titleNatural + SEP.length + MIN_CWD <= budget ? titleCap : Math.max(0, budget - SEP.length - MIN_CWD);
2146
- titleAlloc = Math.min(titleAlloc, Math.max(0, budget - SEP.length - 1));
2147
- const titleCell = truncateRight(r.title, titleAlloc).padEnd(titleAlloc);
2148
- const cwdBudget = Math.max(0, budget - titleAlloc - SEP.length);
2149
- const cwdCell = truncateMiddle(r.cwd, cwdBudget);
2150
- return [fixed, titleCell, cwdCell].join(SEP);
2489
+ const cwdCap = Math.min(w.cwd, cwdMaxWidth);
2490
+ const cwdAlloc = Math.min(cwdCap, Math.max(0, budget - SEP.length - 1));
2491
+ const cwdCell = truncateMiddle(r.cwd, cwdAlloc).padEnd(cwdAlloc);
2492
+ const titleBudget = Math.max(0, budget - cwdAlloc - SEP.length);
2493
+ const titleCell = truncateRight(r.title, titleBudget);
2494
+ return [fixed, cwdCell, titleCell].join(SEP);
2151
2495
  }
2152
2496
  function truncateRight(s, max) {
2153
2497
  if (max <= 0) {
@@ -2175,11 +2519,12 @@ function truncateMiddle(s, max) {
2175
2519
  const tail = max - 1 - head;
2176
2520
  return s.slice(0, head) + "\u2026" + s.slice(s.length - tail);
2177
2521
  }
2178
- var HEADER, SEP, MIN_CWD, TITLE_MAX_WIDTH;
2522
+ var HEADER, SEP, DEFAULT_CWD_MAX_WIDTH;
2179
2523
  var init_session_row = __esm({
2180
2524
  "src/cli/session-row.ts"() {
2181
2525
  "use strict";
2182
2526
  init_agent_display();
2527
+ init_paths();
2183
2528
  init_session();
2184
2529
  HEADER = {
2185
2530
  session: "SESSION",
@@ -2191,14 +2536,13 @@ var init_session_row = __esm({
2191
2536
  cwd: "CWD"
2192
2537
  };
2193
2538
  SEP = " ";
2194
- MIN_CWD = 8;
2195
- TITLE_MAX_WIDTH = 40;
2539
+ DEFAULT_CWD_MAX_WIDTH = 24;
2196
2540
  }
2197
2541
  });
2198
2542
 
2199
2543
  // src/cli/commands/sessions.ts
2200
- import * as fs12 from "fs/promises";
2201
- import * as path6 from "path";
2544
+ import * as fs13 from "fs/promises";
2545
+ import * as path8 from "path";
2202
2546
  async function runSessionsList(opts = {}) {
2203
2547
  const config = await loadConfig();
2204
2548
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
@@ -2237,9 +2581,10 @@ async function runSessionsList(opts = {}) {
2237
2581
  const rows = visible.map((s) => toRow(s, now));
2238
2582
  const widths = computeWidths(rows);
2239
2583
  const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2240
- process.stdout.write(formatRow(HEADER, widths, maxWidth) + "\n");
2584
+ const cwdMax = config.tui.cwdColumnMaxWidth;
2585
+ process.stdout.write(formatRow(HEADER, widths, maxWidth, cwdMax) + "\n");
2241
2586
  for (const r of rows) {
2242
- process.stdout.write(formatRow(r, widths, maxWidth) + "\n");
2587
+ process.stdout.write(formatRow(r, widths, maxWidth, cwdMax) + "\n");
2243
2588
  }
2244
2589
  if (truncated > 0) {
2245
2590
  process.stdout.write(
@@ -2268,9 +2613,9 @@ async function runSessionsKill(id) {
2268
2613
  process.stdout.write(`Killed ${id}
2269
2614
  `);
2270
2615
  }
2271
- async function runSessionsRm(id) {
2616
+ async function runSessionsRemove(id) {
2272
2617
  if (!id) {
2273
- process.stderr.write("Usage: hydra-acp sessions rm <session-id>\n");
2618
+ process.stderr.write("Usage: hydra-acp sessions remove <session-id>\n");
2274
2619
  process.exit(2);
2275
2620
  }
2276
2621
  const config = await loadConfig();
@@ -2317,23 +2662,40 @@ async function runSessionsExport(id, outPath) {
2317
2662
  return;
2318
2663
  }
2319
2664
  const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
2320
- await fs12.mkdir(path6.dirname(path6.resolve(resolved)), { recursive: true });
2321
- await fs12.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2665
+ await fs13.mkdir(path8.dirname(path8.resolve(resolved)), { recursive: true });
2666
+ await fs13.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
2322
2667
  process.stdout.write(`Wrote ${resolved}
2323
2668
  `);
2324
2669
  }
2325
2670
  async function runSessionsImport(file, opts = {}) {
2326
2671
  if (!file) {
2327
2672
  process.stderr.write(
2328
- "Usage: hydra-acp sessions import <file>|- [--replace]\n"
2673
+ "Usage: hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]\n"
2329
2674
  );
2330
2675
  process.exit(2);
2331
2676
  }
2677
+ let cwdOverride;
2678
+ if (opts.cwd !== void 0) {
2679
+ const resolved = path8.resolve(opts.cwd);
2680
+ try {
2681
+ const stat4 = await fs13.stat(resolved);
2682
+ if (!stat4.isDirectory()) {
2683
+ process.stderr.write(`--cwd ${resolved} is not a directory
2684
+ `);
2685
+ process.exit(1);
2686
+ }
2687
+ } catch {
2688
+ process.stderr.write(`--cwd ${resolved} does not exist
2689
+ `);
2690
+ process.exit(1);
2691
+ }
2692
+ cwdOverride = resolved;
2693
+ }
2332
2694
  let body;
2333
2695
  if (file === "-") {
2334
2696
  body = await readStdin();
2335
2697
  } else {
2336
- body = await fs12.readFile(file, "utf8");
2698
+ body = await fs13.readFile(file, "utf8");
2337
2699
  }
2338
2700
  let bundle;
2339
2701
  try {
@@ -2343,6 +2705,11 @@ async function runSessionsImport(file, opts = {}) {
2343
2705
  `);
2344
2706
  process.exit(1);
2345
2707
  }
2708
+ if (opts.info === true) {
2709
+ const inspectConfig = await loadConfigReadOnly();
2710
+ printBundleInfo(bundle, inspectConfig.tui.cwdColumnMaxWidth);
2711
+ return;
2712
+ }
2346
2713
  const config = await loadConfig();
2347
2714
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
2348
2715
  const response = await fetch(`${baseUrl}/v1/sessions/import`, {
@@ -2351,7 +2718,11 @@ async function runSessionsImport(file, opts = {}) {
2351
2718
  "Content-Type": "application/json",
2352
2719
  Authorization: `Bearer ${config.daemon.authToken}`
2353
2720
  },
2354
- body: JSON.stringify({ bundle, replace: opts.replace === true })
2721
+ body: JSON.stringify({
2722
+ bundle,
2723
+ replace: opts.replace === true,
2724
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
2725
+ })
2355
2726
  });
2356
2727
  if (response.status === 409) {
2357
2728
  const detail = await response.json().catch(() => ({}));
@@ -2374,6 +2745,42 @@ async function runSessionsImport(file, opts = {}) {
2374
2745
  `
2375
2746
  );
2376
2747
  }
2748
+ function bundleToSummary(parsed) {
2749
+ return {
2750
+ sessionId: parsed.session.sessionId,
2751
+ upstreamSessionId: "-",
2752
+ cwd: parsed.session.cwd,
2753
+ agentId: parsed.session.agentId,
2754
+ currentUsage: parsed.session.currentUsage,
2755
+ title: parsed.session.title,
2756
+ attachedClients: 0,
2757
+ updatedAt: parsed.session.updatedAt,
2758
+ status: "cold"
2759
+ };
2760
+ }
2761
+ function printBundleInfo(raw, cwdColumnMaxWidth) {
2762
+ let parsed;
2763
+ try {
2764
+ parsed = decodeBundle(raw);
2765
+ } catch (err) {
2766
+ process.stderr.write(`Not a valid bundle: ${err.message}
2767
+ `);
2768
+ process.exit(1);
2769
+ }
2770
+ const summary = bundleToSummary(parsed);
2771
+ const row = toRow(summary);
2772
+ const widths = computeWidths([row]);
2773
+ const maxWidth = process.stdout.isTTY ? process.stdout.columns : void 0;
2774
+ process.stdout.write(formatRow(HEADER, widths, maxWidth, cwdColumnMaxWidth) + "\n");
2775
+ process.stdout.write(formatRow(row, widths, maxWidth, cwdColumnMaxWidth) + "\n");
2776
+ process.stdout.write(
2777
+ `
2778
+ lineage: ${parsed.session.lineageId}
2779
+ exported: ${parsed.exportedAt} from ${parsed.exportedFrom.machine} (hydra ${parsed.exportedFrom.hydraVersion})
2780
+ history entries: ${parsed.history.length}` + (parsed.promptHistory ? `, prompt history: ${parsed.promptHistory.length}
2781
+ ` : "\n")
2782
+ );
2783
+ }
2377
2784
  async function readStdin() {
2378
2785
  const chunks = [];
2379
2786
  for await (const chunk of process.stdin) {
@@ -2400,6 +2807,7 @@ var init_sessions = __esm({
2400
2807
  "src/cli/commands/sessions.ts"() {
2401
2808
  "use strict";
2402
2809
  init_config();
2810
+ init_bundle();
2403
2811
  init_session_row();
2404
2812
  }
2405
2813
  });
@@ -2734,12 +3142,15 @@ async function pickSession(term, opts) {
2734
3142
  return b.updatedAt.localeCompare(a.updatedAt);
2735
3143
  });
2736
3144
  };
2737
- let visible = sortSessions(opts.sessions);
3145
+ let allSessions = sortSessions(opts.sessions);
3146
+ let visible = allSessions;
2738
3147
  let rows = visible.map((s) => toRow(s, Date.now()));
2739
3148
  let widths = computeWidths(rows);
2740
3149
  let total = 1 + visible.length;
2741
3150
  let selectedIdx = 0;
2742
3151
  let scrollOffset = 0;
3152
+ let searchActive = false;
3153
+ let searchTerm = "";
2743
3154
  let mode = "normal";
2744
3155
  let pendingAction = null;
2745
3156
  let transientStatus = null;
@@ -2750,6 +3161,7 @@ async function pickSession(term, opts) {
2750
3161
  let headerLine = "";
2751
3162
  let sessionLines = [];
2752
3163
  let startRow = 1;
3164
+ const cwdMaxWidth = opts.config.tui.cwdColumnMaxWidth;
2753
3165
  const computeLayout = () => {
2754
3166
  termHeight = readTermHeight(term);
2755
3167
  termWidth = readTermWidth(term);
@@ -2757,8 +3169,8 @@ async function pickSession(term, opts) {
2757
3169
  viewportSize = Math.min(visible.length, maxViewportRows);
2758
3170
  const rowMaxWidth = Math.max(10, termWidth - ROW_PREFIX_WIDTH);
2759
3171
  newSessionLabel = formatNewSessionLabel(opts.cwd, rowMaxWidth);
2760
- headerLine = formatRow(HEADER, widths, rowMaxWidth);
2761
- sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth));
3172
+ headerLine = formatRow(HEADER, widths, rowMaxWidth, cwdMaxWidth);
3173
+ sessionLines = rows.map((r) => formatRow(r, widths, rowMaxWidth, cwdMaxWidth));
2762
3174
  };
2763
3175
  const rebuildRows = () => {
2764
3176
  rows = visible.map((s) => toRow(s, Date.now()));
@@ -2766,6 +3178,24 @@ async function pickSession(term, opts) {
2766
3178
  total = 1 + visible.length;
2767
3179
  computeLayout();
2768
3180
  };
3181
+ const applyFilter = () => {
3182
+ if (searchActive && searchTerm.length > 0) {
3183
+ visible = allSessions.filter((s) => matchesSearch(s, searchTerm));
3184
+ } else {
3185
+ visible = allSessions;
3186
+ }
3187
+ rebuildRows();
3188
+ if (searchActive) {
3189
+ scrollOffset = 0;
3190
+ selectedIdx = visible.length > 0 ? 1 : 0;
3191
+ } else if (selectedIdx > total - 1) {
3192
+ selectedIdx = Math.max(0, total - 1);
3193
+ }
3194
+ if (scrollOffset + viewportSize > visible.length) {
3195
+ scrollOffset = Math.max(0, visible.length - viewportSize);
3196
+ }
3197
+ adjustScroll();
3198
+ };
2769
3199
  const adjustScroll = () => {
2770
3200
  if (selectedIdx === 0) {
2771
3201
  return;
@@ -2828,6 +3258,13 @@ async function pickSession(term, opts) {
2828
3258
  term.dim.noFormat(` ${transientStatus}`);
2829
3259
  return;
2830
3260
  }
3261
+ if (searchActive) {
3262
+ term.brightYellow.noFormat(` /${searchTerm}`);
3263
+ term.bgBrightYellow(" ");
3264
+ const hint = visible.length === 0 ? " no matches" : ` ${visible.length} match${visible.length === 1 ? "" : "es"}`;
3265
+ term.dim.noFormat(`${hint} \xB7 ^c clears`);
3266
+ return;
3267
+ }
2831
3268
  term.dim.noFormat(formatIndicator());
2832
3269
  };
2833
3270
  const indicatorRow = () => startRow + 3 + viewportSize;
@@ -2894,8 +3331,8 @@ async function pickSession(term, opts) {
2894
3331
  const refresh = async (preferredId) => {
2895
3332
  try {
2896
3333
  const next = await listSessions(opts.config);
2897
- visible = sortSessions(next);
2898
- rebuildRows();
3334
+ allSessions = sortSessions(next);
3335
+ applyFilter();
2899
3336
  if (preferredId !== void 0) {
2900
3337
  const idx = visible.findIndex((s) => s.sessionId === preferredId);
2901
3338
  if (idx >= 0) {
@@ -2992,7 +3429,37 @@ async function pickSession(term, opts) {
2992
3429
  return;
2993
3430
  }
2994
3431
  clearTransient();
3432
+ if (searchActive) {
3433
+ if (data?.isCharacter) {
3434
+ searchTerm += name;
3435
+ applyFilter();
3436
+ renderFromScratch();
3437
+ return;
3438
+ }
3439
+ if (name === "BACKSPACE") {
3440
+ if (searchTerm.length > 0) {
3441
+ searchTerm = searchTerm.slice(0, -1);
3442
+ applyFilter();
3443
+ renderFromScratch();
3444
+ }
3445
+ return;
3446
+ }
3447
+ if (name === "ESCAPE" || name === "CTRL_C") {
3448
+ searchActive = false;
3449
+ searchTerm = "";
3450
+ applyFilter();
3451
+ renderFromScratch();
3452
+ return;
3453
+ }
3454
+ }
2995
3455
  if (data?.isCharacter) {
3456
+ if (name === "/") {
3457
+ searchActive = true;
3458
+ searchTerm = "";
3459
+ applyFilter();
3460
+ renderFromScratch();
3461
+ return;
3462
+ }
2996
3463
  if (name === "r" || name === "R") {
2997
3464
  const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
2998
3465
  void refresh(currentId);
@@ -3097,13 +3564,34 @@ function readTermWidth(term) {
3097
3564
  function formatNewSessionLabel(cwd, maxWidth) {
3098
3565
  const prefix = "+ New session in ";
3099
3566
  const budget = Math.max(1, maxWidth - prefix.length);
3100
- return prefix + truncateMiddle(cwd, budget);
3567
+ return prefix + truncateMiddle(shortenHomePath(cwd), budget);
3568
+ }
3569
+ function matchesSearch(s, term) {
3570
+ if (term.length === 0) {
3571
+ return true;
3572
+ }
3573
+ const t = term.toLowerCase();
3574
+ const haystacks = [
3575
+ stripHydraSessionPrefix(s.sessionId),
3576
+ s.upstreamSessionId ?? "",
3577
+ s.agentId ?? "",
3578
+ s.title ?? "",
3579
+ s.cwd,
3580
+ shortenHomePath(s.cwd)
3581
+ ];
3582
+ for (const h of haystacks) {
3583
+ if (h.toLowerCase().includes(t)) {
3584
+ return true;
3585
+ }
3586
+ }
3587
+ return false;
3101
3588
  }
3102
3589
  var ROW_PREFIX_WIDTH;
3103
3590
  var init_picker = __esm({
3104
3591
  "src/tui/picker.ts"() {
3105
3592
  "use strict";
3106
3593
  init_session_row();
3594
+ init_paths();
3107
3595
  init_session();
3108
3596
  init_discovery();
3109
3597
  ROW_PREFIX_WIDTH = 2;
@@ -3111,14 +3599,14 @@ var init_picker = __esm({
3111
3599
  });
3112
3600
 
3113
3601
  // src/tui/screen.ts
3114
- import os3 from "os";
3115
3602
  import stringWidth from "string-width";
3116
3603
  import wrapAnsi from "wrap-ansi";
3117
- function formattedLineSig(zone, width, line) {
3604
+ function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null) {
3605
+ const active = activeCol === null ? "" : `a${activeCol}`;
3118
3606
  if (!line) {
3119
- return `${zone}|${width}|empty`;
3607
+ return `${zone}|${width}|empty|${highlight2 ?? ""}|${active}`;
3120
3608
  }
3121
- return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}`;
3609
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}`;
3122
3610
  }
3123
3611
  function computePromptVisualRows(buffer, room) {
3124
3612
  const rows = [];
@@ -3130,9 +3618,24 @@ function computePromptVisualRows(buffer, room) {
3130
3618
  }
3131
3619
  let pos = 0;
3132
3620
  while (pos < line.length) {
3133
- const end = Math.min(line.length, pos + room);
3134
- rows.push({ bufferIdx: i, startCol: pos, endCol: end });
3135
- pos = end;
3621
+ if (line.length - pos <= room) {
3622
+ rows.push({ bufferIdx: i, startCol: pos, endCol: line.length });
3623
+ pos = line.length;
3624
+ break;
3625
+ }
3626
+ let breakAt = -1;
3627
+ for (let j = pos + room - 1; j >= pos; j--) {
3628
+ const c = line[j];
3629
+ if (c === " " || c === " ") {
3630
+ breakAt = j + 1;
3631
+ break;
3632
+ }
3633
+ }
3634
+ if (breakAt === -1) {
3635
+ breakAt = pos + room;
3636
+ }
3637
+ rows.push({ bufferIdx: i, startCol: pos, endCol: breakAt });
3638
+ pos = breakAt;
3136
3639
  }
3137
3640
  }
3138
3641
  if (rows.length === 0) {
@@ -3180,6 +3683,34 @@ function computePromptLayout(visualRows, state, maxRows) {
3180
3683
  }
3181
3684
  return { cursorVisualRow, cursorVisualCol, windowStart, rendered };
3182
3685
  }
3686
+ function writeBodyWithHighlight(termObj, text, style, term, activeCol = null, _activeLength = 0) {
3687
+ if (text.length === 0) {
3688
+ return;
3689
+ }
3690
+ if (term.length === 0) {
3691
+ writeStyled(termObj, text, style);
3692
+ return;
3693
+ }
3694
+ const haystack = text.toLowerCase();
3695
+ let i = 0;
3696
+ while (i < text.length) {
3697
+ const next = haystack.indexOf(term, i);
3698
+ if (next === -1) {
3699
+ writeStyled(termObj, text.slice(i), style);
3700
+ return;
3701
+ }
3702
+ if (next > i) {
3703
+ writeStyled(termObj, text.slice(i, next), style);
3704
+ }
3705
+ const isActive = activeCol !== null && next === activeCol;
3706
+ writeStyled(
3707
+ termObj,
3708
+ text.slice(next, next + term.length),
3709
+ isActive ? "search-highlight-active" : "search-highlight"
3710
+ );
3711
+ i = next + term.length;
3712
+ }
3713
+ }
3183
3714
  function writeStyled(term, text, style) {
3184
3715
  if (text.length === 0) {
3185
3716
  return;
@@ -3242,6 +3773,12 @@ function writeStyled(term, text, style) {
3242
3773
  case "heading-3":
3243
3774
  term.bold.noFormat(text);
3244
3775
  return;
3776
+ case "search-highlight":
3777
+ term.bgBrightYellow.black.noFormat(text);
3778
+ return;
3779
+ case "search-highlight-active":
3780
+ term.bgRed.brightWhite.noFormat(text);
3781
+ return;
3245
3782
  default:
3246
3783
  term.noFormat(text);
3247
3784
  }
@@ -3255,17 +3792,80 @@ function wrapAnsiBody(text, width) {
3255
3792
  }
3256
3793
  return wrapAnsi(text, width, { hard: true, trim: false }).split("\n");
3257
3794
  }
3258
- function wrap(text, width) {
3795
+ function matchTkMarkupAt(text, i) {
3796
+ if (text.charCodeAt(i) !== 94) {
3797
+ return null;
3798
+ }
3799
+ const c = text[i + 1];
3800
+ if (c === void 0) {
3801
+ return null;
3802
+ }
3803
+ if (c === "^") {
3804
+ return { text: "^^", width: 1 };
3805
+ }
3806
+ if (c === "[") {
3807
+ const end = text.indexOf("]", i + 2);
3808
+ if (end !== -1) {
3809
+ return { text: text.slice(i, end + 1), width: 0 };
3810
+ }
3811
+ }
3812
+ if (TK_MARKUP_STYLE_CHAR.test(c)) {
3813
+ return { text: text.slice(i, i + 2), width: 0 };
3814
+ }
3815
+ return null;
3816
+ }
3817
+ function hasTkMarkup(text) {
3818
+ if (!text.includes("^")) {
3819
+ return false;
3820
+ }
3821
+ for (let i = 0; i < text.length; i++) {
3822
+ if (matchTkMarkupAt(text, i)) {
3823
+ return true;
3824
+ }
3825
+ }
3826
+ return false;
3827
+ }
3828
+ function* segmentForWidth(text) {
3829
+ let i = 0;
3830
+ while (i < text.length) {
3831
+ const m = matchTkMarkupAt(text, i);
3832
+ if (m) {
3833
+ yield { text: m.text, width: m.width };
3834
+ i += m.text.length;
3835
+ continue;
3836
+ }
3837
+ let runEnd = text.length;
3838
+ let probe = text.indexOf("^", i);
3839
+ while (probe !== -1 && probe < text.length) {
3840
+ if (matchTkMarkupAt(text, probe)) {
3841
+ runEnd = probe;
3842
+ break;
3843
+ }
3844
+ probe = text.indexOf("^", probe + 1);
3845
+ }
3846
+ if (runEnd === i) {
3847
+ yield { text: "^", width: 1 };
3848
+ i += 1;
3849
+ continue;
3850
+ }
3851
+ for (const { segment } of SEGMENTER.segment(text.slice(i, runEnd))) {
3852
+ yield { text: segment, width: stringWidth(segment) };
3853
+ }
3854
+ i = runEnd;
3855
+ }
3856
+ }
3857
+ function wrap(text, width, opts = {}) {
3259
3858
  if (width <= 0) {
3260
3859
  return [text];
3261
3860
  }
3262
3861
  if (text.length === 0) {
3263
3862
  return [""];
3264
3863
  }
3265
- if (!NON_ASCII.test(text)) {
3864
+ const stripMarkup = opts.stripMarkup === true && hasTkMarkup(text);
3865
+ if (!stripMarkup && !NON_ASCII.test(text)) {
3266
3866
  return wrapAscii(text, width);
3267
3867
  }
3268
- return wrapVisible(text, width);
3868
+ return wrapVisible(text, width, stripMarkup);
3269
3869
  }
3270
3870
  function wrapAscii(text, width) {
3271
3871
  const out = [];
@@ -3290,32 +3890,33 @@ function wrapAscii(text, width) {
3290
3890
  out.push(remaining);
3291
3891
  return out;
3292
3892
  }
3293
- function wrapVisible(text, width) {
3893
+ function wrapVisible(text, width, stripMarkup) {
3294
3894
  const out = [];
3295
- const graphemes = [];
3296
- for (const { segment } of SEGMENTER.segment(text)) {
3297
- graphemes.push({ seg: segment, w: stringWidth(segment) });
3298
- }
3895
+ const segments = stripMarkup ? [...segmentForWidth(text)] : graphemeSegments(text);
3299
3896
  let i = 0;
3300
- while (i < graphemes.length) {
3897
+ while (i < segments.length) {
3301
3898
  let chunk = "";
3302
3899
  let chunkW = 0;
3303
3900
  let lastSpaceI = -1;
3304
3901
  let chunkAtLastSpace = "";
3305
- while (i < graphemes.length) {
3306
- const g = graphemes[i];
3307
- if (chunkW + g.w > width) {
3902
+ while (i < segments.length) {
3903
+ const s = segments[i];
3904
+ if (chunkW + s.width > width) {
3905
+ if (s.text === " " && s.width === 1) {
3906
+ lastSpaceI = i;
3907
+ chunkAtLastSpace = chunk;
3908
+ }
3308
3909
  break;
3309
3910
  }
3310
- if (g.seg === " ") {
3911
+ if (s.text === " " && s.width === 1) {
3311
3912
  lastSpaceI = i;
3312
3913
  chunkAtLastSpace = chunk;
3313
3914
  }
3314
- chunk += g.seg;
3315
- chunkW += g.w;
3915
+ chunk += s.text;
3916
+ chunkW += s.width;
3316
3917
  i += 1;
3317
3918
  }
3318
- if (i >= graphemes.length) {
3919
+ if (i >= segments.length) {
3319
3920
  out.push(chunk);
3320
3921
  break;
3321
3922
  }
@@ -3323,7 +3924,7 @@ function wrapVisible(text, width) {
3323
3924
  out.push(chunkAtLastSpace);
3324
3925
  i = lastSpaceI + 1;
3325
3926
  } else if (chunk.length === 0) {
3326
- out.push(graphemes[i].seg);
3927
+ out.push(segments[i].text);
3327
3928
  i += 1;
3328
3929
  } else {
3329
3930
  out.push(chunk);
@@ -3331,34 +3932,43 @@ function wrapVisible(text, width) {
3331
3932
  }
3332
3933
  return out;
3333
3934
  }
3334
- function shortenHomePath(p) {
3335
- const home = os3.homedir();
3336
- if (!home) {
3337
- return p;
3338
- }
3339
- if (p === home) {
3340
- return "~";
3341
- }
3342
- if (p.startsWith(home + "/")) {
3343
- return "~" + p.slice(home.length);
3935
+ function graphemeSegments(text) {
3936
+ const out = [];
3937
+ for (const { segment } of SEGMENTER.segment(text)) {
3938
+ out.push({ text: segment, width: stringWidth(segment) });
3344
3939
  }
3345
- return p;
3940
+ return out;
3346
3941
  }
3347
- function truncate(text, max) {
3942
+ function truncate(text, max, opts = {}) {
3348
3943
  if (max <= 0) {
3349
3944
  return "";
3350
3945
  }
3351
- if (text.length <= max && !NON_ASCII.test(text)) {
3946
+ const stripMarkup = opts.stripMarkup === true && hasTkMarkup(text);
3947
+ if (!stripMarkup && text.length <= max && !NON_ASCII.test(text)) {
3352
3948
  return text;
3353
3949
  }
3354
- const visible = stringWidth(text);
3950
+ if (!stripMarkup) {
3951
+ const visible2 = stringWidth(text);
3952
+ if (visible2 <= max) {
3953
+ return text;
3954
+ }
3955
+ if (max <= 1) {
3956
+ return takeByWidth(text, max);
3957
+ }
3958
+ return takeByWidth(text, max - 1) + "\u2026";
3959
+ }
3960
+ const segments = [...segmentForWidth(text)];
3961
+ let visible = 0;
3962
+ for (const s of segments) {
3963
+ visible += s.width;
3964
+ }
3355
3965
  if (visible <= max) {
3356
3966
  return text;
3357
3967
  }
3358
3968
  if (max <= 1) {
3359
- return takeByWidth(text, max);
3969
+ return takeFromSegments(segments, max);
3360
3970
  }
3361
- return takeByWidth(text, max - 1) + "\u2026";
3971
+ return takeFromSegments(segments, max - 1) + "\u2026";
3362
3972
  }
3363
3973
  function takeByWidth(text, budget) {
3364
3974
  if (budget <= 0) {
@@ -3371,8 +3981,23 @@ function takeByWidth(text, budget) {
3371
3981
  if (used + w > budget) {
3372
3982
  break;
3373
3983
  }
3374
- out += segment;
3375
- used += w;
3984
+ out += segment;
3985
+ used += w;
3986
+ }
3987
+ return out;
3988
+ }
3989
+ function takeFromSegments(segments, budget) {
3990
+ if (budget <= 0) {
3991
+ return "";
3992
+ }
3993
+ let out = "";
3994
+ let used = 0;
3995
+ for (const s of segments) {
3996
+ if (used + s.width > budget) {
3997
+ break;
3998
+ }
3999
+ out += s.text;
4000
+ used += s.width;
3376
4001
  }
3377
4002
  return out;
3378
4003
  }
@@ -3472,6 +4097,10 @@ function mapKeyName(name) {
3472
4097
  return "ctrl-o";
3473
4098
  case "CTRL_P":
3474
4099
  return "ctrl-p";
4100
+ case "CTRL_R":
4101
+ return "ctrl-r";
4102
+ case "CTRL_S":
4103
+ return "ctrl-s";
3475
4104
  case "CTRL_U":
3476
4105
  return "ctrl-u";
3477
4106
  case "CTRL_W":
@@ -3484,11 +4113,12 @@ function mapKeyName(name) {
3484
4113
  return null;
3485
4114
  }
3486
4115
  }
3487
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, shortId;
4116
+ var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
3488
4117
  var init_screen = __esm({
3489
4118
  "src/tui/screen.ts"() {
3490
4119
  "use strict";
3491
4120
  init_agent_display();
4121
+ init_paths();
3492
4122
  init_session();
3493
4123
  HEADER_ROWS = 2;
3494
4124
  BANNER_ROWS = 1;
@@ -3532,6 +4162,12 @@ var init_screen = __esm({
3532
4162
  lineIds = /* @__PURE__ */ new WeakMap();
3533
4163
  wrapCache = /* @__PURE__ */ new Map();
3534
4164
  wrapCacheWidth = 0;
4165
+ // For each wrapped chunk (produced by wrapOne), record the source
4166
+ // line's id and the col offset where this chunk starts in the source
4167
+ // body. Used by the active-match highlight in scrollback search to
4168
+ // map currentMatch (sourceLineId, sourceCol) onto the wrapped chunk
4169
+ // that owns it without scanning the wrap cache.
4170
+ wrapOrigin = /* @__PURE__ */ new WeakMap();
3535
4171
  // Per-row signature of what was painted to each terminal row on the
3536
4172
  // previous repaint. drawX methods funnel through paintRow(), which
3537
4173
  // skips the moveTo+eraseLineAfter+write sequence when the new
@@ -3549,10 +4185,30 @@ var init_screen = __esm({
3549
4185
  // above the bottom. Mouse wheel and PgUp/PgDn adjust this; new content
3550
4186
  // pushes the view down naturally when at 0.
3551
4187
  scrollOffset = 0;
4188
+ // Scrollback search state. While active the prompt area is taken over
4189
+ // by a single-row search input (drawSearchPrompt) and matches in the
4190
+ // visible scrollback are rendered with a background-highlight style.
4191
+ // baselineScroll captures the scrollOffset at the moment the user
4192
+ // engaged search so cancel can restore the view.
4193
+ scrollbackSearch = null;
4194
+ // Lowercased search term used by drawScrollback to drive per-row
4195
+ // highlight rendering. Mirrors scrollbackSearch?.term but cached as a
4196
+ // separate field so the per-row signature can include it cheaply.
4197
+ scrollbackHighlight = null;
4198
+ // Right-side banner slot. Three sources, in priority order:
4199
+ // 1. Active scrollback search term (auto, from this.scrollbackSearch)
4200
+ // 2. External search indicator pushed by the app while prompt-
4201
+ // history reverse-search is active (gives that mode visible
4202
+ // feedback for its otherwise-hidden query)
4203
+ // 3. Transient notification set via notify(), auto-cleared after
4204
+ // durationMs
4205
+ bannerNotification = null;
4206
+ bannerNotificationTimer = null;
4207
+ bannerSearchIndicator = null;
3552
4208
  banner = {
3553
4209
  status: "ready",
3554
4210
  planMode: false,
3555
- hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D quit",
4211
+ hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
3556
4212
  queued: 0
3557
4213
  };
3558
4214
  header = { agent: "?", cwd: "?", sessionId: "?" };
@@ -3615,6 +4271,10 @@ var init_screen = __esm({
3615
4271
  return;
3616
4272
  }
3617
4273
  this.started = false;
4274
+ if (this.bannerNotificationTimer) {
4275
+ clearTimeout(this.bannerNotificationTimer);
4276
+ this.bannerNotificationTimer = null;
4277
+ }
3618
4278
  this.uninstallBracketedPaste();
3619
4279
  this.term.off("key", this.keyHandler);
3620
4280
  if (this.mouseEnabled) {
@@ -3889,6 +4549,58 @@ var init_screen = __esm({
3889
4549
  this.drawBanner();
3890
4550
  this.placeCursor();
3891
4551
  }
4552
+ // Transient right-side banner message. Cleared automatically after
4553
+ // durationMs (default 4s). Each call resets the timer, so rapid
4554
+ // successive notifications coalesce on the latest text. Active
4555
+ // scrollback / prompt-history search indicators take priority over
4556
+ // notifications, so a notification queued during search is held
4557
+ // behind it and visible once search exits — unless its timer fires
4558
+ // first, in which case it's dropped.
4559
+ notify(text, durationMs = 4e3) {
4560
+ if (this.bannerNotificationTimer) {
4561
+ clearTimeout(this.bannerNotificationTimer);
4562
+ }
4563
+ this.bannerNotification = text;
4564
+ this.bannerNotificationTimer = setTimeout(() => {
4565
+ this.bannerNotification = null;
4566
+ this.bannerNotificationTimer = null;
4567
+ this.drawBanner();
4568
+ this.placeCursor();
4569
+ }, durationMs);
4570
+ this.drawBanner();
4571
+ this.placeCursor();
4572
+ }
4573
+ // Pushed by the app each onKey tick to reflect prompt-history
4574
+ // reverse-search state in the banner — the only place that mode's
4575
+ // query is visible. Pass null when not searching.
4576
+ setBannerSearchIndicator(text) {
4577
+ if (this.bannerSearchIndicator === text) {
4578
+ return;
4579
+ }
4580
+ this.bannerSearchIndicator = text;
4581
+ this.drawBanner();
4582
+ this.placeCursor();
4583
+ }
4584
+ // Computes what (if anything) the right-side banner slot should show
4585
+ // this paint. Priority: scrollback search term > prompt-history
4586
+ // indicator > notification. Scrollback gets a "N/M" counter suffix
4587
+ // since the user can't see which match they're on from the highlight
4588
+ // alone; prompt-history's match is visible in the buffer, so no
4589
+ // counter needed there.
4590
+ bannerRightContent() {
4591
+ if (this.scrollbackSearch !== null) {
4592
+ const sb = this.scrollbackSearch;
4593
+ const counter = sb.matches.length > 0 ? ` ${sb.matchIndex + 1}/${sb.matches.length}` : sb.term.length === 0 ? "" : " 0/0";
4594
+ return { text: `\u{1F50D} ${sb.term}${counter}`, kind: "search" };
4595
+ }
4596
+ if (this.bannerSearchIndicator !== null) {
4597
+ return { text: `\u{1F50D} ${this.bannerSearchIndicator}`, kind: "search" };
4598
+ }
4599
+ if (this.bannerNotification !== null) {
4600
+ return { text: this.bannerNotification, kind: "notify" };
4601
+ }
4602
+ return null;
4603
+ }
3892
4604
  clearScrollback() {
3893
4605
  this.lines = [];
3894
4606
  this.keyedBlocks.clear();
@@ -4070,6 +4782,9 @@ var init_screen = __esm({
4070
4782
  if (delta === 0) {
4071
4783
  return;
4072
4784
  }
4785
+ if (this.scrollbackSearch !== null) {
4786
+ this.acceptScrollbackSearch();
4787
+ }
4073
4788
  const max = this.maxScrollOffset();
4074
4789
  const next = Math.min(max, Math.max(0, this.scrollOffset + delta));
4075
4790
  if (next === this.scrollOffset) {
@@ -4079,6 +4794,9 @@ var init_screen = __esm({
4079
4794
  this.repaint();
4080
4795
  }
4081
4796
  scrollToBottom() {
4797
+ if (this.scrollbackSearch !== null) {
4798
+ this.acceptScrollbackSearch();
4799
+ }
4082
4800
  if (this.scrollOffset === 0) {
4083
4801
  return;
4084
4802
  }
@@ -4086,6 +4804,9 @@ var init_screen = __esm({
4086
4804
  this.repaint();
4087
4805
  }
4088
4806
  scrollToTop() {
4807
+ if (this.scrollbackSearch !== null) {
4808
+ this.acceptScrollbackSearch();
4809
+ }
4089
4810
  const max = this.maxScrollOffset();
4090
4811
  if (this.scrollOffset === max) {
4091
4812
  return;
@@ -4093,6 +4814,221 @@ var init_screen = __esm({
4093
4814
  this.scrollOffset = max;
4094
4815
  this.repaint();
4095
4816
  }
4817
+ // True iff the user is scrolled above the live tail — gates the
4818
+ // app-level decision of whether ^r engages scrollback search vs.
4819
+ // prompt-history search.
4820
+ isScrolledBack() {
4821
+ return this.scrollOffset > 0;
4822
+ }
4823
+ // True iff a scrollback search is currently active. Used by the app
4824
+ // to decide whether to keep routing keys into search vs. the prompt
4825
+ // dispatcher.
4826
+ isScrollbackSearchActive() {
4827
+ return this.scrollbackSearch !== null;
4828
+ }
4829
+ // Engage scrollback reverse-search. Captures the current scroll
4830
+ // position so cancel can restore it, and seeds an empty search term
4831
+ // (the prompt row renders the search input immediately so the user
4832
+ // sees the entry). Idempotent: no-op when already active.
4833
+ enterScrollbackSearch() {
4834
+ if (this.scrollbackSearch !== null) {
4835
+ return;
4836
+ }
4837
+ this.scrollbackSearch = {
4838
+ term: "",
4839
+ matchIndex: 0,
4840
+ matches: [],
4841
+ baselineScroll: this.scrollOffset
4842
+ };
4843
+ this.scrollbackHighlight = null;
4844
+ this.repaint();
4845
+ }
4846
+ // Update the search term and recompute matches. Walks `lines` from
4847
+ // the tail (newest) toward the head (oldest), pushing every case-
4848
+ // insensitive substring hit. Snaps the viewport to the newest match
4849
+ // when found. Called per keystroke; sub-millisecond on typical
4850
+ // scrollback sizes.
4851
+ updateScrollbackSearchTerm(term) {
4852
+ if (this.scrollbackSearch === null) {
4853
+ return;
4854
+ }
4855
+ const lowered = term.toLowerCase();
4856
+ const matches = [];
4857
+ if (lowered.length > 0) {
4858
+ for (let i = this.lines.length - 1; i >= 0; i--) {
4859
+ const line = this.lines[i];
4860
+ if (!line || line.body.length === 0) {
4861
+ continue;
4862
+ }
4863
+ if (line.ansi) {
4864
+ continue;
4865
+ }
4866
+ const hay = line.body.toLowerCase();
4867
+ const lineCols = [];
4868
+ let pos = 0;
4869
+ while (pos < hay.length) {
4870
+ const found = hay.indexOf(lowered, pos);
4871
+ if (found === -1) {
4872
+ break;
4873
+ }
4874
+ lineCols.push(found);
4875
+ pos = found + lowered.length;
4876
+ }
4877
+ for (let j = lineCols.length - 1; j >= 0; j--) {
4878
+ matches.push({ lineIdx: i, col: lineCols[j] });
4879
+ }
4880
+ }
4881
+ }
4882
+ this.scrollbackSearch.term = term;
4883
+ this.scrollbackSearch.matches = matches;
4884
+ this.scrollbackSearch.matchIndex = 0;
4885
+ this.scrollbackHighlight = lowered.length > 0 ? lowered : null;
4886
+ if (matches.length > 0) {
4887
+ this.scrollToMatch(matches[0]);
4888
+ }
4889
+ this.repaint();
4890
+ }
4891
+ // Advance to the next-older match (called for repeated ^r). Stops at
4892
+ // the oldest match (does not wrap). No-op when there are no matches
4893
+ // or search is inactive.
4894
+ advanceScrollbackSearch() {
4895
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
4896
+ return;
4897
+ }
4898
+ const nextIdx = Math.min(
4899
+ this.scrollbackSearch.matches.length - 1,
4900
+ this.scrollbackSearch.matchIndex + 1
4901
+ );
4902
+ if (nextIdx === this.scrollbackSearch.matchIndex) {
4903
+ return;
4904
+ }
4905
+ this.scrollbackSearch.matchIndex = nextIdx;
4906
+ this.scrollToMatch(this.scrollbackSearch.matches[nextIdx]);
4907
+ this.repaint();
4908
+ }
4909
+ // Retreat to the previous (newer) match — ^s forward-search. Stops
4910
+ // at the newest match (no wrap).
4911
+ retreatScrollbackSearch() {
4912
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
4913
+ return;
4914
+ }
4915
+ if (this.scrollbackSearch.matchIndex === 0) {
4916
+ return;
4917
+ }
4918
+ this.scrollbackSearch.matchIndex -= 1;
4919
+ this.scrollToMatch(this.scrollbackSearch.matches[this.scrollbackSearch.matchIndex]);
4920
+ this.repaint();
4921
+ }
4922
+ // Exit search keeping the viewport at the current match. Highlight is
4923
+ // cleared so subsequent scrollback content reads normally.
4924
+ acceptScrollbackSearch() {
4925
+ if (this.scrollbackSearch === null) {
4926
+ return;
4927
+ }
4928
+ this.scrollbackSearch = null;
4929
+ this.scrollbackHighlight = null;
4930
+ this.repaint();
4931
+ }
4932
+ // Exit search and restore the viewport to where the user was when
4933
+ // they engaged search.
4934
+ cancelScrollbackSearch() {
4935
+ if (this.scrollbackSearch === null) {
4936
+ return;
4937
+ }
4938
+ const baseline = this.scrollbackSearch.baselineScroll;
4939
+ this.scrollbackSearch = null;
4940
+ this.scrollbackHighlight = null;
4941
+ this.scrollOffset = baseline;
4942
+ this.repaint();
4943
+ }
4944
+ scrollbackSearchTerm() {
4945
+ return this.scrollbackSearch?.term ?? "";
4946
+ }
4947
+ // Source-line identity + col + term length for whichever match is
4948
+ // currently selected (advanced via ^r / retreated via ^s). Used by
4949
+ // drawScrollback to give the current match a distinct highlight
4950
+ // style without disturbing the bulk-highlight on the other matches.
4951
+ currentMatchInfo() {
4952
+ if (this.scrollbackSearch === null || this.scrollbackSearch.matches.length === 0) {
4953
+ return null;
4954
+ }
4955
+ const match = this.scrollbackSearch.matches[this.scrollbackSearch.matchIndex];
4956
+ if (!match) {
4957
+ return null;
4958
+ }
4959
+ const sourceLine = this.lines[match.lineIdx];
4960
+ if (!sourceLine) {
4961
+ return null;
4962
+ }
4963
+ const lineId = this.lineIds.get(sourceLine);
4964
+ if (lineId === void 0) {
4965
+ return null;
4966
+ }
4967
+ return {
4968
+ lineId,
4969
+ col: match.col,
4970
+ length: this.scrollbackSearch.term.length
4971
+ };
4972
+ }
4973
+ // If `line` is the wrapped chunk that contains the active match,
4974
+ // returns the col within the chunk's body where the match starts;
4975
+ // otherwise null. The chunk's source identity comes from
4976
+ // this.wrapOrigin which wrapOne populates for every wrapped chunk.
4977
+ activeMatchCol(line, info) {
4978
+ if (!line || info === null) {
4979
+ return null;
4980
+ }
4981
+ const origin = this.wrapOrigin.get(line);
4982
+ if (!origin || origin.sourceLineId !== info.lineId) {
4983
+ return null;
4984
+ }
4985
+ const colInChunk = info.col - origin.sourceColOffset;
4986
+ if (colInChunk < 0 || colInChunk >= line.body.length) {
4987
+ return null;
4988
+ }
4989
+ return colInChunk;
4990
+ }
4991
+ // Position scrollOffset so the wrapped row containing the given
4992
+ // (lineIdx, col) lands on a visible row of the scrollback viewport.
4993
+ // Walks wrapTail to count wrapped rows between the target line and
4994
+ // the tail.
4995
+ scrollToMatch(match) {
4996
+ const w = this.term.width;
4997
+ const visibleRows = this.scrollbackVisibleRows();
4998
+ if (visibleRows <= 0) {
4999
+ return;
5000
+ }
5001
+ let rowsBelowMatchLine = 0;
5002
+ for (let i = this.lines.length - 1; i > match.lineIdx; i--) {
5003
+ const line = this.lines[i];
5004
+ if (!line) {
5005
+ continue;
5006
+ }
5007
+ rowsBelowMatchLine += this.wrapOne(line, w).length;
5008
+ }
5009
+ const matchLine = this.lines[match.lineIdx];
5010
+ let rowsWithinMatchLine = 0;
5011
+ if (matchLine) {
5012
+ const wrapped = this.wrapOne(matchLine, w);
5013
+ let consumed = 0;
5014
+ for (let r = 0; r < wrapped.length; r++) {
5015
+ const piece = wrapped[r];
5016
+ if (!piece) {
5017
+ continue;
5018
+ }
5019
+ const bodyLen = piece.body.length;
5020
+ if (match.col < consumed + bodyLen) {
5021
+ rowsWithinMatchLine = wrapped.length - 1 - r;
5022
+ break;
5023
+ }
5024
+ consumed += bodyLen;
5025
+ }
5026
+ }
5027
+ const target = rowsBelowMatchLine + rowsWithinMatchLine;
5028
+ const desired = Math.max(0, target - Math.floor(visibleRows / 2));
5029
+ const max = this.maxScrollOffset();
5030
+ this.scrollOffset = Math.min(max, desired);
5031
+ }
4096
5032
  scrollPageSize() {
4097
5033
  return Math.max(1, this.scrollbackVisibleRows() - 2);
4098
5034
  }
@@ -4215,8 +5151,8 @@ var init_screen = __esm({
4215
5151
  }
4216
5152
  if (usage) {
4217
5153
  const col = Math.max(1, w - usage.length + 1);
4218
- this.term.moveTo(col, 1);
4219
- this.term.dim(usage);
5154
+ this.term.moveTo(col, 1).eraseLineAfter();
5155
+ this.term.dim.noFormat(usage);
4220
5156
  }
4221
5157
  });
4222
5158
  }
@@ -4247,14 +5183,23 @@ var init_screen = __esm({
4247
5183
  const start = Math.max(0, end - visibleRows);
4248
5184
  const slice = wrapped.slice(start, end);
4249
5185
  const padTop = Math.max(0, visibleRows - slice.length);
5186
+ const matchInfo = this.currentMatchInfo();
5187
+ const activeLength = matchInfo?.length ?? 0;
4250
5188
  for (let i = 0; i < visibleRows; i++) {
4251
5189
  const row = top + i;
4252
5190
  const sliceIdx = i - padTop;
4253
5191
  const line = sliceIdx >= 0 ? slice[sliceIdx] : void 0;
4254
- const sig = formattedLineSig("sb", w, line);
5192
+ const activeCol = this.activeMatchCol(line, matchInfo);
5193
+ const sig = formattedLineSig(
5194
+ "sb",
5195
+ w,
5196
+ line,
5197
+ this.scrollbackHighlight,
5198
+ activeCol
5199
+ );
4255
5200
  this.paintRow(row, sig, () => {
4256
5201
  if (line) {
4257
- this.writeFormattedLine(line, w);
5202
+ this.writeFormattedLine(line, w, activeCol, activeLength);
4258
5203
  }
4259
5204
  });
4260
5205
  }
@@ -4454,7 +5399,9 @@ var init_screen = __esm({
4454
5399
  const row = this.term.height;
4455
5400
  const w = this.term.width;
4456
5401
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
4457
- const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}`;
5402
+ const right = this.bannerRightContent();
5403
+ const rightSig = right ? `${right.kind}|${right.text}` : "";
5404
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}|` + rightSig;
4458
5405
  this.paintRow(row, sig, () => {
4459
5406
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
4460
5407
  const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
@@ -4481,6 +5428,16 @@ var init_screen = __esm({
4481
5428
  this.term.dim(planLabel);
4482
5429
  }
4483
5430
  this.term(" \xB7 ").dim(this.banner.hint);
5431
+ if (right) {
5432
+ const visibleWidth = stringWidth(right.text);
5433
+ const col = Math.max(1, w - visibleWidth + 1);
5434
+ this.term.moveTo(col, row).eraseLineAfter();
5435
+ if (right.kind === "search") {
5436
+ this.term.brightCyan.noFormat(right.text);
5437
+ } else {
5438
+ this.term.brightYellow.noFormat(right.text);
5439
+ }
5440
+ }
4484
5441
  });
4485
5442
  }
4486
5443
  placeCursor() {
@@ -4496,6 +5453,11 @@ var init_screen = __esm({
4496
5453
  this.term.moveTo(2, top2);
4497
5454
  return;
4498
5455
  }
5456
+ if (this.scrollbackSearch) {
5457
+ this.term.hideCursor(true);
5458
+ return;
5459
+ }
5460
+ this.term.hideCursor(false);
4499
5461
  const w = this.term.width;
4500
5462
  const room = Math.max(1, w - 2);
4501
5463
  const state = this.dispatcher.state();
@@ -4582,8 +5544,10 @@ var init_screen = __esm({
4582
5544
  }
4583
5545
  const prefix = line.prefix ?? "";
4584
5546
  const room = Math.max(1, width - prefix.length);
4585
- const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room);
5547
+ const stripMarkup = line.bodyStyle === "agent";
5548
+ const chunks = line.ansi ? wrapAnsiBody(line.body, room) : wrap(line.body, room, { stripMarkup });
4586
5549
  const wrapped = [];
5550
+ let scanPos = 0;
4587
5551
  for (let i = 0; i < chunks.length; i++) {
4588
5552
  const chunk = chunks[i] ?? "";
4589
5553
  const wrappedLine = {
@@ -4602,6 +5566,15 @@ var init_screen = __esm({
4602
5566
  if (line.ansi) {
4603
5567
  wrappedLine.ansi = true;
4604
5568
  }
5569
+ if (id !== void 0 && chunk.length > 0) {
5570
+ const found = line.body.indexOf(chunk, scanPos);
5571
+ const colOffset = found === -1 ? scanPos : found;
5572
+ this.wrapOrigin.set(wrappedLine, {
5573
+ sourceLineId: id,
5574
+ sourceColOffset: colOffset
5575
+ });
5576
+ scanPos = colOffset + chunk.length;
5577
+ }
4605
5578
  wrapped.push(wrappedLine);
4606
5579
  }
4607
5580
  if (id !== void 0) {
@@ -4609,13 +5582,25 @@ var init_screen = __esm({
4609
5582
  }
4610
5583
  return wrapped;
4611
5584
  }
4612
- writeFormattedLine(line, width) {
5585
+ writeFormattedLine(line, width, activeMatchCol = null, activeMatchLength = 0) {
4613
5586
  if (line.prefix) {
4614
5587
  writeStyled(this.term, line.prefix, line.prefixStyle ?? line.bodyStyle);
4615
5588
  }
4616
5589
  const remaining = Math.max(0, width - (line.prefix?.length ?? 0));
4617
- const bodyText = line.ansi ? line.body : truncate(line.body, remaining);
4618
- writeStyled(this.term, bodyText, line.bodyStyle);
5590
+ const stripMarkup = line.bodyStyle === "agent";
5591
+ const bodyText = line.ansi ? line.body : truncate(line.body, remaining, { stripMarkup });
5592
+ if (this.scrollbackHighlight !== null && !line.ansi) {
5593
+ writeBodyWithHighlight(
5594
+ this.term,
5595
+ bodyText,
5596
+ line.bodyStyle,
5597
+ this.scrollbackHighlight,
5598
+ activeMatchCol,
5599
+ activeMatchLength
5600
+ );
5601
+ } else {
5602
+ writeStyled(this.term, bodyText, line.bodyStyle);
5603
+ }
4619
5604
  if (line.fillRow) {
4620
5605
  const visible = line.ansi ? stringWidth(bodyText) : bodyText.length;
4621
5606
  const pad = remaining - visible;
@@ -4630,6 +5615,7 @@ var init_screen = __esm({
4630
5615
  };
4631
5616
  NON_ASCII = /[^\x20-\x7e]/;
4632
5617
  SEGMENTER = new Intl.Segmenter(void 0, { granularity: "grapheme" });
5618
+ TK_MARKUP_STYLE_CHAR = /[a-zA-Z+\-:_!#/]/;
4633
5619
  shortId = stripHydraSessionPrefix;
4634
5620
  }
4635
5621
  });
@@ -4653,6 +5639,14 @@ var init_input = __esm({
4653
5639
  queueIndex = -1;
4654
5640
  savedDraft = null;
4655
5641
  history = [];
5642
+ // Active reverse-incremental search over `history`. Set when ^r is
5643
+ // pressed; cleared when the user accepts (Enter / typing / arrows)
5644
+ // or cancels (ESC). `query` is the lowercased substring matched
5645
+ // against history entries; `matchIndices` are history indices in
5646
+ // newest→oldest order; `cursor` is the current index into that list.
5647
+ // `savedDraft` snapshots the buffer/cursor at the moment search
5648
+ // began so ESC can restore it.
5649
+ historySearch = null;
4656
5650
  // Waiting queue snapshot (excludes the in-flight head). Newest item lives
4657
5651
  // at the end so Up walks the array right-to-left.
4658
5652
  queue = [];
@@ -4672,7 +5666,8 @@ var init_input = __esm({
4672
5666
  col: this.col,
4673
5667
  planMode: this.planMode,
4674
5668
  historyIndex: this.historyIndex,
4675
- queueIndex: this.queueIndex
5669
+ queueIndex: this.queueIndex,
5670
+ historySearchQuery: this.historySearch?.query ?? null
4676
5671
  };
4677
5672
  }
4678
5673
  setTurnRunning(running) {
@@ -4682,6 +5677,7 @@ var init_input = __esm({
4682
5677
  this.history = [...history];
4683
5678
  this.historyIndex = -1;
4684
5679
  this.savedDraft = null;
5680
+ this.historySearch = null;
4685
5681
  }
4686
5682
  // Snapshot of the waiting queue (head excluded). Called by the app after
4687
5683
  // every queue mutation so Up/Down can walk a fresh view. queueIndex is
@@ -4710,8 +5706,44 @@ var init_input = __esm({
4710
5706
  this.historyIndex = -1;
4711
5707
  this.queueIndex = -1;
4712
5708
  this.savedDraft = null;
5709
+ this.historySearch = null;
4713
5710
  }
4714
5711
  feed(event) {
5712
+ if (this.historySearch !== null) {
5713
+ if (event.type === "char") {
5714
+ return this.mutateHistorySearchQuery(
5715
+ this.historySearch.query + event.ch.toLowerCase()
5716
+ );
5717
+ }
5718
+ if (event.type === "paste") {
5719
+ return this.mutateHistorySearchQuery(
5720
+ this.historySearch.query + event.text.replace(/\n/g, " ").toLowerCase()
5721
+ );
5722
+ }
5723
+ if (event.type === "key") {
5724
+ if (event.name === "ctrl-r") {
5725
+ return this.advanceHistorySearch();
5726
+ }
5727
+ if (event.name === "ctrl-s") {
5728
+ this.retreatHistorySearch();
5729
+ return [];
5730
+ }
5731
+ if (event.name === "escape") {
5732
+ this.cancelHistorySearch();
5733
+ return [];
5734
+ }
5735
+ if (event.name === "backspace") {
5736
+ if (this.historySearch.query.length === 0) {
5737
+ this.cancelHistorySearch();
5738
+ return [];
5739
+ }
5740
+ return this.mutateHistorySearchQuery(
5741
+ this.historySearch.query.slice(0, -1)
5742
+ );
5743
+ }
5744
+ this.historySearch = null;
5745
+ }
5746
+ }
4715
5747
  if (event.type === "char") {
4716
5748
  this.insertChar(event.ch);
4717
5749
  return [];
@@ -4789,6 +5821,10 @@ var init_input = __esm({
4789
5821
  return [{ type: "redraw" }];
4790
5822
  case "ctrl-p":
4791
5823
  return [{ type: "switch-session" }];
5824
+ case "ctrl-r":
5825
+ return this.startHistorySearch();
5826
+ case "ctrl-s":
5827
+ return [];
4792
5828
  case "ctrl-u":
4793
5829
  this.killLine();
4794
5830
  return [];
@@ -4824,6 +5860,7 @@ var init_input = __esm({
4824
5860
  this.historyIndex = -1;
4825
5861
  this.queueIndex = -1;
4826
5862
  this.savedDraft = null;
5863
+ this.historySearch = null;
4827
5864
  }
4828
5865
  insertChar(ch) {
4829
5866
  if (ch.length === 0) {
@@ -5051,6 +6088,143 @@ var init_input = __esm({
5051
6088
  this.clearBuffer();
5052
6089
  }
5053
6090
  }
6091
+ // Engage reverse-incremental search over prompt history. Uses the
6092
+ // current buffer text as the search query. With an empty buffer we
6093
+ // enter search mode in an "empty query, no match shown" state — the
6094
+ // banner indicator lights up, and as the user types we extend the
6095
+ // query and load top matches. We deliberately do NOT auto-load the
6096
+ // most recent entry on an empty ^R (that's a surprise — Up-arrow
6097
+ // already walks history if that's what they wanted). With a
6098
+ // non-empty query that has no history match, escalate straight to
6099
+ // scrollback search so the typed term searches session output.
6100
+ startHistorySearch() {
6101
+ const query = this.bufferText().toLowerCase();
6102
+ if (query.length === 0) {
6103
+ this.historySearch = {
6104
+ query: "",
6105
+ matchIndices: [],
6106
+ cursor: 0,
6107
+ savedDraft: {
6108
+ buffer: [...this.buffer],
6109
+ row: this.row,
6110
+ col: this.col
6111
+ }
6112
+ };
6113
+ return [];
6114
+ }
6115
+ const matchIndices = this.findHistoryMatches(query);
6116
+ if (matchIndices.length === 0) {
6117
+ return [{ type: "escalate-search", query }];
6118
+ }
6119
+ this.historySearch = {
6120
+ query,
6121
+ matchIndices,
6122
+ cursor: 0,
6123
+ savedDraft: {
6124
+ buffer: [...this.buffer],
6125
+ row: this.row,
6126
+ col: this.col
6127
+ }
6128
+ };
6129
+ this.loadEntry(this.history[matchIndices[0]] ?? "");
6130
+ return [];
6131
+ }
6132
+ // ^R advance. At the oldest match with a non-empty query, falls
6133
+ // through to scrollback search (same escalate path as a never-
6134
+ // matched startHistorySearch). With an empty query at the oldest
6135
+ // match (i.e. the user walked all history with no filter), advance
6136
+ // is a no-op so the buffer stays on the oldest entry.
6137
+ advanceHistorySearch() {
6138
+ if (this.historySearch === null) {
6139
+ return [];
6140
+ }
6141
+ const search = this.historySearch;
6142
+ const atOldest = search.cursor >= search.matchIndices.length - 1;
6143
+ if (atOldest) {
6144
+ if (search.query.length === 0) {
6145
+ return [];
6146
+ }
6147
+ const query = search.query;
6148
+ const draft = search.savedDraft;
6149
+ this.historySearch = null;
6150
+ this.buffer = [...draft.buffer];
6151
+ this.row = draft.row;
6152
+ this.col = draft.col;
6153
+ return [{ type: "escalate-search", query }];
6154
+ }
6155
+ search.cursor += 1;
6156
+ const idx = search.matchIndices[search.cursor];
6157
+ this.loadEntry(this.history[idx] ?? "");
6158
+ return [];
6159
+ }
6160
+ // ^S retreat — walk toward newer matches. No-op at the newest match
6161
+ // (no wrap, mirroring ^R no-wrap at the oldest).
6162
+ retreatHistorySearch() {
6163
+ if (this.historySearch === null) {
6164
+ return;
6165
+ }
6166
+ if (this.historySearch.cursor === 0) {
6167
+ return;
6168
+ }
6169
+ this.historySearch.cursor -= 1;
6170
+ const idx = this.historySearch.matchIndices[this.historySearch.cursor];
6171
+ this.loadEntry(this.history[idx] ?? "");
6172
+ }
6173
+ // Backspace / typing within search mode mutates the query and
6174
+ // re-searches. When the new query is empty, restore the saved
6175
+ // draft buffer (typically empty) and stay in search mode — the
6176
+ // user can keep typing. When the new query has matches, load the
6177
+ // top one. When the new query has no matches, escalate to scrollback
6178
+ // search so the typed term applies there instead.
6179
+ mutateHistorySearchQuery(newQuery) {
6180
+ if (this.historySearch === null) {
6181
+ return [];
6182
+ }
6183
+ if (newQuery.length === 0) {
6184
+ this.historySearch.query = "";
6185
+ this.historySearch.matchIndices = [];
6186
+ this.historySearch.cursor = 0;
6187
+ const draft = this.historySearch.savedDraft;
6188
+ this.buffer = [...draft.buffer];
6189
+ this.row = draft.row;
6190
+ this.col = draft.col;
6191
+ return [];
6192
+ }
6193
+ const matchIndices = this.findHistoryMatches(newQuery);
6194
+ if (matchIndices.length === 0) {
6195
+ const draft = this.historySearch.savedDraft;
6196
+ this.historySearch = null;
6197
+ this.buffer = [...draft.buffer];
6198
+ this.row = draft.row;
6199
+ this.col = draft.col;
6200
+ return [{ type: "escalate-search", query: newQuery }];
6201
+ }
6202
+ this.historySearch.query = newQuery;
6203
+ this.historySearch.matchIndices = matchIndices;
6204
+ this.historySearch.cursor = 0;
6205
+ this.loadEntry(this.history[matchIndices[0]] ?? "");
6206
+ return [];
6207
+ }
6208
+ findHistoryMatches(query) {
6209
+ const out = [];
6210
+ for (let i = this.history.length - 1; i >= 0; i--) {
6211
+ const entry = this.history[i] ?? "";
6212
+ if (query.length === 0 || entry.toLowerCase().includes(query)) {
6213
+ out.push(i);
6214
+ }
6215
+ }
6216
+ return out;
6217
+ }
6218
+ cancelHistorySearch() {
6219
+ if (this.historySearch === null) {
6220
+ return;
6221
+ }
6222
+ const draft = this.historySearch.savedDraft;
6223
+ this.historySearch = null;
6224
+ this.buffer = [...draft.buffer];
6225
+ this.row = draft.row;
6226
+ this.col = draft.col;
6227
+ }
5054
6228
  loadEntry(text) {
5055
6229
  this.buffer = text.split("\n");
5056
6230
  if (this.buffer.length === 0) {
@@ -5790,6 +6964,7 @@ import { nanoid as nanoid3 } from "nanoid";
5790
6964
  import termkit from "terminal-kit";
5791
6965
  async function runTuiApp(opts) {
5792
6966
  const config = await ensureConfig();
6967
+ logMaxBytes = config.tui.logMaxBytes;
5793
6968
  await ensureDaemonReachable(config);
5794
6969
  const term = termkit.terminal;
5795
6970
  const exitHint = {};
@@ -5810,7 +6985,7 @@ async function runSession(term, config, opts, exitHint) {
5810
6985
  process.exit(0);
5811
6986
  }
5812
6987
  const launchLabel = ctx.sessionId === "__new__" ? "Starting new session\u2026" : "Resuming session\u2026";
5813
- term.cyan(launchLabel)("\n");
6988
+ term.brightYellow(launchLabel)("\n");
5814
6989
  const protocol = config.daemon.tls ? "wss" : "ws";
5815
6990
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
5816
6991
  const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
@@ -5997,12 +7172,12 @@ async function runSession(term, config, opts, exitHint) {
5997
7172
  let agentInfoName;
5998
7173
  try {
5999
7174
  const initResult = await conn.request("initialize", {
6000
- protocolVersion: 1,
7175
+ protocolVersion: ACP_PROTOCOL_VERSION,
6001
7176
  clientCapabilities: {
6002
7177
  fs: { readTextFile: false, writeTextFile: false },
6003
7178
  terminal: false
6004
7179
  },
6005
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7180
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6006
7181
  });
6007
7182
  agentInfoName = initResult?.agentInfo?.name;
6008
7183
  } catch {
@@ -6051,7 +7226,7 @@ async function runSession(term, config, opts, exitHint) {
6051
7226
  const attached = await conn.request("session/attach", {
6052
7227
  sessionId: ctx.sessionId,
6053
7228
  historyPolicy: "full",
6054
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
7229
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6055
7230
  });
6056
7231
  resolvedSessionId = attached.sessionId;
6057
7232
  exitHint.sessionId = resolvedSessionId;
@@ -6096,6 +7271,9 @@ async function runSession(term, config, opts, exitHint) {
6096
7271
  if (exitConfirmation && tryHandleExitConfirmKey(ev)) {
6097
7272
  continue;
6098
7273
  }
7274
+ if (tryHandleScrollbackSearchKey(ev)) {
7275
+ continue;
7276
+ }
6099
7277
  if (tryHandleCompletionKey(ev)) {
6100
7278
  continue;
6101
7279
  }
@@ -6105,6 +7283,9 @@ async function runSession(term, config, opts, exitHint) {
6105
7283
  }
6106
7284
  }
6107
7285
  refreshCompletions();
7286
+ screen.setBannerSearchIndicator(
7287
+ dispatcher.state().historySearchQuery
7288
+ );
6108
7289
  screen.refreshPrompt();
6109
7290
  }
6110
7291
  });
@@ -6167,6 +7348,55 @@ async function runSession(term, config, opts, exitHint) {
6167
7348
  dispatcher.replaceFirstLine(next);
6168
7349
  return true;
6169
7350
  };
7351
+ const tryHandleScrollbackSearchKey = (ev) => {
7352
+ if (!screen.isScrollbackSearchActive()) {
7353
+ if (ev.type === "key" && ev.name === "ctrl-r" && screen.isScrolledBack()) {
7354
+ screen.enterScrollbackSearch();
7355
+ screen.updateScrollbackSearchTerm("");
7356
+ return true;
7357
+ }
7358
+ return false;
7359
+ }
7360
+ if (ev.type === "char") {
7361
+ const term2 = screen.scrollbackSearchTerm() + ev.ch;
7362
+ screen.updateScrollbackSearchTerm(term2);
7363
+ return true;
7364
+ }
7365
+ if (ev.type === "paste") {
7366
+ const term2 = screen.scrollbackSearchTerm() + ev.text.replace(/\n/g, " ");
7367
+ screen.updateScrollbackSearchTerm(term2);
7368
+ return true;
7369
+ }
7370
+ if (ev.type === "key") {
7371
+ switch (ev.name) {
7372
+ case "ctrl-r":
7373
+ screen.advanceScrollbackSearch();
7374
+ return true;
7375
+ case "ctrl-s":
7376
+ screen.retreatScrollbackSearch();
7377
+ return true;
7378
+ case "backspace": {
7379
+ const term2 = screen.scrollbackSearchTerm();
7380
+ if (term2.length === 0) {
7381
+ screen.cancelScrollbackSearch();
7382
+ } else {
7383
+ screen.updateScrollbackSearchTerm(term2.slice(0, -1));
7384
+ }
7385
+ return true;
7386
+ }
7387
+ case "enter":
7388
+ screen.acceptScrollbackSearch();
7389
+ return true;
7390
+ case "escape":
7391
+ case "ctrl-c":
7392
+ screen.cancelScrollbackSearch();
7393
+ return true;
7394
+ default:
7395
+ return true;
7396
+ }
7397
+ }
7398
+ return true;
7399
+ };
6170
7400
  const tryHandlePermissionKey = (ev) => {
6171
7401
  if (!pendingPermission) {
6172
7402
  return false;
@@ -6437,6 +7667,10 @@ async function runSession(term, config, opts, exitHint) {
6437
7667
  toolsExpanded = !toolsExpanded;
6438
7668
  renderToolsBlock();
6439
7669
  return;
7670
+ case "escalate-search":
7671
+ screen.enterScrollbackSearch();
7672
+ screen.updateScrollbackSearchTerm(effect.query);
7673
+ return;
6440
7674
  }
6441
7675
  };
6442
7676
  const promptQueue = [];
@@ -6482,6 +7716,7 @@ async function runSession(term, config, opts, exitHint) {
6482
7716
  toolCallOrder.length = 0;
6483
7717
  toolsBlockStartedAt = null;
6484
7718
  toolsBlockEndedAt = null;
7719
+ toolsBlockStopReason = null;
6485
7720
  toolsExpanded = false;
6486
7721
  screen.clearScrollback();
6487
7722
  return true;
@@ -6685,6 +7920,7 @@ async function runSession(term, config, opts, exitHint) {
6685
7920
  let toolsExpanded = false;
6686
7921
  let toolsBlockStartedAt = null;
6687
7922
  let toolsBlockEndedAt = null;
7923
+ let toolsBlockStopReason = null;
6688
7924
  const TOOLS_COLLAPSED_LIMIT = 5;
6689
7925
  let agentBuffer = "";
6690
7926
  let agentKey = null;
@@ -6726,12 +7962,17 @@ async function runSession(term, config, opts, exitHint) {
6726
7962
  const inProgress = toolsBlockEndedAt === null;
6727
7963
  const end = toolsBlockEndedAt ?? Date.now();
6728
7964
  const elapsed = end - toolsBlockStartedAt;
7965
+ const stoppedReason = !inProgress && toolsBlockStopReason !== null && toolsBlockStopReason !== "end_turn" ? toolsBlockStopReason : null;
6729
7966
  let summary;
6730
7967
  if (total === 0) {
6731
- summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
7968
+ if (stoppedReason !== null) {
7969
+ summary = `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}`;
7970
+ } else {
7971
+ summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
7972
+ }
6732
7973
  } else {
6733
7974
  const noun = total === 1 ? "tool" : "tools";
6734
- const timing = inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
7975
+ const timing = stoppedReason !== null ? `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}` : inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
6735
7976
  const parts = [`${total} ${noun}`, timing];
6736
7977
  if (inProgress) {
6737
7978
  if (hidden > 0) {
@@ -6743,12 +7984,14 @@ async function runSession(term, config, opts, exitHint) {
6743
7984
  summary = parts.join(" \xB7 ");
6744
7985
  }
6745
7986
  const pureThinking = total === 0 && inProgress;
7987
+ const frozenStyle = stoppedReason !== null ? "tool-status-fail" : "tool";
7988
+ const frozenBodyStyle = stoppedReason !== null ? "tool-status-fail" : "dim";
6746
7989
  const lines = [
6747
7990
  {
6748
7991
  prefix: "\u2692 ",
6749
- prefixStyle: pureThinking ? "tool-status-running" : "tool",
7992
+ prefixStyle: pureThinking ? "tool-status-running" : frozenStyle,
6750
7993
  body: summary,
6751
- bodyStyle: pureThinking ? "tool-status-running" : "dim"
7994
+ bodyStyle: pureThinking ? "tool-status-running" : frozenBodyStyle
6752
7995
  }
6753
7996
  ];
6754
7997
  for (const id of visibleIds) {
@@ -6762,6 +8005,7 @@ async function runSession(term, config, opts, exitHint) {
6762
8005
  const startToolsBlock = () => {
6763
8006
  toolsBlockStartedAt = Date.now();
6764
8007
  toolsBlockEndedAt = null;
8008
+ toolsBlockStopReason = null;
6765
8009
  renderToolsBlock();
6766
8010
  };
6767
8011
  const recordToolCall = (id, title, status) => {
@@ -6786,6 +8030,7 @@ async function runSession(term, config, opts, exitHint) {
6786
8030
  if (toolsBlockStartedAt === null) {
6787
8031
  toolsBlockStartedAt = Date.now();
6788
8032
  toolsBlockEndedAt = null;
8033
+ toolsBlockStopReason = null;
6789
8034
  }
6790
8035
  toolCallOrder.push(id);
6791
8036
  }
@@ -6887,6 +8132,7 @@ async function runSession(term, config, opts, exitHint) {
6887
8132
  screen.clearKey("plan");
6888
8133
  if (toolsBlockStartedAt !== null) {
6889
8134
  toolsBlockEndedAt = Date.now();
8135
+ toolsBlockStopReason = event.stopReason ?? null;
6890
8136
  renderToolsBlock();
6891
8137
  screen.clearKey("tools");
6892
8138
  }
@@ -6894,6 +8140,7 @@ async function runSession(term, config, opts, exitHint) {
6894
8140
  toolCallOrder.length = 0;
6895
8141
  toolsBlockStartedAt = null;
6896
8142
  toolsBlockEndedAt = null;
8143
+ toolsBlockStopReason = null;
6897
8144
  toolsExpanded = false;
6898
8145
  screen.ensureSeparator();
6899
8146
  }
@@ -6937,12 +8184,14 @@ async function runSession(term, config, opts, exitHint) {
6937
8184
  closeAgentText();
6938
8185
  if (toolsBlockStartedAt !== null) {
6939
8186
  toolsBlockEndedAt = Date.now();
8187
+ toolsBlockStopReason = null;
6940
8188
  renderToolsBlock();
6941
8189
  screen.clearKey("tools");
6942
8190
  toolStates.clear();
6943
8191
  toolCallOrder.length = 0;
6944
8192
  toolsBlockStartedAt = null;
6945
8193
  toolsBlockEndedAt = null;
8194
+ toolsBlockStopReason = null;
6946
8195
  toolsExpanded = false;
6947
8196
  }
6948
8197
  screen.clearKey("plan");
@@ -6960,12 +8209,12 @@ async function runSession(term, config, opts, exitHint) {
6960
8209
  id: `tui-reinit-${nanoid3()}`,
6961
8210
  method: "initialize",
6962
8211
  params: {
6963
- protocolVersion: 1,
8212
+ protocolVersion: ACP_PROTOCOL_VERSION,
6964
8213
  clientCapabilities: {
6965
8214
  fs: { readTextFile: false, writeTextFile: false },
6966
8215
  terminal: false
6967
8216
  },
6968
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" }
8217
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
6969
8218
  }
6970
8219
  };
6971
8220
  try {
@@ -6979,7 +8228,7 @@ async function runSession(term, config, opts, exitHint) {
6979
8228
  params: {
6980
8229
  sessionId: resolvedSessionId,
6981
8230
  historyPolicy: "none",
6982
- clientInfo: { name: "hydra-acp-tui", version: "0.1.0" },
8231
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
6983
8232
  ...upstreamSessionId !== void 0 ? {
6984
8233
  _meta: {
6985
8234
  [HYDRA_META_KEY]: {
@@ -7103,15 +8352,15 @@ function writeDebugLine(payload) {
7103
8352
  }
7104
8353
  function rotateIfBig(target) {
7105
8354
  try {
7106
- const stat3 = statSync(target);
7107
- if (stat3.size < TUI_LOG_MAX_BYTES) {
8355
+ const stat4 = statSync(target);
8356
+ if (stat4.size < logMaxBytes) {
7108
8357
  return;
7109
8358
  }
7110
8359
  renameSync(target, `${target}.0`);
7111
8360
  } catch {
7112
8361
  }
7113
8362
  }
7114
- var PLAN_PREFIX_TEXT, TUI_LOG_MAX_BYTES;
8363
+ var PLAN_PREFIX_TEXT, logMaxBytes;
7115
8364
  var init_app = __esm({
7116
8365
  "src/tui/app.ts"() {
7117
8366
  "use strict";
@@ -7122,6 +8371,7 @@ var init_app = __esm({
7122
8371
  init_daemon_bootstrap();
7123
8372
  init_session();
7124
8373
  init_paths();
8374
+ init_hydra_version();
7125
8375
  init_history();
7126
8376
  init_discovery();
7127
8377
  init_picker();
@@ -7131,7 +8381,7 @@ var init_app = __esm({
7131
8381
  init_render_update();
7132
8382
  init_format();
7133
8383
  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.";
7134
- TUI_LOG_MAX_BYTES = 5 * 1024 * 1024;
8384
+ logMaxBytes = 5 * 1024 * 1024;
7135
8385
  }
7136
8386
  });
7137
8387
 
@@ -7148,9 +8398,9 @@ var init_tui = __esm({
7148
8398
  });
7149
8399
 
7150
8400
  // src/cli.ts
7151
- import { readFileSync } from "fs";
7152
- import { fileURLToPath } from "url";
7153
- import { dirname as dirname4, resolve as resolve4 } from "path";
8401
+ import { readFileSync as readFileSync2 } from "fs";
8402
+ import { fileURLToPath as fileURLToPath2 } from "url";
8403
+ import { dirname as dirname6, resolve as resolve4 } from "path";
7154
8404
 
7155
8405
  // src/cli/parse-args.ts
7156
8406
  function parseArgs(argv) {
@@ -7243,13 +8493,13 @@ New token: ${newToken}
7243
8493
  // src/cli/commands/daemon.ts
7244
8494
  init_paths();
7245
8495
  init_config();
7246
- import * as fsp5 from "fs/promises";
8496
+ import * as fsp6 from "fs/promises";
7247
8497
  import { setTimeout as sleep2 } from "timers/promises";
7248
8498
 
7249
8499
  // src/daemon/server.ts
7250
8500
  init_config();
7251
- import * as fs10 from "fs";
7252
- import * as fsp3 from "fs/promises";
8501
+ import * as fs11 from "fs";
8502
+ import * as fsp4 from "fs/promises";
7253
8503
  import Fastify from "fastify";
7254
8504
  import websocketPlugin from "@fastify/websocket";
7255
8505
  import pino from "pino";
@@ -7404,60 +8654,177 @@ function formatProgress(agentId, received, total, done = false) {
7404
8654
  const tag2 = done ? "downloaded" : "downloading";
7405
8655
  return `hydra-acp: ${tag2} ${agentId} ${rxMb}/${totalMb} MB (${pct}%)`;
7406
8656
  }
7407
- const tag = done ? "downloaded" : "downloading";
7408
- return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
7409
- }
7410
- function inferArchiveName(url) {
7411
- const u = new URL(url);
7412
- const base = path2.posix.basename(u.pathname);
7413
- return base || "archive";
7414
- }
7415
- async function extract(archivePath, dest) {
7416
- const lower = archivePath.toLowerCase();
7417
- if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
7418
- await run("tar", ["-xf", archivePath, "-C", dest]);
7419
- return;
8657
+ const tag = done ? "downloaded" : "downloading";
8658
+ return `hydra-acp: ${tag} ${agentId} ${rxMb} MB`;
8659
+ }
8660
+ function inferArchiveName(url) {
8661
+ const u = new URL(url);
8662
+ const base = path2.posix.basename(u.pathname);
8663
+ return base || "archive";
8664
+ }
8665
+ async function extract(archivePath, dest) {
8666
+ const lower = archivePath.toLowerCase();
8667
+ if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".tar")) {
8668
+ await run("tar", ["-xf", archivePath, "-C", dest]);
8669
+ return;
8670
+ }
8671
+ if (lower.endsWith(".zip")) {
8672
+ if (await hasCommand("unzip")) {
8673
+ await run("unzip", ["-q", archivePath, "-d", dest]);
8674
+ return;
8675
+ }
8676
+ await run("tar", ["-xf", archivePath, "-C", dest]);
8677
+ return;
8678
+ }
8679
+ throw new Error(`Unsupported archive format: ${archivePath}`);
8680
+ }
8681
+ function run(cmd, args) {
8682
+ return new Promise((resolve5, reject) => {
8683
+ const child = spawn(cmd, args, {
8684
+ stdio: ["ignore", "ignore", "inherit"]
8685
+ });
8686
+ child.on("error", reject);
8687
+ child.on("exit", (code, signal) => {
8688
+ if (code === 0) {
8689
+ resolve5();
8690
+ return;
8691
+ }
8692
+ reject(
8693
+ new Error(
8694
+ `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
8695
+ )
8696
+ );
8697
+ });
8698
+ });
8699
+ }
8700
+ async function hasCommand(name) {
8701
+ return new Promise((resolve5) => {
8702
+ const finder = process.platform === "win32" ? "where" : "which";
8703
+ const child = spawn(finder, [name], { stdio: "ignore" });
8704
+ child.on("error", () => resolve5(false));
8705
+ child.on("exit", (code) => resolve5(code === 0));
8706
+ });
8707
+ }
8708
+ async function fileExists(p) {
8709
+ try {
8710
+ await fsp.access(p);
8711
+ return true;
8712
+ } catch {
8713
+ return false;
8714
+ }
8715
+ }
8716
+
8717
+ // src/core/npm-install.ts
8718
+ init_paths();
8719
+ import * as fsp2 from "fs/promises";
8720
+ import * as path3 from "path";
8721
+ import { spawn as spawn2 } from "child_process";
8722
+ var logSink2 = (msg) => {
8723
+ process.stderr.write(msg + "\n");
8724
+ };
8725
+ function setNpmInstallLogger(log) {
8726
+ logSink2 = log ?? ((msg) => process.stderr.write(msg + "\n"));
8727
+ }
8728
+ async function ensureNpmPackage(args) {
8729
+ const platformKey = currentPlatformKey();
8730
+ if (!platformKey) {
8731
+ throw new Error(
8732
+ `Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
8733
+ );
8734
+ }
8735
+ const installDir = paths.agentNpmInstallDir(
8736
+ args.agentId,
8737
+ platformKey,
8738
+ args.version
8739
+ );
8740
+ const binPath = path3.join(installDir, "node_modules", ".bin", args.bin);
8741
+ if (await fileExists2(binPath)) {
8742
+ return binPath;
8743
+ }
8744
+ await installInto({
8745
+ agentId: args.agentId,
8746
+ packageSpec: args.packageSpec,
8747
+ installDir
8748
+ });
8749
+ if (!await fileExists2(binPath)) {
8750
+ throw new Error(
8751
+ `Agent ${args.agentId}: npm install of ${args.packageSpec} did not produce bin ${args.bin} (looked in ${installDir}/node_modules/.bin/)`
8752
+ );
7420
8753
  }
7421
- if (lower.endsWith(".zip")) {
7422
- if (await hasCommand("unzip")) {
7423
- await run("unzip", ["-q", archivePath, "-d", dest]);
7424
- return;
8754
+ return binPath;
8755
+ }
8756
+ async function installInto(args) {
8757
+ await fsp2.mkdir(path3.dirname(args.installDir), { recursive: true });
8758
+ const tempDir = await fsp2.mkdtemp(`${args.installDir}.partial-`);
8759
+ try {
8760
+ logSink2(
8761
+ `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
8762
+ );
8763
+ await runNpmInstall({
8764
+ packageSpec: args.packageSpec,
8765
+ cwd: tempDir
8766
+ });
8767
+ try {
8768
+ await fsp2.rename(tempDir, args.installDir);
8769
+ } catch (err) {
8770
+ const e = err;
8771
+ if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists2(args.installDir)) {
8772
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
8773
+ () => void 0
8774
+ );
8775
+ return;
8776
+ }
8777
+ throw err;
7425
8778
  }
7426
- await run("tar", ["-xf", archivePath, "-C", dest]);
7427
- return;
8779
+ logSink2(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
8780
+ } catch (err) {
8781
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
8782
+ () => void 0
8783
+ );
8784
+ throw err;
7428
8785
  }
7429
- throw new Error(`Unsupported archive format: ${archivePath}`);
7430
8786
  }
7431
- function run(cmd, args) {
8787
+ function runNpmInstall(args) {
7432
8788
  return new Promise((resolve5, reject) => {
7433
- const child = spawn(cmd, args, {
7434
- stdio: ["ignore", "ignore", "inherit"]
8789
+ const child = spawn2(
8790
+ "npm",
8791
+ ["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
8792
+ {
8793
+ cwd: args.cwd,
8794
+ stdio: ["ignore", "pipe", "pipe"]
8795
+ }
8796
+ );
8797
+ let stderrTail = "";
8798
+ child.stdout?.on("data", (chunk) => {
8799
+ void chunk;
8800
+ });
8801
+ child.stderr?.setEncoding("utf8");
8802
+ child.stderr?.on("data", (chunk) => {
8803
+ stderrTail = (stderrTail + chunk).slice(-4096);
8804
+ });
8805
+ child.on("error", (err) => {
8806
+ const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
8807
+ reject(new Error(msg));
7435
8808
  });
7436
- child.on("error", reject);
7437
8809
  child.on("exit", (code, signal) => {
7438
8810
  if (code === 0) {
7439
8811
  resolve5();
7440
8812
  return;
7441
8813
  }
8814
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
8815
+ const tail = stderrTail.trim();
7442
8816
  reject(
7443
8817
  new Error(
7444
- `${cmd} ${args.join(" ")} exited with ${code !== null ? `code ${code}` : `signal ${signal}`}`
8818
+ tail ? `npm install ${args.packageSpec} failed (${reason})
8819
+ stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
7445
8820
  )
7446
8821
  );
7447
8822
  });
7448
8823
  });
7449
8824
  }
7450
- async function hasCommand(name) {
7451
- return new Promise((resolve5) => {
7452
- const finder = process.platform === "win32" ? "where" : "which";
7453
- const child = spawn(finder, [name], { stdio: "ignore" });
7454
- child.on("error", () => resolve5(false));
7455
- child.on("exit", (code) => resolve5(code === 0));
7456
- });
7457
- }
7458
- async function fileExists(p) {
8825
+ async function fileExists2(p) {
7459
8826
  try {
7460
- await fsp.access(p);
8827
+ await fsp2.access(p);
7461
8828
  return true;
7462
8829
  } catch {
7463
8830
  return false;
@@ -7467,6 +8834,10 @@ async function fileExists(p) {
7467
8834
  // src/core/registry.ts
7468
8835
  var NpxDistribution = z2.object({
7469
8836
  package: z2.string(),
8837
+ // The bin to invoke after install. Defaults to the package basename
8838
+ // (e.g. "claude-code" for "@anthropic-ai/claude-code"). Required when
8839
+ // the package exposes a bin name that differs from its basename.
8840
+ bin: z2.string().optional(),
7470
8841
  args: z2.array(z2.string()).optional(),
7471
8842
  env: z2.record(z2.string()).optional()
7472
8843
  });
@@ -7630,9 +9001,23 @@ async function planSpawn(agent, callerArgs = []) {
7630
9001
  if (agent.distribution.npx) {
7631
9002
  const npx = agent.distribution.npx;
7632
9003
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
9004
+ if (process.env.HYDRA_ACP_SKIP_NPM_PREFETCH) {
9005
+ return {
9006
+ command: "npx",
9007
+ args: ["-y", npx.package, ...tail],
9008
+ env: npx.env ?? {}
9009
+ };
9010
+ }
9011
+ const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
9012
+ const binPath = await ensureNpmPackage({
9013
+ agentId: agent.id,
9014
+ version: agent.version ?? "current",
9015
+ packageSpec: npx.package,
9016
+ bin
9017
+ });
7633
9018
  return {
7634
- command: "npx",
7635
- args: ["-y", npx.package, ...tail],
9019
+ command: binPath,
9020
+ args: tail,
7636
9021
  env: npx.env ?? {}
7637
9022
  };
7638
9023
  }
@@ -7667,12 +9052,8 @@ async function planSpawn(agent, callerArgs = []) {
7667
9052
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
7668
9053
  }
7669
9054
 
7670
- // src/core/session-manager.ts
7671
- import * as fs8 from "fs/promises";
7672
- import { customAlphabet as customAlphabet3 } from "nanoid";
7673
-
7674
9055
  // src/core/agent-instance.ts
7675
- import { spawn as spawn2 } from "child_process";
9056
+ import { spawn as spawn3 } from "child_process";
7676
9057
 
7677
9058
  // src/acp/framing.ts
7678
9059
  init_types();
@@ -7753,17 +9134,22 @@ function ndjsonStreamFromStdio(stdout, stdin) {
7753
9134
 
7754
9135
  // src/core/agent-instance.ts
7755
9136
  init_connection();
9137
+ var DEFAULT_STDERR_TAIL_BYTES = 4096;
7756
9138
  var AgentInstance = class _AgentInstance {
7757
9139
  agentId;
7758
9140
  cwd;
7759
9141
  connection;
7760
9142
  child;
7761
9143
  exited = false;
9144
+ killed = false;
9145
+ stderrTail = "";
9146
+ stderrTailBytes;
7762
9147
  exitHandlers = [];
7763
9148
  constructor(opts, child) {
7764
9149
  this.agentId = opts.agentId;
7765
9150
  this.cwd = opts.cwd;
7766
9151
  this.child = child;
9152
+ this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
7767
9153
  if (!child.stdout || !child.stdin) {
7768
9154
  throw new Error("agent subprocess missing stdio");
7769
9155
  }
@@ -7771,22 +9157,36 @@ var AgentInstance = class _AgentInstance {
7771
9157
  this.connection = new JsonRpcConnection(stream);
7772
9158
  child.stderr?.setEncoding("utf8");
7773
9159
  child.stderr?.on("data", (chunk) => {
9160
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.stderrTailBytes);
7774
9161
  process.stderr.write(`[${opts.agentId}] ${chunk}`);
7775
9162
  });
9163
+ child.on("error", (err) => {
9164
+ const msg = this.formatFailure(err.message);
9165
+ this.connection.fail(new Error(msg));
9166
+ });
7776
9167
  child.on("exit", (code, signal) => {
7777
9168
  this.exited = true;
9169
+ if (!this.killed) {
9170
+ const reason = `agent ${opts.agentId} exited before responding (code=${code} signal=${signal})`;
9171
+ this.connection.fail(new Error(this.formatFailure(reason)));
9172
+ }
7778
9173
  for (const handler of this.exitHandlers) {
7779
9174
  handler(code, signal);
7780
9175
  }
7781
9176
  });
7782
9177
  }
9178
+ formatFailure(reason) {
9179
+ const tail = this.stderrTail.trim();
9180
+ return tail ? `${reason}
9181
+ stderr: ${tail}` : reason;
9182
+ }
7783
9183
  static spawn(opts) {
7784
9184
  const env = {
7785
9185
  ...process.env,
7786
9186
  ...opts.plan.env,
7787
9187
  ...opts.extraEnv ?? {}
7788
9188
  };
7789
- const child = spawn2(opts.plan.command, opts.plan.args, {
9189
+ const child = spawn3(opts.plan.command, opts.plan.args, {
7790
9190
  cwd: opts.cwd,
7791
9191
  env,
7792
9192
  stdio: ["pipe", "pipe", "pipe"]
@@ -7803,196 +9203,33 @@ var AgentInstance = class _AgentInstance {
7803
9203
  if (this.exited) {
7804
9204
  return;
7805
9205
  }
9206
+ this.killed = true;
7806
9207
  await this.connection.close().catch(() => void 0);
7807
9208
  this.child.kill(signal);
7808
9209
  }
7809
9210
  };
7810
9211
 
7811
9212
  // src/core/session-manager.ts
9213
+ import * as fs9 from "fs/promises";
9214
+ import * as os2 from "os";
9215
+ import { customAlphabet as customAlphabet3 } from "nanoid";
7812
9216
  init_session();
7813
-
7814
- // src/core/session-store.ts
7815
- init_paths();
7816
- import * as fs5 from "fs/promises";
7817
- import * as path3 from "path";
7818
- import { customAlphabet as customAlphabet2 } from "nanoid";
7819
- import { z as z4 } from "zod";
7820
- var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
7821
- var generateRawId = customAlphabet2(HYDRA_ID_ALPHABET2, 16);
7822
- var HYDRA_LINEAGE_PREFIX = "hydra_lineage_";
7823
- function generateLineageId() {
7824
- return `${HYDRA_LINEAGE_PREFIX}${generateRawId()}`;
7825
- }
7826
- var PersistedAgentCommand = z4.object({
7827
- name: z4.string(),
7828
- description: z4.string().optional()
7829
- });
7830
- var PersistedUsage = z4.object({
7831
- used: z4.number().optional(),
7832
- size: z4.number().optional(),
7833
- costAmount: z4.number().optional(),
7834
- costCurrency: z4.string().optional()
7835
- });
7836
- var SessionRecord = z4.object({
7837
- version: z4.literal(1),
7838
- sessionId: z4.string(),
7839
- // Optional for back-compat with records written before this field
7840
- // existed; mergeForPersistence generates one on next write so any
7841
- // touched session converges to having a lineageId. A record that
7842
- // never gets written again (truly cold and untouched) just won't
7843
- // participate in lineage-based dedup, which is correct — it was
7844
- // never exported, so no incoming bundle can claim its lineage.
7845
- lineageId: z4.string().optional(),
7846
- upstreamSessionId: z4.string(),
7847
- // When non-empty, marks a session that was created by import and is
7848
- // waiting for its first attach to bootstrap a fresh upstream agent
7849
- // and replay the imported history as a takeover transcript. The
7850
- // origin's local id at export time, kept for debuggability and as a
7851
- // breadcrumb in `sessions list` (informational, not used for routing).
7852
- importedFromSessionId: z4.string().optional(),
7853
- agentId: z4.string(),
7854
- cwd: z4.string(),
7855
- title: z4.string().optional(),
7856
- agentArgs: z4.array(z4.string()).optional(),
7857
- // Snapshot of "what is currently true about this session" carried in
7858
- // meta.json so a late-attaching or cold-resurrected client can be
7859
- // told via the attach response _meta without depending on history
7860
- // replay of a snapshot-shaped notification.
7861
- currentModel: z4.string().optional(),
7862
- currentMode: z4.string().optional(),
7863
- currentUsage: PersistedUsage.optional(),
7864
- agentCommands: z4.array(PersistedAgentCommand).optional(),
7865
- createdAt: z4.string(),
7866
- updatedAt: z4.string()
7867
- });
7868
- var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
7869
- function assertSafeId(id) {
7870
- if (!SESSION_ID_PATTERN.test(id)) {
7871
- throw new Error(`unsafe session id: ${id}`);
7872
- }
7873
- }
7874
- var SessionStore = class {
7875
- async write(record) {
7876
- assertSafeId(record.sessionId);
7877
- await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
7878
- const full = { version: 1, ...record };
7879
- await fs5.writeFile(
7880
- paths.sessionFile(record.sessionId),
7881
- JSON.stringify(full, null, 2) + "\n",
7882
- { encoding: "utf8", mode: 384 }
7883
- );
7884
- }
7885
- async read(sessionId) {
7886
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7887
- return void 0;
7888
- }
7889
- let raw;
7890
- try {
7891
- raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
7892
- } catch (err) {
7893
- const e = err;
7894
- if (e.code === "ENOENT") {
7895
- return void 0;
7896
- }
7897
- throw err;
7898
- }
7899
- try {
7900
- return SessionRecord.parse(JSON.parse(raw));
7901
- } catch {
7902
- return void 0;
7903
- }
7904
- }
7905
- async delete(sessionId) {
7906
- if (!SESSION_ID_PATTERN.test(sessionId)) {
7907
- return;
7908
- }
7909
- try {
7910
- await fs5.unlink(paths.sessionFile(sessionId));
7911
- } catch (err) {
7912
- const e = err;
7913
- if (e.code !== "ENOENT") {
7914
- throw err;
7915
- }
7916
- }
7917
- try {
7918
- await fs5.rmdir(paths.sessionDir(sessionId));
7919
- } catch (err) {
7920
- const e = err;
7921
- if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
7922
- throw err;
7923
- }
7924
- }
7925
- }
7926
- // Find a persisted session by lineageId. Used by SessionManager.import
7927
- // to detect bundles that have already been imported (lineageId match)
7928
- // so we can either error out or, with replace:true, overwrite.
7929
- // Returns undefined if no record has that lineageId. Records that
7930
- // pre-date the lineageId field simply don't match — which is
7931
- // correct: they were never exported, so no incoming bundle can
7932
- // legitimately claim their lineage.
7933
- async findByLineageId(lineageId) {
7934
- if (lineageId.length === 0) {
7935
- return void 0;
7936
- }
7937
- const all = await this.list().catch(() => []);
7938
- for (const record of all) {
7939
- if (record.lineageId === lineageId) {
7940
- return record;
7941
- }
7942
- }
7943
- return void 0;
7944
- }
7945
- async list() {
7946
- let entries;
7947
- try {
7948
- entries = await fs5.readdir(paths.sessionsDir());
7949
- } catch (err) {
7950
- const e = err;
7951
- if (e.code === "ENOENT") {
7952
- return [];
7953
- }
7954
- throw err;
7955
- }
7956
- const records = [];
7957
- for (const entry of entries) {
7958
- const record = await this.read(entry);
7959
- if (record) {
7960
- records.push(record);
7961
- }
7962
- }
7963
- return records;
7964
- }
7965
- };
7966
- function recordFromMemorySession(args) {
7967
- const now = (/* @__PURE__ */ new Date()).toISOString();
7968
- return {
7969
- sessionId: args.sessionId,
7970
- lineageId: args.lineageId,
7971
- upstreamSessionId: args.upstreamSessionId,
7972
- importedFromSessionId: args.importedFromSessionId,
7973
- agentId: args.agentId,
7974
- cwd: args.cwd,
7975
- title: args.title,
7976
- agentArgs: args.agentArgs,
7977
- currentModel: args.currentModel,
7978
- currentMode: args.currentMode,
7979
- currentUsage: args.currentUsage,
7980
- agentCommands: args.agentCommands,
7981
- createdAt: args.createdAt ?? now,
7982
- updatedAt: args.updatedAt ?? now
7983
- };
7984
- }
9217
+ init_session_store();
7985
9218
 
7986
9219
  // src/core/history-store.ts
7987
9220
  init_paths();
7988
9221
  import * as fs6 from "fs/promises";
7989
9222
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
7990
- var MAX_ENTRIES = 1e3;
9223
+ var DEFAULT_MAX_ENTRIES = 1e3;
7991
9224
  var HistoryStore = class {
7992
9225
  // Serialize writes per session id so appends and rewrites don't
7993
9226
  // interleave JSONL lines on disk. The chain swallows errors so one
7994
9227
  // failed append doesn't poison every subsequent write.
7995
9228
  writeQueues = /* @__PURE__ */ new Map();
9229
+ maxEntries;
9230
+ constructor(options = {}) {
9231
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
9232
+ }
7996
9233
  async append(sessionId, entry) {
7997
9234
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
7998
9235
  return;
@@ -8094,8 +9331,8 @@ var HistoryStore = class {
8094
9331
  recordedAt: obj.recordedAt
8095
9332
  });
8096
9333
  }
8097
- if (out.length > MAX_ENTRIES) {
8098
- return out.slice(-MAX_ENTRIES);
9334
+ if (out.length > this.maxEntries) {
9335
+ return out.slice(-this.maxEntries);
8099
9336
  }
8100
9337
  return out;
8101
9338
  }
@@ -8140,6 +9377,7 @@ var HistoryStore = class {
8140
9377
  init_paths();
8141
9378
  init_history();
8142
9379
  init_types();
9380
+ init_hydra_version();
8143
9381
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
8144
9382
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
8145
9383
  var SessionManager = class {
@@ -8147,7 +9385,8 @@ var SessionManager = class {
8147
9385
  this.registry = registry;
8148
9386
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
8149
9387
  this.store = store ?? new SessionStore();
8150
- this.histories = new HistoryStore();
9388
+ this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
9389
+ this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
8151
9390
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
8152
9391
  this.defaultModels = options.defaultModels ?? {};
8153
9392
  }
@@ -8159,6 +9398,7 @@ var SessionManager = class {
8159
9398
  histories;
8160
9399
  idleTimeoutMs;
8161
9400
  defaultModels;
9401
+ sessionHistoryMaxEntries;
8162
9402
  // Serialize meta.json read-modify-write operations per session id so
8163
9403
  // concurrent snapshot updates (e.g. an agent emitting model + mode
8164
9404
  // back-to-back) don't lose writes via interleaved reads.
@@ -8182,6 +9422,7 @@ var SessionManager = class {
8182
9422
  idleTimeoutMs: this.idleTimeoutMs,
8183
9423
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8184
9424
  historyStore: this.histories,
9425
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8185
9426
  currentModel: fresh.initialModel
8186
9427
  });
8187
9428
  await this.attachManagerHooks(session);
@@ -8233,11 +9474,16 @@ var SessionManager = class {
8233
9474
  cwd: params.cwd,
8234
9475
  plan
8235
9476
  });
8236
- await agent.connection.request("initialize", {
8237
- protocolVersion: 1,
8238
- clientCapabilities: {},
8239
- clientInfo: { name: "hydra", version: "0.1.0" }
8240
- });
9477
+ try {
9478
+ await agent.connection.request("initialize", {
9479
+ protocolVersion: ACP_PROTOCOL_VERSION,
9480
+ clientCapabilities: {},
9481
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
9482
+ });
9483
+ } catch (err) {
9484
+ await agent.kill().catch(() => void 0);
9485
+ throw err;
9486
+ }
8241
9487
  let loadResult;
8242
9488
  try {
8243
9489
  loadResult = await agent.connection.request(
@@ -8249,10 +9495,12 @@ var SessionManager = class {
8249
9495
  }
8250
9496
  );
8251
9497
  } catch (err) {
8252
- await agent.kill().catch(() => void 0);
8253
- throw new Error(
8254
- `agent ${params.agentId} failed to load upstream session ${params.upstreamSessionId}: ${err.message}`
9498
+ process.stderr.write(
9499
+ `session/load failed for upstream ${params.upstreamSessionId} on ${params.agentId} (${err.message}); recovering via import-reseed
9500
+ `
8255
9501
  );
9502
+ await agent.kill().catch(() => void 0);
9503
+ return this.doResurrectFromImport(params);
8256
9504
  }
8257
9505
  const session = new Session({
8258
9506
  sessionId: params.hydraSessionId,
@@ -8266,6 +9514,7 @@ var SessionManager = class {
8266
9514
  idleTimeoutMs: this.idleTimeoutMs,
8267
9515
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8268
9516
  historyStore: this.histories,
9517
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8269
9518
  // Prefer what we previously stored from a current_model_update; if
8270
9519
  // we never captured one (e.g. old opencode sessions on disk before
8271
9520
  // this fix), fall back to the model the agent ships in its
@@ -8292,15 +9541,16 @@ var SessionManager = class {
8292
9541
  // so subsequent resurrects of this session use the normal session/load
8293
9542
  // path.
8294
9543
  async doResurrectFromImport(params) {
9544
+ const cwd = await this.resolveImportCwd(params.cwd);
8295
9545
  const fresh = await this.bootstrapAgent({
8296
9546
  agentId: params.agentId,
8297
- cwd: params.cwd,
9547
+ cwd,
8298
9548
  agentArgs: params.agentArgs,
8299
9549
  mcpServers: []
8300
9550
  });
8301
9551
  const session = new Session({
8302
9552
  sessionId: params.hydraSessionId,
8303
- cwd: params.cwd,
9553
+ cwd,
8304
9554
  agentId: params.agentId,
8305
9555
  agent: fresh.agent,
8306
9556
  upstreamSessionId: fresh.upstreamSessionId,
@@ -8310,6 +9560,7 @@ var SessionManager = class {
8310
9560
  idleTimeoutMs: this.idleTimeoutMs,
8311
9561
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
8312
9562
  historyStore: this.histories,
9563
+ historyMaxEntries: this.sessionHistoryMaxEntries,
8313
9564
  // Prefer the stored value (set by a previous current_model_update);
8314
9565
  // fall back to whatever the agent ships in its session/new response.
8315
9566
  currentModel: params.currentModel ?? fresh.initialModel,
@@ -8323,6 +9574,16 @@ var SessionManager = class {
8323
9574
  void session.seedFromImport().catch(() => void 0);
8324
9575
  return session;
8325
9576
  }
9577
+ async resolveImportCwd(cwd) {
9578
+ try {
9579
+ const stat4 = await fs9.stat(cwd);
9580
+ if (stat4.isDirectory()) {
9581
+ return cwd;
9582
+ }
9583
+ } catch {
9584
+ }
9585
+ return os2.homedir();
9586
+ }
8326
9587
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
8327
9588
  // → session/new. Shared by create() and the /hydra agent path so both
8328
9589
  // go through the same env / capabilities / error-handling.
@@ -8343,9 +9604,9 @@ var SessionManager = class {
8343
9604
  });
8344
9605
  try {
8345
9606
  await agent.connection.request("initialize", {
8346
- protocolVersion: 1,
9607
+ protocolVersion: ACP_PROTOCOL_VERSION,
8347
9608
  clientCapabilities: {},
8348
- clientInfo: { name: "hydra", version: "0.1.0" }
9609
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
8349
9610
  });
8350
9611
  const newResult = await agent.connection.request(
8351
9612
  "session/new",
@@ -8625,7 +9886,8 @@ var SessionManager = class {
8625
9886
  await this.writeImportedRecord({
8626
9887
  sessionId: existing.sessionId,
8627
9888
  bundle,
8628
- preservedCreatedAt: existing.createdAt
9889
+ preservedCreatedAt: existing.createdAt,
9890
+ cwd: opts.cwd
8629
9891
  });
8630
9892
  return {
8631
9893
  sessionId: existing.sessionId,
@@ -8634,7 +9896,11 @@ var SessionManager = class {
8634
9896
  };
8635
9897
  }
8636
9898
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
8637
- await this.writeImportedRecord({ sessionId: newId, bundle });
9899
+ await this.writeImportedRecord({
9900
+ sessionId: newId,
9901
+ bundle,
9902
+ cwd: opts.cwd
9903
+ });
8638
9904
  return {
8639
9905
  sessionId: newId,
8640
9906
  importedFromSessionId: bundle.session.sessionId,
@@ -8664,7 +9930,7 @@ var SessionManager = class {
8664
9930
  upstreamSessionId: "",
8665
9931
  importedFromSessionId: args.bundle.session.sessionId,
8666
9932
  agentId: args.bundle.session.agentId,
8667
- cwd: args.bundle.session.cwd,
9933
+ cwd: args.cwd ?? args.bundle.session.cwd,
8668
9934
  title: args.bundle.session.title,
8669
9935
  currentModel: args.bundle.session.currentModel,
8670
9936
  currentMode: args.bundle.session.currentMode,
@@ -8857,7 +10123,7 @@ function asString(value) {
8857
10123
  }
8858
10124
  async function loadPromptHistorySafely(sessionId) {
8859
10125
  try {
8860
- const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
10126
+ const raw = await fs9.readFile(paths.tuiHistoryFile(sessionId), "utf8");
8861
10127
  const out = [];
8862
10128
  for (const line of raw.split("\n")) {
8863
10129
  if (line.length === 0) {
@@ -8878,7 +10144,7 @@ async function loadPromptHistorySafely(sessionId) {
8878
10144
  }
8879
10145
  async function historyMtimeIso(sessionId) {
8880
10146
  try {
8881
- const st = await fs8.stat(paths.historyFile(sessionId));
10147
+ const st = await fs9.stat(paths.historyFile(sessionId));
8882
10148
  return new Date(st.mtimeMs).toISOString();
8883
10149
  } catch {
8884
10150
  return void 0;
@@ -8887,10 +10153,10 @@ async function historyMtimeIso(sessionId) {
8887
10153
 
8888
10154
  // src/core/extensions.ts
8889
10155
  init_paths();
8890
- import { spawn as spawn3 } from "child_process";
8891
- import * as fs9 from "fs";
8892
- import * as fsp2 from "fs/promises";
8893
- import * as path5 from "path";
10156
+ import { spawn as spawn4 } from "child_process";
10157
+ import * as fs10 from "fs";
10158
+ import * as fsp3 from "fs/promises";
10159
+ import * as path7 from "path";
8894
10160
  var RESTART_BASE_MS = 1e3;
8895
10161
  var RESTART_CAP_MS = 6e4;
8896
10162
  var STOP_GRACE_MS = 3e3;
@@ -8911,7 +10177,7 @@ var ExtensionManager = class {
8911
10177
  if (!this.context) {
8912
10178
  throw new Error("ExtensionManager: setContext must be called before start");
8913
10179
  }
8914
- await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
10180
+ await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
8915
10181
  await this.reapOrphans();
8916
10182
  for (const entry of this.entries.values()) {
8917
10183
  if (!entry.config.enabled) {
@@ -9120,7 +10386,7 @@ var ExtensionManager = class {
9120
10386
  async reapOrphans() {
9121
10387
  let entries;
9122
10388
  try {
9123
- entries = await fsp2.readdir(paths.extensionsDir());
10389
+ entries = await fsp3.readdir(paths.extensionsDir());
9124
10390
  } catch (err) {
9125
10391
  const e = err;
9126
10392
  if (e.code === "ENOENT") {
@@ -9132,10 +10398,10 @@ var ExtensionManager = class {
9132
10398
  if (!entry.endsWith(".pid")) {
9133
10399
  continue;
9134
10400
  }
9135
- const pidPath = path5.join(paths.extensionsDir(), entry);
10401
+ const pidPath = path7.join(paths.extensionsDir(), entry);
9136
10402
  let pid;
9137
10403
  try {
9138
- const raw = await fsp2.readFile(pidPath, "utf8");
10404
+ const raw = await fsp3.readFile(pidPath, "utf8");
9139
10405
  const parsed = Number.parseInt(raw.trim(), 10);
9140
10406
  if (Number.isInteger(parsed) && parsed > 0) {
9141
10407
  pid = parsed;
@@ -9158,7 +10424,7 @@ var ExtensionManager = class {
9158
10424
  }
9159
10425
  }
9160
10426
  }
9161
- await fsp2.unlink(pidPath).catch(() => void 0);
10427
+ await fsp3.unlink(pidPath).catch(() => void 0);
9162
10428
  }
9163
10429
  }
9164
10430
  spawn(entry, attempt) {
@@ -9171,7 +10437,7 @@ var ExtensionManager = class {
9171
10437
  }
9172
10438
  const ext = entry.config;
9173
10439
  const command = ext.command.length > 0 ? ext.command : [ext.name];
9174
- const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
10440
+ const logStream = fs10.createWriteStream(paths.extensionLogFile(ext.name), {
9175
10441
  flags: "a"
9176
10442
  });
9177
10443
  logStream.write(
@@ -9199,7 +10465,7 @@ var ExtensionManager = class {
9199
10465
  const args = [...baseArgs, ...ext.args];
9200
10466
  let child;
9201
10467
  try {
9202
- child = spawn3(cmd, args, {
10468
+ child = spawn4(cmd, args, {
9203
10469
  env,
9204
10470
  stdio: ["ignore", "pipe", "pipe"],
9205
10471
  detached: false
@@ -9221,7 +10487,7 @@ var ExtensionManager = class {
9221
10487
  }
9222
10488
  if (typeof child.pid === "number") {
9223
10489
  try {
9224
- fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
10490
+ fs10.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
9225
10491
  `, {
9226
10492
  encoding: "utf8",
9227
10493
  mode: 384
@@ -9246,7 +10512,7 @@ var ExtensionManager = class {
9246
10512
  });
9247
10513
  child.on("exit", (code, signal) => {
9248
10514
  try {
9249
- fs9.unlinkSync(paths.extensionPidFile(ext.name));
10515
+ fs10.unlinkSync(paths.extensionPidFile(ext.name));
9250
10516
  } catch {
9251
10517
  }
9252
10518
  logStream.write(
@@ -9304,6 +10570,7 @@ function withCode2(err, code) {
9304
10570
 
9305
10571
  // src/daemon/server.ts
9306
10572
  init_paths();
10573
+ init_hydra_version();
9307
10574
 
9308
10575
  // src/daemon/auth.ts
9309
10576
  var BEARER_PREFIX = "Bearer ";
@@ -9359,78 +10626,10 @@ function constantTimeEqual(a, b) {
9359
10626
 
9360
10627
  // src/daemon/routes/sessions.ts
9361
10628
  init_config();
9362
- import * as os2 from "os";
9363
-
9364
- // src/core/bundle.ts
9365
- import { z as z5 } from "zod";
9366
- var HistoryEntrySchema = z5.object({
9367
- method: z5.string(),
9368
- params: z5.unknown(),
9369
- recordedAt: z5.number()
9370
- });
9371
- var BundleSession = z5.object({
9372
- // The exporter's local id. Regenerated fresh on import (sessionId is
9373
- // the local namespace; lineageId is what survives across hops).
9374
- sessionId: z5.string(),
9375
- // Required on bundles — the export path backfills if the source
9376
- // record was written before lineageId existed.
9377
- lineageId: z5.string(),
9378
- agentId: z5.string(),
9379
- cwd: z5.string(),
9380
- title: z5.string().optional(),
9381
- currentModel: z5.string().optional(),
9382
- currentMode: z5.string().optional(),
9383
- currentUsage: PersistedUsage.optional(),
9384
- agentCommands: z5.array(PersistedAgentCommand).optional(),
9385
- createdAt: z5.string(),
9386
- updatedAt: z5.string()
9387
- });
9388
- var Bundle = z5.object({
9389
- version: z5.literal(1),
9390
- exportedAt: z5.string(),
9391
- exportedFrom: z5.object({
9392
- hydraVersion: z5.string(),
9393
- machine: z5.string()
9394
- }),
9395
- session: BundleSession,
9396
- history: z5.array(HistoryEntrySchema),
9397
- promptHistory: z5.array(z5.string()).optional()
9398
- });
9399
- function encodeBundle(params) {
9400
- const bundle = {
9401
- version: 1,
9402
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9403
- exportedFrom: {
9404
- hydraVersion: params.hydraVersion,
9405
- machine: params.machine
9406
- },
9407
- session: {
9408
- sessionId: params.record.sessionId,
9409
- lineageId: params.record.lineageId,
9410
- agentId: params.record.agentId,
9411
- cwd: params.record.cwd,
9412
- ...params.record.title !== void 0 ? { title: params.record.title } : {},
9413
- ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
9414
- ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
9415
- ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
9416
- ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
9417
- createdAt: params.record.createdAt,
9418
- updatedAt: params.record.updatedAt
9419
- },
9420
- history: params.history
9421
- };
9422
- if (params.promptHistory !== void 0) {
9423
- bundle.promptHistory = params.promptHistory;
9424
- }
9425
- return bundle;
9426
- }
9427
- function decodeBundle(raw) {
9428
- return Bundle.parse(raw);
9429
- }
9430
-
9431
- // src/daemon/routes/sessions.ts
10629
+ init_bundle();
9432
10630
  init_types();
9433
- var HYDRA_VERSION = "0.1.0";
10631
+ init_hydra_version();
10632
+ import * as os3 from "os";
9434
10633
  function registerSessionRoutes(app, manager, defaults) {
9435
10634
  app.get("/v1/sessions", async (request) => {
9436
10635
  const query = request.query;
@@ -9501,7 +10700,7 @@ function registerSessionRoutes(app, manager, defaults) {
9501
10700
  history: exported.history,
9502
10701
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
9503
10702
  hydraVersion: HYDRA_VERSION,
9504
- machine: os2.hostname()
10703
+ machine: os3.hostname()
9505
10704
  });
9506
10705
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
9507
10706
  reply.header(
@@ -9516,6 +10715,14 @@ function registerSessionRoutes(app, manager, defaults) {
9516
10715
  reply.code(400).send({ error: "missing bundle" });
9517
10716
  return;
9518
10717
  }
10718
+ let cwdOverride;
10719
+ if (body.cwd !== void 0) {
10720
+ if (typeof body.cwd !== "string" || body.cwd.length === 0) {
10721
+ reply.code(400).send({ error: "cwd must be a non-empty string" });
10722
+ return;
10723
+ }
10724
+ cwdOverride = body.cwd;
10725
+ }
9519
10726
  let bundle;
9520
10727
  try {
9521
10728
  bundle = decodeBundle(body.bundle);
@@ -9528,7 +10735,8 @@ function registerSessionRoutes(app, manager, defaults) {
9528
10735
  }
9529
10736
  try {
9530
10737
  const result = await manager.importBundle(bundle, {
9531
- replace: body.replace === true
10738
+ replace: body.replace === true,
10739
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
9532
10740
  });
9533
10741
  reply.code(201).send(result);
9534
10742
  } catch (err) {
@@ -9769,8 +10977,7 @@ init_connection();
9769
10977
  init_ws_stream();
9770
10978
  init_types();
9771
10979
  import { nanoid as nanoid2 } from "nanoid";
9772
- var HYDRA_VERSION2 = "0.1.0";
9773
- var HYDRA_PROTOCOL_VERSION = 1;
10980
+ init_hydra_version();
9774
10981
  function registerAcpWsEndpoint(app, deps) {
9775
10982
  app.get("/acp", { websocket: true }, (socket, request) => {
9776
10983
  const token = tokenFromUpgradeRequest({
@@ -10033,8 +11240,8 @@ function buildResponseMeta(session) {
10033
11240
  }
10034
11241
  function buildInitializeResult() {
10035
11242
  return {
10036
- protocolVersion: HYDRA_PROTOCOL_VERSION,
10037
- agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
11243
+ protocolVersion: ACP_PROTOCOL_VERSION,
11244
+ agentInfo: { name: "hydra", version: HYDRA_VERSION },
10038
11245
  agentCapabilities: {
10039
11246
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
10040
11247
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -10073,14 +11280,13 @@ function bindClientToSession(connection, session, state, clientInfo) {
10073
11280
  }
10074
11281
 
10075
11282
  // src/daemon/server.ts
10076
- var HYDRA_VERSION3 = "0.1.0";
10077
11283
  async function startDaemon(config) {
10078
11284
  ensureLoopbackOrTls(config);
10079
11285
  const httpsOptions = config.daemon.tls ? {
10080
- key: await fsp3.readFile(config.daemon.tls.key),
10081
- cert: await fsp3.readFile(config.daemon.tls.cert)
11286
+ key: await fsp4.readFile(config.daemon.tls.key),
11287
+ cert: await fsp4.readFile(config.daemon.tls.cert)
10082
11288
  } : void 0;
10083
- await fsp3.mkdir(paths.home(), { recursive: true });
11289
+ await fsp4.mkdir(paths.home(), { recursive: true });
10084
11290
  const { stream: logStream, fileStream } = await buildLogStream(
10085
11291
  config.daemon.logLevel
10086
11292
  );
@@ -10089,12 +11295,18 @@ async function startDaemon(config) {
10089
11295
  level: config.daemon.logLevel,
10090
11296
  stream: logStream
10091
11297
  },
10092
- https: httpsOptions ?? null
11298
+ https: httpsOptions ?? null,
11299
+ // Session bundles can be large (full history + tool output);
11300
+ // the 1MB Fastify default rejects ordinary imports.
11301
+ bodyLimit: 256 * 1024 * 1024
10093
11302
  });
10094
11303
  await app.register(websocketPlugin);
10095
11304
  setBinaryInstallLogger((msg) => {
10096
11305
  app.log.info(msg);
10097
11306
  });
11307
+ setNpmInstallLogger((msg) => {
11308
+ app.log.info(msg);
11309
+ });
10098
11310
  const auth = bearerAuth({ config });
10099
11311
  app.addHook("onRequest", async (request, reply) => {
10100
11312
  if (request.routeOptions.config?.skipAuth) {
@@ -10106,12 +11318,14 @@ async function startDaemon(config) {
10106
11318
  await auth(request, reply);
10107
11319
  });
10108
11320
  const registry = new Registry(config);
10109
- const manager = new SessionManager(registry, void 0, void 0, {
11321
+ const spawner = (opts) => AgentInstance.spawn({ ...opts, stderrTailBytes: config.daemon.agentStderrTailBytes });
11322
+ const manager = new SessionManager(registry, spawner, void 0, {
10110
11323
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
10111
- defaultModels: config.defaultModels
11324
+ defaultModels: config.defaultModels,
11325
+ sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries
10112
11326
  });
10113
11327
  const extensions = new ExtensionManager(extensionList(config));
10114
- registerHealthRoutes(app, HYDRA_VERSION3);
11328
+ registerHealthRoutes(app, HYDRA_VERSION);
10115
11329
  registerSessionRoutes(app, manager, {
10116
11330
  agentId: config.defaultAgent,
10117
11331
  cwd: config.defaultCwd
@@ -10130,8 +11344,8 @@ async function startDaemon(config) {
10130
11344
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
10131
11345
  const address = app.server.address();
10132
11346
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
10133
- await fsp3.mkdir(paths.home(), { recursive: true });
10134
- await fsp3.writeFile(
11347
+ await fsp4.mkdir(paths.home(), { recursive: true });
11348
+ await fsp4.writeFile(
10135
11349
  paths.pidFile(),
10136
11350
  JSON.stringify({
10137
11351
  pid: process.pid,
@@ -10157,9 +11371,10 @@ async function startDaemon(config) {
10157
11371
  await manager.closeAll();
10158
11372
  await manager.flushMetaWrites();
10159
11373
  setBinaryInstallLogger(null);
11374
+ setNpmInstallLogger(null);
10160
11375
  await app.close();
10161
11376
  try {
10162
- fs10.unlinkSync(paths.pidFile());
11377
+ fs11.unlinkSync(paths.pidFile());
10163
11378
  } catch {
10164
11379
  }
10165
11380
  try {
@@ -10198,13 +11413,13 @@ function ensureLoopbackOrTls(config) {
10198
11413
  init_daemon_bootstrap();
10199
11414
 
10200
11415
  // src/cli/commands/log-tail.ts
10201
- import * as fs11 from "fs";
10202
- import * as fsp4 from "fs/promises";
11416
+ import * as fs12 from "fs";
11417
+ import * as fsp5 from "fs/promises";
10203
11418
  async function runLogTail(logPath, argv, notFoundMessage) {
10204
11419
  const opts = parseLogTailFlags(argv);
10205
- let stat3;
11420
+ let stat4;
10206
11421
  try {
10207
- stat3 = await fsp4.stat(logPath);
11422
+ stat4 = await fsp5.stat(logPath);
10208
11423
  } catch (err) {
10209
11424
  const e = err;
10210
11425
  if (e.code === "ENOENT") {
@@ -10215,14 +11430,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
10215
11430
  }
10216
11431
  throw err;
10217
11432
  }
10218
- let position = await printTail(logPath, stat3.size, opts.tail);
11433
+ let position = await printTail(logPath, stat4.size, opts.tail);
10219
11434
  if (!opts.follow) {
10220
11435
  return;
10221
11436
  }
10222
11437
  process.stdout.write(`-- following ${logPath} --
10223
11438
  `);
10224
11439
  let pending = false;
10225
- const watcher = fs11.watch(logPath, () => {
11440
+ const watcher = fs12.watch(logPath, () => {
10226
11441
  if (pending) {
10227
11442
  return;
10228
11443
  }
@@ -10230,14 +11445,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
10230
11445
  setImmediate(async () => {
10231
11446
  pending = false;
10232
11447
  try {
10233
- const s = await fsp4.stat(logPath);
11448
+ const s = await fsp5.stat(logPath);
10234
11449
  if (s.size <= position) {
10235
11450
  if (s.size < position) {
10236
11451
  position = s.size;
10237
11452
  }
10238
11453
  return;
10239
11454
  }
10240
- const fd = await fsp4.open(logPath, "r");
11455
+ const fd = await fsp5.open(logPath, "r");
10241
11456
  try {
10242
11457
  const buf = Buffer.alloc(s.size - position);
10243
11458
  await fd.read(buf, 0, buf.length, position);
@@ -10264,7 +11479,7 @@ async function printTail(logPath, fileSize, lines) {
10264
11479
  return fileSize;
10265
11480
  }
10266
11481
  const CHUNK = 64 * 1024;
10267
- const fd = await fsp4.open(logPath, "r");
11482
+ const fd = await fsp5.open(logPath, "r");
10268
11483
  try {
10269
11484
  let position = fileSize;
10270
11485
  let collected = "";
@@ -10433,7 +11648,7 @@ async function runDaemonStatus() {
10433
11648
  }
10434
11649
  async function readPidFile() {
10435
11650
  try {
10436
- const raw = await fsp5.readFile(paths.pidFile(), "utf8");
11651
+ const raw = await fsp6.readFile(paths.pidFile(), "utf8");
10437
11652
  return JSON.parse(raw);
10438
11653
  } catch (err) {
10439
11654
  const e = err;
@@ -10458,7 +11673,7 @@ init_sessions();
10458
11673
  // src/cli/commands/extensions.ts
10459
11674
  init_config();
10460
11675
  init_paths();
10461
- import * as fsp6 from "fs/promises";
11676
+ import * as fsp7 from "fs/promises";
10462
11677
  init_sessions();
10463
11678
  async function runExtensionsList() {
10464
11679
  const config = await loadConfig();
@@ -10654,11 +11869,11 @@ async function runExtensionsRemove(name) {
10654
11869
  }
10655
11870
  }
10656
11871
  async function readRawConfig() {
10657
- const raw = await fsp6.readFile(paths.config(), "utf8");
11872
+ const raw = await fsp7.readFile(paths.config(), "utf8");
10658
11873
  return JSON.parse(raw);
10659
11874
  }
10660
11875
  async function writeRawConfig(raw) {
10661
- await fsp6.writeFile(
11876
+ await fsp7.writeFile(
10662
11877
  paths.config(),
10663
11878
  JSON.stringify(raw, null, 2) + "\n",
10664
11879
  { encoding: "utf8", mode: 384 }
@@ -11423,8 +12638,8 @@ async function main() {
11423
12638
  await runSessionsKill(positional[2]);
11424
12639
  return;
11425
12640
  }
11426
- if (sub === "rm") {
11427
- await runSessionsRm(positional[2]);
12641
+ if (sub === "remove") {
12642
+ await runSessionsRemove(positional[2]);
11428
12643
  return;
11429
12644
  }
11430
12645
  if (sub === "export") {
@@ -11433,8 +12648,11 @@ async function main() {
11433
12648
  return;
11434
12649
  }
11435
12650
  if (sub === "import") {
12651
+ const cwd = resolveOption(flags, "cwd");
11436
12652
  await runSessionsImport(positional[2], {
11437
- replace: flags.replace === true
12653
+ replace: flags.replace === true,
12654
+ info: flags.info === true,
12655
+ ...cwd !== void 0 ? { cwd } : {}
11438
12656
  });
11439
12657
  return;
11440
12658
  }
@@ -11537,9 +12755,9 @@ async function dispatchTui(flags, base) {
11537
12755
  }
11538
12756
  function readVersion() {
11539
12757
  try {
11540
- const here = dirname4(fileURLToPath(import.meta.url));
12758
+ const here = dirname6(fileURLToPath2(import.meta.url));
11541
12759
  const pkg = JSON.parse(
11542
- readFileSync(resolve4(here, "../package.json"), "utf8")
12760
+ readFileSync2(resolve4(here, "../package.json"), "utf8")
11543
12761
  );
11544
12762
  return pkg.version ?? "unknown";
11545
12763
  } catch {
@@ -11566,11 +12784,11 @@ function printHelp() {
11566
12784
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
11567
12785
  " hydra-acp sessions [list] [--all] List sessions (live + 20 most-recent cold; --all for everything)",
11568
12786
  " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
11569
- " hydra-acp sessions rm <id> Remove a session entirely (live or cold)",
12787
+ " hydra-acp sessions remove <id> Remove a session entirely (live or cold)",
11570
12788
  " hydra-acp sessions export <id> [--out <file>|.]",
11571
12789
  " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
11572
- " hydra-acp sessions import <file>|- [--replace]",
11573
- " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live)",
12790
+ " hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]",
12791
+ " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live); --cwd overrides the bundle's recorded working directory; --info prints the bundle's meta without importing",
11574
12792
  " hydra-acp extensions list List configured extensions and live state",
11575
12793
  " hydra-acp extensions add <name> [opts] Add an extension to config",
11576
12794
  " hydra-acp extensions remove <name> Remove an extension from config",